Compare commits

..
Author SHA1 Message Date
timmy 67d3242f37 security: encrypt Web Push subscriptions at rest (Closes #1108)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m47s
CI / lint (pull_request) Successful in 2m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 02:34:32 +00:00
timmy fb08bc5ce5 Merge pull request 'Encrypt worker-shared dashboard snapshots at rest' (#1107) from timmy/1106-encrypt-worker-snapshots into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m48s
CI / lint (push) Successful in 2m52s
CI / release-candidate (push) Successful in 7s
2026-08-19 02:04:52 +00:00
timmy 9d9b28af8e security: encrypt worker-shared snapshots (Closes #1106)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m55s
CI / lint (pull_request) Successful in 2m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 01:58:22 +00:00
rockachopa 8ad02933c9 Merge pull request 'Review and resume snoozed mobile deadline reminders' (#1105) from timmy/1104-review-snoozed-deadlines into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m50s
CI / lint (push) Successful in 2m49s
CI / release-candidate (push) Successful in 7s
Review and resume snoozed mobile deadline reminders (#1105)
2026-08-19 01:09:49 +00:00
timmy d5dc3f27db feat: review snoozed deadline reminders (Closes #1104)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m47s
CI / lint (pull_request) Successful in 2m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 01:02:06 +00:00
rockachopa 625d937f64 Merge pull request 'Snooze mobile deadline reminders from the lock screen' (#1103) from timmy/1102-deadline-reminder-snooze into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 2m45s
CI / lint (push) Successful in 2m39s
CI / release-candidate (push) Successful in 6s
2026-08-19 00:08:03 +00:00
timmy 2bd20130b6 feat: snooze mobile deadline reminders (Closes #1102)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m58s
CI / lint (pull_request) Successful in 2m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 00:01:38 +00:00
rockachopa fdf6ae3ddd Merge pull request 'Rotate synchronized Draft encryption keys without downtime' (#1101) from timmy/1100-draft-key-rotation into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 2m42s
CI / lint (push) Successful in 2m31s
CI / release-candidate (push) Successful in 6s
2026-08-18 23:11:07 +00:00
timmy fc9ef1f8ae feat: rotate synchronized Draft encryption keys (Closes #1100)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m35s
CI / lint (pull_request) Successful in 2m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 23:04:30 +00:00
timmy 8cd1d1f684 Merge pull request 'Encrypt synchronized unfiled drafts at rest' (#1099) from timmy/1098-encrypt-unfiled-drafts into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m2s
CI / lint (push) Successful in 2m46s
CI / release-candidate (push) Successful in 7s
2026-08-18 22:27:53 +00:00
timmy e79c457774 test: inject draft encryption key into release journey
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m56s
CI / lint (pull_request) Successful in 2m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 22:15:09 +00:00
timmy d3f53b5a38 security: encrypt synchronized unfiled drafts (Closes #1098)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 2m53s
CI / lint (pull_request) Successful in 2m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 22:07:02 +00:00
rockachopa 19a1184b1c Merge pull request 'Pin Web Push connections to validated public addresses' (#1097) from timmy/1096-pin-web-push-connections into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m54s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 2m46s
2026-08-18 21:34:22 +00:00
timmy ad40cb5fb9 fix: pin Web Push connections to validated addresses (Closes #1096)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m57s
CI / lint (pull_request) Successful in 2m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 21:26:32 +00:00
rockachopa a38934b099 Merge pull request 'Track multiple mobile merges through release' (#1095) from timmy/1094-release-watchlist into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 3m17s
CI / lint (push) Successful in 3m8s
CI / release-candidate (push) Successful in 8s
2026-08-18 20:49:38 +00:00
timmy 3cf33869d6 feat: track multiple mobile releases (Closes #1094)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m14s
CI / lint (pull_request) Successful in 3m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 20:40:59 +00:00
timmy 41195f4c75 Merge pull request 'Track exact release progress after mobile merges' (#1093) from timmy/1092-mobile-release-receipt into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m2s
CI / lint (push) Successful in 3m5s
CI / release-candidate (push) Successful in 8s
2026-08-18 19:49:08 +00:00
timmy 6db0802461 feat: track exact post-merge release progress (Closes #1092)
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 3m28s
CI / lint (pull_request) Successful in 3m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 19:41:00 +00:00
rockachopa 087300f9c3 Merge pull request 'Share a privacy-reviewed summary after mobile Today wrap-up' (#1091) from timmy/1090-mobile-today-summary into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m17s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m6s
2026-08-18 19:01:10 +00:00
timmy abc736d32e fix: isolate Today summary drafts by account
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m7s
CI / lint (pull_request) Successful in 3m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 18:53:21 +00:00
timmy b3a359760c feat: share private Today summaries (Closes #1090)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 3m9s
CI / lint (pull_request) Successful in 3m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 18:39:01 +00:00
rockachopa 5f86d48e6f Merge pull request 'Review and export mobile Agenda deadlines as a calendar snapshot' (#1089) from timmy/1088-agenda-calendar-export into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m55s
CI / lint (push) Successful in 3m2s
CI / release-candidate (push) Successful in 9s
2026-08-18 17:48:38 +00:00
timmy 1c17fcf337 feat: export Agenda calendar snapshots (Closes #1088)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m18s
CI / lint (pull_request) Successful in 3m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 17:37:25 +00:00
rockachopa 1c6f5c685f Merge pull request 'Pause Today during mobile Security Center' (#1087) from timmy/1086-pause-today-security-center into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m42s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m17s
2026-08-18 16:49:47 +00:00
timmy 473f592615 feat: pause Today during mobile Security Center (Closes #1086)
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 2m59s
CI / lint (pull_request) Successful in 3m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 16:41:59 +00:00
rockachopa aba4b55ca0 Merge pull request 'Pause active Today timing during mobile Device Setup' (#1085) from timmy/1084-pause-today-device-setup into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 2m41s
CI / lint (push) Successful in 3m13s
CI / release-candidate (push) Successful in 10s
2026-08-18 15:39:39 +00:00
timmy 4a53a95b2d feat: pause Today during mobile device setup (Closes #1084)
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 3m0s
CI / lint (pull_request) Successful in 3m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 15:30:55 +00:00
timmy a6f34fbfde Merge pull request 'Pause active Today timing while reviewing mobile Insights' (#1083) from timmy/1082-pause-today-insights into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 3m0s
CI / lint (push) Successful in 3m15s
CI / release-candidate (push) Successful in 10s
2026-08-18 14:54:49 +00:00
timmy 472371d5a8 feat: pause Today during mobile Insights (Closes #1082)
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 2m45s
CI / lint (pull_request) Successful in 3m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 14:44:08 +00:00
rockachopa d7b8cd5e71 Pause active Today timing across mobile Find Work and Queues (#1081)
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 2m47s
CI / lint (push) Successful in 3m8s
CI / release-candidate (push) Successful in 8s
Closes #1080
2026-08-18 14:02:05 +00:00
timmy 58cee5ea16 feat: pause Today during mobile work detours (Closes #1080)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m45s
CI / lint (pull_request) Successful in 3m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 13:51:40 +00:00
rockachopa 38efd4eb7c Merge pull request 'Pause active Today timing during mobile Search' (#1079) from timmy/1078-pause-today-search into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 2m48s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 2m54s
2026-08-18 12:54:06 +00:00
timmy d4910b4398 feat: pause Today during mobile Search (Closes #1078)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m21s
CI / lint (pull_request) Successful in 3m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 12:45:08 +00:00
rockachopa ff34416c3c Merge pull request 'Pause Today during quick capture and return to the active task' (#1077) from timmy/1076-pause-today-quick-capture into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m35s
CI / lint (push) Successful in 3m13s
CI / release-candidate (push) Successful in 8s
2026-08-18 11:15:52 +00:00
timmy f8c2541d30 feat: pause Today during quick capture (Closes #1076)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m35s
CI / lint (pull_request) Successful in 3m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 11:06:44 +00:00
rockachopa d9749e3a6f Compact the active Today HUD into a mobile command bar (#1075)
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m14s
CI / lint (push) Successful in 2m56s
CI / release-candidate (push) Successful in 7s
Closes #1074
2026-08-18 09:43:07 +00:00
timmy b77be4f5b0 feat: compact active Today controls on mobile (Closes #1074)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m17s
CI / lint (pull_request) Successful in 2m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 09:36:34 +00:00
rockachopa fb7bfe0a5d Merge pull request 'Keep dashboard Gitea links on the configured forge origin' (#1073) from timmy/1072-configured-forge-link-boundary into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m9s
CI / lint (push) Successful in 2m41s
CI / release-candidate (push) Successful in 7s
2026-08-18 09:04:35 +00:00
timmy 79f6cecab4 fix: keep Gitea links on configured forge (Closes #1072)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m8s
CI / lint (pull_request) Successful in 2m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 08:58:35 +00:00
rockachopa 0472e88d91 Merge pull request 'Open a focused blocker flow from the active Today HUD' (#1071)
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 2m12s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m43s
2026-08-18 08:31:44 +00:00
timmy f710b2ce1d feat: open focused blockers from Today HUD (Closes #1070)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m14s
CI / lint (pull_request) Successful in 2m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 08:25:27 +00:00
rockachopa 027ded1f8f Merge pull request 'Recover admitted Today blockers across reloads' (#1069) from timmy/1068-recover-today-blocker-reload into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m42s
2026-08-18 07:40:38 +00:00
timmy 3c8e2e2720 fix: recover Today blocker planning after reload (Closes #1068)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m6s
CI / lint (pull_request) Successful in 2m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 07:27:30 +00:00
rockachopa 1bd87fc33e Merge pull request 'Report a Today blocker, defer it, and continue' (#1067) from timmy/1066-today-blocker-move-on into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m32s
2026-08-18 06:47:24 +00:00
timmy 4009a4a4c1 feat: report Today blockers and move on (Closes #1066)
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 2m7s
CI / lint (pull_request) Successful in 2m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 06:38:55 +00:00
rockachopa 339baab7ee Merge pull request 'Mention teammates from active Today progress updates' (#1065) from timmy/1064-today-progress-mentions into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m2s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m37s
2026-08-18 05:47:56 +00:00
timmy a6c769901d feat: mention teammates from Today updates (Closes #1064)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m0s
CI / lint (pull_request) Successful in 2m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 05:38:20 +00:00
timmy a65aed5862 Merge pull request 'Correct or retract your own recent Today update in place' (#1063) from timmy/1062-correct-today-progress-update into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 1m48s
CI / lint (push) Successful in 2m35s
CI / release-candidate (push) Successful in 6s
2026-08-18 04:55:04 +00:00
timmy 8b4bd480cc feat: correct recent Today updates in place (Closes #1062)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m56s
CI / lint (pull_request) Successful in 2m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 04:46:53 +00:00
rockachopa 9c46455c35 Merge pull request 'Review recent activity before posting a Today update' (#1061) from timmy/1060-today-progress-recent-activity into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m59s
CI / lint (push) Successful in 2m32s
CI / release-candidate (push) Successful in 7s
2026-08-18 04:06:40 +00:00
timmy 47626338ba feat: review activity before Today updates (Closes #1060)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m53s
CI / lint (pull_request) Successful in 2m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 04:00:58 +00:00
timmy e290293bf1 Merge pull request 'Dictate and review active Today progress updates' (#1059) from timmy/1058-today-progress-voice-dictation into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m4s
CI / lint (push) Successful in 2m44s
CI / release-candidate (push) Successful in 7s
2026-08-18 03:11:36 +00:00
timmy 688eadf17a feat: dictate active Today progress updates (Closes #1058)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m1s
CI / lint (pull_request) Successful in 2m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 03:05:53 +00:00
rockachopa daf34d7cdd Merge pull request 'Keep Today progress photo retries bound to the intended evidence' (#1057) from timmy/1056-today-progress-evidence-retries into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m3s
CI / lint (push) Successful in 2m39s
CI / release-candidate (push) Successful in 8s
2026-08-18 02:35:23 +00:00
timmy 1410a66a36 fix: bind Today progress retries to evidence (Closes #1056)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m2s
CI / lint (pull_request) Successful in 2m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 02:29:13 +00:00
rockachopa 157d485685 Merge pull request 'Add photo evidence to active Today progress updates' (#1055) from timmy/1054-mobile-today-progress-photo-evidence into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m1s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m41s
2026-08-18 01:48:47 +00:00
timmy 4f07402936 fix: preserve photo drafts while reopening Today progress
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m5s
CI / lint (pull_request) Successful in 2m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 01:42:05 +00:00
timmy 9a4a1711fe feat: add photo evidence to Today progress updates (Closes #1054) 2026-08-18 01:33:56 +00:00
timmy 7502d6f44d Merge pull request 'Add mobile Today progress updates without advancing work' (#1053) from timmy/1052-mobile-today-progress-updates into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m58s
CI / lint (push) Successful in 2m40s
CI / release-candidate (push) Successful in 6s
2026-08-18 00:52:44 +00:00
timmy bed9bb4270 fix: keep Today completion undo above progress HUD
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m2s
CI / lint (pull_request) Successful in 2m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 00:43:46 +00:00
timmy 406d75287d feat: add in-session Today progress updates (Closes #1052)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 2m5s
CI / lint (pull_request) Successful in 2m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 00:34:37 +00:00
timmy 8c604e0b04 Merge pull request 'Show timed Today breaks on the mobile lock screen' (#1051) from timmy/1050-mobile-today-break-lock-screen into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m7s
CI / lint (push) Successful in 2m41s
CI / release-candidate (push) Successful in 8s
2026-08-18 00:05:53 +00:00
timmy ed15392097 feat: show timed Today breaks on lock screen (Closes #1050)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m15s
CI / lint (pull_request) Successful in 2m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 23:57:29 +00:00
timmy af86034339 Merge pull request 'Make deployed mobile dashboard reachable through authenticated readiness' (#1049) from timmy/1048-authenticated-deployment-readiness into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m59s
CI / lint (push) Successful in 2m37s
CI / release-candidate (push) Successful in 7s
2026-08-17 23:38:24 +00:00
timmy e8817056f7 feat: verify authenticated deployment readiness (Closes #1048)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m2s
CI / lint (pull_request) Successful in 2m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 23:31:53 +00:00
timmy a757bfe587 Merge pull request 'Make mobile issue capture a focus-contained modal journey' (#1047) from timmy/1046-mobile-issue-capture-modal into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m59s
CI / lint (push) Successful in 2m51s
CI / release-candidate (push) Successful in 7s
Merge pull request 'Make mobile issue capture a focus-contained modal journey' (#1047)
2026-08-17 23:07:36 +00:00
timmy 44aedfd623 fix: preserve capture confirmation dialogs
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 1m59s
CI / lint (pull_request) Successful in 2m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 22:59:31 +00:00
timmy 58299311d0 fix: reset capture viewport after keyboard navigation 2026-08-17 22:58:22 +00:00
timmy 89c25369fc fix: close capture without leaving the dashboard 2026-08-17 22:56:25 +00:00
timmy be10dba4bd feat: contain mobile issue capture focus (Closes #1046) 2026-08-17 22:55:04 +00:00
rockachopa c655cf5861 Merge pull request 'Build release bundles from the declared Git commit' (#1045) from timmy/1044-declared-git-release-bundles into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m47s
CI / lint (push) Successful in 2m29s
CI / release-candidate (push) Successful in 6s
2026-08-17 22:33:47 +00:00
timmy 2082fbd5eb ci: build releases from declared Git commits (Closes #1044)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m24s
CI / lint (pull_request) Successful in 2m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 22:25:38 +00:00
timmy 55156f45c9 Merge pull request 'Recover valid Today edits from a corrupted offline queue' (#1043) from timmy/1042-recover-valid-today-edits into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m20s
CI / lint (push) Successful in 2m47s
CI / release-candidate (push) Successful in 7s
2026-08-17 22:00:18 +00:00
timmy 5bf0f97df8 fix: preserve truthful queue recovery status
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 1m53s
CI / lint (pull_request) Successful in 2m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 21:55:00 +00:00
timmy ce2f04ab6d test: recover queue during active Today journey
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Failing after 2m23s
CI / lint (pull_request) Successful in 2m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 21:46:18 +00:00
timmy f7b2a96206 test: stabilize mobile Today recovery notice
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 2m24s
CI / lint (pull_request) Successful in 2m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 21:39:20 +00:00
timmy a6550f8b1f fix: recover valid Today edits from corrupt queue
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 2m21s
CI / lint (pull_request) Successful in 2m43s
CI / release-candidate (pull_request) Has been skipped
Closes #1042
2026-08-17 21:26:40 +00:00
rockachopa 66968d90c0 Merge pull request 'feat: CI browser journey is nondeterministic after Today cross-device merge' (#1041) from timmy/1040-ci-browser-journey-is-nondeterministic-after-tod into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m18s
CI / lint (push) Successful in 2m55s
CI / release-candidate (push) Successful in 6s
2026-08-17 20:58:12 +00:00
timmy 442d0a157d fix: preserve Today sync enqueue order
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 2m9s
CI / lint (pull_request) Successful in 2m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 20:43:13 +00:00
rockachopa 4a85f02eae Merge pull request 'Continue a timed Today break across devices' (#1039) from timmy/1038-cross-device-today-break into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 2m10s
CI / lint (push) Successful in 2m44s
CI / release-candidate (push) Has been skipped
2026-08-17 20:23:06 +00:00
timmy dfd33aee1c fix: reject contradictory running break sessions
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m5s
CI / lint (pull_request) Successful in 2m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 20:17:43 +00:00
timmy c5b466f281 feat: continue timed breaks across devices (Closes #1038)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 2m8s
CI / lint (pull_request) Successful in 2m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 20:08:05 +00:00
rockachopa 1d242bab75 Merge pull request 'Add a timed mobile break and return-to-Today flow' (#1037) from timmy/1036-timed-mobile-today-break into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m1s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m41s
2026-08-17 19:37:52 +00:00
timmy 40284a7e30 fix: preserve Today order when resuming a break
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m4s
CI / lint (pull_request) Successful in 2m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 19:32:10 +00:00
timmy ea08f566ff feat: add timed mobile Today breaks (Closes #1036)
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Failing after 2m7s
CI / lint (pull_request) Successful in 2m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 19:20:07 +00:00
rockachopa ca671f0d00 Merge pull request 'Undo accidental Today completion on mobile' (#1035) from timmy/1034-undo-today-completion into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m14s
CI / lint (push) Successful in 2m52s
CI / release-candidate (push) Successful in 7s
2026-08-17 18:12:56 +00:00
timmy 6e677f8857 feat: undo accidental Today completion (Closes #1034)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m13s
CI / lint (pull_request) Successful in 2m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 18:06:52 +00:00
rockachopa e47e6329cc Merge pull request 'Finish the current Today item from the mobile lock screen' (#1033) from timmy/1032-finish-today-lock-screen into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m13s
CI / lint (push) Successful in 3m5s
CI / release-candidate (push) Successful in 12s
2026-08-17 17:09:29 +00:00
timmy 11395b655a feat: finish Today from the lock screen (Closes #1032)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m15s
CI / lint (pull_request) Successful in 3m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 17:02:54 +00:00
rockachopa ca520d2eb6 Merge pull request 'Make cross-device Today session sync conflict-convergent' (#1031) from timmy/1030-today-session-conflict-convergence into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m35s
CI / lint (push) Successful in 3m3s
CI / release-candidate (push) Successful in 11s
2026-08-17 16:09:21 +00:00
timmy 25dda21811 fix: converge Today session conflicts across devices (Closes #1030)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m17s
CI / lint (pull_request) Successful in 2m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 16:00:59 +00:00
rockachopa 8a57ea73fb Merge pull request 'Make pull merges durably auditable before mutation' (#1029) from timmy/1028-durable-pull-merge-audit into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m9s
CI / lint (push) Successful in 2m51s
CI / release-candidate (push) Successful in 7s
2026-08-17 15:33:56 +00:00
timmy 6729359ba2 fix: make pull merges durably auditable (Closes #1028)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m8s
CI / lint (pull_request) Successful in 2m54s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 15:28:00 +00:00
timmy f687389046 Merge pull request 'Control an active Today session from the mobile lock screen' (#1027) from timmy/1026-today-lock-screen into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 2m2s
CI / lint (push) Successful in 2m46s
CI / release-candidate (push) Successful in 6s
2026-08-17 14:53:43 +00:00
timmy a7f4f9714f feat: control Today from mobile lock screen (Closes #1026)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m21s
CI / lint (pull_request) Successful in 2m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 14:47:07 +00:00
timmy 7ba238fb53 Merge pull request 'Make Prepare Today the primary truthful mobile work entry' (#1025) from timmy/1024-primary-prepare-today into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m35s
CI / lint (push) Successful in 3m4s
CI / release-candidate (push) Successful in 7s
2026-08-17 13:41:24 +00:00
timmy b845f8ba95 feat: make Prepare Today the primary mobile entry (Closes #1024)
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 2m27s
CI / lint (pull_request) Successful in 3m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 13:34:58 +00:00
timmy 996309f52f Merge pull request 'Continue an active Today session across devices' (#1023) from timmy/1022-today-session-handoff into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m24s
CI / lint (push) Successful in 2m58s
CI / release-candidate (push) Successful in 7s
2026-08-17 12:51:50 +00:00
timmy 8d12a0384b feat: hand off active Today sessions across devices (Closes #1022)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m22s
CI / lint (pull_request) Successful in 3m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 12:44:01 +00:00
112 changed files with 10782 additions and 500 deletions

No files matched your search

+2 -2
View File
@@ -57,7 +57,7 @@ jobs:
pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium
- name: Exercise packaged mobile work journeys
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
release-candidate:
runs-on: ubuntu-latest
@@ -82,7 +82,7 @@ jobs:
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
(cd dist && sha256sum -c ./*.sha256)
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"CI-tested release candidate for commit %s. Verify downloads with the attached SHA-256 checksum.","draft":true,"prerelease":true}\n' \
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
+75 -8
View File
@@ -40,10 +40,10 @@ identity and posts one ordered Markdown evidence comment. After a partial failur
the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA
Share Target accepts the same bounded multi-image bundle through sign-in continuation.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API. In issue, pull-request, and unread-update conversations, typing
at least two characters after `@` offers repository-scoped teammate suggestions;
touch or keyboard selection inserts the login without leaving the draft. Mention
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
issue-comment API. In issue, pull-request, unread-update, and active Today progress
conversations, typing at least two characters after `@` offers repository-scoped
teammate suggestions; touch or keyboard selection inserts the login without leaving
the draft. Mention lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
creation and assignment permission. A fresh capture never silently targets the first repository:
the operator must explicitly choose one, either from the paginated browser or through the bounded
authenticated repository search. Search results include only visible repository identities, stale
@@ -117,8 +117,14 @@ time. An over-capacity plan requires a second explicit save, legacy plans migrat
and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
as unknown rather than unblocked. Starting a Today work session also
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
as unknown rather than unblocked. Completing any Today item now exposes a 10-second, touch-safe
**Undo** receipt. Undo restores the item's original order and estimate, queues the inverse cross-device
plan changes, and leaves the already-advanced work session on its current item; expiry, capacity, or a
concurrently changed plan is reported without overwriting newer work. Starting a Today work session also
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
After wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it.
A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. When work is blocked, **Post blocker & move on** requires a future return time, admits the comment before any planning change, defers the item to Later, and continues the existing Today session. An admitted planning transition remains checkpointed for retry, so a storage or Today-removal failure cannot post the blocker twice. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open
assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps
@@ -185,8 +191,17 @@ cannot alter a newer crash-recovery claim. Device purge cancels an active drain
private outbox storage. Results are coordinated through a bounded SQLite ledger. All private SQLite stores enforce a
filesystem boundary independently of the service umask: the database directory is repaired to
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
paths are rejected before access. This protects state from unrelated local accounts, but it is not
encryption at rest; secure host access, encrypted volumes, and private backups are still required.
paths are rejected before access. Worker-shared live and Find Work snapshots add AES-256-GCM
envelopes authenticated to their store identity (and live generation), so copied databases do not
expose issue bodies, titles, notification metadata, or repository context. Existing plaintext
snapshot rows migrate on their first read without changing freshness, revisions, ordering, or claim
filters. Synchronized unfiled Draft collections use a separate AES-256-GCM key and authenticate the
account and revision; existing plaintext rows likewise migrate on first read. Other private stores
are not encrypted at the application layer. Web Push subscriptions use a third, independent
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
single-device enrollment without retaining capability URLs. Existing plaintext subscriptions
migrate atomically at startup without resetting delivery checkpoints or reminder schedules. Secure
host access, encrypted volumes, and private backups are still required.
Set `STACKCHAIN_STATE_DIR` to a
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
@@ -252,6 +267,30 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
# Required for worker-shared live and Find Work snapshots. Keep this key
# independent from the Draft key and inject the base64 encoding of exactly 32
# random bytes from a secret manager. Never commit it. Missing, malformed,
# wrong-key, or modified snapshot state fails closed without returning content.
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
# Required for cross-device unfiled Draft sync. The single-key setting remains
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
# Inject the base64 encoding of exactly 32 random bytes from a secret manager.
export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
# After every worker runs keyring-capable code, replace the single-key setting with
# a bounded JSON object (at most eight keys) and name one active write key. During
# the first rotation, preserve the original single key under the reserved `legacy`
# ID so existing v1 envelopes remain readable. Key IDs use 1-32 letters, digits,
# underscores, or hyphens. Never commit either setting.
export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS='{"legacy":"<old-base64-key>","2026-08":"<new-base64-key>"}'
export STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID='2026-08'
# New writes use authenticated v2 envelopes carrying the active key ID. Reads
# atomically rewrap plaintext, v1, and inactive-key rows without advancing their
# logical revision. Run the content-free, restart-safe migration until it exits 0:
python3 scripts/rotate_unfiled_drafts.py
# A result such as {"current":42,"failed":0,"migrated":0,"total":42} proves the
# old key has no remaining row dependencies. Only then remove `legacy`/old keys and
# restart. A nonzero exit reports unreadable row counts but never account or Draft
# content. Roll back only to a keyring-capable build and retain every configured key.
# Trust forwarding headers only from these immediate reverse-proxy networks.
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
@@ -267,6 +306,11 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
# Required whenever all three VAPID settings enable Web Push. Keep this key
# independent from the snapshot and Draft keys. Startup authenticates every retained
# subscription and fails closed for a missing, malformed, wrong, or modified key.
# Back up the key separately: losing it makes existing device enrollments unrecoverable.
export STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
@@ -285,6 +329,29 @@ export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqli
uvicorn src.main:app --host 127.0.0.1 --port 8000
```
`GITEA_URL` is also the trust boundary for every Gitea resource link returned
to the browser. Configure the externally reachable scheme, host, and subpath
(for example `https://forge.example.com/git`); cross-origin, downgraded, and
same-host links outside that subpath are discarded.
For a systemd deployment, keep those values in a root-readable environment file
(`chmod 600`), reference it with `EnvironmentFile=`, and keep secrets out of the unit
command line and repository. Liveness alone does not prove that operators can use the
service: `/healthz` intentionally remains healthy when authentication is missing. After
each restart or proxy change, run the complete public-subpath smoke journey with the
operator secret supplied only through the process environment:
```bash
STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>' \
python3 scripts/verify_deployment.py \
https://forge.example.com/dashboard/
```
The verifier requires readiness, rendered sign-in, a manifest whose `scope` and
`start_url` remain inside the supplied subpath, and an authenticated mobile Home with
its New-issue entry. It emits only a small JSON result and never includes the operator
secret in success or failure output.
Token and passkey sign-in failures are scoped to a hashed canonical client address and
persisted across workers and restarts. Public passkey option issuance has a separate
fixed-window admission budget in the same ledger, and live challenges are bounded per
+180
View File
@@ -0,0 +1,180 @@
(function (root) {
'use strict';
function escapeText(value) {
return String(value || '')
.replace(/\\/g, '\\\\')
.replace(/\r?\n/g, '\\n')
.replace(/,/g, '\\,')
.replace(/;/g, '\\;');
}
function calendarDay(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
return match ? match.slice(1).join('') : '';
}
function nextDay(day) {
const date = new Date(Date.UTC(
Number(day.slice(0, 4)), Number(day.slice(4, 6)) - 1, Number(day.slice(6, 8)) + 1
));
return date.toISOString().slice(0, 10).replace(/-/g, '');
}
function uid(item) {
const repository = String(item.repository || '').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
return `issue-${Number(item.number)}@${repository}`;
}
function foldLine(line) {
const encoder = new TextEncoder();
const chunks = [];
let chunk = '';
let bytes = 0;
let limit = 75;
for (const character of String(line)) {
const width = encoder.encode(character).length;
if (chunk && bytes + width > limit) {
chunks.push(chunk);
chunk = character;
bytes = width;
limit = 74;
} else {
chunk += character;
bytes += width;
}
}
chunks.push(chunk);
return chunks.join('\r\n ');
}
function serializeAgendaCalendar(items, { generatedOn } = {}) {
const stampDay = String(generatedOn || new Date().toISOString().slice(0, 10).replace(/-/g, ''));
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Stackchain//Agenda Snapshot//EN',
'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:Stackchain Agenda'];
(items || []).forEach(item => {
const day = calendarDay(item.due_date);
if (!day || !Number.isInteger(Number(item.number)) || !item.repository) return;
lines.push(
'BEGIN:VEVENT',
`UID:${uid(item)}`,
`DTSTAMP:${stampDay}T000000Z`,
`DTSTART;VALUE=DATE:${day}`,
`DTEND;VALUE=DATE:${nextDay(day)}`,
`SUMMARY:${escapeText(item.title)}`,
`DESCRIPTION:${escapeText(`${item.repository}#${item.number} · Stackchain Agenda snapshot`)}`,
`URL:${String(item.url || '')}`,
'TRANSP:TRANSPARENT',
'END:VEVENT',
);
});
lines.push('END:VCALENDAR');
return lines.map(foldLine).join('\r\n') + '\r\n';
}
async function deliverCalendarSnapshot({
text,
filename,
navigator,
document,
urlApi,
FileCtor,
}) {
const file = new FileCtor([text], filename, { type: 'text/calendar;charset=utf-8' });
const sharePayload = { files: [file], title: 'Stackchain Agenda', text: 'Agenda calendar snapshot' };
if (typeof navigator?.share === 'function' && typeof navigator?.canShare === 'function' &&
navigator.canShare(sharePayload)) {
await navigator.share(sharePayload);
return 'shared';
}
const href = urlApi.createObjectURL(file);
try {
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = filename;
anchor.click();
} finally {
urlApi.revokeObjectURL(href);
}
return 'downloaded';
}
function mountAgendaCalendarExport({
qs,
getItems,
escapeHtml,
onDone,
windowObject = root,
navigatorObject = root.navigator,
documentObject = root.document,
urlApi = root.URL,
FileCtor = root.File,
}) {
const sheet = qs('#agenda-export-sheet');
let items = [];
let scrollY = 0;
let trigger = null;
const selectedItems = () => Array.from(qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]'))
.filter(checkbox => checkbox.checked)
.map(checkbox => items[Number(checkbox.value)])
.filter(Boolean);
const updateSelection = () => {
const selected = selectedItems();
qs('#share-agenda-export').disabled = selected.length === 0;
qs('#agenda-export-status').textContent = selected.length + ' of ' + items.length +
(items.length === 1 ? ' deadline selected.' : ' deadlines selected.');
};
const close = () => {
sheet.close();
windowObject.scrollTo({ top:scrollY, behavior:'instant' });
trigger?.focus();
};
qs('#open-agenda-export').addEventListener('click', event => {
items = getItems();
scrollY = windowObject.scrollY;
trigger = event.currentTarget;
qs('#agenda-export-items').innerHTML = items.map((item, index) =>
'<label class="agenda-export-item"><input type="checkbox" value="' + index + '" checked> ' +
'<span><strong>' + escapeHtml(item.title) + '</strong><small>' +
escapeHtml(item.repository + '#' + item.number + ' · ' + item.due_date.slice(0, 10)) +
'</small></span></label>'
).join('');
qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]').forEach(checkbox =>
checkbox.addEventListener('change', updateSelection)
);
updateSelection();
sheet.showModal();
qs('#cancel-agenda-export').focus();
});
qs('#cancel-agenda-export').addEventListener('click', close);
sheet.addEventListener('cancel', event => {
event.preventDefault();
close();
});
qs('#share-agenda-export').addEventListener('click', async () => {
const selected = selectedItems();
if (!selected.length) return;
const button = qs('#share-agenda-export');
button.disabled = true;
qs('#agenda-export-status').textContent = 'Preparing calendar snapshot…';
const day = new Date().toISOString().slice(0, 10);
try {
const text = serializeAgendaCalendar(selected, { generatedOn:day.replace(/-/g, '') });
const result = await deliverCalendarSnapshot({
text, filename:'stackchain-agenda-' + day + '.ics',
navigator:navigatorObject, document:documentObject, urlApi, FileCtor,
});
onDone(result);
close();
} catch (error) {
qs('#agenda-export-status').textContent = error?.name === 'AbortError' ?
'Share cancelled. Nothing was exported.' : 'Calendar export failed. Retry without leaving Agenda.';
button.disabled = false;
}
});
}
const api = { calendarDay, deliverCalendarSnapshot, escapeText, foldLine, mountAgendaCalendarExport, serializeAgendaCalendar };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.StackchainAgendaCalendar = api;
})(typeof window !== 'undefined' ? window : globalThis);
+20 -1
View File
@@ -55,6 +55,20 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
});
}
function operationFingerprint(message) {
return JSON.stringify({
kind:String(message.kind || ''), repository:String(message.repository || ''),
number:Number(message.number || 0), notificationId:Number(message.notificationId || 0),
body:String(message.body || ''), targetKind:String(message.targetKind || ''),
decision:String(message.decision || 'comment'), expectedHeadSha:String(message.expectedHeadSha || ''),
comments:Array.isArray(message.comments) ? message.comments : [],
blockerRepository:String(message.blockerRepository || ''), blockerNumber:Number(message.blockerNumber || 0),
present:message.present === true, title:String(message.title || ''),
expectedUpdatedAt:String(message.expectedUpdatedAt || ''),
attachments:messageAttachments(message).map(attachmentMetadata),
});
}
function clearConfirmedReviewState(item) {
if (item.kind !== 'pull-review') return;
try {
@@ -93,7 +107,12 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const items = read();
const requestedOperationId = String(message.operationId || '').slice(0, 128);
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
if (existing) return { ...existing };
if (existing) {
if (operationFingerprint(existing) !== operationFingerprint(message)) {
throw new Error('This operation ID is already bound to a different queued action.');
}
return { ...existing };
}
if (message.kind === 'pull-review') {
const queuedReview = items.find(item => item.kind === 'pull-review' &&
item.repository === String(message.repository || '') &&
+12 -17
View File
@@ -1,28 +1,23 @@
function createConversationActionHydrator({ load, activate }) {
let actions = null;
let pending = null;
const wiredRoots = new WeakSet();
const wired = new WeakSet();
function ensure() {
if (actions) return Promise.resolve(actions);
if (!pending) {
pending = load().then(() => {
actions = activate();
return actions;
}).catch(error => {
pending = null;
throw error;
});
}
async function ensure() {
if (actions) return actions;
if (!pending) pending = load().then(() => actions = activate()).catch(error => {
pending = null;
throw error;
});
return pending;
}
function show({ root, state, paint, wire, retry }) {
if (actions) {
retry.hidden = true;
if (!wiredRoots.has(root)) {
if (!wired.has(root)) {
wire(actions);
wiredRoots.add(root);
wired.add(root);
}
paint(state, actions);
return Promise.resolve(true);
@@ -31,9 +26,9 @@ function createConversationActionHydrator({ load, activate }) {
paint(state, null);
retry.hidden = true;
return ensure().then(controller => {
if (!wiredRoots.has(root)) {
if (!wired.has(root)) {
wire(controller);
wiredRoots.add(root);
wired.add(root);
}
paint(state, controller);
return true;
@@ -43,7 +38,7 @@ function createConversationActionHydrator({ load, activate }) {
});
}
return { show, ready: () => Boolean(actions) };
return {show,get:ensure};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationActionHydrator;
+19 -12
View File
@@ -35,12 +35,11 @@
await current.pending;
const attachments = await store.load(target);
if (generation !== current.generation) return false;
current.controller.clear();
if (attachments?.length) {
current.restoring = true;
try { current.controller.restore(attachments); }
finally { current.restoring = false; }
}
current.restoring = true;
try {
current.controller.clear();
if (attachments?.length) current.controller.restore(attachments);
} finally { current.restoring = false; }
return Boolean(attachments?.length);
}
@@ -51,9 +50,13 @@
try { await checkpoint(kind); }
catch (_error) { return false; }
if (generation === current.generation) {
current.controller.clear();
current.target = null;
current.generation += 1;
current.restoring = true;
try { current.controller.clear(); }
finally {
current.restoring = false;
current.target = null;
current.generation += 1;
}
}
return true;
}
@@ -64,9 +67,13 @@
const target = { ...current.target };
await current.pending;
await store.remove(target);
current.controller.clear();
current.target = null;
current.generation += 1;
current.restoring = true;
try { current.controller.clear(); }
finally {
current.restoring = false;
current.target = null;
current.generation += 1;
}
return true;
}
+12 -7
View File
@@ -73,9 +73,10 @@
}
return function createConversationReplyDraftStore({
indexedDB = globalThis.indexedDB, transaction, getOwnerLogin = () => '',
indexedDB = globalThis.indexedDB, transaction, getOwnerLogin = () => '', scope = 'conversation',
} = {}) {
const transact = transaction || createTransaction(indexedDB);
const draftScope = String(scope || 'conversation').trim().slice(0, 64) || 'conversation';
function identity(target) {
const ownerLogin = String(getOwnerLogin() || '').trim();
@@ -83,26 +84,30 @@
const normalized = normalizedTarget(target);
const targetKey = normalized.kind === 'update' ? normalized.notificationId :
normalized.repository + ':' + normalized.number;
const identityParts = draftScope === 'conversation' ? [ownerLogin, normalized.kind, targetKey] :
[ownerLogin, draftScope, normalized.kind, targetKey];
return {
ownerLogin, target:normalized,
id:[ownerLogin, normalized.kind, targetKey].map(value => encodeURIComponent(String(value))).join(':'),
ownerLogin, scope:draftScope, target:normalized,
id:identityParts.map(value => encodeURIComponent(String(value))).join(':'),
};
}
async function save(target, values) {
if (!transact) throw new Error('Photo draft storage needs IndexedDB. Your current photos are still here.');
const { id, ownerLogin, target:normalized } = identity(target);
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
if (!list.length) { await transact('delete', id); return null; }
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
await transact('put', id, { id, version:1, ownerLogin, scope:recordScope, ...normalized, attachments:list });
return list;
}
async function load(target) {
if (!transact) return null;
const { id, ownerLogin, target:normalized } = identity(target);
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
const record = await transact('get', id);
const same = record?.version === 1 && record.ownerLogin === ownerLogin &&
const scopeMatches = recordScope === 'conversation' ? (!record?.scope || record.scope === recordScope) :
record?.scope === recordScope;
const same = record?.version === 1 && record.ownerLogin === ownerLogin && scopeMatches &&
record.kind === normalized.kind && (normalized.kind === 'update' ?
Number(record.notificationId) === normalized.notificationId :
record.repository === normalized.repository && Number(record.number) === normalized.number);
+54
View File
@@ -8,6 +8,59 @@ function newIssueOperationId() {
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
}
function createIssueModalLifecycle({ root, background = [], document, requestClose }) {
let launcher = null;
let active = false;
let backgroundState = [];
const focusableSelector = [
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
'select:not([disabled])', 'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
const focusable = () => Array.from(root.querySelectorAll(focusableSelector)).filter(element =>
!element.hidden && !element.disabled && element.getClientRects().length > 0
);
function keydown(event) {
if (!active) return;
if (event.key === 'Escape') {
event.preventDefault();
requestClose();
return;
}
if (event.key !== 'Tab') return;
const controls = focusable();
if (!controls.length) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
root.addEventListener('keydown', keydown);
return {
open(trigger = null) {
launcher = trigger?.isConnected ? trigger : document.activeElement;
backgroundState = background.map(element => [element, element.inert]);
backgroundState.forEach(([element]) => { element.inert = true; });
active = true;
(root.querySelector('#cancel-new-issue') || focusable()[0])?.focus();
},
close({ restore = true } = {}) {
active = false;
backgroundState.forEach(([element, inert]) => { element.inert = inert; });
backgroundState = [];
const panel = root.querySelector('.create-issue-panel');
if (panel) panel.scrollTop = 0;
if (restore && launcher?.isConnected) launcher.focus();
launcher = null;
},
};
}
function normalizeSharedContent(value = {}) {
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
const text = String(value.text || '').trim().slice(0, 9500);
@@ -840,5 +893,6 @@ createIssueCapture.buildRelatedDraft = buildRelatedDraft;
createIssueCapture.relatedChecklistDraft = relatedChecklistDraft;
createIssueCapture.linkChecklistTask = linkChecklistTask;
createIssueCapture.createChecklistPromotion = createChecklistPromotion;
createIssueCapture.createModalLifecycle = createIssueModalLifecycle;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
+132 -11
View File
@@ -14,6 +14,18 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
.issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.issue-filing-receipt[hidden] { display:none; }
.release-receipt-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
.release-receipt-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.release-receipt-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
.release-receipt-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.release-receipt-launcher { width:100%; min-height:44px; border-color:#4ade80; }
.release-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.release-receipt-panel button, .release-receipt-panel .button-link { box-sizing:border-box; display:flex; align-items:center; justify-content:center; min-width:0; min-height:44px; width:100%; text-align:center; }
.release-watchlist { display:grid; gap:8px; margin-top:14px; }
.release-watchlist-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:10px; border:1px solid #334155; border-radius:10px; }
.release-watchlist-item > div { display:grid; min-width:0; gap:3px; overflow-wrap:anywhere; }
.release-watchlist-item > button { width:auto; min-width:88px; }
@media (max-width:359px) { .release-receipt-actions { grid-template-columns:1fr; } }
.issue-filing-receipt-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 #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
.issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; }
.issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
@@ -28,9 +40,11 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
#open-insights { display:none; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
.notification-undo[hidden] { display:none; }
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
.notification-undo { position:fixed; }
.notification-undo button { min-height:44px; }
.notification-undo, .today-completion-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
.notification-undo[hidden], .today-completion-undo[hidden] { display:none; }
.notification-undo button, .today-completion-undo button { min-height:44px; min-width:64px; flex:none; }
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.draft-capacity-sheet[hidden] { display:none; }
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
@@ -135,6 +149,9 @@ textarea { resize: vertical; min-height: 120px; }
#cmd-palette.open { display: block; }
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
.cmd-palette-header-actions { display:flex; gap:8px; }
.search-today-interruption { display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.search-today-interruption[hidden] { display:none; }
.search-today-interruption button { min-height:44px; flex:0 0 auto; }
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
.cmd-search-scope label { font-size:12px; color:#cbd5e1; }
@@ -252,6 +269,59 @@ textarea { resize: vertical; min-height: 120px; }
.today-interruption-panel h2 { margin:.25rem 0; overflow-wrap:anywhere; }
.today-interruption-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.today-interruption-actions button { min-height:44px; width:100%; }
.today-break-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
.today-break-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-break-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(620px,100%); max-height:100dvh; margin:auto; 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; }
.today-break-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-break-panel h2, .today-break-panel p { margin-top:0; }
.today-break-panel header button, .today-break-actions button, .today-break-custom button { min-height:44px; }
.today-break-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:16px 0; }
.today-break-actions button { min-width:0; width:100%; }
.today-break-custom { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; }
.today-break-custom label, .today-break-custom p { grid-column:1 / -1; }
.today-break-custom input { box-sizing:border-box; width:100%; min-height:44px; }
.today-break-status { position:fixed; z-index:46; left:12px; right:12px; bottom:calc(224px + env(safe-area-inset-bottom)); box-sizing:border-box; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#102641; }
.today-break-status:has(> span[hidden]) { display:none; }
.today-break-status button { min-height:44px; min-width:112px; }
@media(max-width:359px) { .today-break-actions { grid-template-columns:1fr; } }
.today-progress-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
.today-progress-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-progress-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(620px,100%); max-height:100dvh; margin:auto; 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; }
.today-progress-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-progress-panel h2, .today-progress-panel p { margin-top:0; }
.today-progress-panel header button, .today-progress-actions button { min-height:44px; }
.today-progress-panel textarea { box-sizing:border-box; width:100%; min-height:120px; resize:vertical; }
.today-progress-activity { display:grid; gap:8px; min-width:0; margin:0 0 14px; padding:12px; border:1px solid #294767; border-radius:12px; background:#0d1b2d; }
.today-progress-activity-heading { display:flex; justify-content:space-between; gap:8px; }
.today-progress-activity-list { display:grid; gap:8px; max-height:32dvh; overflow:auto; overflow-x:hidden; overflow-wrap:anywhere; }
.today-progress-activity-list:empty::before { content:'No recent messages yet.'; color:#9ca3af; font-size:.875rem; }
.today-progress-activity-item { min-width:0; padding:8px; border-radius:8px; background:#101f34; }
.today-progress-activity-item .markdown-content { overflow-wrap:anywhere; }
.today-progress-activity-actions { display:flex; gap:8px; }
.today-progress-activity-actions button { min-height:44px; }
.today-progress-activity-status { margin:0; }
.today-progress-evidence { display:grid; gap:8px; min-width:0; margin-top:12px; }
.today-progress-evidence .issue-attachment-preview { width:100%; box-sizing:border-box; }
.today-progress-evidence .issue-evidence-note textarea { min-height:72px; }
.today-progress-blocker { display:grid; gap:8px; margin-top:14px; padding:12px; border:1px solid #875f2a; border-radius:12px; background:#21180d; }
.today-progress-blocker input, .today-progress-blocker button { box-sizing:border-box; width:100%; min-height:44px; }
.today-blocker-presets { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.today-blocker-presets button { min-width:0; padding-inline:6px; }
.today-progress-blocker p { margin:0; }
.today-progress-blocker .today-progress-blocker-recovery { padding:8px; border-radius:8px; background:#3a270d; color:#fde68a; font-weight:700; }
.today-progress-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; }
.today-progress-actions button { width:100%; }
.today-progress-panel .voice-conversation { margin-top:10px; }
.today-progress-panel .voice-conversation-controls button,
.today-progress-panel .voice-conversation-review-actions button { min-height:44px; }
.today-progress-panel.blocker-mode .today-progress-activity,
.today-progress-panel.blocker-mode .voice-conversation,
.today-progress-panel.blocker-mode .today-progress-evidence,
.today-progress-panel.blocker-mode .today-progress-update-help,
.today-progress-panel.blocker-mode .today-progress-actions { display:none; }
.today-progress-panel.blocker-mode textarea { min-height:112px; }
@media(max-width:359px) { .today-blocker-presets { grid-template-columns:1fr; } }
@media(max-width:359px) { .today-progress-actions { grid-template-columns:1fr; } }
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-recap-sheet[hidden] { display:none; }
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
@@ -284,6 +354,21 @@ textarea { resize: vertical; min-height: 120px; }
.today-wrap-up-item input { width:24px; min-height:24px; }
.today-wrap-up-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-wrap-up-actions button { min-height:44px; width:100%; }
.today-summary-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-summary-sheet[hidden] { display:none; }
.today-summary-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-summary-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-summary-header h2 { margin:.2rem 0; }
.today-summary-header button { min-height:44px; min-width:44px; }
.today-summary-items { display:grid; gap:8px; }
.today-summary-item, .today-summary-option { display:flex; align-items:center; gap:10px; min-height:44px; padding:10px; border:1px solid #2a496e; border-radius:10px; background:#10233d; overflow-wrap:anywhere; }
.today-summary-item input, .today-summary-option input { width:24px; min-height:24px; flex:0 0 auto; }
.today-summary-item span { display:block; min-width:0; }
.today-summary-note { display:grid; gap:6px; margin:14px 0; font-weight:700; }
.today-summary-note textarea { box-sizing:border-box; width:100%; min-height:88px; resize:vertical; }
.today-summary-preview { min-height:72px; white-space:pre-wrap; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#07101e; color:#e8f1ff; }
.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-summary-actions button { min-height:44px; width:100%; }
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
@@ -355,17 +440,36 @@ textarea { resize: vertical; min-height: 120px; }
.draft-filing-session[hidden] { display:none; }
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.deadline-snooze { box-sizing:border-box; min-width:0; margin:10px 0; padding:12px; border:1px solid #a16207; border-radius:12px; background:#2a1d08; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.deadline-snooze p { margin:4px 0 0; overflow-wrap:anywhere; }
.deadline-snooze button { min-height:44px; flex:0 0 auto; }
.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 label { grid-column:1/-1; }
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
.agenda-export { 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; }
.agenda-export p { margin:4px 0 0; }
.agenda-export button { min-height:44px; flex:0 0 auto; }
.agenda-export-sheet { box-sizing:border-box; width:min(560px,100%); max-width:none; max-height:none; height:100dvh; margin:0 0 0 auto; padding:0; border:0; color:var(--text); background:#0b1526; }
.agenda-export-sheet::backdrop { background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
.agenda-export-panel { box-sizing:border-box; min-height:100%; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow:auto; overflow-x:hidden; }
.agenda-export-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.agenda-export-panel h2, .agenda-export-panel header p { margin:0; }
#agenda-export-items { min-width:0; display:grid; gap:8px; margin:0; padding:0; border:0; }
.agenda-export-item { min-width:0; min-height:44px; display:flex; align-items:center; gap:10px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; }
.agenda-export-item input { width:22px; height:22px; flex:0 0 auto; }
.agenda-export-item span, .agenda-export-item strong, .agenda-export-item small { min-width:0; display:block; overflow-wrap:anywhere; }
#cancel-agenda-export, #share-agenda-export { min-height:44px; }
#share-agenda-export { position:sticky; bottom:0; width:100%; margin-top:auto; }
@media(max-width:430px) { .agenda-export { align-items:stretch; flex-direction:column; } .agenda-export button { width:100%; } .agenda-export-panel { padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); } }
.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%; } }
@media(max-width:430px) { .deadline-snooze { align-items:stretch; flex-direction:column; } .deadline-snooze 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); }
@@ -786,6 +890,8 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-sheet.open { display:flex; }
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.today-capture-interruption { display:grid; gap:2px; padding:10px 12px; border:1px solid #3f6f9f; border-radius:10px; background:#10243b; }
.today-capture-interruption[hidden] { display:none; }
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
.mobile-create-issue-nav { display:none; }
.create-issue-form { display:grid; gap:12px; }
@@ -926,6 +1032,18 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-today-hud { display:none; }
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
.mobile-today-actions { box-sizing:border-box; width:min(520px,100%); max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-today-actions::backdrop { background:rgba(3,9,18,.76); }
.mobile-today-actions-panel { box-sizing:border-box; display:grid; gap:12px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-today-actions-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.mobile-today-actions-panel h2 { margin:0; }
.mobile-today-navigation, .mobile-today-secondary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.mobile-today-actions-panel button { min-width:0; min-height:44px; }
.mobile-today-secondary-actions [data-mobile-today-end] { grid-column:1 / -1; border-color:#b45309; }
.today-session-handoff { position:fixed; left:12px; right:12px; bottom:calc(68px + env(safe-area-inset-bottom)); z-index:45; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:12px; border:1px solid #60a5fa; border-radius:12px; background:rgba(16,38,65,.98); box-shadow:0 8px 28px rgba(0,0,0,.35); }
.today-session-handoff[hidden] { display:none; }
.today-session-handoff button { min-height:44px; }
@media(max-width:420px) { .today-session-handoff { grid-template-columns:1fr; } .today-session-handoff button { width:100%; } }
.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; }
@@ -933,9 +1051,11 @@ textarea { resize: vertical; min-height: 120px; }
.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; }
.today-detour-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
.today-detour-interruption button { min-height:44px; flex:0 0 auto; }
@media (max-width: 600px) {
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
body.mobile-today-active { padding-bottom:calc(166px + env(safe-area-inset-bottom)); }
body.mobile-today-active { padding-bottom:calc(var(--mobile-today-clearance, 166px) + env(safe-area-inset-bottom)); }
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
.app-brand .muted, #clock { display:none; }
.app-live-status { margin-left:auto; }
@@ -1060,13 +1180,14 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-update-outcome { margin-top:12px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
.mobile-update-outcome p { margin:0 0 10px; }
.mobile-update-outcome button { width:100%; min-height:44px; }
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mobile-today-hud [data-mobile-today-complete] { grid-area:complete; }
.mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; }
.mobile-today-hud button { min-height:44px; max-width:100%; }
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
.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:repeat(6,minmax(0,1fr)); gap:4px; 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-column:1 / 4; 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-column:4 / 7; align-self:center; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mobile-today-hud [data-mobile-today-complete] { grid-column:1 / 3; }
.mobile-today-hud [data-mobile-today-toggle] { grid-column:3 / 5; }
.mobile-today-hud [data-mobile-today-more] { grid-column:5 / 7; }
.mobile-today-hud button { min-width:0; min-height:44px; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-inline:6px; }
.today-completion-undo { bottom:calc(var(--mobile-today-clearance, 166px) + 8px + env(safe-area-inset-bottom)); }
}
@media (min-width:701px) { .update-gesture-status { display:none; } }
@media (prefers-reduced-motion: reduce) {
+265 -60
View File
@@ -97,6 +97,7 @@
(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result);
}
let mobileQueueCounts = {};
let mobilePreparationItems = {};
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
getItems: () => draftInbox.partition(lastDrafts).deliveries,
getIndex: item => lastDrafts.indexOf(item),
@@ -131,21 +132,31 @@
getCounts: () => mobileQueueCounts,
openFindWork: () => qs('#find-work').click(),
});
function openMobileStartDay() {
const state = mobileStartDay.state();
mobileStartDay.render();
qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';
const sheet = qs('#mobile-queue-sheet');
if (!sheet.open) qs('#mobile-queue-sheet').showModal();
qs('#mobile-start-day-action').focus();
}
const mobileWorkEntry = createMobileWorkEntry({
isTodayActive: () => workSession.checkpointed(),
isTodayResumable: () => workSession.resumable(),
getTodayCount: () => todayMyWork.length,
getEligibleCount: () => activeMyWork.length,
getPreparationState: () => mobileStartDay.state(),
queueLauncher: mobileQueueLauncher,
continueToday: continueTodaySession,
resumeToday: resumeTodaySession,
startToday: startTodaySession,
planToday: () => openPlanToday(mobileTaskButtons.work),
prepareToday: openMobileStartDay,
findWork: () => qs('#find-work').click(),
});
const mobileStartDay = createMobileStartDay({
getCounts: () => mobileQueueCounts,
getPhaseItems: () => mobilePreparationItems,
getLogin: () => confirmedOwnerLogin,
openQueue: name => {
const sheet = qs('#mobile-queue-sheet');
@@ -209,13 +220,13 @@
deadlineBadge: qs('#mobile-deadline-count'),
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() :
name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name),
detour:() => timerView,
overlays: mobileTaskOverlays,
actions: {
work: () => mobileWorkEntry.open(),
find: () => qs('#find-work').click(),
new: () => qs('#new-issue').click(),
search: () => qs('#open-palette').click(),
queues: () => {},
},
observe(callback, overlays) {
const observer = new MutationObserver(callback);
@@ -224,20 +235,33 @@
},
});
mobileTaskDock.start();
const mobileTodayActions = qs('#mobile-today-actions');
createMobileTodayCommandBar({
more:qs('[data-mobile-today-more]'),
sheet:mobileTodayActions,
close:qs('[data-mobile-today-actions-close]'),
firstAction:qs('[data-work-session-previous]', mobileTodayActions),
actionButtons:Array.from(mobileTodayActions.querySelectorAll(
'[data-work-session-previous], [data-work-session-next], [data-mobile-today-update], [data-mobile-today-blocked], [data-today-break-open], [data-work-session-adjust-plan]'
)),
endAction:qs('[data-mobile-today-end]', mobileTodayActions),
existingEndControl:qs('#end-today-session'),
hud:qs('[data-mobile-today-hud]'),
dock:qs('#mobile-task-dock'),
style:document.documentElement.style,
});
const mobileInsights = createMobileInsights({
root: qs('#insights-sheet'),
launcher: qs('#open-insights'),
closeButton: qs('#close-insights'),
dock: qs('#mobile-task-dock'),
hud: qs('[data-mobile-today-hud]'),
backgrounds: [qs('header'), qs('#my-work')],
menu: qs('.app-menu'),
history: window.history,
location: window.location,
eventTarget: window,
mediaQuery: window.matchMedia('(max-width: 600px)'),
detour:() => timerView,
});
mobileInsights.start();
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
let liveMode = true;
@@ -310,6 +334,24 @@
let confirmedOwnerLogin = '';
let planningOwnerLogin = '';
let activeFlushLogin = '';
let releaseReceipt = null;
function attachReleaseReceipt() {
if (releaseReceipt) return releaseReceipt;
releaseReceipt = createReleaseReceipt({
storage:localStorage, getLogin:()=>confirmedOwnerLogin, fetchJson:fetchReviewJson,
launcher:qs('#release-receipt-launcher'), dialog:qs('#release-receipt-sheet'),
statusNode:qs('#release-receipt-status'), checksNode:qs('#release-receipt-checks'),
releaseNode:qs('#release-receipt-link'),
listNode:qs('#release-watchlist'),
});
releaseReceipt.bind();
qs('#close-release-receipt').addEventListener('click', () => qs('#release-receipt-sheet').close());
qs('#refresh-release-receipt').addEventListener('click', () => releaseReceipt.refresh().catch(error => {
qs('#release-receipt-status').textContent = error.message + ' Retry when connected.';
}));
qs('#dismiss-release-receipt').addEventListener('click', () => releaseReceipt.dismiss());
return releaseReceipt;
}
const completedFiledReview = createCompletedFiledReview({
storage: localStorage,
getLogin() { return planningOwnerLogin; },
@@ -365,11 +407,12 @@
onStatus: (state, detail = {}) => {
const status = qs('#today-sync-status');
status.textContent = state === 'saved' ? 'Today saved to account.' :
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Today saved on this device · sync pending.' :
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
(state === 'expired' ? `Today edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
'Today sync unavailable · changes stay on this device.'))));
(state === 'recovered' ? `Today queue recovered · discarded ${detail.discarded} unreadable device record${detail.discarded === 1 ? '' : 's'}.` :
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Today saved on this device · sync pending.' :
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
(state === 'expired' ? `Today edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
'Today sync unavailable · changes stay on this device.')))));
},
});
todaySync.startLifecycle({ window, document });
@@ -485,22 +528,19 @@
{headers:{Accept:'application/json'}},
);
}
const issueMentions = createMentionComposer({
textarea:qs('#issue-comment'), listbox:qs('#issue-comment-mentions'),
status:qs('#issue-comment-mention-status'), getRepository:()=>selectedIssue?.repository,
loadCandidates:loadMentionCandidates,
const [issueMentions, pullMentions, updateMentions, todayProgressMentions] = [
['issue-comment',()=>selectedIssue?.repository],
['pull-comment',()=>selectedPull?.repository],
['update-reply',()=>selectedUpdate?.repository],
['today-progress-body',()=>currentTodayProgressTarget()?.repository],
].map(([id,getRepository]) => {
const controller = createMentionComposer({
textarea:qs('#'+id), listbox:qs('#'+id+'-mentions'),
status:qs('#'+id+'-mention-status'), getRepository, loadCandidates:loadMentionCandidates,
});
controller.start();
return controller;
});
const pullMentions = createMentionComposer({
textarea:qs('#pull-comment'), listbox:qs('#pull-comment-mentions'),
status:qs('#pull-comment-mention-status'), getRepository:()=>selectedPull?.repository,
loadCandidates:loadMentionCandidates,
});
const updateMentions = createMentionComposer({
textarea:qs('#update-reply'), listbox:qs('#update-reply-mentions'),
status:qs('#update-reply-mention-status'), getRepository:()=>selectedUpdate?.repository,
loadCandidates:loadMentionCandidates,
});
[issueMentions, pullMentions, updateMentions].forEach(controller => controller.start());
async function api(url) {
const response = await fetch(url, { headers: { Accept: 'application/json' } });
@@ -575,6 +615,16 @@
window.location.hash = '#/my-work/agenda';
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
});
StackchainAgendaCalendar.mountAgendaCalendarExport({
qs,
getItems:() => agendaMyWork(activeMyWork),
escapeHtml,
onDone:result => {
qs('#my-work-action-status').textContent = result === 'shared' ?
'Agenda calendar snapshot shared.' : 'Agenda calendar snapshot downloaded.';
},
});
let searchReplyAttachmentTarget = null;
let searchReplyRestoreGeneration = 0;
let restoringSearchReplyPhotos = false;
@@ -821,25 +871,21 @@
update:photoDraftLane(updateReplyAttachmentController, '#update-reply-status'),
},
});
const todayProgressPhotos = createTodayProgressPhotos({
qs, document, issueAttachment, fetchJson:fetchReviewJson,
createStore:createConversationReplyDraftStore, createDrafts:createConversationPhotoDrafts,
getLogin:() => confirmedOwnerLogin,
});
const voiceTranscriptStore = createVoiceTranscriptStore();
function mountConversationVoice(kind, draftSelector) {
return createVoiceConversationCapture({
Recognition: window.SpeechRecognition || window.webkitSpeechRecognition,
transcriptStore: voiceTranscriptStore,
getLogin: () => confirmedOwnerLogin,
elements: {
root:qs('#voice-' + kind), start:qs('#start-voice-' + kind),
stop:qs('#stop-voice-' + kind), review:qs('#voice-' + kind + '-review'),
transcript:qs('#voice-' + kind + '-transcript'), append:qs('#append-voice-' + kind),
replace:qs('#replace-with-voice-' + kind), discard:qs('#discard-voice-' + kind),
status:qs('#voice-' + kind + '-status'), draft:qs(draftSelector),
},
});
}
const issueVoiceReply = mountConversationVoice('issue-comment', '#issue-comment');
const pullVoiceReply = mountConversationVoice('pull-comment', '#pull-comment');
const updateVoiceReply = mountConversationVoice('update-reply', '#update-reply');
const searchVoiceReply = mountConversationVoice('search-reply', '#search-preview-reply');
const voiceOptions = {
qs, Recognition:window.SpeechRecognition || window.webkitSpeechRecognition,
transcriptStore:voiceTranscriptStore, getLogin:() => confirmedOwnerLogin,
};
const [issueVoiceReply, pullVoiceReply, updateVoiceReply, searchVoiceReply, todayProgressVoice] = [
['issue-comment', '#issue-comment'], ['pull-comment', '#pull-comment'],
['update-reply', '#update-reply'], ['search-reply', '#search-preview-reply'],
['today-progress', '#today-progress-body'],
].map(([kind, draftSelector]) => mountVoiceConversation({...voiceOptions, kind, draftSelector}));
function conversationVoiceTarget(kind, item) {
return kind + ':' + String(item?.repository || '').trim().toLowerCase() + '#' + Number(item?.number);
}
@@ -964,6 +1010,11 @@
getOwnerLogin: () => confirmedOwnerLogin,
mergeChecklistConflict: mergeChecklistConflict,
});
const todayProgress = createTodayProgress({
storage:localStorage,
getLogin:() => confirmedOwnerLogin,
admit:message => authoredOutbox.enqueueDurably(message),
});
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
});
@@ -977,7 +1028,7 @@
issueOutbox.reconcileBackground(records);
authoredOutbox.reconcileBackground(records);
notificationReadOutbox.reconcileBackground(records);
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
}).catch(() => {});
}
const shareParams = new URLSearchParams(location.search);
const appShortcut = mobileAppShortcuts.createController({
@@ -999,7 +1050,7 @@
'comment-actions': document.querySelector('meta[name="stackchain-feature-comment-actions"]')?.content || '',
},
});
const commentActionHydrator = createConversationActionHydrator({
const actionHydrator = createConversationActionHydrator({
load: () => commentActionFeatures.load('comment-actions'),
activate: () => {
commentActions = createCommentActions({
@@ -1103,6 +1154,7 @@
return false;
});
}
attachReleaseReceipt();
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
if (!wrapPreference) {
wrapPreference = createReviewController.createWrapPreference({
@@ -1112,6 +1164,14 @@
}
});
}
function restoreReleaseReceipt() {
const account = String(confirmedOwnerLogin || '').trim().toLowerCase();
if (!account) return;
try {
if (!localStorage.getItem('stackchain.release-receipt.v1:' + account)) return;
} catch (_error) { return; }
ensurePullWorkflow().then(() => releaseReceipt.restore()).catch(() => {});
}
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
@@ -1795,9 +1855,17 @@
'Agenda progress could not be saved on this device. You can keep working.';
},
});
let todaySessionSync = null;
let todayLockScreen = null;
const timer = createTodayTimer({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
onChange: snapshot => {
todaySessionSync?.publish(snapshot);
queueMicrotask(() => {
if (todayLockScreen) todayLockScreen.sync(snapshot, workSession.checkpointed());
});
},
});
const timerView = createTodayTimerView({
timer,
@@ -1806,13 +1874,32 @@
formatEstimate: formatPlanMinutes,
getItem: identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity),
onReopen: identity => {
taskOverlayHistory.leave();
selectTodayWork();
workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity));
const item = [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity);
if (!workSession.reopen(item)) workSession.resume(item);
},
onResume: identity => {
selectTodayWork();
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
if (!workSession.resume(item)) resumeTodaySession();
},
onComplete: identity => {
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
return completeTodayItem(item);
},
onCapture:() => openCreateIssueSheet(),
});
window.stackchainTodayTimerView = timerView;
mobileInsights.start();
todaySessionSync = attachTodaySessionHandoff({
fetchJson:fetchReviewJson, storage:localStorage, timer, qs,
items:() => [...todayMyWork, ...activeMyWork],
identity:item => todayWork.identity(item),
selectToday:() => selectTodayWork(),
startItem:item => workSession.start(item),
announce:message => { qs('#my-work-action-status').textContent = message; },
renderTimer:() => timerView.render(),
});
renderAttentionInterruption();
qs('#return-to-today').addEventListener('click', () => {
@@ -1842,18 +1929,21 @@
interruptionPrompt.resolve(button.dataset.todayInterruption)
);
});
const todaySummaryView = setupTodaySummary({ qs, escapeHtml, getLogin:() => planningOwnerLogin });
todaySummaryView.resume();
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
onComplete:() => {
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
todayRecapView['completeReplan']();
refreshMyWorkView();
warmTodayOffline();
todaySummaryView.open(workedItems, tomorrowItems);
},
});
const todayRecapView = setupTodayRecap(
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
() => planningOwnerLogin,
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
actualMinutes => todayWrapUpView.open(todayMyWork, actualMinutes)
(actualMinutes, workedItems) => todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)
);
function updateDetailDeferLabels(active) {
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
@@ -1875,6 +1965,7 @@
qs('#resume-today-session').hidden = active || selectedWorkFilter !== 'today' ||
!todayMyWork.length || !workSession.resumable();
qs('#end-today-session').hidden = !active;
todayProgressView.update();
updateDetailDeferLabels(active);
mobileTaskDock.updateWork(mobileWorkEntry.mode());
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
@@ -1904,6 +1995,7 @@
document.querySelectorAll('[data-work-session-complete]').forEach(button => {
button.hidden = !workSession.checkpointed();
});
todayProgressView.update();
},
onFinish: () => {
updateWorkSessionActions();
@@ -1917,6 +2009,25 @@
todayRecapView.finish(selectedWorkFilter);
},
});
function currentTodayProgressTarget() {
if (!workSession.checkpointed()) return null;
const item = workSession.target('continue');
if (!item || item.is_review || !['issue', 'pull'].includes(item.kind) ||
!item.repository || !Number.isInteger(item.number)) return null;
return {
identity:todayWork.identity(item), kind:item.kind, repository:item.repository,
number:item.number, label:item.key || item.repository + '#' + item.number,
title:item.title || '', item,
};
}
const todayProgressView = createTodayProgressView({
progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos,
voice:todayProgressVoice, mentions:todayProgressMentions,
activity:mountTodayProgressActivity(qs,fetchReviewJson,actionHydrator,renderMarkdown),
announce:message => { qs('#my-work-action-status').textContent = message; },
onAdmitted:() => refreshMyWorkView(),
moveOn:(target,until) => detailDefer.deferUntil(target.item,until),
});
function selectTodayWork() {
qs('[data-work-filter="today"]').click();
}
@@ -2115,6 +2226,47 @@
announce: message => { qs('#my-work-action-status').textContent = message; },
advance: () => runTodayTransition('complete'),
});
todayLockScreen = createTodayLockScreen({
storage:localStorage,
getLogin:() => confirmedOwnerLogin,
serviceWorker:navigator.serviceWorker,
NotificationRef:window.Notification,
control:qs('#today-lock-screen'),
status:qs('#today-lock-screen-status'),
locationRef:window.location,
historyRef:window.history,
onAction:(action, expectedIdentity) => {
const state = timer.snapshot();
if (action === 'complete') {
selectTodayWork();
if (!state.identity || state.identity !== expectedIdentity) {
qs('#my-work-action-status').textContent =
'That lock-screen action is stale. The current Today item was not changed.';
return;
}
const item = todayMyWork.find(entry => todayWork.identity(entry) === expectedIdentity);
if (!item) {
qs('#my-work-action-status').textContent =
'That Today item is no longer available. Nothing was changed.';
return;
}
workSession.reopen(item);
completeTodayItem(item);
timerView.render();
return;
}
if (!state.identity || (action === 'pause' && !state.running) ||
(action === 'resume' && state.running)) return;
const changed = action === 'pause' ? timer.pause() : timer.resume();
if (changed === false) return;
selectTodayWork();
const item = todayMyWork.find(entry => todayWork.identity(entry) === state.identity);
if (item) workSession.reopen(item);
timerView.render();
},
});
await todayLockScreen.sync(timer.snapshot(), workSession.checkpointed());
await todayLockScreen.consumeLaunchAction();
async function completeOwnershipExitToday(item) {
if (!item || !todayWork.remove(item)) return null;
todaySync.enqueue('remove', todayWork.identity(item));
@@ -3042,6 +3194,13 @@
counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
counts.delivery = draftInbox.partition(lastDrafts).actionable;
mobilePreparationItems = {
delivery:draftInbox.partition(lastDrafts).deliveries,
agenda:agendaMyWork(activeMyWork),
attention:activeMyWork.filter(item => item.needs_attention),
update:activeMyWork.filter(item => item.has_update),
filed:activeMyWork.filter(item => item.is_filed),
};
if (!launchFilterResolved) {
selectedWorkFilter = mobileLaunch.chooseFilter({
saved: savedWorkFilter, today: counts.today, attention: counts.attention,
@@ -3394,6 +3553,14 @@
qs('#start-agenda-replan').textContent = 'Replan overdue (' + overdue.length + ')';
qs('#agenda-replan-controls').hidden = true;
}
const agendaExport = qs('#agenda-export');
const agendaExportButton = qs('#open-agenda-export');
agendaExport.hidden = selectedWorkFilter !== 'agenda';
agendaExportButton.disabled = queueItems.length === 0;
qs('#agenda-export-empty').textContent = queueItems.length ?
'Review ' + queueItems.length + (queueItems.length === 1 ? ' deadline' : ' deadlines') +
' before sharing a calendar snapshot.' :
'No deadlines are available to export from this Agenda.';
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
const visible = findQueueItems(queueItems, queueFindQuery);
const emptyWorkStart = qs('#empty-work-start');
@@ -3768,7 +3935,7 @@
function showConversationWithActions(kind, state) {
const surface = conversationActionSurfaces[kind];
latestConversationStates[kind] = state;
return commentActionHydrator.show({
return actionHydrator.show({
root:qs(surface.selector), state, paint:surface.paint,
retry:qs('#retry-' + kind + '-comment-actions'),
wire:controller => wireConversationActions(kind, controller),
@@ -4610,7 +4777,25 @@
sharedLaunchState = null;
}
let createIssueModalLifecycle = null;
let createIssueLauncher = null;
function issueCaptureModal() {
if (createIssueModalLifecycle) return createIssueModalLifecycle;
const root = qs('#create-issue-sheet');
const background = Array.from(document.body.children).filter(element =>
element !== root && element.tagName !== 'SCRIPT' && element.getAttribute('role') !== 'dialog'
);
createIssueModalLifecycle = createIssueCapture.createModalLifecycle({
root, background, document, requestClose:() => closeCreateIssueSheet(),
});
return createIssueModalLifecycle;
}
async function openCreateIssueSheet(navigate = true) {
const root = qs('#create-issue-sheet');
if (!root.classList.contains('open') && document.activeElement && !root.contains(document.activeElement)) {
createIssueLauncher = document.activeElement;
}
if (!issueCapture && !await ensureIssueCapture()) return;
if (navigate) {
taskOverlayHistory.open('new');
@@ -4669,6 +4854,7 @@
updateIssueCreateActions();
qs('#create-issue-sheet').classList.add('open');
creatingIssue = true;
issueCaptureModal().open(createIssueLauncher);
if (!sharedImageHandled && sharedImageMarker) {
sharedImageHandled = true;
if (createIssueAttachmentController.state()) {
@@ -4725,7 +4911,7 @@
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
if (navigate && taskOverlayHistory.current() === 'new') {
suppressCreateDraftOnHistoryClose = !preserveDraft;
taskOverlayHistory.close();
taskOverlayHistory.leave();
return;
}
qs('#create-issue-sheet').classList.remove('open');
@@ -4734,13 +4920,16 @@
clearTimeout(duplicateCheckTimer);
qs('#create-issue-duplicates').hidden = true;
creatingIssue = false;
issueCaptureModal().close({restore:!followUpSourceUpdate});
createIssueLauncher = null;
if (createAndStartRequested) timerView.transferCapture();
else timerView.finishCapture();
if (followUpSourceUpdate) {
const source = followUpSourceUpdate;
followUpSourceUpdate = null;
notificationReader.open(source.item, source.detail);
return;
}
qs('#new-issue').focus();
}
function applyOutboxResult(result, openCreated = false, startCreated = false) {
@@ -5084,6 +5273,7 @@
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
restoreReleaseReceipt();
updateDeliveryReceiptControls();
interruptionPrompt.restore();
}
@@ -5294,9 +5484,9 @@
if (shareStatus[state.status]) status.textContent = shareStatus[state.status];
else if (state.status === 'reopening') status.textContent = 'Reopening…';
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
else if (state.status === 'claimed') status.textContent = 'Assignment confirmed. Opening My Work…';
else if (detail.claimable) status.textContent = 'This issue is open and unassigned.';
else if (detail.assigned_to_me) status.textContent = 'This item is already in My Work.';
else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…';
else if (detail.claimable) status.textContent = 'Open and unassigned.';
else if (detail.assigned_to_me) status.textContent = 'Already in My Work.';
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
else status.textContent = 'Ready.';
}
@@ -5805,7 +5995,6 @@
event.currentTarget.disabled = false;
}
});
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
qs('#create-update-follow-up').addEventListener('click', async () => {
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
@@ -5846,16 +6035,19 @@
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
const capture = timerView.finishCapture();
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
qs('#create-issue-body').value = '';
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
if (!capture) {
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
}
refreshMyWorkView();
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
requestAnimationFrame(() => {
if (!capture) requestAnimationFrame(() => {
savedCard?.scrollIntoView({block:'nearest'});
savedCard?.focus({preventScroll:true});
});
@@ -6855,7 +7047,8 @@
button.disabled = true;
qs('#pull-sheet-status').textContent = 'Merging pull request…';
try {
await pullController.merge(selectedPull, selectedPullDetail.head_sha);
const mergeResult = await pullController.merge(selectedPull, selectedPullDetail.head_sha);
releaseReceipt.capture(merging, mergeResult);
closePullSheet();
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
@@ -7329,6 +7522,7 @@
if (!saved) return false;
const outage = mode === 'outage';
confirmedOwnerLogin = String(saved.user?.login || '').trim();
restoreReleaseReceipt();
planningOwnerLogin = confirmedOwnerLogin;
interruptionPrompt.restore();
updatePlanningAvailability();
@@ -7734,6 +7928,16 @@
deadlineStatus:qs('#push-deadline-status'),
deadlineHour:qs('#push-deadline-hour'),
deadlineDays:qs('#push-deadline-days'),
deadlineSnooze:qs('#deadline-snooze'),
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
onReviewDeadlines:() => {
window.location.hash = '#/my-work/agenda';
setTimeout(() => {
const protect = qs('#protect-today');
if (!protect.closest('[hidden]')) protect.click();
}, 0);
},
notification:window.Notification,
serviceWorker:navigator.serviceWorker,
fetchJson:fetchReviewJson,
@@ -7774,6 +7978,7 @@
installApp.start();
deviceSetup = createMobileDeviceSetup.mount({
document, installApp, promptStorage:localStorage,
timerView,
offlineAvailable:() => offlineStorageReady,
offlineEnabled:() => offlineWorkStore.enabled(),
enableOffline:() => setOfflineWorkEnabled(true),
+241 -2
View File
@@ -51,6 +51,10 @@
<div><h2>Security Center</h2><p class="small muted">Investigate activity, remove untrusted access, and verify the result.</p></div>
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
</div>
<aside class="today-detour-interruption" id="security-center-today-detour" data-today-detour role="status" aria-live="polite" hidden>
<strong data-today-detour-label>Today paused</strong>
<button id="return-from-security-center" data-return-from-detour type="button">Return to Today</button>
</aside>
<nav class="security-section-nav" aria-label="Security Center sections">
<button id="security-section-activity" type="button">Activity</button>
<button id="security-section-devices" type="button">Devices</button>
@@ -95,6 +99,10 @@
</div>
<button id="close-device-setup" type="button" aria-label="Close device setup">Close</button>
</div>
<aside class="today-detour-interruption" id="device-setup-today-detour" data-today-detour role="status" aria-live="polite" hidden>
<strong data-today-detour-label>Today paused</strong>
<button id="return-from-device-setup" data-return-from-detour type="button">Return to Today</button>
</aside>
<ol class="device-setup-list">
<li class="device-setup-step">
<div><strong>Install Stackchain</strong><p class="small" id="device-setup-install-status" role="status"></p></div>
@@ -182,6 +190,8 @@
<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>
<label class="push-update-control" for="today-lock-screen"><input id="today-lock-screen" type="checkbox" /> Show active Today controls on my lock screen</label>
<span class="small" id="today-lock-screen-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>
@@ -205,6 +215,10 @@
<span><strong>Today paused</strong> while handling Attention</span>
<button id="return-to-today" type="button">Return to Today</button>
</aside>
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
<strong data-today-detour-label>Today paused</strong>
<button data-return-from-detour type="button">Return to Today</button>
</aside>
<section class="device-readiness-card" id="device-readiness-card" aria-labelledby="device-readiness-heading" hidden>
<div>
<strong id="device-readiness-heading">Make this phone work-ready</strong>
@@ -246,6 +260,13 @@
<button id="empty-work-create" type="button">Create an issue</button>
</div>
</section>
<section class="deadline-snooze" id="deadline-snooze" aria-labelledby="deadline-snooze-heading" hidden>
<div>
<strong id="deadline-snooze-heading">Deadline reminders paused</strong>
<p class="small" id="deadline-snooze-status" role="status" aria-live="polite"></p>
</div>
<button id="review-snoozed-deadlines" type="button">Review now</button>
</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>
@@ -263,6 +284,13 @@
</div>
</div>
</section>
<section class="agenda-export" id="agenda-export" aria-labelledby="agenda-export-heading" hidden>
<div>
<strong id="agenda-export-heading">Take deadlines with you</strong>
<p class="small muted" id="agenda-export-empty">Review this Agenda before sharing a calendar snapshot.</p>
</div>
<button id="open-agenda-export" type="button">Export to calendar</button>
</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>
@@ -281,8 +309,10 @@
<div class="small" id="notification-page-status" aria-live="polite"></div>
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
<button class="release-receipt-launcher" id="release-receipt-launcher" type="button" hidden>Merged · tracking release</button>
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
<div class="small" id="today-session-sync-status" role="status" aria-live="polite" hidden></div>
<div class="small" id="later-sync-status" aria-live="polite">Later is saved on this device.</div>
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
<div class="small" id="work-route-share-status" aria-live="polite"></div>
@@ -297,11 +327,38 @@
</div>
</div>
</section>
<dialog class="release-receipt-sheet" id="release-receipt-sheet" aria-labelledby="release-receipt-title">
<section class="release-receipt-panel">
<header><div><p class="small muted">Exact merge evidence</p><h2 id="release-receipt-title">Release watchlist</h2></div><button id="close-release-receipt" type="button">Close</button></header>
<p id="release-receipt-status" role="status" aria-live="polite">Checking the exact merge commit…</p>
<p class="small" id="release-receipt-checks"></p>
<a class="button-link" id="release-receipt-link" href="" hidden>Open release</a>
<div class="release-watchlist" id="release-watchlist" aria-label="Tracked merges"></div>
<div class="release-receipt-actions"><button id="refresh-release-receipt" type="button">Refresh all</button><button id="dismiss-release-receipt" type="button">Clear all</button></div>
</section>
</dialog>
<dialog class="agenda-export-sheet" id="agenda-export-sheet" aria-labelledby="agenda-export-title">
<section class="agenda-export-panel">
<header>
<div><p class="small muted">Calendar handoff</p><h2 id="agenda-export-title">Review Agenda export</h2></div>
<button id="cancel-agenda-export" type="button">Cancel</button>
</header>
<p>This exports a snapshot, not ongoing synchronization. Only checked deadline details leave Stackchain.</p>
<fieldset id="agenda-export-items" aria-describedby="agenda-export-privacy"></fieldset>
<p class="small muted" id="agenda-export-privacy">Your issue titles, repositories, dates, and forge links are included in the calendar file.</p>
<p class="small" id="agenda-export-status" role="status" aria-live="polite"></p>
<button id="share-agenda-export" type="button">Share calendar file</button>
</section>
</dialog>
<section class="insights-shell" id="insights-sheet" aria-labelledby="insights-heading">
<div class="insights-header">
<div><h2 id="insights-heading">Insights</h2><p class="small muted">Context, activity, live signals, and workspace tools.</p></div>
<button id="close-insights" type="button">Close</button>
</div>
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
<strong data-today-detour-label>Today paused</strong>
<button data-return-from-detour type="button">Return to Today</button>
</aside>
<aside class="sidebar">
<details class="panel stack" data-panel-key="context" open>
<summary><h2>Context &amp; view</h2></summary>
@@ -468,6 +525,29 @@
</section>
</div>
<div class="today-summary-sheet" id="today-summary-sheet" role="dialog" aria-modal="true" aria-labelledby="today-summary-title" hidden>
<section class="today-summary-panel">
<div class="today-summary-header">
<div><div class="small">Ready to communicate</div><h2 id="today-summary-title">Share day summary</h2></div>
<button id="close-today-summary" type="button">Not now</button>
</div>
<p class="small muted">Review exactly what will leave this device. Time stays private unless you include it.</p>
<div id="today-summary-status" class="small" role="status" aria-live="polite"></div>
<h3>Worked on</h3>
<div id="today-summary-worked" class="today-summary-items"></div>
<h3>Tomorrow</h3>
<div id="today-summary-tomorrow" class="today-summary-items"></div>
<label class="today-summary-option"><input id="today-summary-include-actuals" type="checkbox"> Include actual time</label>
<label class="today-summary-note">Optional note<textarea id="today-summary-note" maxlength="1000" rows="3"></textarea></label>
<h3>Preview</h3>
<pre id="today-summary-preview" class="today-summary-preview"></pre>
<div class="today-summary-actions">
<button id="share-today-summary" type="button">Share summary</button>
<button id="discard-today-summary" type="button">Discard draft</button>
</div>
</section>
</div>
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
<section class="today-readiness-panel">
<div class="today-readiness-header">
@@ -518,6 +598,10 @@
<button id="close-command-palette" type="button">Close</button>
</div>
</div>
<aside class="search-today-interruption" data-search-today-interruption role="status" aria-live="polite" hidden>
<strong data-search-today-label>Today paused</strong>
<button data-return-from-search type="button">Return to Today</button>
</aside>
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
<fieldset class="cmd-search-scope">
<legend>Filter search results</legend>
@@ -595,6 +679,10 @@
</div>
<button id="close-search-preview" type="button">Back to search</button>
</div>
<aside class="search-today-interruption" data-search-today-interruption role="status" aria-live="polite" hidden>
<strong data-search-today-label>Today paused</strong>
<button data-return-from-search type="button">Return to Today</button>
</aside>
<nav class="mobile-search-preview-nav" aria-label="Search preview sections">
<button type="button" data-search-preview-section="overview">Overview</button>
<button type="button" data-search-preview-section="conversation">Conversation</button>
@@ -898,6 +986,10 @@
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
<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>
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
<strong data-today-detour-label>Today paused</strong>
<button data-return-from-detour type="button">Return to Today</button>
</aside>
<p class="small">Choose available issues, review the batch, then fit it into Today.</p>
<nav class="mobile-find-work-nav" aria-label="Find Work steps">
<button type="button" data-find-work-stage="discover">Discover</button>
@@ -961,6 +1053,10 @@
<h3 id="create-issue-heading">Capture work</h3>
<button id="cancel-new-issue" type="button">Cancel</button>
</div>
<aside class="today-capture-interruption" id="today-capture-interruption" role="status" hidden>
<strong id="today-capture-interruption-label">Today paused</strong>
<span class="small">Capture this thought, then return without counting interruption time.</span>
</aside>
<nav class="mobile-create-issue-nav" aria-label="New issue sections">
<button type="button" data-create-issue-section="describe">Describe</button>
<button type="button" data-create-issue-section="evidence">Evidence</button>
@@ -1305,6 +1401,11 @@
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
</div>
<div class="today-completion-undo" id="today-completion-undo" role="status" aria-live="assertive" hidden>
<span>Removed from Today. Undo?</span>
<button id="undo-today-completion" type="button" aria-label="Undo removing the completed item from Today">Undo</button>
</div>
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
<section class="pull-sheet-panel">
<div class="pull-sheet-header">
@@ -1515,16 +1616,146 @@
</form>
</dialog>
<section class="today-session-handoff" id="today-session-handoff" aria-live="polite" hidden>
<div>
<strong>Today is active on another device</strong>
<div class="small" id="today-session-handoff-summary"></div>
</div>
<button id="continue-today-session" type="button">Continue here</button>
</section>
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
<button data-mobile-today-complete type="button" hidden>Done &amp; next</button>
<div class="small" data-work-session-progress aria-live="polite"></div>
<button data-mobile-today-complete type="button" hidden>Done &amp; next</button>
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
<button data-mobile-today-more type="button" aria-controls="mobile-today-actions" aria-haspopup="dialog">More</button>
</section>
<dialog class="mobile-today-actions" id="mobile-today-actions" aria-modal="true" aria-labelledby="mobile-today-actions-title">
<section class="mobile-today-actions-panel">
<header><h2 id="mobile-today-actions-title">Today session</h2><button data-mobile-today-actions-close type="button">Close</button></header>
<nav class="mobile-today-navigation" aria-label="Choose active Today item">
<button type="button" data-work-session-previous>Previous item</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
<div class="mobile-today-secondary-actions">
<button data-mobile-today-update type="button" hidden>Add update</button>
<button class="mobile-today-blocked" data-mobile-today-blocked type="button" hidden>Report blocker</button>
<button data-today-break-open type="button">Take break</button>
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
<button data-mobile-today-end type="button">End session</button>
</div>
</section>
</dialog>
<dialog class="today-progress-sheet" id="today-progress-sheet" aria-labelledby="today-progress-title">
<section class="today-progress-panel">
<header><div><p class="small muted">Active Today item</p><h2 id="today-progress-title">Add progress update</h2></div><button id="cancel-today-progress" type="button">Cancel</button></header>
<p id="today-progress-target" class="small"></p>
<section class="today-progress-activity" id="today-progress-activity" aria-labelledby="today-progress-activity-title">
<div class="today-progress-activity-heading">
<strong id="today-progress-activity-title">Recent activity</strong>
<span id="today-progress-activity-count" class="small muted"></span>
</div>
<div id="today-progress-activity-list" class="today-progress-activity-list" aria-live="polite"></div>
<p id="today-progress-activity-status" class="small" role="status" aria-live="polite"></p>
<div class="today-progress-activity-actions">
<button id="retry-today-progress-activity" type="button" hidden>Retry activity</button>
<button id="load-older-today-progress-activity" type="button" hidden>Load older</button>
</div>
</section>
<label id="today-progress-body-label" for="today-progress-body">Update</label>
<textarea id="today-progress-body" rows="5" maxlength="2000" placeholder="What changed, what you learned, or what comes next"></textarea>
<div class="mention-options" id="today-progress-body-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="today-progress-body-mention-status" aria-live="polite"></div>
<section class="voice-conversation" id="voice-today-progress" data-draft-label="progress update" aria-label="Dictate Today progress update" hidden>
<div class="voice-conversation-controls">
<button id="start-voice-today-progress" type="button">Dictate update</button>
<button id="stop-voice-today-progress" type="button" hidden>Stop listening</button>
</div>
<div id="voice-today-progress-status" class="small" aria-live="polite"></div>
<div class="voice-conversation-review" id="voice-today-progress-review" hidden>
<label for="voice-today-progress-transcript">Review transcript</label>
<textarea id="voice-today-progress-transcript" maxlength="2000"></textarea>
<div class="voice-conversation-review-actions">
<button id="append-voice-today-progress" type="button">Use transcript</button>
<button id="replace-with-voice-today-progress" type="button" hidden>Replace update</button>
<button id="discard-voice-today-progress" type="button">Discard</button>
</div>
</div>
</section>
<section class="today-progress-evidence" aria-labelledby="today-progress-evidence-label">
<strong id="today-progress-evidence-label">Photo evidence <span class="small">Optional · Up to 5</span></strong>
<div class="issue-attachment-controls conversation-photo-actions">
<label class="issue-attachment-trigger" for="take-today-progress-photo">Take photo</label>
<input class="visually-hidden" id="take-today-progress-photo" type="file" accept="image/*" capture="environment" />
<label class="issue-attachment-trigger" for="today-progress-attachment">Choose existing</label>
<input class="visually-hidden" id="today-progress-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
</div>
<div class="issue-attachment-preview conversation-evidence-review" id="today-progress-attachment-preview" hidden>
<img id="today-progress-attachment-image" alt="Selected progress photo preview" />
<span class="small" id="today-progress-attachment-meta"></span>
<div class="issue-evidence-tray" id="today-progress-attachment-tray" role="toolbar" aria-label="Progress photos" hidden></div>
<label class="issue-evidence-note" for="today-progress-attachment-note">
<span id="today-progress-attachment-note-label">Photo caption (optional)</span>
<textarea id="today-progress-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
<button id="move-today-progress-attachment-earlier" type="button">Move earlier</button>
<button id="edit-today-progress-attachment" type="button">Edit photo</button>
<button id="remove-today-progress-attachment" type="button">Remove selected</button>
<button id="move-today-progress-attachment-later" type="button">Move later</button>
</div>
</div>
</section>
<p class="small muted today-progress-update-help">Save privately on this device, or post to the exact Gitea item. Today keeps running either way.</p>
<section class="today-progress-blocker" aria-labelledby="today-progress-blocker-title">
<strong id="today-progress-blocker-title">Blocked?</strong>
<p id="today-progress-blocker-recovery" class="today-progress-blocker-recovery small" role="status" hidden></p>
<label for="today-blocker-return-at">Return this item to Later at</label>
<input id="today-blocker-return-at" type="datetime-local" />
<div class="today-blocker-presets" aria-label="Return time choices">
<button data-today-blocker-preset="later-today" type="button">Later today</button>
<button data-today-blocker-preset="tomorrow" type="button">Tomorrow</button>
<button data-today-blocker-custom type="button">Choose time</button>
</div>
<button id="post-today-blocker" type="button">Post blocker &amp; move on</button>
<p class="small muted">Posts this update first, then defers the item and continues Today.</p>
</section>
<p id="today-progress-status" class="small" role="status" aria-live="polite"></p>
<div class="today-progress-actions">
<button id="save-today-progress" type="button">Save to this item</button>
<button id="post-today-progress" type="button">Post now</button>
</div>
</section>
</dialog>
<div class="today-break-status" role="status" aria-live="polite">
<span id="today-break-status" hidden></span>
<button id="resume-today-break" type="button" hidden>Resume Today</button>
</div>
<dialog class="today-break-sheet" id="today-break-sheet" aria-labelledby="today-break-title">
<section class="today-break-panel">
<header><div><p class="small muted">Today timer</p><h2 id="today-break-title">Take a timed break</h2></div><button id="cancel-today-break" type="button">Cancel</button></header>
<p class="small">Your current item stays selected and tracked time remains paused until you explicitly resume.</p>
<div class="today-break-actions" aria-label="Break duration">
<button type="button" data-today-break-minutes="5">5 minutes</button>
<button type="button" data-today-break-minutes="15">15 minutes</button>
<button type="button" data-today-break-minutes="30">30 minutes</button>
</div>
<form id="today-break-custom-form" class="today-break-custom">
<label for="today-break-custom-minutes">Custom minutes</label>
<input id="today-break-custom-minutes" type="number" inputmode="numeric" min="1" max="120" step="1" required />
<button type="submit">Start break</button>
<p id="today-break-error" class="small" role="alert"></p>
</form>
</section>
</dialog>
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
<section class="mobile-queue-panel">
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
<strong data-today-detour-label>Today paused</strong>
<button data-return-from-detour type="button">Return to Today</button>
</aside>
<div id="mobile-update-outcome" class="mobile-update-outcome" role="status" aria-live="polite" hidden>
<p id="mobile-update-outcome-summary"></p>
<button id="review-kept-updates" type="button" hidden>Review kept unread</button>
@@ -1610,14 +1841,20 @@
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/agenda-replan.js"></script>
<script src="static/agenda-calendar.js"></script>
<script src="static/protect-today.js"></script>
<script src="static/notification-undo.js"></script>
<script src="static/card-planning.js"></script>
<script src="static/work-selection.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-timer.js"></script>
<script src="static/today-break.js"></script>
<script src="static/today-progress.js"></script>
<script src="static/today-lock-screen.js"></script>
<script src="static/today-session-sync.js"></script>
<script src="static/today-recap.js"></script>
<script src="static/today-wrap-up.js"></script>
<script src="static/today-summary.js"></script>
<script src="static/today-handoff.js"></script>
<script src="static/today-completion.js"></script>
<script src="static/today-readiness.js"></script>
@@ -1656,10 +1893,12 @@
<script src="static/queue-today.js"></script>
<script src="static/pull-sheet.js"></script>
<script src="static/review-sheet.js"></script>
<script src="static/release-receipt.js"></script>
<script src="static/work-route.js"></script>
<script src="static/task-overlay-history.js"></script>
<script src="static/context-poller.js"></script>
<script src="static/live-data-status.js"></script>
<script src="static/mobile-today-command-bar.js"></script>
<script src="static/mobile-task-dock.js"></script>
<script src="static/mobile-pull-refresh.js"></script>
<script src="static/mobile-work-entry.js"></script>
+11 -1
View File
@@ -11,6 +11,7 @@
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
];
let trigger = options.launcher;
let ownsDetour = false;
function readinessCounts(readiness) {
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
@@ -50,13 +51,18 @@
async function open(event) {
trigger = event?.currentTarget || options.launcher;
if (options.isMobile?.()) {
ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup';
}
await render();
options.sheet.hidden = false;
options.closeButton.focus();
}
function close() {
function close(resume = true) {
options.sheet.hidden = true;
if (resume && ownsDetour) options.timerView?.finishDetour?.();
ownsDetour = false;
trigger?.focus?.();
}
@@ -70,6 +76,7 @@
options.promptCard.hidden = true;
});
options.closeButton.addEventListener('click', close);
options.returnButton?.addEventListener('click', () => close(false));
options.sheet.addEventListener('click', event => {
if (event.target === options.sheet) close();
});
@@ -108,6 +115,9 @@ function mountMobileDeviceSetup(options) {
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
promptStorage:options.promptStorage,
returnButton:qs('#return-from-device-setup'),
timerView:options.timerView,
isMobile:options.isMobile || (() => innerWidth <= 600),
escapeTarget:options.document,
getReadiness:() => ({
install:options.installApp.state(),
+30 -8
View File
@@ -3,8 +3,11 @@
else root.createMobileInsights = factory;
})(typeof self !== 'undefined' ? self : this, function (o) {
const route = '#/insights';
const events = o.eventTarget || self;
const location = o.location || self.location;
let active = false;
let previous = '#/my-work';
let retries = 0;
function attr(element, name, value) {
if (value === null) element.removeAttribute(name);
@@ -12,7 +15,14 @@
}
function render(open, focus = false) {
const wasActive = active;
active = Boolean(open && o.mediaQuery.matches);
const detour = active ? o.detour?.() : null;
const started = detour?.beginDetour('insights');
if (detour && !started && retries++ < 40) setTimeout(sync, 250);
else if (started && retries) { retries = 0; setTimeout(sync, 500); }
else retries = 0;
if (wasActive && !active) o.detour?.()?.finishDetour();
attr(o.root, 'data-mobile-open', active ? 'true' : null);
attr(o.root, 'role', active ? 'dialog' : null);
attr(o.root, 'aria-modal', active ? 'true' : null);
@@ -26,20 +36,21 @@
}
function url(hash) {
return (o.location.pathname || '') + (o.location.search || '') + hash;
return (location.pathname || '') + (location.search || '') + hash;
}
function open() {
if (!o.mediaQuery.matches || active) return false;
previous = o.location.hash && o.location.hash !== route ? o.location.hash : '#/my-work';
if (o.menu) o.menu.open = false;
previous = location.hash && location.hash !== route ? location.hash : '#/my-work';
const menu = o.menu || o.launcher.closest?.('details');
if (menu) menu.open = false;
o.history.pushState({ ...(o.history.state || {}), mobileInsights:true, previousHash:previous }, '', url(route));
render(true);
return true;
}
function sync() {
render(o.location.hash === route, active && o.location.hash !== route);
render(location.hash === route, active && location.hash !== route);
}
function close() {
@@ -55,12 +66,23 @@
return true;
}
function returnToToday() {
if (!active) return false;
const state = { ...(o.history.state || {}) };
delete state.mobileInsights;
delete state.previousHash;
o.history.replaceState(state, '', url(previous));
render(false);
return true;
}
function start() {
o.launcher.addEventListener('click', open);
o.closeButton.addEventListener('click', close);
o.eventTarget.addEventListener('popstate', sync);
o.eventTarget.addEventListener('hashchange', sync);
o.eventTarget.addEventListener('keydown', event => {
(o.returnButton || o.root.querySelector?.('[data-return-from-detour]'))?.addEventListener('click', returnToToday);
events.addEventListener('popstate', sync);
events.addEventListener('hashchange', sync);
events.addEventListener('keydown', event => {
if (active && event.key === 'Escape') {
event.preventDefault?.();
close();
@@ -70,5 +92,5 @@
sync();
}
return { start, open, close, current:() => active };
return { start, open, close, sync, current:() => active };
});
+35 -4
View File
@@ -11,11 +11,44 @@
['update', 'Updates'],
['filed', 'Filed'],
];
const anonymousItems = new WeakMap();
let anonymousItemSequence = 0;
function count(value) {
return Math.max(0, Number(value) || 0);
}
function itemIdentity(item) {
if (item === null || item === undefined) return '';
if (typeof item !== 'object') return String(item);
const explicit = item.outbox_id || item.id || item.key || item.url;
if (explicit !== null && explicit !== undefined && String(explicit)) {
return String(item.kind || 'item') + ':' + String(explicit);
}
if (!anonymousItems.has(item)) anonymousItems.set(item, 'anonymous:' + (++anonymousItemSequence));
return anonymousItems.get(item);
}
function reviewPhases(counts) {
if (!options.getPhaseItems) {
return reviewOrder
.map(([name, label]) => ({name, label, count: count(counts[name])}))
.filter(phase => phase.count > 0);
}
const items = options.getPhaseItems() || {};
const seen = new Set();
return reviewOrder.flatMap(([name, label]) => {
let phaseCount = 0;
(items[name] || []).forEach(item => {
const identity = itemIdentity(item);
if (!identity || seen.has(identity)) return;
seen.add(identity);
phaseCount += 1;
});
return phaseCount ? [{name, label, count:phaseCount}] : [];
});
}
function localDay() {
const now = new Date();
const pad = value => String(value).padStart(2, '0');
@@ -69,12 +102,10 @@
function briefing() {
const counts = options.getCounts ? options.getCounts() : {};
const phases = reviewOrder
.map(([name, label]) => ({name, label, count: count(counts[name])}))
.filter(phase => phase.count > 0);
const phases = reviewPhases(counts);
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
const today = count(counts.today);
const delivery = count(counts.delivery);
const delivery = phases.find(phase => phase.name === 'delivery')?.count || 0;
const other = total - delivery;
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
const nextLabel = next === 'delivery' ? 'Review Delivery' :
+13 -7
View File
@@ -33,8 +33,9 @@
options.queueSheet.showModal();
}
function closeQueues() {
function closeQueues(returnToToday = false) {
options.queueSheet?.open && options.queueSheet.close();
if (returnToToday) options.detour?.()?.finishDetour();
}
function start() {
@@ -42,14 +43,16 @@
button.addEventListener('click', event => {
launcher = event.currentTarget;
select(name);
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
if (name === 'queues') options.detour?.()?.beginDetour(name);
options.actions?.[name]?.();
if (name === 'queues' && options.queueSheet) openQueues();
});
});
if (options.queueSheet) {
options.queueClose?.addEventListener('click', closeQueues);
options.queueClose?.addEventListener('click', () => closeQueues(true));
options.queueSheet.addEventListener('cancel', event => {
event.preventDefault();
closeQueues();
closeQueues(true);
});
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
@@ -66,13 +69,16 @@
function updateWork(mode) {
const text = {
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
prepare:'Prepare', 'prepare-resume':'Resume prep',
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
}[mode] || 'Work';
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', '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'));
const actionLabel = mode === 'prepare' ? 'Prepare Today' :
mode === 'prepare-resume' ? 'Resume preparation' :
queue ? (mode === 'update' ? 'Resume ' : 'Open ') + text :
mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today');
if (buttons.work) buttons.work.setAttribute('aria-label', actionLabel);
}
function updateAttention(count) {
+56
View File
@@ -0,0 +1,56 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.createMobileTodayCommandBar = factory;
})(typeof window !== 'undefined' ? window : this, function createMobileTodayCommandBar({
more,
sheet,
close,
firstAction,
endAction,
existingEndControl,
actionButtons = [],
hud,
dock,
style,
ResizeObserverImpl = typeof ResizeObserver === 'undefined' ? null : ResizeObserver,
}) {
if (!more || !sheet) return null;
const dismiss = () => {
if (sheet.open) sheet.close();
};
const open = () => {
if (sheet.open) return;
more.setAttribute('aria-expanded', 'true');
sheet.showModal();
firstAction?.focus();
};
more.setAttribute('aria-expanded', 'false');
more.addEventListener('click', open);
close?.addEventListener('click', dismiss);
actionButtons.forEach(button => button?.addEventListener('click', dismiss));
endAction?.addEventListener('click', () => {
dismiss();
existingEndControl?.click();
});
sheet.addEventListener('cancel', event => {
event.preventDefault();
dismiss();
});
sheet.addEventListener('close', () => {
more.setAttribute('aria-expanded', 'false');
more.focus();
});
const measure = () => {
if (!style || !hud || !dock) return;
style.setProperty('--mobile-today-clearance', `${hud.offsetHeight + dock.offsetHeight + 16}px`);
};
const observer = ResizeObserverImpl && hud && dock ? new ResizeObserverImpl(measure) : null;
observer?.observe(hud);
observer?.observe(dock);
measure();
return { open, close:dismiss, measure, destroy:() => observer?.disconnect() };
});
+4
View File
@@ -4,6 +4,9 @@
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
function mode() {
if (options.isTodayActive()) return 'continue';
const preparation = options.getPreparationState?.();
if (preparation?.active) return 'prepare-resume';
if (preparation?.next && !['today', 'find'].includes(preparation.next)) return 'prepare';
const recommended = options.queueLauncher && options.queueLauncher.recommend();
if (recommended && !['today', 'find'].includes(recommended.name)) return recommended.name;
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
@@ -15,6 +18,7 @@
function open() {
const current = mode();
if (current === 'continue') options.continueToday();
else if (['prepare', 'prepare-resume'].includes(current)) options.prepareToday();
else if (current === 'resume') options.resumeToday();
else if (current === 'start') options.startToday();
else if (current === 'plan') options.planToday();
+31 -1
View File
@@ -2,7 +2,9 @@
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createPushNotifications = factory;
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, notification, serviceWorker, fetchJson,
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
notification, serviceWorker, fetchJson,
}) {
let configuration = null;
@@ -16,6 +18,32 @@
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
}
function renderDeadlineSnooze() {
if (!deadlineSnooze) return;
const wakeAt = Number(configuration?.snoozed_until || 0);
deadlineSnooze.hidden = !wakeAt;
if (wakeAt && deadlineSnoozeStatus) {
const localTime = new Intl.DateTimeFormat(undefined, {
hour:'numeric', minute:'2-digit',
}).format(new Date(wakeAt * 1000));
deadlineSnoozeStatus.textContent = `Deadline reminders snoozed until ${localTime}.`;
}
}
async function reviewSnoozedDeadlines() {
deadlineSnoozeReview.disabled = true;
try {
await fetchJson('api/v1/push-subscription/deadlines/snooze', {method:'DELETE'});
configuration.snoozed_until = null;
renderDeadlineSnooze();
onReviewDeadlines?.();
} catch (error) {
deadlineSnoozeStatus.textContent = 'Could not resume deadline reminders. Check your connection and try again.';
} finally {
deadlineSnoozeReview.disabled = false;
}
}
function deadlineReadiness() {
if (!configuration?.available || deadlineControl?.disabled) {
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
@@ -136,6 +164,7 @@
if (!control || !notification || !serviceWorker) return;
control.addEventListener('change', change);
deadlineControl?.addEventListener('change', changeDeadline);
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
configuration = await fetchJson('api/v1/push-subscription');
if (!configuration.available) {
control.disabled = true;
@@ -153,6 +182,7 @@
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
: 'Deadline reminders are off for this device.';
renderDeadlineSnooze();
}
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
+201
View File
@@ -0,0 +1,201 @@
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
const prefix = 'stackchain.release-receipt.v1:';
const limit = 12;
let entries = [];
let refreshing = null;
let timer = null;
let bound = false;
documentRef ||= typeof document !== 'undefined' ? document : null;
windowRef ||= typeof window !== 'undefined' ? window : null;
const login = () => String(getLogin?.() || '').trim().toLowerCase();
const key = () => prefix + login();
const identity = value => String(value.repository || '') + '@' + String(value.commit_sha || '');
const path = value => 'api/v1/repos/' + String(value.repository || '').split('/')
.map(encodeURIComponent).join('/') + '/release-receipt/' + encodeURIComponent(value.commit_sha);
function valid(value, account) {
return value && value.account === account && value.repository && value.commit_sha;
}
function persist() {
if (!entries.length) storage?.removeItem(key());
else storage?.setItem(key(), JSON.stringify({ version: 2, account: login(), entries }));
}
const hasPending = () => entries.some(entry => !entry.status?.release && entry.status?.label !== 'Checks failed');
function schedule(delay = pollMs) {
if (!bound || documentRef?.hidden || !hasPending()) return;
if (timer !== null) clearTimer(timer);
timer = setTimer(async () => {
timer = null;
try { await refresh(); }
catch (error) { if (statusNode) statusNode.textContent = error.message + ' Retry when connected.'; }
schedule();
}, delay);
}
function restore() {
const account = login();
if (!account) return [];
try {
const parsed = JSON.parse(storage?.getItem(prefix + account) || 'null');
const stored = parsed?.version === 2 && parsed.account === account
? parsed.entries : (valid(parsed, account) ? [parsed] : []);
entries = Array.isArray(stored) ? stored.filter(value => valid(value, account)).slice(-limit) : [];
if (entries.length && parsed?.version !== 2) persist();
} catch (_error) { entries = []; }
render();
schedule();
return entries.map(value => ({ ...value }));
}
function capture(item, mergeResult) {
const account = login();
const commitSha = String(mergeResult?.merge_commit_sha || '').trim();
if (!account || !item?.repository || !commitSha) throw new Error('The exact merge commit is unavailable.');
if (!entries.length) restore();
const entry = {
account,
repository: item.repository,
number: Number(item.number),
key: item.key || item.repository + '#' + item.number,
commit_sha: commitSha,
status: null,
captured_at: new Date().toISOString(),
};
const existing = entries.findIndex(value => identity(value) === identity(entry));
if (existing >= 0) {
entry.status = entries[existing].status || null;
entries.splice(existing, 1, entry);
} else {
entries.push(entry);
entries = entries.slice(-limit);
}
persist();
render();
schedule();
return { ...entry };
}
function summarize(payload) {
const checks = Array.isArray(payload?.checks) ? payload.checks : [];
const failing = checks.filter(check => ['failure', 'error'].includes(check.state)).map(check => check.name).filter(Boolean);
const pending = checks.filter(check => check.state === 'pending').map(check => check.name).filter(Boolean);
if (payload?.release) return { ...payload, label: 'Released · ' + payload.release.tag, checks: [] };
if (failing.length) return { ...payload, label: 'Checks failed', checks: failing };
if (payload?.ci_state === 'success') return { ...payload, label: 'Checks passed · waiting for release', checks: [] };
return { ...payload, label: 'Checks running', checks: pending };
}
function render() {
const visible = entries.map((entry, index) => ({ entry, index }))
.sort((a, b) => Number(b.entry.status?.label === 'Checks failed') - Number(a.entry.status?.label === 'Checks failed') || a.index - b.index)
.map(value => value.entry);
const first = visible[0] || null;
const failed = visible.filter(entry => entry.status?.label === 'Checks failed').length;
if (launcher) {
launcher.hidden = !entries.length;
launcher.textContent = failed
? failed + ' release ' + (failed === 1 ? 'failure' : 'failures') + ' · ' + visible.length + ' tracked'
: visible.length + ' ' + (visible.length === 1 ? 'merge' : 'merges') + ' · tracking release';
}
if (statusNode) statusNode.textContent = first?.status?.label || 'Checking the exact merge commit…';
if (checksNode) checksNode.textContent = (first?.status?.checks || []).join(', ');
if (releaseNode) {
releaseNode.hidden = !first?.status?.release?.url;
if (first?.status?.release?.url) {
releaseNode.href = first.status.release.url;
releaseNode.textContent = 'Open release ' + first.status.release.tag;
}
}
if (listNode) {
const rows = visible.map(entry => {
const row = document.createElement('article');
row.className = 'release-watchlist-item';
const copy = document.createElement('div');
const title = document.createElement('strong');
title.textContent = entry.key;
const state = document.createElement('span');
state.className = 'small';
state.textContent = entry.status?.label || 'Checking the exact merge commit…';
copy.append(title, state);
if (entry.status?.release?.url) {
const link = document.createElement('a');
link.href = entry.status.release.url;
link.textContent = 'Open release ' + entry.status.release.tag;
copy.append(link);
}
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Dismiss';
button.setAttribute('aria-label', 'Dismiss ' + entry.key + ' from release tracking');
button.addEventListener('click', () => dismiss(entry.repository, entry.commit_sha));
row.append(copy, button);
return row;
});
listNode.replaceChildren(...rows);
}
}
async function refreshEntry(entry) {
const payload = await fetchJson(path(entry), { headers: { Accept: 'application/json' } });
if (payload?.commit_sha !== entry.commit_sha) throw new Error('Release evidence did not match the merged commit.');
entry.status = summarize(payload);
return entry.status;
}
async function refresh() {
if (refreshing) return refreshing;
if (!entries.length) restore();
if (!entries.length) return [];
refreshing = (async () => {
const statuses = [];
for (const entry of entries) {
try { statuses.push(await refreshEntry(entry)); }
catch (error) {
entry.status = { label: 'Status unavailable', checks: [], error: String(error?.message || error) };
statuses.push(entry.status);
}
}
persist();
render();
return statuses;
})();
try { return await refreshing; }
finally { refreshing = null; }
}
function dismiss(repository, commitSha) {
if (repository && commitSha) entries = entries.filter(entry => identity(entry) !== repository + '@' + commitSha);
else entries = [];
persist();
render();
schedule();
if (!entries.length && dialog?.open) dialog.close();
return entries.map(value => ({ ...value }));
}
function bind() {
bound = true;
launcher?.addEventListener('click', async () => {
dialog?.showModal?.();
try { await refresh(); }
catch (error) { if (statusNode) statusNode.textContent = error.message + ' Retry when connected.'; }
});
documentRef?.addEventListener('visibilitychange', () => {
if (documentRef.hidden) {
if (timer !== null) clearTimer(timer);
timer = null;
} else schedule(0);
});
windowRef?.addEventListener('online', () => schedule(0));
schedule();
}
return { capture, restore, refresh, dismiss, bind, fetchJson };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;
+4 -2
View File
@@ -1,7 +1,7 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.attachSecurityCenter = boundary => factory({ root, boundary });
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) {
else root.attachSecurityCenter = (boundary, options = {}) => factory({ root, boundary, ...options });
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary, onOpen = () => {}, onClose = () => {} }) {
const devicesButton = root.document.getElementById('active-devices');
const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesList = root.document.getElementById('active-devices-list');
@@ -52,6 +52,7 @@
if (!devicesSheet.hidden) {
devicesSheet.hidden = true;
devicesButton?.focus();
onClose();
}
});
@@ -222,6 +223,7 @@
const open = () => {
if (!devicesSheet) return;
onOpen();
devicesSheet.hidden = false;
root.document.getElementById('close-active-devices')?.focus();
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
+107 -6
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v117';
const CACHE = 'stackchain-dashboard-shell-v121';
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;
@@ -45,14 +45,20 @@ const SHELL = [
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/agenda-replan.js',
BASE + 'static/agenda-calendar.js',
BASE + 'static/protect-today.js',
BASE + 'static/notification-undo.js',
BASE + 'static/card-planning.js',
BASE + 'static/work-selection.js',
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',
BASE + 'static/today-break.js',
BASE + 'static/today-progress.js',
BASE + 'static/today-lock-screen.js',
BASE + 'static/today-session-sync.js',
BASE + 'static/today-recap.js',
BASE + 'static/today-wrap-up.js',
BASE + 'static/today-summary.js',
BASE + 'static/today-handoff.js',
BASE + 'static/today-completion.js',
BASE + 'static/today-readiness.js',
@@ -99,10 +105,12 @@ const SHELL = [
BASE + 'static/queue-today.js',
BASE + 'static/pull-sheet.js',
BASE + 'static/review-sheet.js',
BASE + 'static/release-receipt.js',
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-today-command-bar.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js',
@@ -418,6 +426,39 @@ self.addEventListener('sync', event => {
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
});
async function updateTodayLockScreen(active, running, rawActionToken = '', rawBreakDeadline = 0) {
const tag = 'stackchain-today-session';
if (!active) {
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach(notification => notification.close());
return;
}
const actionToken = /^[A-Za-z0-9_-]{16,128}$/.test(rawActionToken) ? rawActionToken : '';
const now = Date.now();
const breakDeadline = Number(rawBreakDeadline);
const onBreak = !running && actionToken && Number.isSafeInteger(breakDeadline) &&
breakDeadline > now && breakDeadline <= now + 120 * 60 * 1000;
const title = onBreak ? 'On a Today break' :
running ? 'Today session running' : 'Today session paused';
const body = onBreak ? 'Return at ' + new Date(breakDeadline).toLocaleTimeString([], {
hour:'numeric', minute:'2-digit',
}) : running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.';
await self.registration.showNotification(title, {
body,
tag,
renotify:false,
silent:true,
actions: onBreak ? [
{ action:'resume-today', title:'Resume now' },
{ action:'open-today', title:'Open Today' },
] : [
{ action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' },
...(actionToken ? [{ action:'finish-today', title:'Finish current' }] : []),
],
data: { route:'#/my-work/today', ...(actionToken ? { actionToken } : {}) },
});
}
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
await issueSync.resume();
@@ -430,11 +471,20 @@ self.addEventListener('message', event => {
try {
await issueSync.purge();
await deletePrivateDatabases();
await updateTodayLockScreen(false, false);
event.ports?.[0]?.postMessage({ ok: true });
} catch (error) {
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
}
})());
if (event.data?.type === 'stackchain-today-lock-screen') {
event.waitUntil(updateTodayLockScreen(
event.data.active === true,
event.data.running === true,
String(event.data.actionToken || ''),
Number(event.data.breakDeadlineAt || 0)
));
}
});
self.addEventListener('push', event => {
@@ -462,7 +512,7 @@ self.addEventListener('push', event => {
tag,
actions: [
{ action: 'protect-today', title: 'Protect Today' },
{ action: 'open-agenda', title: 'Open Agenda' },
{ action: 'snooze-deadline', title: 'Remind in 1 hour' },
],
data: {route, protectRoute},
}
@@ -527,9 +577,44 @@ async function openCanonicalIssueUrl(rawUrl) {
return client.focus();
}
async function applyTodayTimerAction(action, actionToken = '') {
if (!['pause', 'resume', 'complete'].includes(action)) return;
if (actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(actionToken)) return;
if (action === 'complete' && !actionToken) return;
const route = '#/my-work/today';
const windows = await self.clients.matchAll({ type:'window', includeUncontrolled:true });
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
if (client) {
client.postMessage?.({
type:'stackchain-today-timer-action', action,
...(actionToken ? { actionToken } : {}),
});
return client.focus?.();
}
const query = '?today_timer_action=' + action +
(actionToken ? '&today_action_token=' + encodeURIComponent(actionToken) : '');
const target = new URL(BASE + query + route, self.location.origin).href;
return self.clients.openWindow(target);
}
self.addEventListener('notificationclick', event => {
const route = String(event.notification.data?.route || '');
const issueUrl = String(event.notification.data?.url || '');
if (
event.notification.tag === 'stackchain-today-session'
&& route === '#/my-work/today'
&& ['pause-today', 'resume-today', 'finish-today', 'open-today', ''].includes(event.action)
) {
event.notification.close();
if (['pause-today', 'resume-today', 'finish-today'].includes(event.action)) {
const action = event.action === 'pause-today' ? 'pause' :
event.action === 'resume-today' ? 'resume' : 'complete';
event.waitUntil(applyTodayTimerAction(action, String(event.notification.data?.actionToken || '')));
} else {
event.waitUntil(openWorkRoute(route));
}
return;
}
if (issueUrl) {
event.notification.close();
event.waitUntil(openCanonicalIssueUrl(issueUrl));
@@ -545,10 +630,26 @@ self.addEventListener('notificationclick', event => {
event.waitUntil(openWorkRoute(protectRoute));
return;
}
if (event.action === 'open-agenda') {
if (route !== '#/my-work/agenda') return;
event.notification.close();
event.waitUntil(openWorkRoute(route));
if (event.action === 'snooze-deadline') {
if (
route !== '#/my-work/agenda'
|| !/^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(event.notification.tag)
) return;
event.waitUntil((async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
try {
await fetchJson(BASE + 'api/v1/push-subscription/deadlines/snooze', {
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
});
event.notification.close();
} catch (_error) {
await openWorkRoute(route);
event.notification.close();
} finally {
clearTimeout(timeout);
}
})());
return;
}
if (event.action === 'tomorrow') {
+16 -1
View File
@@ -45,6 +45,12 @@
const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesStatus = root.document.getElementById('active-devices-status');
const closeDevices = root.document.getElementById('close-active-devices');
const returnFromSecurity = root.document.getElementById('return-from-security-center');
const beginSecurityDetour = () => {
if (root.matchMedia?.('(max-width: 600px)')?.matches) {
root.stackchainTodayTimerView?.beginDetour?.('security-center');
}
};
const securityFeatures = root.createFeatureLoader({
document: root.document,
urls: {
@@ -57,6 +63,7 @@
const openSecurityCenter = async () => {
if (loadingSecurityCenter) return;
loadingSecurityCenter = true;
beginSecurityDetour();
devicesSheet.hidden = false;
closeDevices?.focus();
try {
@@ -66,7 +73,10 @@
retryLabel: 'Tap Active devices to retry.',
}, () => {
devicesButton.removeEventListener('click', openSecurityCenter);
root.attachSecurityCenter(boundary).open();
root.attachSecurityCenter(boundary, {
onOpen: beginSecurityDetour,
onClose: () => root.stackchainTodayTimerView?.finishDetour?.('security-center'),
}).open();
});
} finally {
loadingSecurityCenter = false;
@@ -78,6 +88,11 @@
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
devicesSheet.hidden = true;
devicesButton?.focus();
root.stackchainTodayTimerView?.finishDetour?.('security-center');
});
if (returnFromSecurity && devicesSheet) returnFromSecurity.addEventListener('click', () => {
if (root.history?.state?.stackchainSecuritySection) root.history.back();
else devicesSheet.hidden = true;
});
boundary.refreshOfflineLease().then(valid => {
if (valid) boundary.resumeQueuedWork();
+106
View File
@@ -0,0 +1,106 @@
function createTodayBreak({
timer, sheet, openButtons = null, cancelButton, presetButtons = null, form = null,
minutesInput = null, error = null, status, resumeButton,
qs = null, queryAll = null,
historyRef = null, windowRef = null,
now = () => Date.now(), setIntervalRef = setInterval, clearIntervalRef = clearInterval,
onChange = () => {}, onResume = () => {},
}) {
queryAll ||= selector => globalThis.document?.querySelectorAll(selector) || [];
historyRef ||= globalThis.history;
windowRef ||= globalThis.window;
sheet ||= qs?.('#today-break-sheet');
openButtons ||= queryAll?.('[data-today-break-open]') || [];
cancelButton ||= qs?.('#cancel-today-break');
presetButtons ||= queryAll?.('[data-today-break-minutes]') || [];
form ||= qs?.('#today-break-custom-form');
minutesInput ||= qs?.('#today-break-custom-minutes');
error ||= qs?.('#today-break-error');
status ||= qs?.('#today-break-status');
resumeButton ||= qs?.('#resume-today-break');
let ticker = null;
let historyEntry = false;
const stopTicker = () => {
if (ticker !== null) clearIntervalRef(ticker);
ticker = null;
};
const render = () => {
const pending = timer.breakSnapshot();
if (!pending) {
stopTicker();
status.hidden = true;
resumeButton.hidden = true;
openButtons.forEach(button => { button.hidden = false; });
return false;
}
status.hidden = false;
resumeButton.hidden = false;
openButtons.forEach(button => { button.hidden = true; });
const remaining = Math.max(0, pending.deadline_at - now());
if (pending.expired || remaining <= 0) {
status.textContent = 'Break over · ready to resume';
} else {
const seconds = Math.ceil(remaining / 1000);
const minutes = Math.floor(seconds / 60);
status.textContent = 'On break · resume in ' + minutes + ':' + String(seconds % 60).padStart(2, '0');
}
if (ticker === null) ticker = setIntervalRef(render, 1000);
return true;
};
const close = () => {
if (sheet.open) sheet.close();
if (historyEntry) {
historyEntry = false;
historyRef?.back();
}
};
const start = minutes => {
if (!timer.startBreak(Number(minutes))) return false;
if (error) error.textContent = '';
close();
render();
onChange();
return true;
};
const open = () => {
if (!timer.snapshot().identity) return false;
if (!sheet.open) {
sheet.showModal();
if (historyRef) {
historyRef.pushState({...(historyRef.state || {}), todayBreak:true}, '');
historyEntry = true;
}
}
return true;
};
openButtons.forEach(button => button.addEventListener('click', open));
cancelButton?.addEventListener('click', close);
windowRef?.addEventListener('popstate', () => {
historyEntry = false;
if (sheet.open) sheet.close();
});
presetButtons.forEach(button => button.addEventListener('click', () =>
start(button.dataset.todayBreakMinutes)
));
form?.addEventListener('submit', event => {
event.preventDefault();
if (!start(Number(minutesInput?.value))) {
if (error) error.textContent = 'Choose a whole number from 1 to 120 minutes.';
minutesInput?.focus();
}
});
resumeButton.addEventListener('click', () => {
const pending = timer.breakSnapshot();
if (!timer.resumeBreak()) return;
onResume(pending.identity);
render();
onChange();
});
render();
return { open, close, start, render };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayBreak;
+65 -3
View File
@@ -1,6 +1,25 @@
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
return function completeTodayItem(item, options = {}) {
if (!item || !todayWork.remove(item)) {
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null,
now = Date.now, ttlMs = 10000, onOffer = null, onClear = null,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout,
undo = [globalThis.document?.getElementById('today-completion-undo'),
globalThis.document?.getElementById('undo-today-completion')] }) {
const [undoReceipt, undoControl] = undo;
let pending = null;
let expiryTimer = null;
function clear() {
if (expiryTimer !== null) clearTimer?.(expiryTimer);
expiryTimer = null;
pending = null;
if (undoReceipt) undoReceipt.hidden = true;
onClear?.();
}
function completeTodayItem(item, options = {}) {
const identity = item && todayWork.identity(item);
const snapshot = item && (todayWork.capture?.(item) ||
(identity ? { id: identity, index: 0, ids: [identity], plan: { capacity_minutes: null, estimates: {} } } : null));
if (!snapshot || !todayWork.remove(item)) {
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
return false;
}
@@ -10,8 +29,51 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
warm();
(advance || (() => workSession.complete()))();
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
pending = { snapshot, expires_at: now() + ttlMs };
if (undoReceipt) undoReceipt.hidden = false;
if (undoControl) undoControl.disabled = false;
onOffer?.({ identity: snapshot.id, expires_at: pending.expires_at });
expiryTimer = setTimer?.(clear, ttlMs) ?? null;
expiryTimer?.unref?.();
return true;
}
completeTodayItem.undo = () => {
if (!pending) return 'missing';
if (now() >= pending.expires_at) {
clear();
announce('Undo expired. Add the item back to Today from My Work.');
return 'expired';
}
const snapshot = pending.snapshot;
const result = todayWork.restore?.(snapshot) || 'unavailable';
if (result !== 'restored') {
const messages = {
full: 'Today is full. Remove another item before adding this work back.',
changed: 'Today changed on this device. Add the item back from My Work.',
unavailable: 'Could not restore Today on this device. Add the item back from My Work.',
};
clear();
announce(messages[result] || messages.unavailable);
return result;
}
todaySync.enqueue('add', snapshot.id);
for (let index = snapshot.ids.length - 1; index > snapshot.index; index -= 1) {
todaySync.enqueue('move', snapshot.id, 'up');
}
todaySync.flush();
refresh();
warm();
clear();
announce('Restored to Today. Your current work session is unchanged.');
return 'restored';
};
completeTodayItem.clear = clear;
undoControl?.addEventListener?.('click', () => {
undoControl.disabled = true;
if (completeTodayItem.undo() !== 'restored') undoControl.disabled = false;
});
return completeTodayItem;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;
+178
View File
@@ -0,0 +1,178 @@
function createTodayLockScreen({
storage,
getLogin,
serviceWorker,
NotificationRef,
control,
status,
locationRef,
historyRef,
randomToken = () => {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
},
fingerprint = async (token, identity) => {
const input = new TextEncoder().encode(token + '\0' + identity);
const digest = await globalThis.crypto.subtle.digest('SHA-256', input);
return Array.from(new Uint8Array(digest), value =>
value.toString(16).padStart(2, '0')).join('');
},
onAction = () => {},
}) {
let activeIdentity = '';
let activeActionIdentity = '';
let activeBreakDeadline = 0;
const preferenceKey = () => {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? 'stackchain.today-lock-screen.v1.' + encodeURIComponent(login) : '';
};
const supported = Boolean(serviceWorker && NotificationRef);
const actionKey = () => {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? 'stackchain.today-lock-screen-action.v1.' + encodeURIComponent(login) : '';
};
const readAction = () => {
const key = actionKey();
if (!key) return null;
try {
const value = JSON.parse(storage?.getItem(key) || 'null');
return typeof value?.token === 'string' && value.token &&
typeof value.fingerprint === 'string' && value.fingerprint ? value : null;
} catch (_error) { return null; }
};
const clearAction = () => {
const key = actionKey();
try { if (key) storage?.removeItem(key); }
catch (_error) { return false; }
return true;
};
const actionFor = async identity => {
const existing = readAction();
if (existing && await fingerprint(existing.token, identity) === existing.fingerprint) return existing;
const key = actionKey();
if (!key) return null;
try {
const token = String(randomToken() || '');
const value = { token, fingerprint:token ? await fingerprint(token, identity) : '' };
if (!value.token) return null;
storage?.setItem(key, JSON.stringify(value));
return value;
} catch (_error) { return null; }
};
const enabled = () => {
const key = preferenceKey();
return Boolean(key && storage?.getItem(key) === '1');
};
const setStatus = message => {
if (status) status.textContent = message;
};
const post = async message => {
const target = serviceWorker?.controller || (await serviceWorker?.ready)?.active;
target?.postMessage?.(message);
};
const hide = () => post({
type:'stackchain-today-lock-screen', active:false, running:false,
});
const render = () => {
if (control) {
control.checked = enabled();
control.disabled = !supported;
}
if (!supported) setStatus('Lock-screen controls are not supported on this device.');
else if (enabled()) setStatus('Lock-screen Today controls are on.');
};
const consumeAction = async (action, token = '') => {
if (!enabled() || !['pause', 'resume', 'complete'].includes(action)) return false;
const requiresToken = action === 'complete' || (action === 'resume' && activeBreakDeadline > 0);
if (!requiresToken) {
onAction(action, null);
return true;
}
const pending = readAction();
if (!pending || !token || !activeIdentity || pending.token !== token ||
await fingerprint(token, activeActionIdentity) !== pending.fingerprint) return false;
if (!clearAction()) return false;
onAction(action, activeIdentity);
return true;
};
const api = {
enabled,
async enable() {
if (!supported) {
render();
return false;
}
const permission = NotificationRef.permission === 'granted' ?
'granted' : await NotificationRef.requestPermission();
if (permission !== 'granted') {
if (control) control.checked = false;
setStatus(permission === 'denied' ?
'Lock-screen controls are blocked in browser settings.' :
'Lock-screen controls were not enabled.');
return false;
}
const key = preferenceKey();
if (!key) return false;
storage?.setItem(key, '1');
render();
return true;
},
async disable() {
const key = preferenceKey();
if (key) storage?.removeItem(key);
clearAction();
if (control) control.checked = false;
setStatus('Lock-screen Today controls are off.');
await hide();
return true;
},
async sync(snapshot, active) {
if (!enabled() || NotificationRef?.permission !== 'granted') return false;
const visible = Boolean(active && snapshot?.identity);
activeIdentity = visible ? snapshot.identity : '';
const rawBreakDeadline = Number(snapshot?.break_deadline_at);
activeBreakDeadline = visible && Number.isSafeInteger(rawBreakDeadline) && rawBreakDeadline > 0 ?
rawBreakDeadline : 0;
activeActionIdentity = activeIdentity + (activeBreakDeadline ? '\0break:' + activeBreakDeadline : '');
const pending = visible ? await actionFor(activeActionIdentity) : null;
if (!visible) clearAction();
await post({
type:'stackchain-today-lock-screen',
active:visible,
running:visible && Boolean(snapshot.running),
...(activeBreakDeadline ? { breakDeadlineAt:activeBreakDeadline } : {}),
...(pending?.token ? { actionToken:pending.token } : {}),
});
if (visible && !pending) setStatus('Finish current is unavailable because its one-time action could not be saved.');
return true;
},
consumeAction,
async consumeLaunchAction() {
let url;
try { url = new URL(locationRef?.href || ''); }
catch (_error) { return false; }
const action = url.searchParams.get('today_timer_action');
if (!['pause', 'resume', 'complete'].includes(action)) return false;
const token = url.searchParams.get('today_action_token') || '';
url.searchParams.delete('today_timer_action');
url.searchParams.delete('today_action_token');
historyRef?.replaceState?.(null, '', url.pathname + url.search + url.hash);
return await consumeAction(action, token);
},
render,
};
control?.addEventListener?.('change', () => {
if (control.checked) api.enable();
else api.disable();
});
serviceWorker?.addEventListener?.('message', event => {
if (event.data?.type === 'stackchain-today-timer-action') {
consumeAction(String(event.data.action || ''), String(event.data.actionToken || ''));
}
});
render();
return api;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayLockScreen;
+553
View File
@@ -0,0 +1,553 @@
function createTodayProgress({ storage, getLogin = () => '', admit, makeId = () =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
maxLength = 2000, maxItems = 20 }) {
const prefix = 'stackchain.today-progress.v1.';
const login = () => String(getLogin() || '').trim().toLowerCase();
const storageKey = () => login() ? prefix + encodeURIComponent(login()) : '';
const validIdentity = identity => typeof identity === 'string' && identity.length > 0 && identity.length <= 500;
const attachmentFingerprint = attachments => JSON.stringify((Array.isArray(attachments) ? attachments : []).filter(Boolean).slice(0, 5).map(value => ({
filename:String(value.filename || ''), contentType:String(value.contentType || ''),
note:String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240),
operationId:String(value.operationId || '').slice(0, 128),
markdown:String(value.confirmed?.markdown || ''),
})));
const payloadFingerprint = (body, attachments) => JSON.stringify({body, attachments:attachmentFingerprint(attachments)});
const validRecord = record => record && typeof record.body === 'string' && (record.body.length > 0 || record.has_attachments === true) &&
record.body.length <= maxLength && typeof record.operation_id === 'string' && record.operation_id.length > 0 &&
record.operation_id.length <= 128;
function read() {
const key = storageKey();
if (!storage || !key) return {};
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
if (!saved || saved.version !== 1 || !saved.drafts || typeof saved.drafts !== 'object' ||
Array.isArray(saved.drafts)) return {};
const entries = Object.entries(saved.drafts).filter(([identity, record]) =>
validIdentity(identity) && validRecord(record)
).slice(0, maxItems);
return Object.fromEntries(entries);
} catch (_error) {
return {};
}
}
function write(drafts) {
const key = storageKey();
if (!storage || !key) return false;
try {
if (Object.keys(drafts).length) storage.setItem(key, JSON.stringify({ version:1, drafts }));
else storage.removeItem(key);
return true;
} catch (_error) {
return false;
}
}
function load(identity) {
if (!validIdentity(identity)) return '';
return read()[identity]?.body || '';
}
function save(identity, value, attachments = []) {
if (!validIdentity(identity) || !storageKey()) return false;
const body = String(value || '').trim();
const hasAttachments = attachments === true || (Array.isArray(attachments) && attachments.filter(Boolean).length > 0);
if (body.length > maxLength) return false;
const drafts = read();
if (!body && !hasAttachments) {
delete drafts[identity];
return write(drafts);
}
if (!drafts[identity] && Object.keys(drafts).length >= maxItems) return false;
const previous = drafts[identity];
const fingerprint = payloadFingerprint(body, Array.isArray(attachments) ? attachments : []);
const previousFingerprint = previous?.payload_fingerprint ||
(previous && !previous.has_attachments ? payloadFingerprint(previous.body, []) : '');
drafts[identity] = {
body,
operation_id: previousFingerprint === fingerprint ? previous.operation_id : String(makeId()).slice(0, 128),
payload_fingerprint:fingerprint,
...(hasAttachments ? {has_attachments:true} : {}),
};
return write(drafts);
}
function validTarget(target) {
return target && ['issue', 'pull'].includes(target.kind) && validIdentity(target.identity) &&
typeof target.repository === 'string' && target.repository.length > 0 && target.repository.length <= 200 &&
Number.isInteger(target.number) && target.number > 0;
}
async function post(target, value, attachments = [], completeEvidence) {
if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
const pendingRecord = read()[target.identity];
if (pendingRecord?.cleanup_pending === true) {
try {
if (typeof completeEvidence === 'function') await completeEvidence();
} catch (_error) {
const error = new Error('Progress update is already queued; photo cleanup is pending.');
error.deliveryAdmitted = true;
throw error;
}
const pendingDrafts = read();
if (pendingDrafts[target.identity]?.operation_id === pendingRecord.operation_id) {
delete pendingDrafts[target.identity];
write(pendingDrafts);
}
return { background:true, alreadyAdmitted:true, cleanupRecovered:true };
}
const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
if ((value !== undefined || evidence.length) && !save(target.identity, value ?? load(target.identity), evidence)) {
throw new Error('Progress update could not be saved on this device.');
}
const record = read()[target.identity];
if (!record) throw new Error('Write a progress update before posting.');
if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
const admission = await admit({
kind:target.kind + '-comment', repository:target.repository, number:target.number,
body:record.body, operationId:record.operation_id,
...(evidence.length ? {attachments:evidence} : {}),
});
const admittedDrafts = read();
if (admittedDrafts[target.identity]?.operation_id === record.operation_id) {
admittedDrafts[target.identity] = { ...admittedDrafts[target.identity], cleanup_pending:true };
write(admittedDrafts);
}
try {
if (typeof completeEvidence === 'function') await completeEvidence();
} catch (_error) {
const error = new Error('Progress update is already queued; photo cleanup is pending.');
error.deliveryAdmitted = true;
throw error;
}
const drafts = read();
if (drafts[target.identity]?.operation_id === record.operation_id) {
delete drafts[target.identity];
write(drafts);
}
return admission;
}
async function postBlocker(target, value, attachments = [], completeEvidence, until, transition) {
if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
if (typeof transition !== 'function') throw new Error('Today planning is unavailable.');
let record = read()[target.identity];
let admission = { background:true, alreadyAdmitted:true };
if (record?.blocker_pending !== true) {
const body = String(value ?? record?.body ?? '').trim();
if (!body) throw new Error('Describe what is blocking this Today item.');
const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
if (!save(target.identity, body, evidence)) {
throw new Error('Blocker update could not be saved on this device.');
}
record = read()[target.identity];
if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
admission = await admit({
kind:target.kind + '-comment', repository:target.repository, number:target.number,
body:record.body, operationId:record.operation_id,
...(evidence.length ? {attachments:evidence} : {}),
});
const drafts = read();
if (drafts[target.identity]?.operation_id === record.operation_id) {
drafts[target.identity] = { ...drafts[target.identity], blocker_pending:true, blocker_until:String(until || '') };
write(drafts);
}
} else {
const replacementUntil = String(until || '').trim();
until = replacementUntil || record.blocker_until;
if (replacementUntil && replacementUntil !== record.blocker_until) {
const drafts = read();
if (drafts[target.identity]?.operation_id === record.operation_id) {
drafts[target.identity] = { ...drafts[target.identity], blocker_until:replacementUntil };
write(drafts);
record = drafts[target.identity];
}
}
}
try {
if (typeof completeEvidence === 'function') await completeEvidence();
} catch (_error) {
const error = new Error('Blocker is already queued; photo cleanup is pending.');
error.deliveryAdmitted = true;
throw error;
}
if (await transition(target, until) !== true) {
const error = new Error('Blocker was posted, but Today could not move on. Retry the planning step.');
error.deliveryAdmitted = true;
throw error;
}
const drafts = read();
if (drafts[target.identity]?.operation_id === record.operation_id) {
delete drafts[target.identity];
write(drafts);
}
return admission;
}
function blockerRecovery(identity) {
if (!validIdentity(identity)) return null;
const record = read()[identity];
return record?.blocker_pending === true ? { pending:true, until:String(record.blocker_until || '') } : null;
}
return { load, save, discard:identity => save(identity, ''), post, postBlocker, blockerRecovery };
}
function createTodayProgressActivity({ fetchJson, createPager, getActions, onActions, surfaceStatus, paint = () => {}, setStatus = () => {} }) {
let requestToken = 0;
let pager = null;
let target = null;
let actions = null;
const pathFor = (value, page) => {
const repository = String(value.repository || '').split('/').map(encodeURIComponent).join('/');
const resource = value.kind === 'pull' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(value.number) +
'/comments?' + (Number.isInteger(page) ? 'page=' + encodeURIComponent(page) + '&' : '') + 'limit=20';
};
const loadPage = page => fetchJson(pathFor(target, page));
async function open(value) {
const token = ++requestToken;
target = { ...value };
setStatus('Loading recent activity…');
try {
const page = await loadPage();
if (token !== requestToken || target.identity !== value.identity) return false;
pager = createPager({ loadPage });
actions = typeof getActions === 'function' ? await getActions() : null;
if (token !== requestToken || target.identity !== value.identity) return false;
onActions?.(actions);
paint(pager.reset(page));
setStatus('');
return true;
} catch (error) {
if (token === requestToken) setStatus('Recent activity unavailable. Retry.');
return false;
}
}
async function loadOlder() {
if (!pager || !target) return false;
const token = requestToken;
setStatus('Loading older activity…');
try {
const page = await pager.loadOlder();
if (token !== requestToken) return false;
paint(page);
setStatus('');
return true;
} catch (_error) {
if (token === requestToken) setStatus('Older activity unavailable. Retry.');
return false;
}
}
return {
open,
retry:() => target ? open(target) : Promise.resolve(false),
loadOlder,
actionHtml:comment => actions?.actionHtml?.(comment) || '',
surface:() => ({
context:{ kind:target?.kind, item:target }, pager,
status:surfaceStatus, render:paint,
}),
close:() => { requestToken++; target = null; pager = null; actions = null; setStatus(''); },
};
}
function mountTodayProgressActivity(qs, fetchJson, actionSource = {}, render) {
const options = actionSource.get && !actionSource.getActions ?
{ getActions:actionSource.get, isOffline:() => !navigator.onLine } : actionSource;
const escape = options.escape || (value => String(value).replace(/[&<>"']/g,
character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[character]));
render ||= options.render;
const list = qs('#today-progress-activity-list');
const optionsStatus = qs('#today-progress-activity-status');
let actionsWired = false;
const activity = createTodayProgressActivity({
fetchJson,
createPager:options.createPager || createConversationPager,
getActions:options.getActions,
surfaceStatus:optionsStatus,
onActions:actions => {
if (actionsWired || !actions?.wire) return;
actions.wire({ root:list, getSurface:() => activity.surface(),
isOffline:options.isOffline || (() => false), escapeHtml:escape });
actionsWired = true;
},
paint:state => {
list.innerHTML = (state.comments || []).map(comment => {
const author = comment.author || comment.user?.login || 'Unknown author';
const timing = comment.created_at ? ' · ' + new Date(comment.created_at).toLocaleString() : '';
return '<article class="today-progress-activity-item issue-comment" data-comment-id="' +
escape(String(comment.id)) + '"><div class="small muted">' +
escape(author) + escape(timing) + '</div><div class="markdown-content">' +
render(comment.body || 'No message body provided.') + '</div>' +
activity.actionHtml(comment) + '</article>';
}).join('');
qs('#today-progress-activity-count').textContent = state.total ?
String(state.comments.length) + ' of ' + String(state.total) + ' messages' : '';
qs('#load-older-today-progress-activity').hidden = !Number.isInteger(state.older_page);
},
setStatus:message => {
optionsStatus.textContent = message;
qs('#retry-today-progress-activity').hidden = !message.includes('unavailable');
},
});
qs('#retry-today-progress-activity').addEventListener('click', () => activity.retry());
qs('#load-older-today-progress-activity').addEventListener('click', async () => {
const list = qs('#today-progress-activity-list');
const previousHeight = list.scrollHeight;
const button = qs('#load-older-today-progress-activity');
button.disabled = true;
await activity.loadOlder();
list.scrollTop += list.scrollHeight - previousHeight;
button.disabled = false;
});
return activity;
}
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {}, moveOn = null, now = () => new Date() }) {
const sheet = qs('#today-progress-sheet');
const panel = qs('.today-progress-panel');
const title = qs('#today-progress-title');
const body = qs('#today-progress-body');
const bodyLabel = qs('#today-progress-body-label');
const status = qs('#today-progress-status');
const launcher = qs('[data-mobile-today-update]');
const blockerLauncher = qs('[data-mobile-today-blocked]');
const blockerButton = qs('#post-today-blocker');
const blockerReturn = qs('#today-blocker-return-at');
const blockerRecovery = qs('#today-progress-blocker-recovery');
const saveButton = qs('#save-today-progress');
const postButton = qs('#post-today-progress');
let openedTarget = null;
const localDateTime = value => {
const date = new Date(value || '');
if (!Number.isFinite(date.getTime())) return '';
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
};
const showBlockerRecovery = recovery => {
const pending = recovery?.pending === true;
body.disabled = pending;
saveButton.disabled = pending;
postButton.disabled = pending;
if (blockerButton) blockerButton.textContent = pending ? 'Finish moving on' : 'Post blocker & move on';
if (blockerReturn) blockerReturn.value = pending ? localDateTime(recovery.until) : '';
if (blockerRecovery) {
blockerRecovery.hidden = !pending;
blockerRecovery.textContent = pending ? 'Blocker queued—finish moving on. The posted update cannot be changed.' : '';
}
};
const update = () => {
const target = currentTarget();
launcher.hidden = !target;
if (blockerLauncher) blockerLauncher.hidden = !target;
if (openedTarget && target?.identity !== openedTarget.identity) mentions?.dismiss?.();
};
const checkpoint = async () => {
if (!openedTarget) return false;
try {
await photos?.checkpoint?.();
const checkpointAttachments = await photos?.serialize?.() || [];
if (progress.save(openedTarget.identity, body.value, checkpointAttachments)) return true;
status.textContent = 'Update must be 2,000 characters or fewer and device storage must be available.';
return false;
} catch (error) {
status.textContent = error.message + ' Your photos remain here; retry.';
return false;
}
};
const close = () => {
voice?.cancel?.();
activity?.close?.();
mentions?.dismiss?.();
if (sheet.open) sheet.close();
panel?.classList?.remove('blocker-mode');
openedTarget = null;
};
const open = async blockerMode => {
const target = currentTarget();
if (!target) return;
mentions?.dismiss?.();
openedTarget = target;
panel?.classList?.[blockerMode ? 'add' : 'remove']('blocker-mode');
if (title) title.textContent = blockerMode ? 'Report blocker' : 'Add progress update';
if (bodyLabel) bodyLabel.textContent = blockerMode ? 'Blocker description' : 'Update';
qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
body.value = progress.load(target.identity);
showBlockerRecovery(progress.blockerRecovery?.(target.identity));
sheet.showModal();
if (!blockerMode) {
status.textContent = 'Restoring saved photo evidence…';
try {
await voice?.open?.(target.identity);
await photos?.open?.(target);
await activity?.open?.(target);
status.textContent = '';
}
catch (error) { status.textContent = error.message + ' You can retry by reopening this update.'; }
}
else status.textContent = '';
body.focus();
};
launcher.addEventListener('click', () => open(false));
blockerLauncher?.addEventListener('click', () => open(true));
const setReturn = preset => {
const date = new Date(now());
if (preset === 'later-today') date.setHours(date.getHours() + 4);
else {
date.setDate(date.getDate() + 1);
date.setHours(9, 0, 0, 0);
}
blockerReturn.value = localDateTime(date);
};
qs('[data-today-blocker-preset="later-today"]')?.addEventListener('click', () => setReturn('later-today'));
qs('[data-today-blocker-preset="tomorrow"]')?.addEventListener('click', () => setReturn('tomorrow'));
qs('[data-today-blocker-custom]')?.addEventListener('click', () => blockerReturn?.focus?.());
qs('#cancel-today-progress').addEventListener('click', async () => {
if (await checkpoint()) close();
});
sheet.addEventListener('cancel', async event => {
event.preventDefault();
if (await checkpoint()) close();
});
qs('#save-today-progress').addEventListener('click', async () => {
if (!await checkpoint()) return;
announce('Progress update saved privately to this Today item.');
close();
});
blockerButton?.addEventListener('click', async () => {
const target = currentTarget();
if (!target || target.identity !== openedTarget?.identity) {
status.textContent = 'The active Today item changed. Close and open its update again.';
return;
}
const returnInput = blockerReturn;
const wakeAt = new Date(returnInput?.value || '');
if (!body.value.trim()) {
status.textContent = 'Describe what is blocking this Today item.';
body.focus();
return;
}
if (!Number.isFinite(wakeAt.getTime()) || wakeAt.getTime() <= Date.now()) {
status.textContent = 'Choose a valid future return time.';
returnInput?.focus?.();
return;
}
blockerButton.disabled = true;
status.textContent = 'Posting blocker before moving Today…';
try {
await photos?.checkpoint?.();
const attachments = await photos?.serialize?.() || [];
const admission = await progress.postBlocker(
target, body.value, attachments, () => photos?.complete?.(), wakeAt.toISOString(), moveOn
);
onAdmitted(admission);
announce('Blocker queued, deferred to Later, and Today moved on.');
close();
} catch (error) {
status.textContent = error.deliveryAdmitted ? error.message : error.message + ' Your blocker remains here; retry.';
body.focus();
} finally {
blockerButton.disabled = false;
}
});
qs('#post-today-progress').addEventListener('click', async () => {
const target = currentTarget();
if (!target || target.identity !== openedTarget?.identity) {
status.textContent = 'The active Today item changed. Close and open its update again.';
return;
}
const button = qs('#post-today-progress');
button.disabled = true;
status.textContent = 'Saving for delivery…';
try {
await photos?.checkpoint?.();
const attachments = await photos?.serialize?.() || [];
const admission = await progress.post(target, body.value, attachments, () => photos?.complete?.());
onAdmitted(admission);
announce(admission.background ?
'Progress update queued for delivery. Today is still on the same item.' :
'Progress update saved for next launch. Today is still on the same item.');
close();
} catch (error) {
status.textContent = error.deliveryAdmitted ?
error.message + ' Retry to finish local cleanup; delivery will not be queued again.' :
error.message + ' Your update remains on this item; retry.';
body.focus();
} finally {
button.disabled = false;
}
});
update();
return { update };
}
function createTodayProgressPhotos({ qs, document, issueAttachment, fetchJson, createStore, createDrafts, getLogin }) {
let target = null;
let drafts = null;
const controller = issueAttachment.mount({
maxFiles:5, input:qs('#today-progress-attachment'),
inputs:[qs('#take-today-progress-photo'), qs('#today-progress-attachment')],
preview:qs('#today-progress-attachment-preview'), image:qs('#today-progress-attachment-image'),
meta:qs('#today-progress-attachment-meta'), remove:qs('#remove-today-progress-attachment'),
tray:qs('#today-progress-attachment-tray'), earlier:qs('#move-today-progress-attachment-earlier'),
later:qs('#move-today-progress-attachment-later'), note:qs('#today-progress-attachment-note'),
noteLabel:qs('#today-progress-attachment-note-label'), status:qs('#today-progress-status'),
onChange:() => drafts?.checkpoint('today').catch(() => {}),
onCheckpoint:() => drafts.checkpoint('today'),
readyMessage:'Photo ready to post with this Today update.',
removedMessage:'Photo removed. Your progress text is unchanged.',
editor:{
document, edit:qs('#edit-today-progress-attachment'), dialog:qs('#issue-evidence-editor'),
canvas:qs('#issue-evidence-editor-canvas'), exportCanvas:qs('#issue-evidence-editor-export'),
crop:qs('#crop-issue-evidence'), redact:qs('#redact-issue-evidence'),
highlight:qs('#highlight-issue-evidence'), arrow:qs('#arrow-issue-evidence'),
undo:qs('#undo-issue-evidence-edit'), reset:qs('#reset-issue-evidence-edit'),
cancel:qs('#cancel-issue-evidence-edit'), apply:qs('#apply-issue-evidence-edit'),
status:qs('#issue-evidence-editor-status'), appliedMessage:'Edited photo ready for this Today update.',
},
createObjectURL:file => URL.createObjectURL(file), revokeObjectURL:url => URL.revokeObjectURL(url),
upload:payload => {
if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
return Promise.reject(new Error('The active Today item changed. Reopen its update before posting.'));
}
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
const resource = target.kind === 'pull' ? 'pulls' : 'issues';
return fetchJson('api/v1/repos/' + repository + '/' + resource + '/' +
encodeURIComponent(payload.number) + '/attachments', {
method:'POST', headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
body:issueAttachment.multipart(payload),
});
},
});
const store = createStore({ indexedDB:globalThis.indexedDB, getOwnerLogin:getLogin, scope:'today-progress' });
drafts = createDrafts({ store, lanes:{ today:{ controller, onError:error => {
qs('#today-progress-status').textContent = error.message + ' Your photos remain here; retry.';
} } } });
return {
has:() => Boolean(controller.state()), serialize:() => controller.serialize(),
checkpoint:() => drafts.checkpoint('today'),
open:async value => {
if (drafts.hasTarget('today')) await drafts.switchTo('today', value);
else await drafts.open('today', value);
target = { ...value };
},
complete:async () => { await drafts.complete('today'); target = null; },
};
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createTodayProgress;
module.exports.createView = createTodayProgressView;
module.exports.createPhotos = createTodayProgressPhotos;
module.exports.createActivity = createTodayProgressActivity;
module.exports.mountActivity = mountTodayProgressActivity;
}
+4 -2
View File
@@ -283,6 +283,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
qs('#open-today-recaps').focus();
};
const saveDraft = async button => {
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
button.disabled = true;
qs('#today-recap-status').textContent = 'Saving recap…';
try {
@@ -290,7 +291,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
qs('#today-recap-status').textContent = 'Recap saved to your account.';
await loadHistory(); render(); button.hidden = true;
close();
openWrapUp(handoff.actual_minutes);
openWrapUp(handoff.actual_minutes, workedItems);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
} finally { button.disabled = false; }
@@ -303,6 +304,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
return;
}
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
button.disabled = true;
qs('#today-recap-status').textContent = 'Logging selected time…';
try {
@@ -310,7 +312,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
await loadHistory(); render(); button.hidden = true;
close();
openWrapUp(actualMinutes);
openWrapUp(actualMinutes, workedItems);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
render();
+225
View File
@@ -0,0 +1,225 @@
function createTodaySessionSync({
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval,
}) {
let current = null;
let ownedRevision = 0;
let pollTimer = null;
let requestTail = Promise.resolve();
let latestSnapshot = null;
let publishRequested = 0;
let publishSent = 0;
const endpoint = 'api/v1/today/session';
const deviceId = () => String(getDeviceId?.() || '').trim();
function serialize(operation) {
const result = requestTail.then(operation, operation);
requestTail = result.catch(() => null);
return result;
}
function adopt(session) {
if (!session || !Number.isInteger(session.revision)) return null;
const previousOwned = current?.device_id === deviceId() &&
(current?.running || Number.isFinite(current?.break_deadline_at));
current = session;
if (session.device_id === deviceId()) {
ownedRevision = session.revision;
onRemote(null);
} else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) {
if (previousOwned) {
timer?.pause?.();
onTransferred(session);
}
onRemote(session);
} else {
onRemote(null);
}
return session;
}
async function refreshNow() {
try {
onStatus('syncing');
const session = await fetchJson(endpoint);
onStatus('online');
return adopt(session);
} catch (error) {
onStatus('offline', error);
return null;
}
}
const refresh = () => serialize(refreshNow);
function claim() {
return serialize(async () => {
if ((!current?.running && !Number.isFinite(current?.break_deadline_at)) ||
!current.identity || current.device_id === deviceId() || !deviceId()) return null;
try {
onStatus('syncing');
const body = {
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
elapsed_ms:current.elapsed_ms, running:true,
};
if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null;
const session = await fetchJson(endpoint, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify(body),
});
adopt(session);
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
return session;
} catch (error) {
if (error?.status === 409 || error?.code === 'session_changed') {
onStatus('conflict', error);
await refreshNow();
} else {
onStatus('offline', error);
}
return null;
}
});
}
async function publishNow(snapshot) {
if (!snapshot?.identity || !deviceId()) return null;
try {
onStatus('syncing');
const body = {
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
running:Boolean(snapshot.running),
};
if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) {
body.break_deadline_at = Number.isFinite(snapshot.break_deadline_at) ?
Math.floor(snapshot.break_deadline_at) : null;
}
const session = await fetchJson(endpoint, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify(body),
});
adopt(session);
return session;
} catch (error) {
if (error?.status === 409 || error?.code === 'session_changed') {
onStatus('conflict', error);
await refreshNow();
} else {
onStatus('offline', error);
}
return null;
}
}
function publish(snapshot = timer?.snapshot?.()) {
snapshot = snapshot?.identity ? snapshot : latestSnapshot;
if (!snapshot?.identity || !deviceId()) return Promise.resolve(null);
latestSnapshot = snapshot;
publishRequested += 1;
return serialize(async () => {
if (publishSent === publishRequested) return current;
if (current?.device_id && current.device_id !== deviceId()) {
publishSent = publishRequested;
return current;
}
const version = publishRequested;
const result = await publishNow(latestSnapshot);
publishSent = current?.device_id && current.device_id !== deviceId()
? publishRequested
: version;
return result;
});
}
const pulse = () => current?.device_id === deviceId() && current?.running
? publish()
: refresh();
return {
refresh, claim, publish,
session:() => current,
start(intervalMs = 15000) {
if (pollTimer !== null) return false;
pulse();
pollTimer = setInterval?.(pulse, intervalMs);
pollTimer?.unref?.();
return true;
},
stop() {
if (pollTimer === null) return false;
clearInterval?.(pollTimer);
pollTimer = null;
return true;
},
};
}
function todaySessionHandoffSummary(session, item) {
if (Number.isFinite(session?.break_deadline_at)) {
const end = new Date(session.break_deadline_at).toLocaleTimeString([], {
hour:'numeric', minute:'2-digit',
});
return `On break until ${end} · ready to resume here`;
}
const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000));
return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
}
function attachTodaySessionHandoff({
fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer,
}) {
const deviceKey = 'stackchain.today-session-device.v1';
const getDeviceId = () => {
let value = storage.getItem(deviceKey);
if (!value) {
value = globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2);
storage.setItem(deviceKey, value);
}
return value;
};
let offered = null;
const sessionStatus = qs('#today-session-sync-status');
const showSessionStatus = message => {
sessionStatus.textContent = message;
sessionStatus.hidden = !message;
};
const sync = createTodaySessionSync({
fetchJson, getDeviceId, timer,
onStatus:state => showSessionStatus(
state === 'syncing' ? 'Session syncing…' :
(state === 'offline' ? 'Session offline · will retry.' : '')
),
onRemote:session => {
offered = session;
const handoff = qs('#today-session-handoff');
handoff.hidden = !session;
if (!session) return;
const item = items().find(entry => identity(entry) === session.identity);
qs('#today-session-handoff-summary').textContent = todaySessionHandoffSummary(session, item);
qs('#continue-today-session').textContent = Number.isFinite(session.break_deadline_at) ?
'Resume Today here' : 'Continue here';
},
onTransferred:() => {
showSessionStatus('Session continued on another device.');
announce('Today continued on another device. Timer paused here.');
renderTimer();
},
});
qs('#continue-today-session').addEventListener('click', async () => {
const target = offered;
const claimed = await sync.claim();
if (!claimed || !target) return;
selectToday();
const item = items().find(entry => identity(entry) === claimed.identity);
if (item) startItem(item);
else announce('Today session moved here; refresh work to open its item.');
renderTimer();
});
sync.start(5000);
return sync;
}
if (typeof module !== 'undefined' && module.exports) module.exports = {
createTodaySessionSync, attachTodaySessionHandoff, todaySessionHandoffSummary,
};
+240
View File
@@ -0,0 +1,240 @@
function createTodaySummary({ storage = null, getLogin = () => '', share = null, copy = null } = {}) {
let draft = null;
let draftKey = '';
const storageKey = () => {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? 'stackchain.today-summary-draft.v1.' + login : '';
};
const syncAccount = () => {
const key = storageKey();
if (key !== draftKey) {
draft = null;
draftKey = key;
}
};
const copyRows = rows => rows.map(row => ({ ...row }));
const snapshot = () => {
syncAccount();
return draft ? {
...draft,
worked:copyRows(draft.worked),
tomorrow:copyRows(draft.tomorrow),
} : null;
};
const persist = () => {
const key = storageKey();
if (storage && key && draft) storage.setItem(key, JSON.stringify(draft));
};
const validRows = (rows, includeTime) => Array.isArray(rows) && rows.length <= 20 && rows.every(row =>
row && typeof row.identity === 'string' && row.identity.length > 0 && row.identity.length <= 500 &&
typeof row.title === 'string' && row.title.length <= 180 && typeof row.context === 'string' &&
row.context.length <= 180 && typeof row.include === 'boolean' && (!includeTime ||
(Number.isInteger(row.actual_minutes) && row.actual_minutes >= 0 && row.actual_minutes <= 1440))
);
const load = () => {
const key = storageKey();
if (!storage || !key) return null;
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000) {
if (saved !== null) storage.removeItem(key);
return null;
}
return saved;
} catch (_error) {
try { storage.removeItem(key); } catch (_ignored) {}
return null;
}
};
const cleanRow = (row, includeTime) => {
const cleaned = {
identity:String(row.identity),
title:String(row.title || row.label || row.identity).slice(0, 180),
context:String(row.context || '').slice(0, 180),
};
if (includeTime) cleaned.actual_minutes = Math.min(1440, Math.max(0, Number(row.actual_minutes) || 0));
cleaned.include = true;
return cleaned;
};
const selectedLines = (rows, showTime = false) => rows.filter(row => row.include).map(row =>
'- ' + row.title + (showTime ? ' (' + row.actual_minutes + 'm)' : '')
);
const discard = () => {
const key = storageKey();
if (storage && key) storage.removeItem(key);
draft = null;
};
return {
begin(worked, tomorrow) {
draftKey = storageKey();
draft = {
worked:(worked || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, true)),
tomorrow:(tomorrow || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, false)),
include_actuals:false,
note:'',
};
persist();
return snapshot();
},
snapshot,
restore() {
draftKey = storageKey();
draft = load();
return Boolean(draft);
},
choose(section, identity, include) {
syncAccount();
const row = draft?.[section]?.find(candidate => candidate.identity === identity);
if (!row || typeof include !== 'boolean') return false;
row.include = include;
persist();
return true;
},
includeActuals(include) {
syncAccount();
if (!draft || typeof include !== 'boolean') return false;
draft.include_actuals = include;
persist();
return true;
},
setNote(note) {
syncAccount();
if (!draft) return false;
draft.note = String(note || '').trim().slice(0, 1000);
persist();
return true;
},
discard,
text() {
syncAccount();
if (!draft) return '';
const sections = [];
const worked = selectedLines(draft.worked, draft.include_actuals);
const tomorrow = selectedLines(draft.tomorrow);
if (worked.length) sections.push(['Today', ...worked].join('\n'));
if (tomorrow.length) sections.push(['Tomorrow', ...tomorrow].join('\n'));
if (draft.note) sections.push('Note\n' + draft.note);
return sections.join('\n\n');
},
async shareSummary() {
const text = this.text();
if (!text) throw new Error('Select at least one summary item or add a note.');
if (typeof share === 'function') {
try {
await share({ title:'Today summary', text });
discard();
return { status:'shared' };
} catch (error) {
if (error?.name === 'AbortError') return { status:'canceled' };
throw error;
}
}
if (typeof copy !== 'function') throw new Error('Sharing is unavailable on this device.');
await copy(text);
discard();
return { status:'copied' };
},
};
}
function createTodaySummaryView({ summary, qs, escapeHtml }) {
const rowMarkup = (section, row) => '<label class="today-summary-item"><input type="checkbox" data-summary-section="' +
section + '" data-summary-identity="' + escapeHtml(row.identity) + '"' + (row.include ? ' checked' : '') +
'><span><strong>' + escapeHtml(row.title) + '</strong><span class="small muted">' +
escapeHtml(row.context) + '</span></span></label>';
function close() {
qs('#today-summary-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
qs('#start-work-session')?.focus();
}
function render() {
const draft = summary.snapshot();
if (!draft) return;
qs('#today-summary-worked').innerHTML = draft.worked.map(row => rowMarkup('worked', row)).join('') ||
'<p class="small muted">No worked items selected.</p>';
qs('#today-summary-tomorrow').innerHTML = draft.tomorrow.map(row => rowMarkup('tomorrow', row)).join('') ||
'<p class="small muted">Nothing scheduled for tomorrow.</p>';
qs('#today-summary-include-actuals').checked = draft.include_actuals;
qs('#today-summary-note').value = draft.note;
qs('#today-summary-preview').textContent = summary.text();
qs('#today-summary-sheet').querySelectorAll('[data-summary-identity]').forEach(input => {
input.addEventListener('change', () => {
summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked);
render();
});
});
}
function show() {
qs('#today-summary-status').textContent = '';
render();
qs('#today-summary-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
requestAnimationFrame(() => (qs('#today-summary-worked input') || qs('#share-today-summary')).focus());
}
function open(worked, tomorrow) {
summary.begin(worked, tomorrow);
show();
}
async function shareDraft(button) {
button.disabled = true;
qs('#today-summary-status').textContent = 'Opening share options…';
try {
const result = await summary.shareSummary();
if (result.status === 'canceled') {
qs('#today-summary-status').textContent = 'Share canceled. Your private draft is still here.';
return;
}
qs('#my-work-action-status').textContent = result.status === 'shared' ?
'Today summary shared.' : 'Today summary copied to your clipboard.';
close();
} catch (error) {
qs('#today-summary-status').textContent = error.message || 'Summary could not be shared. Your draft is unchanged.';
} finally {
button.disabled = false;
}
}
function bind() {
qs('#close-today-summary').addEventListener('click', close);
qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); });
qs('#today-summary-include-actuals').addEventListener('change', event => {
summary.includeActuals(event.currentTarget.checked); render();
});
qs('#today-summary-note').addEventListener('input', event => {
summary.setNote(event.currentTarget.value);
qs('#today-summary-preview').textContent = summary.text();
});
qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
}
return {
open, close, render, bind, shareDraft,
resume() { if (summary.restore()) show(); },
};
}
function setupTodaySummary({ qs, escapeHtml, getLogin }) {
const summary = createTodaySummary({
storage:localStorage,
getLogin,
share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null,
copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null,
});
const view = createTodaySummaryView({ summary, qs, escapeHtml });
view.bind();
return view;
}
if (typeof module !== 'undefined' && module.exports) {
createTodaySummary.createView = createTodaySummaryView;
createTodaySummary.setup = setupTodaySummary;
module.exports = createTodaySummary;
}
+38 -9
View File
@@ -10,6 +10,8 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
let retryTimer = null;
let retryAttempt = 0;
let expiredCount = 0;
let discardedCount = 0;
let recoveryNotice = { discarded: 0, until: 0 };
const knownOperationKeys = new Set();
function cancelRetry() {
@@ -108,10 +110,28 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
const candidate = storage.key?.(index);
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
}
const records = [...keys].map(recordKey => {
const record = JSON.parse(storage.getItem(recordKey) || 'null');
return record ? { ...record, recordKey } : null;
}).filter(record => record?.operation);
const records = [];
for (const recordKey of keys) {
let record;
try {
record = JSON.parse(storage.getItem(recordKey) || 'null');
} catch (_error) {
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
discardedCount += 1;
continue;
}
const operation = record?.operation;
const valid = operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) &&
typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at));
if (valid) records.push({ ...record, recordKey });
else {
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
discardedCount += 1;
}
}
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
now() - Number(record.queued_at) > maxOfflineMs);
expired.forEach(record => {
@@ -173,7 +193,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false;
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() + operations.length }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('today');
@@ -272,14 +292,23 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
operations = pending();
}
adopt(plan);
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
const stillPending = pending().length;
if (discardedCount) recoveryNotice = { discarded: discardedCount, until: now() + 5000 };
const recovered = recoveryNotice.until > now() ? recoveryNotice.discarded : 0;
onStatus?.(stillPending ? 'pending' : hadConflict ? 'full' :
expiredCount ? 'expired' : recovered ? 'recovered' : 'saved',
expiredCount ? { count: expiredCount } : recovered ? { discarded: recovered } : {});
if (!stillPending) discardedCount = 0;
retryAttempt = 0;
cancelRetry();
return !hadConflict;
} catch (error) {
if (pending().length) scheduleRetry(error, ownerKey);
else onStatus?.('error');
const stillPending = pending().length;
if (discardedCount) recoveryNotice = { discarded: discardedCount, until: now() + 5000 };
const recovered = recoveryNotice.until > now() ? recoveryNotice.discarded : 0;
if (stillPending) scheduleRetry(error, ownerKey);
else onStatus?.(recovered ? 'recovered' : 'error', recovered ? { discarded: recovered } : {});
discardedCount = 0;
return false;
}
}
+368 -8
View File
@@ -1,11 +1,11 @@
function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange = () => {} }) {
const key = () => {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
};
const empty = () => ({
version:1, active_identity:'', entries:{}, away_at:null,
pending_interruption:null, attention_interruption:null,
pending_interruption:null, attention_interruption:null, capture_interruption:null, search_interruption:null, detour_interruption:null, timed_break:null,
});
const read = () => {
const ownerKey = key();
@@ -24,6 +24,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
if (!ownerKey || !storage) return false;
try {
storage.setItem(ownerKey, JSON.stringify(state));
onChange(snapshot());
return true;
} catch (_error) {
return false;
@@ -41,6 +42,30 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
const validCapture = state => {
const pending = state.capture_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
const validSearch = state => {
const pending = state.search_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
const validDetour = state => {
const pending = state.detour_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(pending.reason) ?
{ identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
};
const validBreak = state => {
const value = state.timed_break;
return value && typeof value.identity === 'string' && value.identity &&
Number.isFinite(value.deadline_at) && value.deadline_at >= 0 ?
{ identity:value.identity, deadline_at:value.deadline_at, expired:now() >= value.deadline_at } : null;
};
const settle = (state, at = now()) => {
const entry = state.entries[state.active_identity];
if (!entry?.running) return state;
@@ -54,12 +79,38 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
const state = read();
const selected = identity || state.active_identity;
const entry = state.entries[selected];
if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false };
const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0);
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
const result = !selected || !entry ?
{ identity:selected, elapsed_ms:0, running:false } :
{
identity:selected,
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0),
running:Boolean(entry.running),
};
const timedBreak = validBreak(state);
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
return result;
};
return {
adopt(identity, elapsedMs, running) {
if (!key() || typeof identity !== 'string' || !identity ||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
const state = read();
settle(state);
state.active_identity = identity;
state.entries[identity] = {
elapsed_ms:Math.floor(Number(elapsedMs)),
started_at:running ? now() : null,
running:Boolean(running),
};
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
activate(identity) {
if (!key() || typeof identity !== 'string' || !identity) return false;
const state = read();
@@ -73,6 +124,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
pause() {
@@ -89,6 +143,32 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
entry.started_at = now();
entry.running = true;
}
state.timed_break = null;
return write(state);
},
startBreak(minutes) {
const duration = Number(minutes);
if (!Number.isInteger(duration) || duration < 1 || duration > 120) return false;
const state = read();
const identity = state.active_identity;
if (!identity || !state.entries[identity]) return false;
settle(state);
state.away_at = null;
state.pending_interruption = null;
state.timed_break = { identity, deadline_at:now() + duration * 60000 };
return write(state) ? validBreak(state) : false;
},
breakSnapshot() {
return validBreak(read());
},
resumeBreak() {
const state = read();
const pending = validBreak(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry || state.active_identity !== pending.identity || entry.running) return false;
state.timed_break = null;
entry.started_at = now();
entry.running = true;
return write(state);
},
stop() {
@@ -97,6 +177,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
beginAttention() {
@@ -129,6 +212,113 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed:pending.resume } : null;
},
beginCapture() {
const state = read();
const existing = validCapture(state);
if (existing) return existing;
const identity = state.active_identity;
const entry = state.entries[identity];
if (!identity || !entry) return null;
const resume = Boolean(entry.running);
if (resume) settle(state);
state.away_at = null;
state.capture_interruption = { identity, resume };
return write(state) ? { ...state.capture_interruption } : null;
},
captureInterruption() {
return validCapture(read());
},
abandonCapture() {
const state = read();
const pending = validCapture(state);
if (!pending) return null;
state.capture_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed:false } : null;
},
returnFromCapture() {
const state = read();
const pending = validCapture(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry) return null;
const resumed = pending.resume && state.active_identity === pending.identity;
if (resumed && !entry.running) {
entry.started_at = now();
entry.running = true;
}
state.capture_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed } : null;
},
beginSearch() {
const state = read();
const existing = validSearch(state);
if (existing) return existing;
const identity = state.active_identity;
const entry = state.entries[identity];
if (!identity || !entry?.running) return null;
const resume = true;
settle(state);
state.away_at = null;
state.search_interruption = { identity, resume };
return write(state) ? { ...state.search_interruption } : null;
},
searchInterruption() {
return validSearch(read());
},
abandonSearch() {
const state = read();
const pending = validSearch(state);
if (!pending) return null;
state.search_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed:false } : null;
},
returnFromSearch() {
const state = read();
const pending = validSearch(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry) return null;
const resumed = pending.resume && state.active_identity === pending.identity;
if (resumed && !entry.running) {
entry.started_at = now();
entry.running = true;
}
state.search_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed } : null;
},
beginDetour(reason) {
if (!['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(reason)) return null;
const state = read();
const existing = validDetour(state);
if (existing) return existing;
const identity = state.active_identity;
const entry = state.entries[identity];
if (!identity || !entry?.running) return null;
settle(state);
state.away_at = null;
state.detour_interruption = { identity, resume:true, reason };
return write(state) ? { ...state.detour_interruption } : null;
},
detourInterruption() {
return validDetour(read());
},
returnFromDetour(reason = '') {
const state = read();
const pending = validDetour(state);
if (reason && pending?.reason !== reason) return null;
const entry = pending && state.entries[pending.identity];
if (!pending || !entry || state.active_identity !== pending.identity) return null;
const resumed = pending.resume && !entry.running;
if (resumed) {
entry.started_at = now();
entry.running = true;
}
state.detour_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed, reason:pending.reason } : null;
},
markAway() {
const state = read();
const entry = state.entries[state.active_identity];
@@ -200,9 +390,28 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
};
}
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onResume, onComplete, onCapture }) {
let progress = null;
let runway = null;
let breakView = null;
if (typeof createTodayBreak === 'function') {
breakView = createTodayBreak({timer, qs:selector => queryAll(selector)[0], queryAll, onChange:() => render(), onResume:onResume || onReopen});
}
const captureView = createTodayCaptureInterruption({
timer,
banner:queryAll('#today-capture-interruption')[0],
label:queryAll('#today-capture-interruption-label')[0],
getItem,
onReturn:() => render(),
});
const searchView = createTodaySearchInterruption({
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
onReturn:identity => onReopen?.(identity),
});
const detourView = createTodayDetourInterruption({
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
onReturn:identity => onReopen?.(identity),
});
queryAll('[data-mobile-today-open]').forEach(button =>
button.addEventListener('click', () => {
const identity = timer.snapshot().identity;
@@ -248,6 +457,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
};
const render = () => {
const snapshot = timer.snapshot();
breakView?.render();
const active = Boolean(progress && isActive() && snapshot.identity);
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
queryAll('[data-mobile-today-open]').forEach(element => {
@@ -302,19 +512,45 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
button.setAttribute('aria-pressed', String(!snapshot.running));
});
};
return {
const view = {
open(identity, active) {
const current = timer.snapshot();
if (active && current.identity !== identity) timer.activate(identity);
else if (!active) timer.stop();
render();
},
reopen(identity) { onReopen?.(identity); },
search(action, ...args) { return searchView[action]?.(...args); },
beginDetour(reason) { return detourView.open(reason); },
finishDetour(reason) { return detourView.finish(reason); },
finish() { timer.stop(); progress = null; runway = null; render(); },
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
reset() { progress = null; runway = null; render(); },
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); },
beginCapture() {
const result = captureView.open();
this.restoreCapture();
render();
return result;
},
restoreCapture() {
const result = captureView.restore();
const button = queryAll('#save-unfiled-issue')[0];
if (button) button.textContent = result ? 'Save & return to Today' : 'Save to Drafts';
return result;
},
finishCapture() { return captureView.finish(); },
transferCapture() { return captureView.transfer(); },
render,
};
queryAll('#new-issue').forEach(button => button.addEventListener('click', () => {
view.beginCapture();
onCapture?.();
}));
if (timer.captureInterruption) view.restoreCapture();
searchView.restore();
detourView.restore();
return view;
}
function createTodayBudgetReplan({ timer, openPlan }) {
@@ -366,9 +602,133 @@ function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel
};
}
function createTodaySearchInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
const render = (pending = timer.searchInterruption?.()) => {
queryAll('[data-search-today-interruption]').forEach(element => { element.hidden = !pending; });
queryAll('[data-search-today-label]').forEach(element => {
element.textContent = pending ? 'Today paused · ' +
String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
});
onChange?.();
return pending;
};
const view = {
open() {
const wasRunning = timer.snapshot().running;
const pending = timer.beginSearch?.();
render(pending || timer.searchInterruption?.());
if (wasRunning && !pending) {
const status = queryAll('#cmd-search-action-status')[0];
if (status) status.textContent = 'Search is open, but Today timing could not be paused on this device.';
}
return pending;
},
restore() { return render(); },
finish() {
const result = timer.returnFromSearch?.();
render(null);
return result;
},
transfer() {
const result = timer.abandonSearch?.();
render(null);
return result;
},
};
queryAll('#open-palette').forEach(button => button.addEventListener('click', () => view.open()));
queryAll('[data-return-from-search]').forEach(button => button.addEventListener('click', () => {
const pending = timer.searchInterruption?.();
view.finish();
if (pending) onReturn?.(pending.identity);
}));
if (typeof MutationObserver !== 'undefined') {
const overlays = [...queryAll('#cmd-palette'), ...queryAll('#search-preview')];
const observer = new MutationObserver(() => {
if (overlays.every(element => !element.classList.contains('open'))) view.finish();
});
overlays.forEach(element => observer.observe(element, {attributes:true, attributeFilter:['class']}));
}
return view;
}
function createTodayCaptureInterruption({ timer, banner, label, getItem, getItemLabel, onReturn }) {
banner ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption');
label ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption-label');
const render = pending => {
if (!banner || !label) return;
banner.hidden = !pending;
label.textContent = pending ? 'Today paused · ' +
String(getItem?.(pending.identity)?.title || getItemLabel?.(pending.identity) || 'Current Today item') : '';
};
return {
open() {
const pending = timer.beginCapture();
render(pending);
return pending;
},
restore() {
const pending = timer.captureInterruption();
render(pending);
return pending;
},
finish() {
const result = timer.returnFromCapture();
render(null);
if (result) onReturn?.(result);
return result;
},
transfer() {
const result = timer.abandonCapture();
render(null);
return result;
},
};
}
function createTodayDetourInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
const render = (pending = timer.detourInterruption?.()) => {
queryAll('[data-today-detour]').forEach(element => { element.hidden = !pending; });
queryAll('[data-today-detour-label]').forEach(element => {
element.textContent = pending ? 'Today paused · ' +
String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
});
onChange?.();
return pending;
};
const view = {
open(reason) {
const pending = timer.beginDetour?.(reason);
render(pending || timer.detourInterruption?.());
return pending;
},
restore() { return render(); },
finish(reason) {
const result = timer.returnFromDetour?.(reason);
if (result || !reason) render(null);
return result;
},
};
queryAll('[data-return-from-detour]').forEach(button => button.addEventListener('click', () => {
const pending = timer.detourInterruption?.();
view.finish();
if (pending) onReturn?.(pending.identity);
}));
if (typeof MutationObserver !== 'undefined') {
queryAll('#find-work-sheet').forEach(sheet => new MutationObserver(() => {
const pending = timer.detourInterruption?.();
if (sheet.classList.contains('open')) view.open('find');
else if (pending?.reason === 'find') view.finish();
}).observe(sheet, {attributes:true, attributeFilter:['class']}));
}
return view;
}
if (typeof module !== 'undefined' && module.exports) {
createTodayTimer.createView = createTodayTimerView;
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
createTodayTimer.createSearchInterruption = createTodaySearchInterruption;
createTodayTimer.createCaptureInterruption = createTodayCaptureInterruption;
createTodayTimer.createDetourInterruption = createTodayDetourInterruption;
module.exports = createTodayTimer;
}
+24 -1
View File
@@ -127,6 +127,29 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return saved;
}
function capture(item) {
const id = identity(item);
const ids = read();
const index = ids.indexOf(id);
if (index < 0) return null;
return { id, index, ids: ids.slice(), plan: planning() };
}
function restore(snapshot) {
if (!snapshot || typeof snapshot.id !== 'string' || !Array.isArray(snapshot.ids)) return 'unavailable';
const current = read();
const expected = snapshot.ids.filter(id => id !== snapshot.id);
if (current.includes(snapshot.id)) return 'changed';
if (current.length >= limit) return 'full';
if (current.length !== expected.length || current.some((id, index) => id !== expected[index])) return 'changed';
if (!write(snapshot.ids)) return 'unavailable';
if (!replacePlanning(snapshot.plan)) {
write(current);
return 'unavailable';
}
return 'restored';
}
function move(item, direction) {
const ids = read();
const index = ids.indexOf(identity(item));
@@ -177,7 +200,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
};
}
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, capture, restore, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
+9 -2
View File
@@ -53,6 +53,7 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
let actualMinutes = {};
let workedItems = [];
function close() {
qs('#today-wrap-up-sheet').hidden = true;
@@ -75,8 +76,9 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
});
}
function open(items, recapActualMinutes = {}) {
function open(items, recapActualMinutes = {}, recapWorkedItems = []) {
actualMinutes = { ...recapActualMinutes };
workedItems = recapWorkedItems.map(item => ({...item}));
controller.open(items);
qs('#today-wrap-up-status').textContent = '';
render();
@@ -89,10 +91,15 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
button.disabled = true;
qs('#today-wrap-up-status').textContent = 'Saving tomorrow’s plan…';
try {
const tomorrowItems = controller.snapshot().filter(row => row.schedule).map(row => ({
identity:row.identity,
title:String(row.item.title || row.identity).slice(0, 180),
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
}));
const result = await controller.finish();
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
close();
onComplete(result, actualMinutes);
onComplete(result, actualMinutes, workedItems, tomorrowItems);
} catch (error) {
qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.';
} finally {
+28 -7
View File
@@ -1,21 +1,26 @@
function createVoiceConversationCapture({
Recognition, elements, transcriptStore = null, getLogin = () => '',
createEvent = name => new Event(name, {bubbles:true}),
maxLength = 0, draftLabel = '',
}) {
const supported = typeof Recognition === 'function';
const label = String(draftLabel || elements.root.dataset?.draftLabel || 'reply').trim() || 'reply';
let recognition = null;
let target = '';
let generation = 0;
elements.root.hidden = !supported;
if (!supported) {
elements.status.textContent = 'Voice capture is unavailable; type the reply instead.';
elements.status.textContent = 'Voice capture is unavailable; type the ' + label + ' instead.';
}
const normalize = value => String(value || '').replace(/\s+/g, ' ').trim();
const login = () => String(getLogin() || '').trim();
const composerLimit = Number(elements.draft.maxLength);
const limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength :
Number.isInteger(composerLimit) && composerLimit > 0 ? composerLimit : 10000;
function showReview(value, recovered = false) {
const transcript = normalize(value).slice(0, 10000);
const transcript = normalize(value).slice(0, limit);
elements.transcript.value = transcript;
elements.review.hidden = false;
elements.append.textContent = elements.draft.value.trim() ? 'Append to draft' : 'Use transcript';
@@ -36,7 +41,7 @@ function createVoiceConversationCapture({
elements.review.hidden = true;
elements.status.textContent = '';
if (!supported || !target || !transcriptStore || !login()) return;
const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, 10000);
const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, limit);
if (opening === generation && recovered) showReview(recovered, true);
}
@@ -45,13 +50,13 @@ function createVoiceConversationCapture({
}
function commit(replace) {
const transcript = normalize(elements.transcript.value).slice(0, 10000);
const transcript = normalize(elements.transcript.value).slice(0, limit);
elements.draft.value = replace ? transcript :
[elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, 10000);
[elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, limit);
elements.draft.dispatchEvent(createEvent('input'));
clearCheckpoint();
elements.review.hidden = true;
elements.status.textContent = 'Transcript added. Review the reply before sending.';
elements.status.textContent = 'Transcript added. Review the ' + label + ' before sending.';
}
elements.start.addEventListener('click', () => {
@@ -121,4 +126,20 @@ function createVoiceConversationCapture({
return {supported, open, cancel};
}
if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture};
function mountVoiceConversation({
kind, draftSelector, qs, Recognition, transcriptStore, getLogin,
createCapture = createVoiceConversationCapture,
}) {
return createCapture({
Recognition, transcriptStore, getLogin,
elements:{
root:qs('#voice-' + kind), start:qs('#start-voice-' + kind),
stop:qs('#stop-voice-' + kind), review:qs('#voice-' + kind + '-review'),
transcript:qs('#voice-' + kind + '-transcript'), append:qs('#append-voice-' + kind),
replace:qs('#replace-with-voice-' + kind), discard:qs('#discard-voice-' + kind),
status:qs('#voice-' + kind + '-status'), draft:qs(draftSelector),
},
});
}
if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture, mountVoiceConversation};
+1
View File
@@ -1,4 +1,5 @@
fastapi==0.133.1
cryptography==50.0.0
httpx==0.28.1
pydantic==2.13.4
Pillow==12.3.0
+79 -29
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Build a reproducible, verifiable Stackchain Dashboard release bundle."""
"""Build a reproducible release bundle from an exact Git commit."""
from __future__ import annotations
@@ -8,37 +8,92 @@ import gzip
import hashlib
import io
import json
import re
import subprocess
import tarfile
from pathlib import Path
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
RUNTIME_FILES = ("README.md", "requirements.txt")
RUNTIME_FILES = (
"README.md",
"requirements.txt",
"scripts/rotate_unfiled_drafts.py",
)
EXCLUDED_PARTS = {"__pycache__", ".pytest_cache"}
EXCLUDED_SUFFIXES = (".pyc", ".pyo")
COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}")
@dataclass(frozen=True)
class RuntimeFile:
name: str
data: bytes
executable: bool
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def runtime_files(root: Path) -> list[Path]:
files = [root / name for name in RUNTIME_FILES if (root / name).is_file()]
for name in RUNTIME_DIRECTORIES:
directory = root / name
if directory.is_dir():
files.extend(path for path in directory.rglob("*") if path.is_file())
return sorted(files, key=lambda path: path.relative_to(root).as_posix())
def _git(root: Path, *args: str, text: bool = False) -> subprocess.CompletedProcess:
return subprocess.run(
["git", "-C", str(root), *args],
capture_output=True,
check=False,
text=text,
)
def manifest_for(root: Path, files: list[Path], commit: str) -> dict:
def runtime_files_at_commit(root: Path, commit: str) -> list[RuntimeFile]:
"""Read the runtime allowlist directly from the declared Git tree."""
root = root.resolve()
if not COMMIT_PATTERN.fullmatch(commit):
raise ValueError("declared commit must be a full 40-character Git object ID")
resolved = _git(root, "rev-parse", "--verify", f"{commit}^{{commit}}", text=True)
if resolved.returncode != 0 or resolved.stdout.strip() != commit:
raise ValueError("declared commit is unavailable")
listing = _git(root, "ls-tree", "-rz", "--full-tree", commit)
if listing.returncode != 0:
raise ValueError("could not inspect the declared commit")
files: list[RuntimeFile] = []
for record in listing.stdout.split(b"\0"):
if not record:
continue
metadata, raw_name = record.split(b"\t", 1)
mode, object_type, object_id = metadata.decode("ascii").split()
name = raw_name.decode("utf-8")
path = PurePosixPath(name)
selected = name in RUNTIME_FILES or (path.parts and path.parts[0] in RUNTIME_DIRECTORIES)
if not selected or object_type != "blob":
continue
if mode not in {"100644", "100755"}:
raise ValueError(f"release member is not a regular file: {name}")
if EXCLUDED_PARTS.intersection(path.parts) or name.endswith(EXCLUDED_SUFFIXES):
raise ValueError(f"declared commit contains generated release member: {name}")
blob = _git(root, "cat-file", "blob", object_id)
if blob.returncode != 0:
raise ValueError(f"could not read release member: {name}")
files.append(RuntimeFile(name, blob.stdout, mode == "100755"))
names = {item.name for item in files}
missing = [name for name in RUNTIME_FILES if name not in names]
if missing or not any(name.startswith("src/") for name in names) or not any(
name.startswith("frontend/") for name in names
):
raise ValueError("declared commit is missing required runtime files")
return sorted(files, key=lambda item: item.name)
def manifest_for(files: list[RuntimeFile], commit: str) -> dict:
return {
"schema_version": 1,
"commit": commit,
"files": {
path.relative_to(root).as_posix(): {
"sha256": sha256(path.read_bytes()),
"size": path.stat().st_size,
}
for path in files
item.name: {"sha256": sha256(item.data), "size": len(item.data)} for item in files
},
}
@@ -58,19 +113,10 @@ def tar_info(name: str, size: int, epoch: int, executable: bool = False) -> tarf
def build(root: Path, output_dir: Path, commit: str, epoch: int) -> tuple[Path, Path, Path]:
root = root.resolve()
files = runtime_files(root)
missing = [name for name in RUNTIME_FILES if not (root / name).is_file()]
if missing or not (root / "src").is_dir() or not (root / "frontend").is_dir():
raise ValueError("release root is missing required runtime files")
embedded_manifest = manifest_for(root, files, commit)
files = runtime_files_at_commit(root, commit)
embedded_manifest = manifest_for(files, commit)
archive_members: dict[str, tuple[bytes, bool]] = {
path.relative_to(root).as_posix(): (
path.read_bytes(),
bool(path.stat().st_mode & 0o111),
)
for path in files
item.name: (item.data, item.executable) for item in files
}
archive_members["release-manifest.json"] = (json_bytes(embedded_manifest), False)
@@ -112,7 +158,11 @@ def parse_args() -> argparse.Namespace:
def main() -> None:
args = parse_args()
for path in build(args.root, args.output_dir, args.commit, args.source_date_epoch):
try:
paths = build(args.root, args.output_dir, args.commit, args.source_date_epoch)
except (OSError, ValueError) as error:
raise SystemExit(f"release build failed: {error}") from error
for path in paths:
print(path)
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Rewrap synchronized Draft rows under the configured active encryption key."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.unfiled_draft_store import ( # noqa: E402
UnfiledDraftEncryptionError,
UnfiledDraftStore,
decode_unfiled_draft_encryption_keyring,
)
def main() -> int:
try:
keys, active = decode_unfiled_draft_encryption_keyring(
os.getenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS", ""),
os.getenv("STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID", ""),
)
store = UnfiledDraftStore(
os.getenv(
"STACKCHAIN_UNFILED_DRAFT_DB",
str(Path(os.getenv("STACKCHAIN_STATE_DIR", ".")) / "unfiled-drafts.sqlite3"),
),
encryption_keys=keys,
active_key_id=active,
)
result = store.rewrap_all()
except (OSError, UnfiledDraftEncryptionError):
print(json.dumps({"error": "Draft rotation configuration is unavailable"}, sort_keys=True))
return 2
print(json.dumps(result, sort_keys=True))
return 1 if result["failed"] else 0
if __name__ == "__main__":
raise SystemExit(main())
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Verify that a deployed dashboard is usable, not merely alive."""
from __future__ import annotations
import argparse
import http.cookiejar
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
class DeploymentVerificationError(RuntimeError):
"""A public deployment failed one observable user-flow check."""
@dataclass(frozen=True)
class Response:
status: int
url: str
content_type: str
body: bytes
def _request(opener: urllib.request.OpenerDirector, request: str | urllib.request.Request, label: str) -> Response:
try:
with opener.open(request, timeout=10) as response:
return Response(
status=response.status,
url=response.geturl(),
content_type=response.headers.get_content_type(),
body=response.read(),
)
except urllib.error.HTTPError as error:
raise DeploymentVerificationError(f"{label} returned HTTP {error.code}") from None
except (OSError, urllib.error.URLError, TimeoutError):
raise DeploymentVerificationError(f"{label} could not be reached") from None
def verify_deployment(base_url: str, access_token: str) -> dict[str, str]:
"""Exercise liveness, readiness, sign-in, PWA scope, and mobile Home."""
parsed = urllib.parse.urlsplit(base_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise DeploymentVerificationError("deployment URL must be absolute HTTP(S)")
if not access_token:
raise DeploymentVerificationError("operator access token is required")
base_url = base_url.rstrip("/") + "/"
base_path = urllib.parse.urlsplit(base_url).path
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
health = _request(opener, urllib.parse.urljoin(base_url, "healthz"), "liveness")
if health.status != 200:
raise DeploymentVerificationError(f"liveness returned HTTP {health.status}")
readiness = _request(opener, urllib.parse.urljoin(base_url, "readyz"), "readiness")
if readiness.status != 200:
raise DeploymentVerificationError(f"readiness returned HTTP {readiness.status}")
public_entry = _request(opener, base_url, "public entry")
public_text = public_entry.body.decode("utf-8", errors="replace")
if public_entry.content_type != "text/html" or 'name="access_token"' not in public_text:
raise DeploymentVerificationError("public entry did not render the operator sign-in form")
manifest_response = _request(
opener, urllib.parse.urljoin(base_url, "manifest.webmanifest"), "manifest"
)
try:
manifest = json.loads(manifest_response.body)
except (UnicodeDecodeError, json.JSONDecodeError):
raise DeploymentVerificationError("manifest did not return valid JSON") from None
manifest_scope = urllib.parse.urljoin(manifest_response.url, str(manifest.get("scope", "")))
manifest_start = urllib.parse.urljoin(manifest_response.url, str(manifest.get("start_url", "")))
expected = urllib.parse.urlsplit(base_url)
resolved_scope = urllib.parse.urlsplit(manifest_scope)
resolved_start = urllib.parse.urlsplit(manifest_start)
if (
(resolved_scope.scheme, resolved_scope.netloc, resolved_scope.path)
!= (expected.scheme, expected.netloc, expected.path)
or (resolved_start.scheme, resolved_start.netloc, resolved_start.path)
!= (expected.scheme, expected.netloc, expected.path)
):
raise DeploymentVerificationError("manifest scope or start URL escaped the deployment subpath")
encoded = json.dumps({
"access_token": access_token,
"device_label": "Deployment smoke verifier",
}).encode()
login_request = urllib.request.Request(
urllib.parse.urljoin(base_url, "api/v1/session"),
data=encoded,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
authenticated = _request(opener, login_request, "operator sign-in")
try:
authenticated_payload = json.loads(authenticated.body)
except (UnicodeDecodeError, json.JSONDecodeError):
raise DeploymentVerificationError("operator sign-in did not return valid JSON") from None
if authenticated_payload.get("authenticated") is not True:
raise DeploymentVerificationError("operator sign-in was not accepted")
home = _request(opener, base_url, "authenticated Home")
home_text = home.body.decode("utf-8", errors="replace")
if home.content_type != "text/html" or (
'id="mobile-task-dock"' not in home_text or 'id="new-issue"' not in home_text
):
raise DeploymentVerificationError("operator sign-in did not reach the mobile dashboard Home")
return {
"health": "ok",
"readiness": "ready",
"public_entry": "login",
"manifest_scope": base_path,
"authenticated_home": "mobile",
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("url", help="Dashboard URL including its public subpath")
args = parser.parse_args(argv)
token = os.environ.get("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
try:
result = verify_deployment(args.url, token)
except DeploymentVerificationError as error:
print(f"deployment verification failed: {error}", file=sys.stderr)
return 1
print(json.dumps(result, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+12 -2
View File
@@ -10,6 +10,8 @@ import sys
import tarfile
from pathlib import Path, PurePosixPath
from build_release import manifest_for, runtime_files_at_commit
def one(directory: Path, pattern: str) -> Path:
matches = sorted(directory.glob(pattern))
@@ -22,7 +24,7 @@ def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def verify(input_dir: Path, commit: str) -> None:
def verify(input_dir: Path, commit: str, repository: Path) -> None:
archive_path = one(input_dir, "*.tar.gz")
manifest_path = one(input_dir, "*.manifest.json")
checksum_path = one(input_dir, "*.sha256")
@@ -45,6 +47,11 @@ def verify(input_dir: Path, commit: str) -> None:
expected_files = manifest.get("files")
if not isinstance(expected_files, dict):
raise ValueError("manifest files must be an object")
source_files = runtime_files_at_commit(repository, commit)
source_manifest = manifest_for(source_files, commit)["files"]
if expected_files != source_manifest:
raise ValueError("bundle manifest does not match the declared Git commit")
source_by_name = {item.name: item for item in source_files}
with tarfile.open(archive_path, "r:gz") as archive:
members = archive.getmembers()
for member in members:
@@ -67,19 +74,22 @@ def verify(input_dir: Path, commit: str) -> None:
data = bundled.read()
if metadata != {"sha256": digest(data), "size": len(data)}:
raise ValueError(f"bundle member failed verification: {name}")
if data != source_by_name[name].data:
raise ValueError(f"bundle member differs from the declared Git commit: {name}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-dir", type=Path, required=True)
parser.add_argument("--commit", required=True)
parser.add_argument("--repository", type=Path, default=Path.cwd())
return parser.parse_args()
def main() -> None:
args = parse_args()
try:
verify(args.input_dir, args.commit)
verify(args.input_dir, args.commit, args.repository)
except (OSError, ValueError, json.JSONDecodeError, tarfile.TarError) as error:
print(f"release verification failed: {error}", file=sys.stderr)
raise SystemExit(1) from error
+27 -6
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import json
import secrets
import sqlite3
import time
@@ -10,6 +9,11 @@ from dataclasses import dataclass
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import (
PrivateStateCipher,
PrivateStateEncryptionError,
private_state_encryption_key,
)
class RefreshLeaseLost(RuntimeError):
@@ -26,9 +30,13 @@ class AvailableIssueSnapshotState:
class AvailableIssueSnapshotStore:
def __init__(self, path, *, clock=None):
def __init__(self, path, *, clock=None, encryption_key=None):
self.path = Path(path)
self.clock = clock or time.time
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(),
store="available-issue-snapshot",
)
with self._connect() as connection:
connection.executescript(
"""
@@ -91,8 +99,21 @@ class AvailableIssueSnapshotStore:
"SELECT expires_at FROM available_issue_refresh_lease "
"WHERE singleton = 1 AND expires_at > ?", (now,)
).fetchone()
items = None
if row["items_json"]:
items, legacy = self._cipher.open(row["items_json"])
if not isinstance(items, list):
raise PrivateStateEncryptionError("private state could not be decrypted")
if legacy:
migrated = self._cipher.seal(items)
with self._connect() as connection:
connection.execute(
"UPDATE available_issue_snapshot SET items_json = ? "
"WHERE singleton = 1 AND items_json = ?",
(migrated, row["items_json"]),
)
return AvailableIssueSnapshotState(
items=json.loads(row["items_json"]) if row["items_json"] else None,
items=items,
created_at=row["created_at"],
retry_at=row["retry_at"],
refreshing=lease is not None,
@@ -127,7 +148,7 @@ class AvailableIssueSnapshotStore:
connection.execute(
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, "
"retry_at = NULL WHERE singleton = 1",
(json.dumps(items, separators=(",", ":")), now),
(self._cipher.seal(items), now),
)
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
connection.commit()
@@ -143,7 +164,7 @@ class AvailableIssueSnapshotStore:
row = connection.execute(
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
).fetchone()
items = json.loads(row["items_json"]) if row["items_json"] else None
items = self._cipher.open(row["items_json"])[0] if row["items_json"] else None
if items is not None:
items = [
item for item in items
@@ -151,7 +172,7 @@ class AvailableIssueSnapshotStore:
]
connection.execute(
"UPDATE available_issue_snapshot SET items_json = ? WHERE singleton = 1",
(json.dumps(items, separators=(",", ":")),),
(self._cipher.seal(items),),
)
connection.commit()
return self.load()
+4 -4
View File
@@ -25,7 +25,7 @@ FEATURE_SOURCES = {
"issue-capture": (
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
"push-notifications": ("static/push-notifications.js",),
"device-setup": (
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
@@ -34,11 +34,11 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"today-timer": (
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
"static/search-batch-plan.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
),
+74 -30
View File
@@ -445,7 +445,7 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
return None
repository = item.get("repository")
repository = repository if isinstance(repository, dict) else {}
url = _safe_web_url(item.get("html_url"))
url = _safe_gitea_web_url(item.get("html_url"))
if not (
isinstance(item.get("number"), int)
and isinstance(item.get("title"), str)
@@ -614,7 +614,7 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": assignee_names,
"url": _safe_web_url(issue.get("html_url")),
"url": _safe_gitea_web_url(issue.get("html_url")),
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
"reopenable": actual_kind == "issue" and state == "closed",
"assigned_to_me": bool(login and login in assignee_names),
@@ -650,7 +650,7 @@ def _normalize_available_issue(item: Any) -> dict | None:
"assignees": [],
"updated_at": item.get("updated_at", "")
if isinstance(item.get("updated_at"), str) else "",
"url": _safe_web_url(item.get("html_url")),
"url": _safe_gitea_web_url(item.get("html_url")),
}
@@ -756,13 +756,6 @@ async def issues() -> WorkItems:
)
def _safe_web_url(value: Any) -> str:
if not isinstance(value, str):
return ""
parsed = urlsplit(value)
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
def _safe_gitea_web_url(value: Any) -> str:
if not isinstance(value, str) or not value.strip():
return ""
@@ -819,8 +812,8 @@ def _normalize_notifications(threads: Any) -> list[dict]:
subject = thread.get("subject")
repository = repository if isinstance(repository, dict) else {}
subject = subject if isinstance(subject, dict) else {}
subject_url = _safe_web_url(subject.get("html_url"))
latest_url = _safe_web_url(subject.get("latest_comment_html_url"))
subject_url = _safe_gitea_web_url(subject.get("html_url"))
latest_url = _safe_gitea_web_url(subject.get("latest_comment_html_url"))
number_text = (
urlsplit(subject_url).path.rstrip("/").rsplit("/", 1)[-1]
if subject_url
@@ -1053,8 +1046,8 @@ async def notification_detail(thread_id: int) -> dict:
comment = comment if isinstance(comment, dict) else {}
user_value = comment.get("user")
user = user_value if isinstance(user_value, dict) else {}
subject_url = _safe_web_url(subject.get("html_url"))
latest_url = _safe_web_url(comment.get("html_url")) or _safe_web_url(
subject_url = _safe_gitea_web_url(subject.get("html_url"))
latest_url = _safe_gitea_web_url(comment.get("html_url")) or _safe_gitea_web_url(
subject.get("latest_comment_html_url")
)
assignee_values = subject_detail.get("assignees")
@@ -1236,7 +1229,7 @@ async def upload_notification_attachment(
if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url"))
url = _safe_gitea_web_url(attachment.get("browser_download_url"))
size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment")
@@ -1275,7 +1268,7 @@ def _normalize_issue_comment(comment: dict) -> dict:
"created_at": comment.get("created_at", "")
if isinstance(comment.get("created_at"), str)
else "",
"url": _safe_web_url(comment.get("html_url")),
"url": _safe_gitea_web_url(comment.get("html_url")),
}
@@ -1357,7 +1350,7 @@ async def upload_assigned_issue_attachment(
if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url"))
url = _safe_gitea_web_url(attachment.get("browser_download_url"))
size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment")
@@ -1383,7 +1376,7 @@ async def upload_preview_attachment(
if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url"))
url = _safe_gitea_web_url(attachment.get("browser_download_url"))
size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment")
@@ -1410,7 +1403,7 @@ async def upload_assigned_pull_attachment(
if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url"))
url = _safe_gitea_web_url(attachment.get("browser_download_url"))
size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment")
@@ -1586,7 +1579,7 @@ async def create_issue(
"updated_at": issue.get("updated_at", "")
if isinstance(issue.get("updated_at"), str)
else "",
"url": _safe_web_url(issue.get("html_url")),
"url": _safe_gitea_web_url(issue.get("html_url")),
}
@@ -1640,7 +1633,7 @@ async def claim_available_issue(repository: str, number: int) -> dict:
"assignees": logins,
"updated_at": confirmed.get("updated_at", "")
if isinstance(confirmed.get("updated_at"), str) else "",
"url": _safe_web_url(confirmed.get("html_url")),
"url": _safe_gitea_web_url(confirmed.get("html_url")),
}
@@ -1700,7 +1693,7 @@ async def reopen_issue(repository: str, number: int) -> dict:
"assignees": logins,
"updated_at": confirmed.get("updated_at", "")
if isinstance(confirmed.get("updated_at"), str) else "",
"url": _safe_web_url(confirmed.get("html_url")),
"url": _safe_gitea_web_url(confirmed.get("html_url")),
}
@@ -2256,7 +2249,7 @@ async def issue_detail(repository: str, number: int) -> dict:
and isinstance(issue["milestone"].get("title"), str)
else None
),
"url": _safe_web_url(issue.get("html_url")),
"url": _safe_gitea_web_url(issue.get("html_url")),
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
@@ -2297,7 +2290,7 @@ async def issue_dependencies(repository: str, number: int, limit: int = 20) -> l
"number": dependency_number,
"title": item.get("title", "") if isinstance(item.get("title"), str) else "",
"state": "open",
"url": _safe_web_url(item.get("html_url")),
"url": _safe_gitea_web_url(item.get("html_url")),
})
return dependencies
@@ -2425,7 +2418,7 @@ async def _update_issue_content(
"body": body,
"state": issue.get("state", "open"),
"updated_at": issue.get("updated_at", ""),
"url": _safe_web_url(issue.get("html_url")),
"url": _safe_gitea_web_url(issue.get("html_url")),
}
raise IssueEditConflictError("issue changed upstream")
@@ -2450,7 +2443,7 @@ async def _update_issue_content(
"body": body,
"state": confirmed.get("state", "open"),
"updated_at": confirmed.get("updated_at", ""),
"url": _safe_web_url(confirmed.get("html_url")),
"url": _safe_gitea_web_url(confirmed.get("html_url")),
}
@@ -2518,7 +2511,7 @@ async def resolve_work_route(
"number": number,
"title": target.get("title", "") if isinstance(target.get("title"), str) else "",
"state": state,
"url": _safe_web_url(target.get("html_url")),
"url": _safe_gitea_web_url(target.get("html_url")),
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
**({
"is_filed": True,
@@ -2623,7 +2616,7 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
"number": number,
"title": pull.get("title") if isinstance(pull.get("title"), str) else "",
"body": pull.get("body") if isinstance(pull.get("body"), str) else "",
"url": _safe_web_url(pull.get("html_url")),
"url": _safe_gitea_web_url(pull.get("html_url")),
"author": user.get("login") if isinstance(user.get("login"), str) else "",
"head_sha": sha,
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
@@ -2681,6 +2674,48 @@ async def pull_completion_review(repository: str, number: int) -> dict:
}
async def release_receipt_status(repository: str, commit_sha: str) -> dict:
"""Return CI and release evidence for one exact merge commit."""
status, releases = await asyncio.gather(
fetch(f"repos/{repository}/commits/{commit_sha}/status"),
fetch(f"repos/{repository}/releases?limit=20"),
)
normalized_checks = _normalize_commit_checks(status)
checks = [
{"name": check["name"], "state": check["state"], "url": check["url"]}
for check in normalized_checks
]
matching = next(
(
release
for release in (releases if isinstance(releases, list) else [])
if isinstance(release, dict) and release.get("target_commitish") == commit_sha
),
None,
)
normalized_release = None
if matching is not None:
assets = matching.get("assets")
normalized_release = {
"tag": matching.get("tag_name") if isinstance(matching.get("tag_name"), str) else "",
"url": _safe_gitea_web_url(matching.get("html_url")),
"assets": [
{
"name": asset.get("name") if isinstance(asset.get("name"), str) else "",
"url": _safe_gitea_web_url(asset.get("browser_download_url")),
}
for asset in (assets if isinstance(assets, list) else [])[:20]
if isinstance(asset, dict)
],
}
return {
"commit_sha": commit_sha,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"checks": checks,
"release": normalized_release,
}
async def pull_check_status(repository: str, number: int) -> dict:
"""Load only mutable pull and CI state, without immutable review data."""
base = f"repos/{repository}/pulls/{number}"
@@ -2748,7 +2783,16 @@ async def merge_assigned_pull(
json={"Do": "merge", "head_commit_id": current_sha},
)
response.raise_for_status()
return {"number": number, "merged": True, "state": "closed"}
payload = response.json()
merge_commit_sha = payload.get("sha") if isinstance(payload, dict) else None
if not isinstance(merge_commit_sha, str) or not merge_commit_sha:
raise ValueError("Gitea merge response did not include the merge commit")
return {
"number": number,
"merged": True,
"state": "closed",
"merge_commit_sha": merge_commit_sha,
}
async def pull_review_detail(repository: str, number: int) -> dict:
@@ -2865,7 +2909,7 @@ async def submit_pull_review(
return {
"id": review.get("id"),
"state": review.get("state") or "COMMENT",
"url": _safe_web_url(review.get("html_url")),
"url": _safe_gitea_web_url(review.get("html_url")),
}
+33 -6
View File
@@ -12,6 +12,11 @@ from pathlib import Path
from typing import Callable, Iterable
from src.private_state import connect_private_sqlite
from src.state_encryption import (
PrivateStateCipher,
PrivateStateEncryptionError,
private_state_encryption_key,
)
SECTIONS = ("context", "events", "notifications")
@@ -53,9 +58,14 @@ class LiveSnapshotStore:
path: str | os.PathLike[str],
*,
clock: Callable[[], float] | None = None,
encryption_key: bytes | None = None,
):
self.path = Path(path)
self.clock = clock or time.time
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(),
store="live-snapshot",
)
self._initialize()
def _connect(self) -> sqlite3.Connection:
@@ -138,8 +148,23 @@ class LiveSnapshotStore:
(now,),
).fetchone()
assert row is not None
value = None
if row["value_json"] is not None:
value, legacy = self._cipher.open(
row["value_json"], binding=row["generation"]
)
if not isinstance(value, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
if legacy:
migrated = self._cipher.seal(value, binding=row["generation"])
with self._connect() as connection:
connection.execute(
"UPDATE live_snapshot SET value_json = ? "
"WHERE singleton = 1 AND value_json = ?",
(migrated, row["value_json"]),
)
return LiveSnapshotState(
value=json.loads(row["value_json"]) if row["value_json"] is not None else None,
value=value,
created_at=json.loads(row["created_at_json"]),
failure_count=json.loads(row["failure_count_json"]),
retry_at=json.loads(row["retry_at_json"]),
@@ -198,7 +223,7 @@ class LiveSnapshotStore:
connection.rollback()
raise RefreshLeaseLost("live refresh lease expired or changed owner")
row = connection.execute(
"SELECT revisions_json FROM live_snapshot WHERE singleton = 1"
"SELECT revisions_json, generation FROM live_snapshot WHERE singleton = 1"
).fetchone()
revisions = json.loads(row["revisions_json"])
notifications = value.get("notifications")
@@ -232,7 +257,7 @@ class LiveSnapshotStore:
failure_count_json = ?, retry_at_json = ?, revisions_json = ?
WHERE singleton = 1""",
(
json.dumps(value, separators=(",", ":")),
self._cipher.seal(value, binding=row["generation"]),
json.dumps(created_at, separators=(",", ":")),
json.dumps(failure_count, separators=(",", ":")),
json.dumps(retry_at, separators=(",", ":")),
@@ -257,9 +282,11 @@ class LiveSnapshotStore:
((notification_id,) for notification_id in read_ids),
)
row = connection.execute(
"SELECT value_json, revisions_json FROM live_snapshot WHERE singleton = 1"
"SELECT value_json, revisions_json, generation FROM live_snapshot WHERE singleton = 1"
).fetchone()
value = json.loads(row["value_json"]) if row["value_json"] else None
value = self._cipher.open(
row["value_json"], binding=row["generation"]
)[0] if row["value_json"] else None
revisions = json.loads(row["revisions_json"])
if value is not None and isinstance(value.get("notifications"), list):
previous = value["notifications"]
@@ -274,7 +301,7 @@ class LiveSnapshotStore:
connection.execute(
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
(
json.dumps(value, separators=(",", ":")),
self._cipher.seal(value, binding=row["generation"]),
json.dumps(revisions, separators=(",", ":")),
),
)
+166 -23
View File
@@ -57,14 +57,20 @@ from src.push_notifications import (
dispatch_unread_updates,
)
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
from src.push_subscription_store import PushSubscriptionStore
from src.push_subscription_store import build_push_subscription_store
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
from src.unfiled_draft_store import (
UnfiledDraftConflict,
UnfiledDraftEncryptionError,
UnfiledDraftStore,
decode_unfiled_draft_encryption_key,
decode_unfiled_draft_encryption_keyring,
)
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
from src.suggestion_engine import compute
from src.later_store import LaterStore
from src.today_store import TodayPlanFull, TodayStore
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore
from src.views import FRONTEND_BUILD, router as frontend_router
@@ -347,8 +353,16 @@ _available_issue_snapshot_store = AvailableIssueSnapshotStore(
str(_state_dir / "available-issue-snapshot.sqlite3"),
)
)
_push_subscription_store = PushSubscriptionStore(
os.getenv("STACKCHAIN_PUSH_DB", str(_state_dir / "push-subscriptions.sqlite3"))
_push_subscription_store = build_push_subscription_store(
os.getenv("STACKCHAIN_PUSH_DB", str(_state_dir / "push-subscriptions.sqlite3")),
push_enabled=all(
os.getenv(name, "").strip()
for name in (
"STACKCHAIN_VAPID_PUBLIC_KEY",
"STACKCHAIN_VAPID_PRIVATE_KEY",
"STACKCHAIN_VAPID_SUBJECT",
)
),
)
@@ -588,6 +602,21 @@ class TodayRecap(BaseModel):
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
class TodaySessionUpdate(BaseModel):
base_revision: int = Field(ge=0)
device_id: str = Field(min_length=1, max_length=100)
identity: str = Field(max_length=500)
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
running: bool
break_deadline_at: int | None = Field(default=None, ge=0, le=10_000_000_000_000)
@model_validator(mode="after")
def validate_break_is_paused(self):
if self.running and self.break_deadline_at is not None:
raise ValueError("break must remain paused")
return self
class TodayRecapTimeLog(TodayRecap):
log_identities: list[str] = Field(min_length=1, max_length=20)
@@ -1322,7 +1351,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
path = dashboard_auth.application_path(request)
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@@ -2127,7 +2156,7 @@ async def push_status(request: Request):
device_id,
)
preferences = await asyncio.to_thread(
_push_subscription_store.deadline_preferences, device_id
_push_subscription_store.deadline_preferences, device_id, now=time.time()
)
return {
"available": configuration.enabled,
@@ -2137,6 +2166,7 @@ async def push_status(request: Request):
"timezone": preferences["timezone"],
"reminder_hour": preferences["reminder_hour"],
"reminder_days": preferences["reminder_days"],
"snoozed_until": preferences["snoozed_until"],
}
@@ -2215,6 +2245,35 @@ async def update_deadline_reminders(payload: DeadlineReminderPayload, request: R
}
@app.patch("/api/v1/push-subscription/deadlines/snooze")
async def snooze_deadline_reminder(request: Request):
device_id = await dashboard_auth.session_management_id(
request.state.dashboard_session
)
now = int(time.time())
snoozed = await asyncio.to_thread(
_push_subscription_store.snooze_deadline_reminder,
device_id,
now=now,
delay_seconds=3_600,
)
if not snoozed:
raise HTTPException(status_code=409, detail="Enable deadline reminders first")
return {"snoozed": True, "snoozed_until": now + 3_600}
@app.delete("/api/v1/push-subscription/deadlines/snooze")
async def resume_deadline_reminders(request: Request):
device_id = await dashboard_auth.session_management_id(
request.state.dashboard_session
)
await asyncio.to_thread(
_push_subscription_store.clear_deadline_snooze,
device_id,
)
return {"snoozed": False}
@app.post("/api/v1/session/activity")
async def record_session_activity(request: Request):
session = request.state.dashboard_session
@@ -2269,8 +2328,22 @@ def _completed_filed_review_store() -> CompletedFiledReviewStore:
def _unfiled_draft_store() -> UnfiledDraftStore:
encoded_keyring = os.getenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS")
if encoded_keyring is not None:
keys, active = decode_unfiled_draft_encryption_keyring(
encoded_keyring,
os.getenv("STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID", ""),
)
return UnfiledDraftStore(
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3")),
encryption_keys=keys,
active_key_id=active,
)
return UnfiledDraftStore(
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3"))
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3")),
encryption_key=decode_unfiled_draft_encryption_key(
os.getenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY", "")
),
)
@@ -2371,7 +2444,7 @@ async def get_unfiled_drafts(response: Response):
login = await _confirmed_login()
try:
snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, UnfiledDraftEncryptionError):
raise HTTPException(
status_code=503,
detail="Draft synchronization is unavailable",
@@ -2401,7 +2474,7 @@ async def replace_unfiled_drafts(payload: UnfiledDraftCollection):
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, UnfiledDraftEncryptionError):
raise HTTPException(
status_code=503,
detail="Draft synchronization is unavailable",
@@ -2456,6 +2529,37 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
)
@app.get("/api/v1/today/session")
async def get_today_session():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_today_store().get_session, login)
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503, detail="Today session synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.patch("/api/v1/today/session")
async def update_today_session(payload: TodaySessionUpdate):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().update_session, login, **payload.model_dump()
)
except TodaySessionConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "session_changed", "session": error.session},
)
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503, detail="Today session synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/today/recaps")
async def get_today_recaps(response: Response):
login = await _confirmed_login()
@@ -5879,31 +5983,60 @@ async def merge_assigned_pull(
target=f"{repository}#{number}",
)
async def merge_pull():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.merge_assigned_pull(
repository, number, submission.expected_head_sha
try:
assigned = await asyncio.wait_for(
gitea_proxy.is_assigned_pull(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{"error": "The pull request assignment could not be verified. Nothing was merged."},
status_code=503,
headers={"Retry-After": "1"},
)
if not assigned:
raise HTTPException(status_code=404, detail="Assigned pull request not found")
journal = _security_event_store()
try:
operation_id = await asyncio.to_thread(
journal.reserve,
"pull_merged",
target=f"{repository}#{number}",
)
except SecurityEventStoreError:
return JSONResponse(
{"error": "Security activity is temporarily unavailable. Nothing was merged."},
status_code=503,
headers={"Retry-After": "1"},
)
try:
result = await asyncio.wait_for(
merge_pull(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
await asyncio.to_thread(
_security_event_store().record,
"pull_merged",
target=f"{repository}#{number}",
gitea_proxy.merge_assigned_pull(
repository, number, submission.expected_head_sha
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
pass
return result
except HTTPException:
raise
except gitea_proxy.StalePullError:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
{"error": "New commits were pushed. Refresh before merging."},
status_code=409,
)
except gitea_proxy.PullNotMergeableError:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
{"error": "This pull request is not currently safe to merge. Refresh its status."},
status_code=409,
@@ -5919,6 +6052,10 @@ async def merge_assigned_pull(
except Exception:
merged = False
if merged:
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
pass
return {"number": number, "merged": True, "state": "closed"}
return JSONResponse(
{
@@ -5933,6 +6070,12 @@ async def merge_assigned_pull(
)
@app.get("/api/v1/repos/{owner}/{repo}/release-receipt/{commit_sha}")
async def release_receipt(owner: str, repo: str, commit_sha: str):
result = await gitea_proxy.release_receipt_status(f"{owner}/{repo}", commit_sha)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
async def submit_review(
submission: PullReviewSubmission,
+33 -4
View File
@@ -2,6 +2,7 @@ import asyncio
import ipaddress
import socket
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from urllib.parse import urlsplit
@@ -12,6 +13,14 @@ class UnsafePushEndpoint(ValueError):
Resolver = Callable[[str, int], Awaitable[list[str]]]
@dataclass(frozen=True)
class ResolvedPushEndpoint:
endpoint: str
hostname: str
port: int
addresses: tuple[str, ...]
async def _resolve(host: str, port: int) -> list[str]:
loop = asyncio.get_running_loop()
records = await loop.getaddrinfo(
@@ -23,13 +32,13 @@ async def _resolve(host: str, port: int) -> list[str]:
return sorted({record[4][0] for record in records})
async def validate_public_push_endpoint(
async def resolve_public_push_endpoint(
endpoint: str,
*,
resolver: Resolver = _resolve,
timeout_seconds: float = 2.0,
) -> str:
"""Return a canonical public HTTPS push endpoint or fail closed."""
) -> ResolvedPushEndpoint:
"""Resolve a canonical endpoint to public addresses for a pinned connection."""
parsed = urlsplit(endpoint)
try:
port = parsed.port
@@ -59,4 +68,24 @@ async def validate_public_push_endpoint(
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
if not public:
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
return endpoint
return ResolvedPushEndpoint(
endpoint=endpoint,
hostname=parsed.hostname,
port=port or 443,
addresses=tuple(sorted(set(addresses))),
)
async def validate_public_push_endpoint(
endpoint: str,
*,
resolver: Resolver = _resolve,
timeout_seconds: float = 2.0,
) -> str:
"""Return a canonical public HTTPS push endpoint or fail closed."""
resolved = await resolve_public_push_endpoint(
endpoint,
resolver=resolver,
timeout_seconds=timeout_seconds,
)
return resolved.endpoint
+89 -15
View File
@@ -5,10 +5,18 @@ import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import requests
from src.push_subscription_store import PushSubscriptionStore
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
from src.push_endpoint_policy import (
ResolvedPushEndpoint,
UnsafePushEndpoint,
resolve_public_push_endpoint,
validate_public_push_endpoint,
)
@dataclass(frozen=True)
@@ -22,17 +30,59 @@ class PushConfiguration:
return bool(self.public_key and self.private_key and self.subject)
class _PinnedHTTPSAdapter(requests.adapters.HTTPAdapter):
"""Dial one approved IP while authenticating the endpoint's original host."""
def __init__(self, resolved: ResolvedPushEndpoint):
self.resolved = resolved
super().__init__()
def add_headers(self, request, **kwargs):
super().add_headers(request, **kwargs)
request.headers["Host"] = self.resolved.hostname
def build_connection_pool_key_attributes(self, request, verify, cert=None):
parsed = urlsplit(request.url)
if parsed.scheme != "https" or parsed.hostname != self.resolved.hostname:
raise UnsafePushEndpoint("Push transport attempted an unvalidated destination")
host, tls = super().build_connection_pool_key_attributes(request, verify, cert)
host.update(
host=self.resolved.addresses[0],
port=self.resolved.port,
)
tls.update(
assert_hostname=self.resolved.hostname,
server_hostname=self.resolved.hostname,
)
return host, tls
async def send_web_push(
subscription: dict, payload: str, configuration: PushConfiguration
subscription: dict,
payload: str,
configuration: PushConfiguration,
*,
endpoint_resolver: Callable[[str], Awaitable[ResolvedPushEndpoint]] | None = None,
webpush_sender: Callable[..., object] | None = None,
) -> None:
import requests
from pywebpush import webpush
if endpoint_resolver is None:
endpoint_resolver = resolve_public_push_endpoint
resolved = await endpoint_resolver(subscription["endpoint"])
if not resolved.addresses:
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
if webpush_sender is None:
from pywebpush import webpush
webpush_sender = webpush
session = requests.Session()
session.trust_env = False
session.max_redirects = 0
origin = f"https://{resolved.hostname}"
session.mount(origin, _PinnedHTTPSAdapter(resolved))
await asyncio.to_thread(
webpush,
webpush_sender,
subscription_info=subscription,
data=payload,
vapid_private_key=configuration.private_key,
@@ -160,7 +210,7 @@ async def dispatch_unread_updates(
status = getattr(
getattr(error, "response", None), "status_code", None
)
if status in {404, 410}:
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
await asyncio.to_thread(
store.delete_session, delivery.session_id
)
@@ -207,7 +257,7 @@ async def dispatch_unread_updates(
status = getattr(
getattr(error, "response", None), "status_code", None
)
if status in {404, 410}:
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
await asyncio.to_thread(
store.delete_session, delivery.session_id
)
@@ -298,10 +348,16 @@ async def _dispatch_deadline_reminders_unlocked(
local_now = current.astimezone(ZoneInfo(device.timezone))
except ZoneInfoNotFoundError:
continue
if (
local_now.hour >= device.reminder_hour
snooze_due = (
device.snoozed_until is not None
and device.snoozed_until <= current.timestamp()
)
daily_due = (
device.snoozed_until is None
and local_now.hour >= device.reminder_hour
and device.delivered_local_day != local_now.date().isoformat()
):
)
if snooze_due or daily_due:
eligible_devices.append(device)
if not eligible_devices:
return 0
@@ -319,6 +375,11 @@ async def _dispatch_deadline_reminders_unlocked(
continue
due_days.append(due_day)
if not due_days:
await asyncio.gather(*(
asyncio.to_thread(store.clear_deadline_snooze, device.session_id)
for device in eligible_devices
if device.snoozed_until is not None
))
return 0
due_counts = {}
for device in eligible_devices:
@@ -327,6 +388,11 @@ async def _dispatch_deadline_reminders_unlocked(
due_count = sum(due_day <= local_cutoff for due_day in due_days)
if due_count:
due_counts[device.session_id] = due_count
await asyncio.gather(*(
asyncio.to_thread(store.clear_deadline_snooze, device.session_id)
for device in eligible_devices
if device.snoozed_until is not None and device.session_id not in due_counts
))
eligible_devices = [
device for device in eligible_devices if device.session_id in due_counts
]
@@ -358,10 +424,16 @@ async def _dispatch_deadline_reminders_unlocked(
except ZoneInfoNotFoundError:
return 0
local_day = local_now.date().isoformat()
if (
local_now.hour < device.reminder_hour
or device.delivered_local_day == local_day
):
snooze_due = (
device.snoozed_until is not None
and device.snoozed_until <= current.timestamp()
)
daily_due = (
device.snoozed_until is None
and local_now.hour >= device.reminder_hour
and device.delivered_local_day != local_day
)
if not (snooze_due or daily_due):
return 0
due_count = due_counts[device.session_id]
still_owner = await asyncio.to_thread(
@@ -388,7 +460,9 @@ async def _dispatch_deadline_reminders_unlocked(
else send_web_push(device.subscription, payload, configuration)
)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception:
except Exception as error:
if isinstance(error, UnsafePushEndpoint):
await asyncio.to_thread(store.delete_session, device.session_id)
return 0
await asyncio.to_thread(
store.mark_deadline_reminder_delivered, device.session_id, local_day
+167 -11
View File
@@ -1,10 +1,19 @@
import json
from __future__ import annotations
import hashlib
import hmac
import os
import sqlite3
import time
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import (
PrivateStateCipher,
decode_private_state_encryption_key,
)
@dataclass(frozen=True)
@@ -31,6 +40,75 @@ class DeadlineReminderDevice:
reminder_hour: int
reminder_days: int
delivered_local_day: str | None
snoozed_until: float | None
class DisabledPushSubscriptionStore:
"""No-persistence store used when Web Push is not configured."""
def is_subscribed(self, session_id: str) -> bool:
return False
def deadline_preferences(
self, session_id: str, *, now: float | None = None
) -> dict:
return {
"enabled": False,
"timezone": "UTC",
"reminder_hour": 9,
"reminder_days": 2,
"snoozed_until": None,
}
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
return []
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
return []
def acquire_dispatch_lease(self, *args, **kwargs) -> bool:
return False
def release_dispatch_lease(self, *args, **kwargs) -> bool:
return False
def snooze_deadline_reminder(self, *args, **kwargs) -> bool:
return False
def upsert(self, *args, **kwargs) -> None:
raise RuntimeError("push notifications are not configured")
def delete_session(self, *args, **kwargs) -> None:
return None
def delete_all(self, *args, **kwargs) -> None:
return None
def set_deadline_preferences(self, *args, **kwargs) -> None:
return None
def clear_deadline_snooze(self, *args, **kwargs) -> None:
return None
def mark_deadline_reminder_delivered(self, *args, **kwargs) -> None:
return None
def reconcile_unread(self, *args, **kwargs) -> None:
return None
def mark_digest_pending(self, *args, **kwargs) -> None:
return None
def mark_delivered(self, *args, **kwargs) -> None:
return None
def build_push_subscription_store(
path: str | Path, *, push_enabled: bool
) -> PushSubscriptionStore | DisabledPushSubscriptionStore:
if not push_enabled:
return DisabledPushSubscriptionStore()
return PushSubscriptionStore(path)
def _revisions(
@@ -56,8 +134,15 @@ def _revisions(
class PushSubscriptionStore:
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
def __init__(self, path: str | Path):
def __init__(self, path: str | Path, *, encryption_key: bytes | None = None):
self.path = Path(path)
key = encryption_key
if key is None:
key = decode_private_state_encryption_key(
os.getenv("STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY", "")
)
self._encryption_key = key
self._cipher = PrivateStateCipher(key, store="push-subscriptions")
with self._connect() as connection:
connection.executescript(
"""
@@ -94,6 +179,7 @@ class PushSubscriptionStore:
reminder_hour INTEGER NOT NULL DEFAULT 9,
reminder_days INTEGER NOT NULL DEFAULT 2,
delivered_local_day TEXT,
snoozed_until REAL,
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
@@ -140,6 +226,35 @@ class PushSubscriptionStore:
connection.execute(
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
)
if "snoozed_until" not in preference_columns:
connection.execute(
"ALTER TABLE push_deadline_preferences ADD COLUMN snoozed_until REAL"
)
rows = connection.execute(
"SELECT session_id, endpoint, subscription_json FROM push_subscriptions"
).fetchall()
for session_id, endpoint, payload in rows:
subscription, plaintext = self._cipher.open(payload, binding=session_id)
if not isinstance(subscription, dict) or not subscription.get("endpoint"):
raise ValueError("push subscription payload is invalid")
endpoint_index = self._endpoint_index(subscription["endpoint"])
if plaintext or endpoint != endpoint_index:
connection.execute(
"UPDATE push_subscriptions SET endpoint = ?, subscription_json = ? WHERE session_id = ?",
(
endpoint_index,
self._cipher.seal(subscription, binding=session_id),
session_id,
),
)
def _endpoint_index(self, endpoint: str) -> str:
return hmac.new(
self._encryption_key,
b"stackchain:push-endpoint:v1\0" + endpoint.encode(),
hashlib.sha256,
).hexdigest()
def _connect(self):
connection = connect_private_sqlite(self.path, timeout=2)
connection.execute("PRAGMA foreign_keys = ON")
@@ -175,13 +290,16 @@ class PushSubscriptionStore:
def upsert(self, session_id: str, subscription: dict) -> None:
endpoint = subscription["endpoint"]
encoded = json.dumps(subscription, separators=(",", ":"), sort_keys=True)
endpoint_index = self._endpoint_index(endpoint)
encoded = self._cipher.seal(subscription, binding=session_id)
with self._connect() as connection:
connection.execute("DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,))
connection.execute(
"DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint_index,)
)
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
connection.execute(
"INSERT INTO push_subscriptions(session_id, endpoint, subscription_json) VALUES (?, ?, ?)",
(session_id, endpoint, encoded),
(session_id, endpoint_index, encoded),
)
def delete_session(self, session_id: str) -> None:
@@ -215,38 +333,70 @@ class PushSubscriptionStore:
(session_id, int(enabled), timezone, reminder_hour, reminder_days),
)
def deadline_preferences(self, session_id: str) -> dict:
def deadline_preferences(self, session_id: str, *, now: float | None = None) -> dict:
with self._connect() as connection:
row = connection.execute(
"""SELECT enabled, timezone, reminder_hour, reminder_days
"""SELECT enabled, timezone, reminder_hour, reminder_days, snoozed_until
FROM push_deadline_preferences WHERE session_id = ?""",
(session_id,),
).fetchone()
current_time = time.time() if now is None else now
snoozed_until = row[4] if row and row[4] and row[4] > current_time else None
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,
"snoozed_until": snoozed_until,
}
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
p.reminder_hour, p.reminder_days, p.delivered_local_day,
p.snoozed_until
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])
DeadlineReminderDevice(
row[0],
self._open_subscription(row[0], row[1]),
row[2],
row[3],
row[4],
row[5],
row[6],
)
for row in rows
]
def snooze_deadline_reminder(
self, session_id: str, *, now: float, delay_seconds: int = 3_600
) -> bool:
with self._connect() as connection:
result = connection.execute(
"""UPDATE push_deadline_preferences SET snoozed_until = ?
WHERE session_id = ? AND enabled = 1""",
(now + delay_seconds, session_id),
)
return result.rowcount == 1
def clear_deadline_snooze(self, session_id: str) -> None:
with self._connect() as connection:
connection.execute(
"""UPDATE push_deadline_preferences SET snoozed_until = NULL
WHERE session_id = ?""",
(session_id,),
)
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 = ?
"""UPDATE push_deadline_preferences
SET delivered_local_day = ?, snoozed_until = NULL
WHERE session_id = ? AND enabled = 1""",
(local_day, session_id),
)
@@ -313,13 +463,19 @@ class PushSubscriptionStore:
deliveries.append(
PushDelivery(
session_id,
json.loads(encoded),
self._open_subscription(session_id, encoded),
unseen,
tuple(digest_revisions),
)
)
return deliveries
def _open_subscription(self, session_id: str, payload: str) -> dict:
subscription, _plaintext = self._cipher.open(payload, binding=session_id)
if not isinstance(subscription, dict):
raise ValueError("push subscription payload is invalid")
return subscription
def reconcile_unread(self, thread_ids: Iterable[int]) -> None:
"""Prune per-device checkpoints that are absent from a complete snapshot."""
unread_ids = tuple(sorted({int(value) for value in thread_ids if int(value) > 0}))
+89
View File
@@ -0,0 +1,89 @@
"""Authenticated envelopes for retained private dashboard state."""
from __future__ import annotations
import base64
import binascii
import json
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class PrivateStateEncryptionError(RuntimeError):
"""Private retained state could not be authenticated or decrypted."""
def decode_private_state_encryption_key(encoded: str) -> bytes:
"""Decode the independently injected 256-bit private-state key."""
try:
key = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as error:
raise PrivateStateEncryptionError(
"private state encryption key is invalid"
) from error
if len(key) != 32:
raise PrivateStateEncryptionError(
"private state encryption key must decode to exactly 32 bytes"
)
return key
def private_state_encryption_key() -> bytes:
return decode_private_state_encryption_key(
os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", "")
)
class PrivateStateCipher:
"""Seal JSON values with store-specific authenticated context."""
def __init__(self, key: bytes, *, store: str):
if not isinstance(key, bytes) or len(key) != 32:
raise PrivateStateEncryptionError(
"private state encryption requires exactly 32 key bytes"
)
if not store or "\0" in store:
raise ValueError("private state store identity is invalid")
self._cipher = AESGCM(key)
self._store = store
def _aad(self, binding: str) -> bytes:
return f"stackchain:private-state:v1\0{self._store}\0{binding}".encode()
def seal(self, value: object, *, binding: str = "singleton") -> str:
plaintext = json.dumps(value, separators=(",", ":")).encode()
nonce = os.urandom(12)
sealed = nonce + self._cipher.encrypt(nonce, plaintext, self._aad(binding))
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
def open(self, payload: str, *, binding: str = "singleton") -> tuple[object, bool]:
"""Return the decoded value and whether plaintext migration is required."""
if not isinstance(payload, str):
raise PrivateStateEncryptionError("private state could not be decrypted")
if not payload.startswith("v1:"):
try:
return json.loads(payload), True
except (TypeError, json.JSONDecodeError) as error:
raise PrivateStateEncryptionError(
"private state could not be decrypted"
) from error
try:
sealed = base64.b64decode(payload[3:], altchars=b"-_", validate=True)
if len(sealed) < 28:
raise ValueError("encrypted payload is too short")
plaintext = self._cipher.decrypt(
sealed[:12], sealed[12:], self._aad(binding)
)
return json.loads(plaintext), False
except (
binascii.Error,
InvalidTag,
UnicodeDecodeError,
ValueError,
json.JSONDecodeError,
) as error:
raise PrivateStateEncryptionError(
"private state could not be decrypted"
) from error
+80 -2
View File
@@ -13,6 +13,14 @@ class TodayPlanFull(ValueError):
"""Raised when an add would exceed the bounded Today plan."""
class TodaySessionConflict(ValueError):
"""Raised when a device updates an obsolete active-session revision."""
def __init__(self, session: dict):
super().__init__("active Today session changed on another device")
self.session = session
class TodayStore:
def __init__(
self,
@@ -36,7 +44,7 @@ class TodayStore:
def _initialize(self) -> None:
connection = connect_private_sqlite(self.path, timeout=self.timeout)
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
if connection.execute("PRAGMA user_version").fetchone()[0] >= 3:
connection.close()
return
connection.execute("PRAGMA journal_mode=WAL")
@@ -106,7 +114,24 @@ class TodayStore:
)
"""
)
connection.execute("PRAGMA user_version = 1")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_sessions (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
device_id TEXT NOT NULL,
identity TEXT NOT NULL,
elapsed_ms INTEGER NOT NULL,
running INTEGER NOT NULL,
break_deadline_at INTEGER,
updated_at REAL NOT NULL
)
"""
)
session_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_sessions)")}
if "break_deadline_at" not in session_columns:
connection.execute("ALTER TABLE today_sessions ADD COLUMN break_deadline_at INTEGER")
connection.execute("PRAGMA user_version = 3")
connection.commit()
connection.close()
@@ -163,6 +188,59 @@ class TodayStore:
).fetchone()
return self._snapshot(row)
@staticmethod
def _empty_session() -> dict:
return {
"revision": 0, "device_id": "", "identity": "",
"elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
}
def get_session(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
"FROM today_sessions WHERE login = ?",
(self._normalize_login(login),),
).fetchone()
if row is None:
return self._empty_session()
return {
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
"elapsed_ms": int(row[3]), "running": bool(row[4]),
"break_deadline_at": row[5], "updated_at": row[6],
}
def update_session(
self, login: str, *, base_revision: int, device_id: str,
identity: str, elapsed_ms: int, running: bool, break_deadline_at: int | None = None,
) -> dict:
login = self._normalize_login(login)
updated_at = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
current = connection.execute(
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
"FROM today_sessions WHERE login = ?", (login,)
).fetchone()
current_revision = int(current[0]) if current else 0
if base_revision != current_revision:
session = self._empty_session() if current is None else {
"revision": current_revision, "device_id": current[1], "identity": current[2],
"elapsed_ms": int(current[3]), "running": bool(current[4]),
"break_deadline_at": current[5], "updated_at": current[6],
}
raise TodaySessionConflict(session)
revision = current_revision + 1
connection.execute(
"INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, device_id=excluded.device_id, "
"identity=excluded.identity, elapsed_ms=excluded.elapsed_ms, running=excluded.running, "
"break_deadline_at=excluded.break_deadline_at, updated_at=excluded.updated_at",
(login, revision, device_id, identity, elapsed_ms, int(running), break_deadline_at, updated_at),
)
return self.get_session(login)
@staticmethod
def _normalize_recap_items(items: list[dict]) -> list[dict]:
if not isinstance(items, list) or not items:
+190 -8
View File
@@ -3,17 +3,22 @@
import base64
import binascii
import json
import os
import re
import sqlite3
from datetime import datetime
from pathlib import Path
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from src.private_state import connect_private_sqlite
_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"}
_KEY_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
class UnfiledDraftConflict(ValueError):
@@ -24,16 +29,97 @@ class UnfiledDraftConflict(ValueError):
self.snapshot = snapshot
class UnfiledDraftEncryptionError(RuntimeError):
"""Raised when private draft state cannot be authenticated and decrypted."""
def decode_unfiled_draft_encryption_key(encoded: str) -> bytes:
"""Decode the independently injected 256-bit draft-encryption key."""
try:
key = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as error:
raise UnfiledDraftEncryptionError(
"unfiled draft encryption key is invalid"
) from error
if len(key) != 32:
raise UnfiledDraftEncryptionError(
"unfiled draft encryption key must decode to exactly 32 bytes"
)
return key
def decode_unfiled_draft_encryption_keyring(
encoded: str, active_key_id: str
) -> tuple[dict[str, bytes], str]:
"""Decode a bounded JSON object of named keys without exposing key material."""
def unique_object(pairs):
value = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate key id")
value[key] = item
return value
try:
raw = json.loads(encoded, object_pairs_hook=unique_object)
if not isinstance(raw, dict) or not raw or len(raw) > 8:
raise ValueError("invalid keyring size")
if active_key_id not in raw:
raise ValueError("active key is unavailable")
keys = {}
for key_id, value in raw.items():
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
raise ValueError("invalid key id")
if not isinstance(value, str):
raise ValueError("invalid key value")
keys[key_id] = decode_unfiled_draft_encryption_key(value)
if len(set(keys.values())) != len(keys):
raise ValueError("duplicate encryption key")
return keys, active_key_id
except (json.JSONDecodeError, TypeError, ValueError, UnfiledDraftEncryptionError) as error:
raise UnfiledDraftEncryptionError(
"unfiled draft encryption keyring is invalid"
) from error
class UnfiledDraftStore:
def __init__(
self,
path: str | Path,
*,
encryption_key: bytes | None = None,
encryption_keys: dict[str, bytes] | None = None,
active_key_id: str | None = None,
limit: int = 20,
max_total_bytes: int = 12 * 1024 * 1024,
timeout: float = 1.0,
):
self.path = Path(path)
if encryption_keys is None:
if not isinstance(encryption_key, bytes) or len(encryption_key) != 32:
raise UnfiledDraftEncryptionError(
"unfiled draft encryption requires exactly 32 key bytes"
)
self._ciphers = {"legacy": AESGCM(encryption_key)}
self._active_key_id = None
else:
if (
not encryption_keys
or len(encryption_keys) > 8
or active_key_id not in encryption_keys
or any(
not isinstance(key_id, str)
or not _KEY_ID.fullmatch(key_id)
or not isinstance(key, bytes)
or len(key) != 32
for key_id, key in encryption_keys.items()
)
):
raise UnfiledDraftEncryptionError("unfiled draft encryption keyring is invalid")
self._ciphers = {
key_id: AESGCM(key) for key_id, key in encryption_keys.items()
}
self._active_key_id = active_key_id
self.limit = limit
self.max_total_bytes = max_total_bytes
self.timeout = timeout
@@ -61,18 +147,114 @@ class UnfiledDraftStore:
return normalized
@staticmethod
def _snapshot(row) -> dict:
return {"revision": 0, "drafts": []} if row is None else {
"revision": int(row[0]), "drafts": json.loads(row[1])
}
def _aad(login: str, revision: int) -> bytes:
return f"stackchain:unfiled-drafts:v1\0{login}\0{revision}".encode()
@staticmethod
def _v2_aad(login: str, revision: int, key_id: str) -> bytes:
return f"stackchain:unfiled-drafts:v2\0{key_id}\0{login}\0{revision}".encode()
def _encrypt(self, login: str, revision: int, drafts: list[dict]) -> str:
plaintext = json.dumps(drafts, separators=(",", ":")).encode()
nonce = os.urandom(12)
if self._active_key_id is None:
sealed = nonce + self._ciphers["legacy"].encrypt(
nonce, plaintext, self._aad(login, revision)
)
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
key_id = self._active_key_id
sealed = nonce + self._ciphers[key_id].encrypt(
nonce, plaintext, self._v2_aad(login, revision, key_id)
)
return f"v2:{key_id}:" + base64.urlsafe_b64encode(sealed).decode()
def _decrypt(self, login: str, revision: int, payload: str) -> list[dict]:
try:
if payload.startswith("v1:"):
encoded = payload[3:]
cipher = self._ciphers.get("legacy")
aad = self._aad(login, revision)
elif payload.startswith("v2:"):
_version, key_id, encoded = payload.split(":", 2)
cipher = self._ciphers.get(key_id)
aad = self._v2_aad(login, revision, key_id)
else:
raise ValueError("encrypted payload version is invalid")
if cipher is None:
raise ValueError("encrypted payload key is unavailable")
sealed = base64.b64decode(encoded, altchars=b"-_", validate=True)
if len(sealed) < 12 + 16:
raise ValueError("encrypted payload is too short")
plaintext = cipher.decrypt(sealed[:12], sealed[12:], aad)
drafts = json.loads(plaintext)
if not isinstance(drafts, list):
raise ValueError("decrypted payload is not a collection")
return drafts
except (binascii.Error, InvalidTag, UnicodeDecodeError, ValueError, json.JSONDecodeError) as error:
raise UnfiledDraftEncryptionError(
"unfiled drafts could not be decrypted"
) from error
def _snapshot(self, login: str, row) -> tuple[dict, bool]:
if row is None:
return {"revision": 0, "drafts": []}, False
revision, payload = int(row[0]), row[1]
if payload.startswith(("v1:", "v2:")):
drafts = self._decrypt(login, revision, payload)
active_prefix = (
"v1:" if self._active_key_id is None else f"v2:{self._active_key_id}:"
)
return {"revision": revision, "drafts": drafts}, not payload.startswith(active_prefix)
try:
drafts = json.loads(payload)
except (TypeError, json.JSONDecodeError) as error:
raise UnfiledDraftEncryptionError(
"legacy unfiled drafts could not be decoded"
) from error
if not isinstance(drafts, list):
raise UnfiledDraftEncryptionError(
"legacy unfiled drafts could not be decoded"
)
return {"revision": revision, "drafts": drafts}, True
def get(self, login: str) -> dict:
login = self._login(login)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
(self._login(login),),
(login,),
).fetchone()
return self._snapshot(row)
snapshot, legacy = self._snapshot(login, row)
if legacy:
connection.execute(
"UPDATE unfiled_drafts SET drafts = ? WHERE login = ?",
(
self._encrypt(login, snapshot["revision"], snapshot["drafts"]),
login,
),
)
return snapshot
def rewrap_all(self) -> dict[str, int]:
"""Rewrap every readable row and return aggregate counts only."""
with self._connect() as connection:
rows = connection.execute(
"SELECT login, drafts FROM unfiled_drafts ORDER BY login"
).fetchall()
result = {"total": len(rows), "migrated": 0, "current": 0, "failed": 0}
active_prefix = (
"v1:" if self._active_key_id is None else f"v2:{self._active_key_id}:"
)
for login, payload in rows:
was_current = payload.startswith(active_prefix)
try:
self.get(login)
except UnfiledDraftEncryptionError:
result["failed"] += 1
else:
result["current" if was_current else "migrated"] += 1
return result
@staticmethod
def _filing_plan(raw: object) -> dict | None:
@@ -233,16 +415,16 @@ class UnfiledDraftStore:
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
raise ValueError("revision is invalid")
normalized = self._normalize(drafts)
serialized = json.dumps(normalized, separators=(",", ":"))
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
).fetchone()
current = self._snapshot(row)
current, _legacy = self._snapshot(login, row)
if current["revision"] != expected_revision:
raise UnfiledDraftConflict(current)
revision = expected_revision + 1
serialized = self._encrypt(login, revision, normalized)
connection.execute(
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",
+13
View File
@@ -0,0 +1,13 @@
"""Test-only secret injection for encrypted private state stores."""
import os
os.environ.setdefault(
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
"c3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3M=",
)
os.environ.setdefault(
"STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY",
"cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHA=",
)
+56 -1
View File
@@ -55,6 +55,8 @@ class FakeGiteaServer(ThreadingHTTPServer):
self.issue_creation_ready = Event()
self.assigned_issue_numbers = [issue["number"] for issue in AVAILABLE_ISSUES]
self.comments: list[tuple[int, str]] = []
self.activity_comments: dict[int, list[dict]] = {}
self.edited_comments: list[tuple[int, int, str]] = []
self.requests: list[tuple[str, str]] = []
@@ -87,6 +89,11 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
self._json(200, REPOSITORY)
elif path == "/api/v1/repos/search":
self._json(200, {"data": [REPOSITORY], "ok": True})
elif path == "/api/v1/repos/acme/mobile/assignees":
self._json(200, [
USER,
{"id": 2, "login": "alex", "full_name": "Alexander", "active": True},
])
elif path == "/api/v1/repos/issues/search":
is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"]
is_available_scan = not any(
@@ -103,8 +110,21 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
]
issues = assigned if is_assigned_scan else available if is_available_scan else []
self._json(200, issues, **{"X-Total-Count": str(len(issues))})
elif path.startswith("/api/v1/repos/acme/mobile/issues/comments/"):
try:
comment_id = int(path.rsplit("/", 1)[-1])
except ValueError:
comment_id = 0
comment = next((item for comments in self.server.activity_comments.values()
for item in comments if item.get("id") == comment_id), None)
self._json(200, {**comment, "user": USER}) if comment else self._json(404, {"message": "not found"})
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"):
self._json(200, [], **{"X-Total-Count": "0"})
try:
number = int(path.split("/")[-2])
except ValueError:
number = 0
comments = self.server.activity_comments.get(number, [])
self._json(200, comments, **{"X-Total-Count": str(len(comments))})
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
self._json(200, [])
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):
@@ -175,8 +195,43 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
def do_PATCH(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = urlsplit(self.path).path
self.server.requests.append(("PATCH", self.path))
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
parts = path.strip("/").split("/")
if len(parts) == 8 and parts[:7] == ["api", "v1", "repos", "acme", "mobile", "issues", "comments"]:
try:
comment_id = int(parts[7])
except ValueError:
self._json(404, {"message": "not found"})
return
match = next(((number, item) for number, comments in self.server.activity_comments.items()
for item in comments if item.get("id") == comment_id), None)
if match is None:
self._json(404, {"message": "not found"})
return
number, comment = match
body = str(payload.get("body", ""))
comment["body"] = body
self.server.edited_comments.append((number, comment_id, body))
self._json(200, {**comment, "user": USER, "updated_at": "2026-08-18T01:00:00Z"})
return
if len(parts) == 9 and parts[:5] == ["api", "v1", "repos", "acme", "mobile"] and parts[5] == "issues" and parts[7] == "comments":
try:
number, comment_id = int(parts[6]), int(parts[8])
except ValueError:
self._json(404, {"message": "not found"})
return
comments = self.server.activity_comments.get(number, [])
comment = next((item for item in comments if item.get("id") == comment_id), None)
if comment is None:
self._json(404, {"message": "not found"})
return
body = str(payload.get("body", ""))
comment["body"] = body
self.server.edited_comments.append((number, comment_id, body))
self._json(200, {**comment, "user": USER, "updated_at": "2026-08-18T01:00:00Z"})
return
if not path.startswith("/api/v1/repos/acme/mobile/issues/"):
self._json(404, {"message": "not found"})
return
@@ -11,7 +11,7 @@ if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from fake_gitea import AVAILABLE_ISSUES, FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@@ -250,3 +250,72 @@ def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_reviews_and_downloads_mobile_agenda_snapshot(
tmp_path: Path, width: int, height: int
):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
original_due_dates = [item.get("due_date") for item in AVAILABLE_ISSUES]
AVAILABLE_ISSUES[0]["due_date"] = "2026-08-19"
AVAILABLE_ISSUES[1]["due_date"] = "2026-08-20"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
failed_responses: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": width, "height": height})
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
page.on("response", lambda response: failed_responses.append(f"{response.status} {response.url}") if response.status >= 400 else None)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Agenda export release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="agenda"]').click()
expect(page.locator("#issue-sheet")).to_be_visible()
page.locator("#close-issue-sheet").click()
expect(page.locator("#issue-sheet")).to_be_hidden()
expect(page.locator("#agenda-export")).to_be_visible()
trigger = page.locator("#open-agenda-export")
expect(trigger).to_be_enabled()
bounds = trigger.bounding_box()
assert bounds and bounds["height"] >= 44
trigger.click()
expect(page.locator("#agenda-export-sheet")).to_be_visible()
expect(page.locator("#agenda-export-items input:checked")).to_have_count(2)
page.locator("#agenda-export-items input").nth(1).uncheck()
expect(page.locator("#agenda-export-status")).to_have_text("1 of 2 deadlines selected.")
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
with page.expect_download() as pending:
page.locator("#share-agenda-export").click()
download = pending.value
text = Path(download.path()).read_text()
assert download.suggested_filename.startswith("stackchain-agenda-")
assert text.count("BEGIN:VEVENT") == 1
assert "SUMMARY:Ship mobile capture" in text
assert "Polish desktop filters" not in text
expect(trigger).to_be_focused()
assert browser_errors == []
assert failed_responses == []
browser.close()
finally:
for item, due_date in zip(AVAILABLE_ISSUES, original_due_dates):
if due_date is None:
item.pop("due_date", None)
else:
item["due_date"] = due_date
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
+14 -1
View File
@@ -84,6 +84,9 @@ def release_server(archive: Path, tmp_path: Path, gitea_url: str):
"STACKCHAIN_DASHBOARD_AUTH_MODE": "operator",
"STACKCHAIN_DASHBOARD_ACCESS_TOKEN": ACCESS_TOKEN,
"STACKCHAIN_DASHBOARD_SESSION_SECRET": "artifact-browser-independent-session-secret-863",
"STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY": (
"ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQ="
),
"STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN": origin,
"STACKCHAIN_STATE_DIR": str(tmp_path / "state"),
}
@@ -187,7 +190,17 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
new_action.click()
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
assert page.evaluate("document.activeElement?.id") != "create-issue-title"
assert page.evaluate("document.activeElement?.id") == "cancel-new-issue"
assert page.evaluate("document.querySelector('main').inert") is True
page.keyboard.press("Shift+Tab")
assert page.evaluate("document.activeElement.closest('#create-issue-sheet')?.id") == "create-issue-sheet"
page.keyboard.press("Escape")
expect(page.locator("#create-issue-sheet")).not_to_have_class("create-issue-sheet open")
assert page.evaluate("document.activeElement?.dataset.mobileTask") == "new"
assert page.evaluate("document.querySelector('main').inert") is False
new_action.click()
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
assert page.evaluate("document.activeElement?.id") == "cancel-new-issue"
create_navigation = page.locator(".mobile-create-issue-nav")
expect(create_navigation).to_be_visible()
create_navigation_buttons = create_navigation.locator("button")
+489 -3
View File
@@ -15,6 +15,13 @@ from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
def open_today_action(page, selector: str):
page.locator("[data-mobile-today-more]").click()
expect(page.locator("#mobile-today-actions")).to_be_visible()
page.locator(selector).click()
expect(page.locator("#mobile-today-actions")).to_be_hidden()
def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
@@ -46,7 +53,10 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
expect(page.locator("#my-work-status")).to_contain_text("2")
try:
expect(page.locator("#my-work-status")).to_contain_text("2")
except AssertionError as error:
raise AssertionError({"browser_errors": browser_errors, "failed_responses": failed_responses}) from error
page.locator('[data-mobile-task="work"]').click()
expect(page.locator("#plan-today-sheet")).to_be_visible()
expect(page.locator("#plan-today-candidates .plan-today-item")).to_have_count(2)
@@ -80,6 +90,221 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
expect(page.locator("#plan-today-sheet")).to_be_hidden()
page.locator("#close-issue-sheet").click()
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
page.locator('[data-mobile-task="new"]').click()
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
expect(page.locator("#today-capture-interruption")).to_be_visible()
expect(page.locator("#today-capture-interruption-label")).to_contain_text("Ship mobile capture")
expect(page.locator("#save-unfiled-issue")).to_have_text("Save & return to Today")
paused_timer = page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const state = JSON.parse(localStorage.getItem(key));
return state.entries[state.active_identity];
}""")
assert paused_timer["running"] is False
page.locator("#create-issue-title").fill("Remember release note")
page.locator("#save-unfiled-issue").click()
expect(page.locator("#create-issue-sheet")).not_to_have_class("create-issue-sheet open")
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
resumed = page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
const captures = JSON.parse(localStorage.getItem('stackchain.unfiled-issues.v1') || 'null');
return {running:timer.entries[timer.active_identity].running,
saved:JSON.stringify(captures).includes('Remember release note')};
}""")
assert resumed == {"running": True, "saved": True}
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator('[data-mobile-task="search"]').click()
search_pause = page.locator('#cmd-palette [data-search-today-interruption]')
expect(search_pause).to_be_visible()
expect(search_pause).to_contain_text("Today paused · Ship mobile capture")
return_button = search_pause.locator('[data-return-from-search]')
bounds = return_button.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === false &&
timer.search_interruption?.identity === timer.active_identity;
}""")
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
return_button.click()
expect(page.locator("#cmd-palette")).not_to_have_class("open")
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === true && !timer.search_interruption;
}""")
page.locator("#close-issue-sheet").click()
page.locator("[data-mobile-today-more]").click()
expect(page.locator("#mobile-today-actions")).to_be_visible()
blocked = page.locator("[data-mobile-today-blocked]")
expect(blocked).to_be_visible()
blocked_bounds = blocked.bounding_box()
assert blocked_bounds and blocked_bounds["height"] >= 44
blocked.click()
expect(page.locator("#mobile-today-actions")).to_be_hidden()
expect(page.locator("#today-progress-sheet")).to_be_visible()
expect(page.locator("#today-progress-title")).to_have_text("Report blocker")
expect(page.locator("#today-progress-target")).to_contain_text("Ship mobile capture")
expect(page.locator("#today-progress-body")).to_be_focused()
expect(page.locator("#today-progress-activity")).to_be_hidden()
expect(page.locator(".today-progress-evidence")).to_be_hidden()
for control in page.locator(".today-blocker-presets button, #post-today-blocker").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator('[data-today-blocker-preset="later-today"]').click()
assert page.locator("#today-blocker-return-at").input_value()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#cancel-today-progress").click()
expect(page.locator("#today-progress-sheet")).to_be_hidden()
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Ship mobile capture")
fake.activity_comments = {
41: [{
"id": 701,
"user": {"login": "timmy"},
"body": "Wrong voice transcript",
"issue_url": f"{fake_url}/api/v1/repos/acme/mobile/issues/41",
"created_at": "2026-08-18T00:00:00Z",
}]
}
open_today_action(page, "[data-mobile-today-update]")
expect(page.locator("#today-progress-sheet")).to_be_visible()
progress = page.locator("#today-progress-body")
progress.fill("Pairing with @al")
mention = page.locator('#today-progress-body-mentions [data-login="alex"]')
try:
expect(mention).to_be_visible()
except AssertionError as error:
raise AssertionError({
"status": page.locator("#today-progress-body-mention-status").text_content(),
"requests": fake.requests[-20:],
"browser_errors": browser_errors,
"failed_responses": failed_responses,
}) from error
mention_bounds = mention.bounding_box()
assert mention_bounds and mention_bounds["height"] >= 44
mention.click()
expect(progress).to_have_value("Pairing with @alex ")
page.locator("#post-today-progress").click()
expect(page.locator("#today-progress-sheet")).to_be_hidden()
admitted = page.evaluate("""() => {
const record = JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') || 'null');
return record?.items?.find(item => item.kind === 'issue-comment' && item.number === 41);
}""")
assert admitted and admitted["repository"] == "acme/mobile"
assert admitted["body"] == "Pairing with @alex"
open_today_action(page, "[data-mobile-today-update]")
expect(page.locator("#today-progress-sheet")).to_be_visible()
owned = page.locator('[data-comment-id="701"]')
try:
expect(owned.locator('[data-comment-action="edit"]')).to_be_visible()
except AssertionError as error:
direct = page.evaluate("""async () => {
const response = await fetch('api/v1/repos/acme/mobile/issues/41/comments?limit=20');
return [response.status, await response.text()];
}""")
raise AssertionError({
"activity": page.locator("#today-progress-activity").inner_html(),
"direct": direct,
"requests": fake.requests[-20:],
"browser_errors": browser_errors,
}) from error
owned.locator('[data-comment-action="edit"]').click()
correction = owned.locator(".comment-edit-textarea")
correction.fill("Corrected voice transcript")
owned.locator("[data-comment-edit-save]").click()
try:
expect(owned.locator(".markdown-content")).to_have_text("Corrected voice transcript")
except AssertionError as error:
raise AssertionError({
"activity": page.locator("#today-progress-activity").inner_html(),
"edited": fake.edited_comments,
"requests": fake.requests[-10:],
"failed_responses": failed_responses,
}) from error
expect(page.locator("#today-progress-sheet")).to_be_visible()
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Ship mobile capture")
assert fake.edited_comments == [(41, 701, "Corrected voice transcript")]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#cancel-today-progress").click()
expect(page.locator("#today-progress-sheet")).to_be_hidden()
page.locator("[data-mobile-today-more]").click()
expect(page.locator("#mobile-today-actions")).to_be_visible()
take_break = page.locator("[data-today-break-open]")
expect(take_break).to_be_visible()
take_break.click()
expect(page.locator("#mobile-today-actions")).to_be_hidden()
expect(page.locator("#today-break-sheet")).to_be_visible()
for control in page.locator(".today-break-actions button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.go_back()
expect(page.locator("#today-break-sheet")).to_be_hidden()
open_today_action(page, "[data-today-break-open]")
page.locator('[data-today-break-minutes="5"]').click()
expect(page.locator("#today-break-status")).to_have_text("On break · resume in 5:00")
expect(page.locator("[data-mobile-today-toggle]")).to_have_text("Resume timer")
page.evaluate("""() => {
const prefix = 'stackchain.today-sync.v1.timmy.operation.';
localStorage.setItem(prefix + 'broken', '{not-json');
}""")
page.reload(wait_until="networkidle")
expect(page.locator("#my-work-status")).to_contain_text("2")
expect(page.locator("#today-sync-status")).to_have_text(
"Today queue recovered · discarded 1 unreadable device record."
)
assert page.evaluate("""() => !Object.keys(localStorage).some(
key => key.startsWith('stackchain.today-sync.v1.timmy.operation.')
)""")
expect(page.locator("#today-break-status")).to_contain_text("On break · resume in")
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
page.locator("#close-issue-sheet").click()
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
expect(page.locator("#resume-today-break")).to_be_visible()
page.locator("#resume-today-break").click()
expect(page.locator("#today-break-status")).to_be_hidden()
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
page.locator("#close-issue-sheet").click()
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
page.set_viewport_size({"width": 320, "height": 568})
layout = page.evaluate("""() => {
const hud = document.querySelector('[data-mobile-today-hud]').getBoundingClientRect();
const dock = document.querySelector('.mobile-task-dock').getBoundingClientRect();
const padding = parseFloat(getComputedStyle(document.body).paddingBottom);
return {hudHeight:hud.height, hudTop:hud.top, dockTop:dock.top, padding, viewport:window.innerHeight};
}""")
assert layout["hudHeight"] <= 116
assert layout["padding"] >= layout["viewport"] - min(layout["hudTop"], layout["dockTop"])
for control in page.locator("[data-mobile-today-hud] button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
open_today_action(page, "[data-today-break-open]")
expect(page.locator("#today-break-sheet")).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
custom = page.locator("#today-break-custom-minutes")
bounds = custom.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#cancel-today-break").click()
expect(page.locator("#today-break-sheet")).to_be_hidden()
page.set_viewport_size({"width": 390, "height": 844})
page.locator("[data-mobile-today-open]").click()
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
page.locator('[data-issue-section="reply"]').click()
page.locator("#issue-comment").fill("Handoff complete; continuing with the next Today item.")
page.locator("#send-issue-comment-next").click()
@@ -88,9 +313,34 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
expect(page.locator("#issue-comment")).to_have_value("")
expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true")
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
assert fake.comments == [(41, "Handoff complete; continuing with the next Today item.")]
assert fake.comments == [
(41, "Pairing with @alex"),
(41, "Handoff complete; continuing with the next Today item."),
]
receipt = page.locator("#today-completion-undo")
expect(receipt).to_be_visible()
expect(receipt).to_contain_text("Removed from Today. Undo?")
undo = page.locator("#undo-today-completion")
bounds = undo.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#close-issue-sheet").click()
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
expect(page.locator(".mobile-task-dock")).to_be_visible()
layout = page.evaluate("""() => {
const receipt = document.querySelector('#today-completion-undo').getBoundingClientRect();
const hud = document.querySelector('[data-mobile-today-hud]').getBoundingClientRect();
const dock = document.querySelector('.mobile-task-dock').getBoundingClientRect();
return {receiptBottom:receipt.bottom, hudTop:hud.top, dockTop:dock.top};
}""")
assert layout["receiptBottom"] <= min(layout["hudTop"], layout["dockTop"])
undo.click()
expect(receipt).to_be_hidden()
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
assert today == ["issue:acme/mobile:42:"]
assert today == ["issue:acme/mobile:41:", "issue:acme/mobile:42:"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert browser_errors == []
assert failed_responses == []
@@ -99,3 +349,239 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1
fake = FakeGiteaServer(("127.0.0.1", 0))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Today detour phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
expect(page.locator("#my-work-status")).to_contain_text("2")
page.locator('[data-mobile-task="work"]').click()
page.locator("#plan-today-available").fill("90")
page.locator("#plan-today-available").press("Tab")
page.locator("#build-today-plan").click()
missing = page.locator("[data-plan-missing-estimate]")
expect(missing).to_have_count(2)
for _ in range(2):
missing.nth(0).fill("30")
missing.nth(0).press("Tab")
page.locator("#save-and-start-today").click()
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
page.locator("#close-issue-sheet").click(force=True)
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
page.locator('[data-mobile-task="find"]').click()
find_pause = page.locator("#find-work-sheet [data-today-detour]")
expect(find_pause).to_be_visible()
expect(find_pause).to_contain_text("Today paused · Ship mobile capture")
bounds = find_pause.locator("[data-return-from-detour]").bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'find';
}""")
page.locator("#close-find-work").click()
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
}""")
page.locator('[data-mobile-task="queues"]').click()
queue_pause = page.locator("#mobile-queue-sheet [data-today-detour]")
expect(queue_pause).to_be_visible()
expect(queue_pause).to_contain_text("Today paused · Ship mobile capture")
page.locator('[data-mobile-queue="later"]').click()
persistent_pause = page.locator("#my-work > [data-today-detour]")
expect(persistent_pause).to_be_visible()
return_button = persistent_pause.locator("[data-return-from-detour]")
bounds = return_button.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
return_button.click()
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
}""")
page.locator("#close-issue-sheet").click()
page.locator("#app-menu-toggle").click()
page.locator("#open-insights").click()
insights_pause = page.locator("#insights-sheet > [data-today-detour]")
expect(insights_pause).to_be_visible()
expect(insights_pause).to_contain_text("Today paused · Ship mobile capture")
bounds = insights_pause.locator("[data-return-from-detour]").bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'insights';
}""")
page.reload(wait_until="networkidle")
restored_pause = page.locator("#insights-sheet > [data-today-detour]")
expect(restored_pause).to_be_visible()
expect(restored_pause).to_contain_text("Today paused · Ship mobile capture")
restored_pause.locator("[data-return-from-detour]").click()
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
}""")
page.locator("#close-issue-sheet").click()
page.locator("#app-menu-toggle").click()
page.locator("#active-devices").click()
security_pause = page.locator("#security-center-today-detour")
expect(security_pause).to_be_visible()
expect(security_pause).to_contain_text("Today paused · Ship mobile capture")
bounds = page.locator("#return-from-security-center").bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'security-center';
}""")
page.locator("#security-section-devices").click()
page.go_back()
expect(security_pause).to_be_visible()
page.go_back()
expect(page.locator("#active-devices-sheet")).to_be_hidden()
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
}""")
page.locator("#active-devices").click()
expect(security_pause).to_be_visible()
page.locator("#return-from-security-center").click()
expect(page.locator("#active-devices-sheet")).to_be_hidden()
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)
def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_mobile_issue(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1
fake = FakeGiteaServer(("127.0.0.1", 0))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Blocker flow phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
expect(page.locator("#my-work-status")).to_contain_text("2")
page.locator('[data-mobile-task="work"]').click()
page.locator("#plan-today-available").fill("90")
page.locator("#plan-today-available").press("Tab")
page.locator("#build-today-plan").click()
missing = page.locator("[data-plan-missing-estimate]")
expect(missing).to_have_count(2)
for _ in range(2):
missing.nth(0).fill("30")
missing.nth(0).press("Tab")
page.locator("#save-and-start-today").click()
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
page.locator("#close-issue-sheet").click()
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
open_today_action(page, "[data-mobile-today-update]")
expect(page.locator("#today-progress-sheet")).to_be_visible()
page.locator("#today-progress-body").fill("Blocked waiting for the design owner")
page.locator("#post-today-progress").click()
expect(page.locator("#today-progress-sheet")).to_be_hidden()
open_today_action(page, "[data-today-break-open]")
page.locator('[data-today-break-minutes="5"]').click()
expect(page.locator("#resume-today-break")).to_be_visible()
page.evaluate("""() => {
localStorage.setItem('stackchain.today-progress.v1.timmy', JSON.stringify({
version: 1,
drafts: {
'issue:acme/mobile:41:': {
body: 'Blocked waiting for the design owner',
operation_id: 'already-admitted-blocker',
blocker_pending: true,
blocker_until: '2000-01-02T03:04:00.000Z'
}
}
}));
}""")
page.reload(wait_until="networkidle")
expect(page.locator("#my-work-status")).to_contain_text("2")
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
page.locator("#close-issue-sheet").click()
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
expect(page.locator("#resume-today-break")).to_be_visible()
page.locator("#resume-today-break").click()
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
page.locator("#close-issue-sheet").click()
open_today_action(page, "[data-mobile-today-update]")
expect(page.locator("#today-progress-sheet")).to_be_visible()
expect(page.locator("#today-progress-blocker-recovery")).to_be_visible()
expect(page.locator("#today-progress-blocker-recovery")).to_contain_text("Blocker queued")
expect(page.locator("#today-progress-body")).to_be_disabled()
expect(page.locator("#today-blocker-return-at")).to_have_value("2000-01-02T03:04")
page.locator("#today-blocker-return-at").fill("2099-08-19T09:00")
blocker = page.locator("#post-today-blocker")
expect(blocker).to_have_text("Finish moving on")
bounds = blocker.bounding_box()
assert bounds and bounds["height"] >= 44
blocker.click()
expect(page.locator("#today-progress-sheet")).to_be_hidden()
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
state = page.evaluate("""() => ({
later: JSON.parse(localStorage.getItem('stackchain.later-work.v1.timmy') || '{}'),
today: JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]'),
outbox: JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') || 'null'),
})""")
assert state["today"] == ["issue:acme/mobile:42:"]
assert state["later"]["issue:acme/mobile:41:"].startswith("2099-08-19T09:00")
comments = [item for item in state["outbox"]["items"] if item["kind"] == "issue-comment"]
assert len(comments) == 1
assert comments[0]["number"] == 41
assert comments[0]["body"] == "Blocked waiting for the design owner"
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert browser_errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)
@@ -0,0 +1,111 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Today summary journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
tmp_path: Path, width: int, height: int
):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
page.add_init_script(
"Object.defineProperty(navigator, 'share', {configurable:true, value:async payload => { window.__sharedSummary = payload; }});"
)
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on(
"console",
lambda message: browser_errors.append(message.text)
if message.type == "error"
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Today summary release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate(
"""
() => {
const summary=createTodaySummary({
storage:localStorage,getLogin:()=> 'timmy',
share:payload=>navigator.share(payload),
copy:text=>navigator.clipboard.writeText(text),
});
const view=createTodaySummaryView({summary,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
window.__summaryTest={summary,view};
view.open(
[{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}],
[{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}],
);
}
"""
)
sheet = page.locator("#today-summary-sheet")
expect(sheet).to_be_visible()
expect(page.locator("#today-summary-preview")).to_have_text(
"Today\n- Ship mobile capture\n\nTomorrow\n- Review release"
)
assert "42m" not in page.locator("#today-summary-preview").inner_text()
for control in page.locator(".today-summary-actions button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.evaluate(
"""
() => {
window.__summaryTest.summary.includeActuals(true);
window.__summaryTest.summary.setNote('Waiting on review');
window.__summaryTest.view.render();
}
"""
)
expect(page.locator("#today-summary-preview")).to_contain_text(
"Ship mobile capture (42m)"
)
page.evaluate(
"window.__summaryTest.view.shareDraft(document.querySelector('#share-today-summary'))"
)
expect(sheet).to_be_hidden()
assert page.evaluate("window.__sharedSummary") == {
"title": "Today summary",
"text": "Today\n- Ship mobile capture (42m)\n\nTomorrow\n- Review release\n\nNote\nWaiting on review",
}
assert page.evaluate(
"localStorage.getItem('stackchain.today-summary-draft.v1.timmy')"
) is None
assert browser_errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
+118
View File
@@ -0,0 +1,118 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
AGENDA_CALENDAR = Path(__file__).parents[1] / "frontend" / "agenda-calendar.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
def run_node(source: str):
completed = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_calendar_snapshot_serializes_ordered_all_day_events_with_stable_identity():
items = [
{
"repository": "stackchain/dashboard",
"number": 12,
"title": "Plan, ship; verify\\path\nnext",
"due_date": "2026-08-19T23:00:00-07:00",
"url": "https://forge.example/git/stackchain/dashboard/issues/12",
},
{
"repository": "stackchain/api",
"number": 7,
"title": "API release",
"due_date": "2026-08-20",
"url": "https://forge.example/git/stackchain/api/issues/7",
},
]
source = f"""
const calendar = require({json.dumps(str(AGENDA_CALENDAR))});
const text = calendar.serializeAgendaCalendar({json.dumps(items)}, {{generatedOn:'20260818'}});
process.stdout.write(JSON.stringify({{text}}));
"""
text = run_node(source)["text"]
assert text.startswith("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n")
assert text.endswith("END:VCALENDAR\r\n")
assert text.count("BEGIN:VEVENT") == 2
assert text.index("stackchain/dashboard#12") < text.index("stackchain/api#7")
assert "UID:issue-12@stackchain-dashboard" in text
assert "DTSTART;VALUE=DATE:20260819" in text
assert "SUMMARY:Plan\\, ship\\; verify\\\\path\\nnext" in text
assert "URL:https://forge.example/git/stackchain/dashboard/issues/12" in text
assert "DTSTAMP:20260818T000000Z" in text
assert "\n" not in text.replace("\r\n", "")
def test_calendar_snapshot_folds_utf8_lines_to_rfc_octet_limit_without_splitting_characters():
item = {
"repository": "stackchain/dashboard",
"number": 99,
"title": "Launch 🚀 " + "é" * 60,
"due_date": "2026-08-21",
"url": "https://forge.example/git/stackchain/dashboard/issues/99",
}
source = f"""
const calendar = require({json.dumps(str(AGENDA_CALENDAR))});
const text = calendar.serializeAgendaCalendar([{json.dumps(item)}], {{generatedOn:'20260818'}});
process.stdout.write(JSON.stringify({{text}}));
"""
text = run_node(source)["text"]
physical_lines = text.split("\r\n")
assert all(len(line.encode("utf-8")) <= 75 for line in physical_lines)
assert any(line.startswith(" ") for line in physical_lines)
unfolded = text.replace("\r\n ", "")
assert "SUMMARY:Launch 🚀 " + "é" * 60 in unfolded
def test_calendar_snapshot_prefers_native_file_share_and_falls_back_to_one_download():
source = f"""
const calendar = require({json.dumps(str(AGENDA_CALENDAR))});
class FakeFile {{ constructor(parts, name, options) {{ this.parts=parts; this.name=name; this.type=options.type; }} }}
const shared=[];
const nativeNavigator={{canShare:payload => payload.files[0].name.endsWith('.ics'), share:async payload => shared.push(payload)}};
const clicks=[]; const revoked=[];
const document={{createElement:() => ({{click(){{clicks.push({{download:this.download,href:this.href}})}}}})}};
const urlApi={{createObjectURL:()=>'blob:agenda', revokeObjectURL:value=>revoked.push(value)}};
(async () => {{
const native = await calendar.deliverCalendarSnapshot({{text:'BEGIN:VCALENDAR', filename:'stackchain-agenda.ics', navigator:nativeNavigator, document, urlApi, FileCtor:FakeFile}});
const fallback = await calendar.deliverCalendarSnapshot({{text:'BEGIN:VCALENDAR', filename:'stackchain-agenda.ics', navigator:{{}}, document, urlApi, FileCtor:FakeFile}});
process.stdout.write(JSON.stringify({{native,fallback,shared:shared.length,clicks,revoked}}));
}})();
"""
assert run_node(source) == {
"native": "shared",
"fallback": "downloaded",
"shared": 1,
"clicks": [{"download": "stackchain-agenda.ics", "href": "blob:agenda"}],
"revoked": ["blob:agenda"],
}
@pytest.mark.anyio
async def test_agenda_export_review_requires_selection_and_exposes_snapshot_privacy_boundary():
markup = INDEX.read_text()
source = await dashboard()
feature_source = source + AGENDA_CALENDAR.read_text()
assert 'id="open-agenda-export"' in markup
assert 'id="agenda-export-sheet"' in markup
assert 'id="agenda-export-items"' in markup
assert 'id="share-agenda-export"' in markup
assert "This exports a snapshot" in markup
assert "agendaMyWork(activeMyWork)" in feature_source
assert "checkbox.checked" in feature_source
assert "serializeAgendaCalendar(selected" in feature_source
assert "deliverCalendarSnapshot" in feature_source
assert "agenda-export-empty" in feature_source
+38
View File
@@ -15,6 +15,44 @@ def run_node(script: str):
return json.loads(result.stdout)
def test_repeated_operation_id_rejects_a_different_comment_payload():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy'}});
const original={{kind:'issue-comment',repository:'o/r',number:7,body:'Done',operationId:'stable',attachments:[{{filename:'before.jpg',contentType:'image/jpeg',note:'Before',operationId:'photo-1'}}]}};
const identical=outbox.enqueue(original);
let error='';
try {{ outbox.enqueue({{...original,attachments:[{{filename:'after.jpg',contentType:'image/jpeg',note:'After',operationId:'photo-2'}}]}}); }}
catch (caught) {{ error=caught.message; }}
process.stdout.write(JSON.stringify({{identical,error,items:outbox.list()}}));
"""
output = run_node(script)
assert output["identical"]["operationId"] == "stable"
assert output["error"] == "This operation ID is already bound to a different queued action."
assert len(output["items"]) == 1
assert output["items"][0]["attachment"]["operationId"] == "photo-1"
def test_repeated_pull_review_operation_uses_the_default_comment_decision():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy'}});
const review={{kind:'pull-review',repository:'o/r',number:8,body:'Notes',expectedHeadSha:'abc',operationId:'review-op'}};
outbox.enqueue(review);
let error='';
try {{ outbox.enqueue(review); }} catch (caught) {{ error=caught.message; }}
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
"""
output = run_node(script)
assert output["error"] == ""
assert len(output["items"]) == 1
assert output["items"][0]["decision"] == "comment"
def test_authored_outbox_quarantines_legacy_and_cross_account_messages():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -1,7 +1,78 @@
import threading
import os
import sqlite3
import pytest
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
from src.state_encryption import PrivateStateEncryptionError
PRIVATE_KEY = b"a" * 32
def test_published_find_work_catalog_is_encrypted_at_rest_and_survives_restart(tmp_path):
path = tmp_path / "available.sqlite3"
store = AvailableIssueSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
owner = store.try_acquire_refresh(lease_seconds=5)
canary = "private-find-work-body-canary"
published = store.publish(
owner,
items=[{"repository": "stackchain/api", "number": 7, "body": canary}],
)
with sqlite3.connect(path) as connection:
payload = connection.execute(
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
).fetchone()[0]
assert payload.startswith("v1:")
assert canary not in payload
assert AvailableIssueSnapshotStore(
path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY
).load().items == published.items
def test_find_work_catalog_lazily_migrates_plaintext_without_changing_freshness(tmp_path):
path = tmp_path / "available.sqlite3"
store = AvailableIssueSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
with sqlite3.connect(path) as connection:
connection.execute(
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, retry_at = ?",
('[{"repository":"stackchain/api","number":7}]', 91.0, 105.0),
)
state = store.load()
with sqlite3.connect(path) as connection:
migrated = connection.execute(
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
).fetchone()[0]
assert state.items == [{"repository": "stackchain/api", "number": 7}]
assert (state.created_at, state.retry_at) == (91.0, 105.0)
assert migrated.startswith("v1:")
def test_find_work_ciphertext_cannot_be_substituted_into_live_snapshot(tmp_path):
available_path = tmp_path / "available.sqlite3"
available = AvailableIssueSnapshotStore(
available_path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY
)
owner = available.try_acquire_refresh(lease_seconds=5)
available.publish(owner, items=[{"body": "secret"}])
with sqlite3.connect(available_path) as connection:
payload = connection.execute(
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
).fetchone()[0]
live_path = tmp_path / "live.sqlite3"
from src.live_snapshot_store import LiveSnapshotStore
live = LiveSnapshotStore(live_path, encryption_key=PRIVATE_KEY)
with sqlite3.connect(live_path) as connection:
connection.execute("UPDATE live_snapshot SET value_json = ?", (payload,))
with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
live.load()
def test_independent_workers_allow_only_one_catalog_refresh(tmp_path):
+5 -1
View File
@@ -41,7 +41,10 @@ def test_release_promotion_waits_for_tests_and_bundle():
assert "needs: [lint, build-release, browser-journey]" in release
assert "github.event_name == 'push'" in release
assert "actions/download-artifact@v3" in release
assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release
assert (
'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .'
in release
)
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
@@ -62,6 +65,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
"tests/e2e/test_mobile_home_bootstrap_release.py "
"tests/e2e/test_mobile_today_handoff_release.py "
"tests/e2e/test_mobile_today_wrap_up_release.py "
"tests/e2e/test_mobile_today_summary_release.py "
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
) in browser
assert "needs: [lint, build-release, browser-journey]" in release
+11
View File
@@ -951,6 +951,17 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
def test_mobile_search_pauses_today_and_offers_a_lossless_return():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert html.count('<aside class="search-today-interruption" data-search-today-interruption') == 2
assert html.count('<button data-return-from-search type="button">') == 2
assert "taskOverlayHistory.leave();" in html
assert ".search-today-interruption" in css
assert ".search-today-interruption button { min-height:44px;" in css
def test_search_preview_queues_eligible_issue_and_continues_preserved_search():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
+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-v117" in worker
assert "stackchain-dashboard-shell-v121" in worker
@@ -96,6 +96,21 @@ const options = {{
}
def test_other_focused_surfaces_can_request_the_same_lazy_action_controller():
script = f"""
const createConversationActionHydrator = require({json.dumps(str(HYDRATOR))});
let loads=0; const controller={{name:'shared-actions'}};
const hydrator=createConversationActionHydrator({{
load:async()=>{{loads++;}}, activate:()=>controller,
}});
(async()=>{{
const [first,second]=await Promise.all([hydrator.get(),hydrator.get()]);
process.stdout.write(JSON.stringify({{loads,same:first===second,name:first.name}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {"loads": 1, "same": True, "name": "shared-actions"}
def test_issue_pull_and_update_conversations_trigger_optional_actions_not_startup():
html = (FRONTEND / "index.html").read_text()
javascript = (FRONTEND / "dashboard.js").read_text()
+59
View File
@@ -79,6 +79,36 @@ const photos = [
}
def test_store_isolates_today_progress_photos_from_conversation_photos():
script = f"""
const createStore = require({json.dumps(str(STORE))});
const records = new Map();
const transaction = async (operation, key, value) => {{
if (operation === 'put') records.set(key, structuredClone(value));
if (operation === 'get') return records.has(key) ? structuredClone(records.get(key)) : null;
if (operation === 'delete') records.delete(key);
}};
const conversation = createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
const progress = createStore({{transaction,getOwnerLogin:()=> 'timmy',scope:'today-progress'}});
const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}};
(async()=>{{
await conversation.save(target,[{{filename:'reply.jpg',contentType:'image/jpeg',blob:new Blob(['reply'])}}]);
await progress.save(target,[{{filename:'progress.jpg',contentType:'image/jpeg',blob:new Blob(['progress'])}}]);
const reply=await conversation.load(target); const today=await progress.load(target);
await progress.remove(target);
process.stdout.write(JSON.stringify({{reply:reply[0].filename,today:today[0].filename,
replyAfter:(await conversation.load(target))[0].filename,progressAfter:await progress.load(target),count:records.size}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert json.loads(run_node(script)) == {
"reply": "reply.jpg",
"today": "progress.jpg",
"replyAfter": "reply.jpg",
"progressAfter": None,
"count": 1,
}
def test_coordinator_ignores_stale_restores_and_clears_only_after_durable_completion():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
@@ -138,6 +168,35 @@ const drafts = createCoordinator({{store, lanes:{{issue:{{controller}}}}}});
assert json.loads(run_node(script)) == ["save:1:first.webp", "restore:second.webp"]
def test_coordinator_clear_does_not_delete_the_durable_draft_during_reopen():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const records=new Map(); let current=[]; let drafts; const restored=[];
const key=target=>target.kind+':'+target.number;
const store={{
save:async(target,attachments)=>attachments?.length?records.set(key(target),attachments):records.delete(key(target)),
load:async target=>records.get(key(target))||null,
remove:async target=>records.delete(key(target)),
}};
const controller={{
serialize:async()=>current,
restore:value=>{{current=value;restored.push(value[0].filename);}},
clear:()=>{{current=[];drafts.checkpoint('today').catch(()=>{{}});}},
}};
drafts=createCoordinator({{store,lanes:{{today:{{controller}}}}}});
const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}};
(async()=>{{
await drafts.open('today',target);
current=[{{filename:'proof.webp',contentType:'image/webp',blob:new Blob(['proof'])}}];
await drafts.checkpoint('today');
await drafts.switchTo('today',target);
await Promise.resolve();
process.stdout.write(JSON.stringify({{restored,saved:(await store.load(target))?.[0]?.filename||null}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert json.loads(run_node(script)) == {"restored": ["proof.webp"], "saved": "proof.webp"}
def test_my_work_wires_durable_photo_drafts_into_all_conversation_boundaries():
dashboard = DASHBOARD.read_text()
html = INDEX.read_text()
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.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_issue_capture_modal_inerts_background_focuses_cancel_and_restores_exact_launcher():
result = run_node(
f"""
const createIssueCapture = require({json.dumps(str(MODULE))});
function element(id) {{
return {{id, inert:false, hidden:false, disabled:false, isConnected:true,
focus() {{ document.activeElement = this; }},
getClientRects() {{ return [{{}}]; }} }};
}}
const launcher = element('mobile-new');
const cancel = element('cancel-new-issue');
const title = element('create-issue-title');
const panel = {{...element('create-issue-panel'), scrollTop:420}};
const background = [element('header'), element('main'), element('mobile-task-dock')];
const root = {{
querySelector: selector => selector === '#cancel-new-issue' ? cancel :
(selector === '.create-issue-panel' ? panel : null),
querySelectorAll: () => [cancel, title],
addEventListener() {{}}, removeEventListener() {{}},
}};
const document = {{activeElement: launcher}};
const lifecycle = createIssueCapture.createModalLifecycle({{
root, background, document, requestClose() {{}},
}});
lifecycle.open(launcher);
const opened = {{active:document.activeElement.id, inert:background.map(item => item.inert)}};
lifecycle.close();
process.stdout.write(JSON.stringify({{
opened, closed:{{active:document.activeElement.id, inert:background.map(item => item.inert), scrollTop:panel.scrollTop}}
}}));
"""
)
assert result == {
"opened": {"active": "cancel-new-issue", "inert": [True, True, True]},
"closed": {"active": "mobile-new", "inert": [False, False, False], "scrollTop": 0},
}
def test_issue_capture_modal_wraps_visible_focus_and_escape_uses_close_request():
result = run_node(
f"""
const createIssueCapture = require({json.dumps(str(MODULE))});
function element(id, visible=true) {{
return {{id, inert:false, hidden:!visible, disabled:false, isConnected:true,
focus() {{ document.activeElement = this; }},
getClientRects() {{ return visible ? [{{}}] : []; }} }};
}}
const launcher = element('empty-work-create');
const cancel = element('cancel-new-issue');
const hidden = element('hidden-control', false);
const submit = element('submit-new-issue');
const listeners = {{}};
const root = {{
querySelector: selector => selector === '#cancel-new-issue' ? cancel : null,
querySelectorAll: () => [cancel, hidden, submit],
addEventListener: (name, callback) => {{ listeners[name] = callback; }},
removeEventListener() {{}},
}};
const document = {{activeElement: launcher}};
let closes = 0;
const lifecycle = createIssueCapture.createModalLifecycle({{
root, background:[], document, requestClose() {{ closes += 1; }},
}});
lifecycle.open(launcher);
document.activeElement = submit;
let prevented = 0;
listeners.keydown({{key:'Tab', shiftKey:false, preventDefault() {{ prevented += 1; }}}});
const wrappedForward = document.activeElement.id;
document.activeElement = cancel;
listeners.keydown({{key:'Tab', shiftKey:true, preventDefault() {{ prevented += 1; }}}});
const wrappedBackward = document.activeElement.id;
listeners.keydown({{key:'Escape', shiftKey:false, preventDefault() {{ prevented += 1; }}}});
process.stdout.write(JSON.stringify({{wrappedForward, wrappedBackward, closes, prevented}}));
"""
)
assert result == {
"wrappedForward": "cancel-new-issue",
"wrappedBackward": "submit-new-issue",
"closes": 1,
"prevented": 3,
}
+13
View File
@@ -12,6 +12,19 @@ SESSION_JS = ROOT / "frontend" / "session.js"
SECURITY_CENTER_JS = ROOT / "frontend" / "security-center.js"
@pytest.mark.anyio
async def test_security_center_wires_mobile_today_pause_before_lazy_loading_and_visible_return():
html = await dashboard()
session = SESSION_JS.read_text()
dashboard_js = (ROOT / "frontend" / "dashboard.js").read_text()
assert 'id="security-center-today-detour" data-today-detour' in html
assert 'id="return-from-security-center" data-return-from-detour type="button">Return to Today</button>' in html
assert session.index("beginDetour?.('security-center')") < session.index("devicesSheet.hidden = false;")
assert "onClose: () => root.stackchainTodayTimerView?.finishDetour?.('security-center')" in session
assert "window.stackchainTodayTimerView = timerView;" in dashboard_js
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
+139 -1
View File
@@ -55,6 +55,110 @@ async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplic
assert "private/repo" not in json.dumps(sent)
@pytest.mark.anyio
async def test_snoozed_deadline_revalidates_and_sends_once_after_expiry(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="UTC", reminder_hour=9
)
store.mark_deadline_reminder_delivered("device-a", "2026-08-13")
snoozed_at = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc)
store.snooze_deadline_reminder(
"device-a", now=snoozed_at.timestamp(), delay_seconds=3_600
)
snapshots = 0
sent = []
async def assigned():
nonlocal snapshots
snapshots += 1
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14"}]}
async def send(_subscription, payload):
sent.append(json.loads(payload))
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_deadline_reminders(
store, config, assigned, send,
now=datetime(2026, 8, 13, 10, 59, tzinfo=timezone.utc),
) == 0
assert snapshots == 0
assert await dispatch_deadline_reminders(
store, config, assigned, send,
now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc),
) == 1
assert snapshots == 1
assert sent[0]["deadline_count"] == 1
assert sent[0]["tag"] == "stackchain-deadline-digest-2026-08-13"
assert store.deadline_reminder_devices()[0].snoozed_until is None
assert await dispatch_deadline_reminders(
store, config, assigned, send,
now=datetime(2026, 8, 13, 11, 1, tzinfo=timezone.utc),
) == 0
assert snapshots == 1
@pytest.mark.anyio
async def test_expired_deadline_snooze_clears_when_no_deadlines_remain(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="UTC", reminder_hour=9
)
store.mark_deadline_reminder_delivered("device-a", "2026-08-13")
store.snooze_deadline_reminder(
"device-a",
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc).timestamp(),
)
async def resolved():
return {"complete": True, "items": []}
assert await dispatch_deadline_reminders(
store,
PushConfiguration("public", "private", "mailto:ops@example.com"),
resolved,
now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc),
) == 0
assert store.deadline_reminder_devices()[0].snoozed_until is None
@pytest.mark.anyio
async def test_expired_deadline_snooze_clears_when_deadlines_move_beyond_horizon(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="UTC", reminder_hour=9, reminder_days=2
)
store.mark_deadline_reminder_delivered("device-a", "2026-08-13")
store.snooze_deadline_reminder(
"device-a",
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc).timestamp(),
)
async def replanned():
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-20"}]}
assert await dispatch_deadline_reminders(
store,
PushConfiguration("public", "private", "mailto:ops@example.com"),
replanned,
now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc),
) == 0
assert store.deadline_reminder_devices()[0].snoozed_until is None
@pytest.mark.anyio
async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -237,9 +341,39 @@ def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_pa
"timezone": "Europe/London",
"reminder_hour": 8,
"reminder_days": 2,
"snoozed_until": None,
}
def test_deadline_snooze_is_device_bound_and_requires_enabled_reminders(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("enabled", "disabled"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(
"enabled", enabled=True, timezone="UTC", reminder_hour=9
)
store.set_deadline_preferences(
"disabled", enabled=False, timezone="UTC", reminder_hour=9
)
assert store.snooze_deadline_reminder(
"enabled", now=1_765_000_000, delay_seconds=3_600
) is True
assert store.snooze_deadline_reminder(
"disabled", now=1_765_000_000, delay_seconds=3_600
) is False
assert store.snooze_deadline_reminder(
"missing", now=1_765_000_000, delay_seconds=3_600
) is False
devices = {device.session_id: device for device in store.deadline_reminder_devices()}
assert devices["enabled"].snoozed_until == 1_765_003_600
assert "disabled" not in devices
def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
database = tmp_path / "push.sqlite3"
import sqlite3
@@ -258,7 +392,11 @@ def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
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_subscriptions VALUES (
'device-a',
'https://push.example/a',
'{"endpoint":"https://push.example/a","keys":{"p256dh":"key","auth":"secret"}}'
);
INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL);
""")
+5 -2
View File
@@ -74,10 +74,13 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createDetailDefer" not in first.runtime_bytes
assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
assert len(first.runtime_gzip_bytes) <= 99 * 1024
# Core mobile workflows stay below 100 KiB, including synced Search, Update decisions,
# and interruption-safe Security Center navigation.
assert len(first.runtime_gzip_bytes) <= 100 * 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
+13 -13
View File
@@ -20,7 +20,7 @@ async def test_notification_page_preserves_upstream_total_without_loading_other_
"subject": {
"title": "Retry failed deploy",
"type": "Issue",
"html_url": "https://forge.example/stackchain/api/issues/7",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
},
}],
)
@@ -170,8 +170,8 @@ async def test_unread_notifications_are_normalized_for_mobile_handoff():
"title": "Retry failed deploy",
"type": "Issue",
"state": "open",
"html_url": "https://forge.example/stackchain/api/issues/7",
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
"latest_comment_html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
},
},
{"id": 43, "repository": None, "subject": None},
@@ -193,8 +193,8 @@ async def test_unread_notifications_are_normalized_for_mobile_handoff():
"title": "Retry failed deploy",
"subject_type": "Issue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
"subject_url": "https://forge.example/stackchain/api/issues/7",
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
"subject_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
},
{
"id": 43,
@@ -245,8 +245,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"state": "open",
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
"latest_comment_url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
"html_url": "https://forge.example/stackchain/api/issues/7",
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
"latest_comment_html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
},
})
if request.url.path.endswith("/issues/7"):
@@ -262,7 +262,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"user": {"login": "alexander"},
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
})
if request.url.path.endswith("/issues/7/comments"):
return httpx.Response(200, json=[{
@@ -270,7 +270,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"user": {"login": "alexander"},
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
}], headers={"X-Total-Count": "1"})
return httpx.Response(404)
@@ -292,13 +292,13 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"title": "Retry failed deploy",
"subject_type": "Issue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
"subject_body": "Deploy fails after **three** retries.",
"latest_comment": {
"author": "alexander",
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
},
"issue": {"number": 7, "assignees": [], "claimable": True},
"acknowledge_supported": True,
@@ -362,7 +362,7 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
"user": {"login": "timmy"},
"body": "Please retry the worker.",
"created_at": "2026-08-07T19:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-91",
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
@@ -388,7 +388,7 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
"author": "timmy",
"body": "Please retry the worker.",
"created_at": "2026-08-07T19:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-91",
}
+7 -7
View File
@@ -47,7 +47,7 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages(
"title": "Older review",
"state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/pulls/51",
"html_url": "http://127.0.0.1:3000/stackchain/api/pulls/51",
}],
)
@@ -76,7 +76,7 @@ async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_dist
"id": 878, "number": 878, "title": "Completed delegation", "state": "closed",
"updated_at": "2026-08-15T12:00:00Z",
"repository": {"full_name": "stackchain/dashboard"},
"html_url": "https://forge.example/stackchain/dashboard/issues/878",
"html_url": "http://127.0.0.1:3000/stackchain/dashboard/issues/878",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
@@ -94,7 +94,7 @@ async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_dist
"id": 878, "number": 878, "title": "Completed delegation", "state": "closed",
"updated_at": "2026-08-15T12:00:00Z",
"repository": {"full_name": "stackchain/dashboard"},
"html_url": "https://forge.example/stackchain/dashboard/issues/878",
"html_url": "http://127.0.0.1:3000/stackchain/dashboard/issues/878",
"work_reasons": ["created_by_me"],
}],
}
@@ -106,7 +106,7 @@ async def test_issue_context_merges_self_assigned_and_filed_streams_without_dupl
del page, limit
common = {
"id": 7, "number": 7, "title": "My own filing", "state": "open",
"repository": {"full_name": "stackchain/api"}, "html_url": "https://forge.example/issues/7",
"repository": {"full_name": "stackchain/api"}, "html_url": "http://127.0.0.1:3000/issues/7",
}
if stream == "issue":
return {"items": [{**common, "assignees": [{"login": "timmy"}]}], "page": 1, "total": 1, "has_more": False}
@@ -816,7 +816,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
"repository": {"full_name": "stackchain/mobile"},
"updated_at": "2026-08-06T12:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"html_url": "https://forge.example/stackchain/mobile/issues/7",
"html_url": "http://127.0.0.1:3000/stackchain/mobile/issues/7",
}]
async def pulls():
@@ -831,7 +831,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
"work_reasons": ["assigned_to_me", "review_requested"],
"repository": {"full_name": "stackchain/api"},
"updated_at": "2026-08-06T11:00:00Z",
"html_url": "https://forge.example/stackchain/api/pulls/7",
"html_url": "http://127.0.0.1:3000/stackchain/api/pulls/7",
}]
monkeypatch.setattr(main, "current_user", user)
@@ -864,7 +864,7 @@ async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypat
return {
"title": "Review API",
"body": "Please check the retry flow.",
"html_url": "https://forge.example/stackchain/api/pulls/7",
"html_url": "http://127.0.0.1:3000/stackchain/api/pulls/7",
"user": {"login": "alex"},
"head": {"sha": "abc123"},
}
+56 -24
View File
@@ -131,7 +131,7 @@ async def test_global_search_specific_type_uses_one_full_width_scoped_stream():
"title": "Review mobile search",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -161,7 +161,7 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
"number": 42,
"title": "Repair mobile queue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/42",
"url": "http://127.0.0.1:3000/stackchain/api/issues/42",
}],
"partial": False,
}
@@ -180,7 +180,7 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
"number": 42,
"title": "Repair mobile queue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/42",
"url": "http://127.0.0.1:3000/stackchain/api/issues/42",
}], "partial": False}
@@ -200,7 +200,7 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
"author": "alex",
"labels": ["P1"],
"assignees": [],
"url": "https://forge.example/stackchain/api/issues/42",
"url": "http://127.0.0.1:3000/stackchain/api/issues/42",
"claimable": True,
"assigned_to_me": False,
}
@@ -233,7 +233,7 @@ async def test_global_search_preview_conversation_returns_a_bounded_authorized_p
"author": "alexander",
"body": "Current **decision**",
"created_at": "2026-08-14T12:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42#issuecomment-7",
"url": "http://127.0.0.1:3000/stackchain/api/issues/42#issuecomment-7",
}],
"page": 3,
"older_page": 2,
@@ -278,7 +278,7 @@ async def test_global_search_preview_reply_is_idempotent_for_an_authorized_pull(
"author": "timmy",
"body": body,
"created_at": "2026-08-14T12:30:00Z",
"url": "https://forge.example/stackchain/api/pulls/42#issuecomment-91",
"url": "http://127.0.0.1:3000/stackchain/api/pulls/42#issuecomment-91",
}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
@@ -345,7 +345,7 @@ async def test_global_search_preview_attachment_is_idempotent_for_exact_visible_
async def upload(repository, number, filename, content_type, content):
uploads.append((repository, number, filename, content_type, content))
return {"name": filename, "url": "https://forge.example/evidence/photo.png", "size": len(content)}
return {"name": filename, "url": "http://127.0.0.1:3000/evidence/photo.png", "size": len(content)}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
@@ -367,7 +367,7 @@ async def test_global_search_preview_attachment_is_idempotent_for_exact_visible_
assert first.status_code == replay.status_code == 201
assert replay.json() == first.json()
assert first.json()["markdown"] == "![photo.png](<https://forge.example/evidence/photo.png>)"
assert first.json()["markdown"] == "![photo.png](<http://127.0.0.1:3000/evidence/photo.png>)"
assert previews == [("stackchain/api", "pull", 42)]
assert uploads == [("stackchain/api", 42, "photo.png", "image/png", image)]
@@ -408,11 +408,11 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
return httpx.Response(200, json=[{
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/42",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
}, {
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/42",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
}, {
"id": 5, "number": 43, "title": "Unsafe", "state": "open",
"repository": {"full_name": "stackchain/api"},
@@ -421,7 +421,7 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
return httpx.Response(200, json=[{
"id": 8, "number": 9, "title": "Improve search", "state": "closed",
"repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -440,11 +440,11 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
"items": [{
"kind": "issue", "repository": "stackchain/api", "number": 42,
"title": "Repair queue", "state": "open",
"url": "https://forge.example/stackchain/api/issues/42",
"url": "http://127.0.0.1:3000/stackchain/api/issues/42",
}, {
"kind": "pull", "repository": "stackchain/web", "number": 9,
"title": "Improve search", "state": "closed",
"url": "https://forge.example/stackchain/web/pulls/9",
"url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
}],
"partial": False,
"has_more": False,
@@ -454,6 +454,38 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
}
@pytest.mark.anyio
async def test_global_search_keeps_results_on_the_configured_forge_origin(monkeypatch):
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example/git")
async def handler(request):
if request.url.params["type"] == "pulls":
return httpx.Response(200, json=[])
return httpx.Response(200, json=[{
"id": 1, "number": 41, "title": "Foreign", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://attacker.example/stackchain/api/issues/41",
}, {
"id": 3, "number": 43, "title": "Downgraded", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "http://forge.example/git/stackchain/api/issues/43",
}, {
"id": 2, "number": 42, "title": "Relative", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "/git/stackchain/api/issues/42#issuecomment-7",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search("queue", 10)
finally:
await gitea_proxy.stop_client()
assert [(item["number"], item["url"]) for item in result["items"]] == [
(42, "https://forge.example/git/stackchain/api/issues/42#issuecomment-7")
]
@pytest.mark.anyio
async def test_global_search_returns_healthy_stream_when_other_stream_fails():
async def handler(request):
@@ -464,7 +496,7 @@ async def test_global_search_returns_healthy_stream_when_other_stream_fails():
"title": "Improve search",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -480,7 +512,7 @@ async def test_global_search_returns_healthy_stream_when_other_stream_fails():
"number": 9,
"title": "Improve search",
"state": "open",
"url": "https://forge.example/stackchain/web/pulls/9",
"url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
}],
"partial": True,
"has_more": True,
@@ -509,7 +541,7 @@ async def test_global_search_retries_a_failed_stream_without_advancing_its_page(
"title": f'{params["type"]} {number}',
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}',
"html_url": f'http://127.0.0.1:3000/stackchain/web/{params["type"]}/{number}',
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -546,7 +578,7 @@ async def test_global_search_odd_limit_keeps_both_streams_contiguous_across_page
"title": f'{params["type"]} {number}',
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}',
"html_url": f'http://127.0.0.1:3000/stackchain/web/{params["type"]}/{number}',
} for number in range(start, start + stream_limit)])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -579,7 +611,7 @@ async def test_global_search_caps_combined_results_to_requested_limit():
"title": f"{kind} {number}",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f"https://forge.example/stackchain/web/{kind}/{number}",
"html_url": f"http://127.0.0.1:3000/stackchain/web/{kind}/{number}",
} for number in (1, 2)]
return httpx.Response(200, json=items)
@@ -607,7 +639,7 @@ async def test_global_search_pages_each_stream_and_balances_results():
"title": f"{kind} page {page} result {number}",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f"https://forge.example/stackchain/web/{kind}/{page * 10 + number}",
"html_url": f"http://127.0.0.1:3000/stackchain/web/{kind}/{page * 10 + number}",
} for number in (1, 2, 3, 4)]
return httpx.Response(200, json=items)
@@ -650,7 +682,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
"title": "Repair queue",
"body": "Keep mobile operators moving.",
"state": "open",
"html_url": "https://forge.example/stackchain/api/issues/42",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
"user": {"login": "alex"},
"labels": [{"name": "P1"}, None],
"assignees": [],
@@ -672,7 +704,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
"author": "alex",
"labels": ["P1"],
"assignees": [],
"url": "https://forge.example/stackchain/api/issues/42",
"url": "http://127.0.0.1:3000/stackchain/api/issues/42",
"claimable": True,
"reopenable": False,
"assigned_to_me": False,
@@ -690,7 +722,7 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
"number": 9,
"title": "Improve search",
"state": "open",
"html_url": "https://forge.example/stackchain/web/pulls/9",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"pull_request": {"merged": False},
"assignees": [],
})
@@ -722,7 +754,7 @@ async def test_work_preview_marks_authoritative_requested_pull_review_actionable
"number": 9,
"title": "Improve search",
"state": "open",
"html_url": "https://forge.example/stackchain/web/pulls/9",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"pull_request": {"merged": False},
"assignees": [],
})
@@ -746,7 +778,7 @@ async def test_work_preview_offers_reopen_only_for_closed_issues():
"number": 42,
"title": "Resume work",
"state": "closed",
"html_url": "https://forge.example/stackchain/api/issues/42",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
"assignees": [],
})
+38 -38
View File
@@ -680,7 +680,7 @@ async def test_gitea_edit_issue_confirms_already_applied_content_after_lost_resp
"updated_at": "2026-08-07T10:02:00Z",
"assignees": [{"login": "timmy"}],
"pull_request": None,
"html_url": "https://forge.example/stackchain/api/issues/17",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -699,7 +699,7 @@ async def test_gitea_edit_issue_confirms_already_applied_content_after_lost_resp
assert result == {
"repository": "stackchain/api", "number": 17, "title": "My draft",
"body": "Draft body", "state": "open", "updated_at": "2026-08-07T10:02:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}
@@ -720,7 +720,7 @@ async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
return httpx.Response(200, json={
"number": 17, "title": "Clarified", "body": "New body", "state": "open",
"updated_at": "2026-08-07T10:01:00Z",
"html_url": "https://forge.example/stackchain/api/issues/17",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -740,7 +740,7 @@ async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
assert result == {
"repository": "stackchain/api", "number": 17, "title": "Clarified",
"body": "New body", "state": "open", "updated_at": "2026-08-07T10:01:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}
@@ -804,7 +804,7 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
"labels": ["P0"],
"assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
@@ -1014,7 +1014,7 @@ async def test_repository_search_endpoint_returns_minimal_visible_matches(monkey
"id": 151,
"name": "mobile",
"full_name": "stackchain/mobile",
"html_url": "https://forge.example/stackchain/mobile",
"html_url": "http://127.0.0.1:3000/stackchain/mobile",
"description": "secret context that the picker does not need",
"private": True,
}]
@@ -1042,7 +1042,7 @@ async def test_repository_page_endpoint_and_issue_creation_support_later_reposit
return {
"items": [{
"id": 51, "name": "later", "full_name": "stackchain/later",
"description": "", "html_url": "https://forge.example/stackchain/later",
"description": "", "html_url": "http://127.0.0.1:3000/stackchain/later",
"updated_at": "2026-08-09T12:00:00Z",
}],
"page": 2, "total": 51, "has_more": False,
@@ -1061,7 +1061,7 @@ async def test_repository_page_endpoint_and_issue_creation_support_later_reposit
"id": 403, "number": 403, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-09T12:00:00Z",
"url": "https://forge.example/stackchain/later/issues/403",
"url": "http://127.0.0.1:3000/stackchain/later/issues/403",
}
monkeypatch.setattr(main.gitea_proxy, "repo_page", page)
@@ -1087,7 +1087,7 @@ def test_context_exposes_truthful_repository_pagination():
repositories = gitea_proxy.RepositoryItems(
[{
"id": 1, "name": "api", "full_name": "stackchain/api",
"description": "", "html_url": "https://forge.example/stackchain/api",
"description": "", "html_url": "http://127.0.0.1:3000/stackchain/api",
"updated_at": "2026-08-09T12:00:00Z",
}],
{"page": 1, "total": 51, "has_more": True},
@@ -1124,7 +1124,7 @@ async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypa
"milestone": {"id": 9, "title": "August RC"},
"due_date": "2026-08-31T23:59:59Z",
"updated_at": "2026-08-07T20:00:00Z",
"url": "https://forge.example/stackchain/api/issues/221",
"url": "http://127.0.0.1:3000/stackchain/api/issues/221",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
@@ -1176,7 +1176,7 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
@@ -1222,7 +1222,7 @@ async def test_completed_issue_creation_replays_after_ledger_reconstruction(monk
"id": 201, "number": 201, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T15:00:00Z",
"url": "https://forge.example/stackchain/api/issues/201",
"url": "http://127.0.0.1:3000/stackchain/api/issues/201",
}
database = tmp_path / "issues.sqlite3"
@@ -1280,7 +1280,7 @@ async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
@@ -1324,7 +1324,7 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
@@ -1367,7 +1367,7 @@ async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}
ledger = IdempotencyLedger(
@@ -1407,7 +1407,7 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
return httpx.Response(201, json={
"id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
"updated_at": "2026-08-07T03:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/17",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
"assignees": [{"login": "timmy"}],
"labels": [{"id": 3, "name": "P0"}],
})
@@ -1441,7 +1441,7 @@ async def test_gitea_create_issue_omits_assignment_and_confirms_no_owner():
return httpx.Response(201, json={
"id": 83, "number": 19, "title": "Backlog capture", "state": "open",
"updated_at": "2026-08-07T03:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/19",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/19",
"assignees": [], "labels": [],
})
@@ -1634,7 +1634,7 @@ async def test_gitea_create_issue_requires_confirmed_self_assignment():
async def handler(_request):
return httpx.Response(201, json={
"id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
"html_url": "https://forge.example/stackchain/api/issues/17", "assignees": [],
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17", "assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -1657,14 +1657,14 @@ async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Available", "state": "open",
"assignees": None, "pull_request": None, "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
})
if request.method == "GET" and request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Available", "state": "open",
"assignees": [{"login": "timmy"}], "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -1693,14 +1693,14 @@ async def test_gitea_reopen_issue_rechecks_closed_state_and_confirms_self_assign
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Resume work", "state": "closed",
"assignees": [], "pull_request": None, "labels": [{"name": "P1"}],
"html_url": "https://forge.example/stackchain/api/issues/17",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
})
if request.method == "GET" and request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Resume work", "state": "open",
"assignees": [{"login": "timmy"}], "labels": [{"name": "P1"}],
"html_url": "https://forge.example/stackchain/api/issues/17",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -1719,7 +1719,7 @@ async def test_gitea_reopen_issue_rechecks_closed_state_and_confirms_self_assign
"id": 81, "number": 17, "title": "Resume work", "state": "open",
"repository": "stackchain/api", "labels": ["P1"],
"assignees": ["timmy"], "updated_at": "",
"url": "https://forge.example/stackchain/api/issues/17",
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}
@@ -1734,7 +1734,7 @@ async def test_gitea_reopen_issue_retry_accepts_already_open_self_assigned_issue
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Resume work", "state": "open",
"assignees": [{"login": "timmy"}], "pull_request": None, "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -1757,7 +1757,7 @@ async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkey
return {
"id": 81, "number": number, "title": "Available", "state": "open",
"repository": repository, "labels": [], "assignees": ["timmy"],
"url": "https://forge.example/stackchain/api/issues/17",
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
@@ -1797,7 +1797,7 @@ async def test_reopen_issue_endpoint_returns_confirmed_resumable_work_item(monke
return {
"id": 81, "number": number, "title": "Resume work", "state": "open",
"repository": repository, "labels": ["P1"], "assignees": ["timmy"],
"url": "https://forge.example/stackchain/api/issues/17",
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "reopen_issue", reopen, raising=False)
@@ -2564,7 +2564,7 @@ async def test_gitea_issue_comment_posts_body_and_returns_safe_identity():
"id": 82,
"body": "Ready to ship",
"created_at": "2026-08-07T10:10:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-82",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-82",
"user": {"login": "timmy"},
},
)
@@ -2584,7 +2584,7 @@ async def test_gitea_issue_comment_posts_body_and_returns_safe_identity():
"author": "timmy",
"body": "Ready to ship",
"created_at": "2026-08-07T10:10:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-82",
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-82",
}
@@ -2602,7 +2602,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"number": 3,
"title": "Restore signing service",
"state": "open",
"html_url": "https://forge.example/stackchain/platform/issues/3",
"html_url": "http://127.0.0.1:3000/stackchain/platform/issues/3",
"repository": {"full_name": "stackchain/platform"},
},
{
@@ -2621,7 +2621,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"id": 81,
"body": "Latest update",
"created_at": "2026-08-07T10:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-81",
"user": {"login": "sam"},
}
],
@@ -2635,7 +2635,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"body": "Full issue context",
"updated_at": "2026-08-07T09:59:00Z",
"milestone": {"id": 9, "title": "August RC", "description": "not exposed"},
"html_url": "https://forge.example/stackchain/api/issues/7",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
"labels": [{"name": "P1"}],
"assignees": [{"login": "timmy"}],
},
@@ -2661,7 +2661,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"updated_at": "2026-08-07T09:59:00Z",
"due_date": None,
"milestone": {"id": 9, "title": "August RC"},
"url": "https://forge.example/stackchain/api/issues/7",
"url": "http://127.0.0.1:3000/stackchain/api/issues/7",
"labels": ["P1"],
"assignees": ["timmy"],
"dependencies_available": True,
@@ -2671,7 +2671,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"number": 3,
"title": "Restore signing service",
"state": "open",
"url": "https://forge.example/stackchain/platform/issues/3",
"url": "http://127.0.0.1:3000/stackchain/platform/issues/3",
}
],
"comments": [
@@ -2680,7 +2680,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"author": "sam",
"body": "Latest update",
"created_at": "2026-08-07T10:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-81",
}
],
"conversation": {
@@ -2690,7 +2690,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"author": "sam",
"body": "Latest update",
"created_at": "2026-08-07T10:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-81",
}
],
"page": 1,
@@ -2984,7 +2984,7 @@ async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmat
return httpx.Response(200, json=[{
"number": 9, "state": "open", "title": "Restore API",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/9",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/9",
}] if mutated else [])
if request.method == "POST" and path.endswith("/dependencies"):
return httpx.Response(201, json={})
@@ -3003,7 +3003,7 @@ async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmat
assert json.loads(mutation.content) == {"owner": "stackchain", "repo": "api", "index": 9}
assert result["dependencies"] == [{
"repository": "stackchain/api", "number": 9, "title": "Restore API",
"state": "open", "url": "https://forge.example/stackchain/api/issues/9",
"state": "open", "url": "http://127.0.0.1:3000/stackchain/api/issues/9",
}]
@@ -3013,7 +3013,7 @@ async def test_gitea_dependency_retry_returns_canonical_state_without_duplicate_
dependency = {
"number": 9, "state": "open", "title": "Restore API",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/9",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/9",
}
async def handler(request):
+4 -4
View File
@@ -356,7 +356,7 @@ async def test_gitea_attachment_revalidates_assignment_and_sends_multipart():
"id": 9,
"name": "checkout.png",
"size": len(PNG_BYTES),
"browser_download_url": "https://forge.example/attachments/checkout.png",
"browser_download_url": "http://127.0.0.1:3000/attachments/checkout.png",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -379,7 +379,7 @@ async def test_gitea_attachment_revalidates_assignment_and_sends_multipart():
assert PNG_BYTES in upload.content
assert result == {
"name": "checkout.png",
"url": "https://forge.example/attachments/checkout.png",
"url": "http://127.0.0.1:3000/attachments/checkout.png",
"size": len(PNG_BYTES),
}
@@ -514,7 +514,7 @@ async def test_gitea_pull_attachment_revalidates_assignment_before_exact_pull_up
})
return httpx.Response(201, json={
"name": "pull.png", "size": len(PNG_BYTES),
"browser_download_url": "https://forge.example/attachments/pull.png",
"browser_download_url": "http://127.0.0.1:3000/attachments/pull.png",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
@@ -531,4 +531,4 @@ async def test_gitea_pull_attachment_revalidates_assignment_before_exact_pull_up
("POST", "/api/v1/repos/stackchain/web/issues/31/assets"),
]
assert PNG_BYTES in requests[-1].content
assert result["url"] == "https://forge.example/attachments/pull.png"
assert result["url"] == "http://127.0.0.1:3000/attachments/pull.png"
+1 -1
View File
@@ -435,5 +435,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-v117" in source
assert "stackchain-dashboard-shell-v121" in source
assert "BASE + 'static/later-sync.js'" in source
+69 -1
View File
@@ -6,6 +6,74 @@ import pytest
from src import live_snapshot_store
from src.live_snapshot_store import LiveSnapshotStore, RefreshLeaseLost
from src.state_encryption import PrivateStateEncryptionError
PRIVATE_KEY = b"l" * 32
def test_published_live_snapshot_is_encrypted_at_rest_and_survives_restart(tmp_path):
path = tmp_path / "live.sqlite3"
store = LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
lease = store.try_acquire_refresh({"context"}, lease_seconds=5)
canary = "private-live-title-canary"
published = store.publish_refresh(
lease,
value={"context": {"title": canary}, "events": [], "notifications": []},
created_at={section: 100.0 for section in live_snapshot_store.SECTIONS},
failure_count={section: 0 for section in live_snapshot_store.SECTIONS},
retry_at={section: None for section in live_snapshot_store.SECTIONS},
changed_sections={"context"},
)
with sqlite3.connect(path) as connection:
payload = connection.execute(
"SELECT value_json FROM live_snapshot WHERE singleton = 1"
).fetchone()[0]
assert payload.startswith("v1:")
assert canary not in payload
assert LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY).load().value == published.value
def test_live_snapshot_lazily_migrates_plaintext_without_changing_metadata(tmp_path):
path = tmp_path / "live.sqlite3"
store = LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
legacy = {"context": {"title": "legacy"}, "events": [], "notifications": []}
with sqlite3.connect(path) as connection:
connection.execute(
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
('{"context":{"title":"legacy"},"events":[],"notifications":[]}',
'{"context":4,"events":2,"notifications":1}'),
)
state = store.load()
with sqlite3.connect(path) as connection:
migrated = connection.execute(
"SELECT value_json FROM live_snapshot WHERE singleton = 1"
).fetchone()[0]
assert state.value == legacy
assert state.revisions == {"context": 4, "events": 2, "notifications": 1}
assert migrated.startswith("v1:")
def test_live_snapshot_authentication_failure_returns_no_private_content(tmp_path):
path = tmp_path / "live.sqlite3"
store = LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
lease = store.try_acquire_refresh({"context"}, lease_seconds=5)
metadata = {section: None for section in live_snapshot_store.SECTIONS}
store.publish_refresh(
lease,
value={"context": {"title": "secret"}},
created_at=metadata,
failure_count={section: 0 for section in metadata},
retry_at=metadata,
changed_sections={"context"},
)
with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
LiveSnapshotStore(path, encryption_key=b"x" * 32).load()
def test_metadata_load_does_not_retrieve_or_decode_snapshot_value(tmp_path):
@@ -24,7 +92,7 @@ def test_metadata_load_does_not_retrieve_or_decode_snapshot_value(tmp_path):
assert metadata.revisions == {
section: 0 for section in ("context", "events", "notifications")
}
with pytest.raises(ValueError):
with pytest.raises(PrivateStateEncryptionError):
store.load()
+1 -1
View File
@@ -256,4 +256,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-v117" in worker
assert "stackchain-dashboard-shell-v121" in worker
+6 -5
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-v117" in worker
assert "stackchain-dashboard-shell-v121" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
@@ -54,12 +54,13 @@ def test_all_conversation_composers_offer_accessible_mobile_mentions():
css = CSS.read_text()
assert '<script src="static/mention-composer.js"></script>' in html
for composer in ("issue-comment", "pull-comment", "update-reply"):
for composer in ("issue-comment", "pull-comment", "update-reply", "today-progress-body"):
assert f'id="{composer}-mentions"' in html
assert f'id="{composer}-mention-status"' in html
assert f"textarea:qs('#{composer}')" in dashboard
assert f"listbox:qs('#{composer}-mentions')" in dashboard
assert dashboard.count("createMentionComposer({") == 3
assert f"['{composer}'," in dashboard
assert dashboard.count("createMentionComposer({") == 1
assert "['today-progress-body',()=>currentTodayProgressTarget()?.repository]" in dashboard
assert "mentions:todayProgressMentions" in dashboard
assert "mention-candidates?q=" in dashboard
assert ".mention-options" in css
assert "min-height:44px" in css
+45 -3
View File
@@ -15,7 +15,10 @@ 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, deadlineCalls:0, focused:null};
const state = {
installCalls:0, offlineCalls:0, pushCalls:0, deadlineCalls:0, focused:null,
events:[], beginCalls:[], finishCalls:0,
};
const launcher = new FakeTarget();
const closeButton = new FakeTarget();
const sheet = new FakeTarget(); sheet.hidden = true;
@@ -33,6 +36,7 @@ const promptCard = new FakeTarget(); promptCard.hidden = true;
const promptSummary = new FakeTarget();
const promptLauncher = new FakeTarget();
const promptDismiss = new FakeTarget();
const returnButton = new FakeTarget();
let now = 1000;
const promptStorage = {
values:{},
@@ -49,8 +53,13 @@ const setup = createMobileDeviceSetup({
launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton,
installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
promptCard, promptSummary, promptLauncher, promptDismiss,
returnButton, isMobile:() => true,
timerView:{
beginDetour:reason => { state.events.push('pause'); state.beginCalls.push(reason); return {reason}; },
finishDetour:() => { state.events.push('resume'); state.finishCalls += 1; },
},
promptStorage, now:() => now,
getReadiness:() => readiness,
getReadiness:() => { state.events.push('readiness'); return readiness; },
install:async () => { state.installCalls += 1; },
enableOffline:async () => { state.offlineCalls += 1; },
enablePush:async () => { state.pushCalls += 1; },
@@ -170,6 +179,35 @@ process.stdout.write(JSON.stringify({
assert result == {"hidden": True, "launcherFocused": True}
def test_mobile_setup_pauses_before_readiness_and_each_exit_resumes_once():
result = run_scenario("""
state.events = [];
await launcher.dispatch('click', {currentTarget:launcher});
const firstOpen = {events:[...state.events], reasons:[...state.beginCalls]};
await sheet.dispatch('click', {target:sheet});
const backdropFinish = state.finishCalls;
await promptLauncher.dispatch('click', {currentTarget:promptLauncher});
await escapeTarget.dispatch('keydown', {key:'Escape'});
const escapeFinish = state.finishCalls;
await launcher.dispatch('click', {currentTarget:launcher});
returnButton.addEventListener('click', () => { state.events.push('shared-resume'); state.finishCalls += 1; });
await returnButton.dispatch('click');
process.stdout.write(JSON.stringify({
firstOpen, backdropFinish, escapeFinish,
returnFinish:state.finishCalls,
hidden:sheet.hidden,
}));
""")
assert result == {
"firstOpen": {"events": ["pause", "readiness"], "reasons": ["device-setup"]},
"backdropFinish": 1,
"escapeFinish": 2,
"returnFinish": 3,
"hidden": 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.'};
@@ -207,6 +245,8 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert 'id="dismiss-device-readiness"' in html
assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"')
assert 'id="device-setup-sheet"' in html
assert 'id="device-setup-today-detour"' in html
assert 'id="return-from-device-setup"' 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", "deadline"))
assert 'id="device-storage-summary"' in html
@@ -218,12 +258,14 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
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 "timerView," in dashboard
assert "isMobile:options.isMobile || (() => innerWidth <= 600)" in MODULE.read_text()
assert "createDeviceStorage.mount(document)" in dashboard
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v117" in worker
assert "stackchain-dashboard-shell-v121" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+70 -1
View File
@@ -150,6 +150,70 @@ process.stdout.write(JSON.stringify({{backs:history.backs,prevented}}));
assert run_node(script) == {"backs": 1, "prevented": 1}
def test_insights_route_owns_the_today_detour_for_open_back_and_reload():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/today'}};
const calls=[];
const detour={{beginDetour(reason){{calls.push('begin:'+reason);return {{identity:'issue:r:42:',reason}};}},finishDetour(){{calls.push('finish');return {{identity:'issue:r:42:'}};}}}};
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{}}}};
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start();
launcher.listeners.click();
location.hash='#/my-work/today'; history.state=null; listeners.popstate();
listeners.popstate();
location.hash='#/insights'; listeners.hashchange();
process.stdout.write(JSON.stringify({{calls,current:controller.current()}}));
"""
assert run_node(script) == {
"calls": ["begin:insights", "finish", "begin:insights"],
"current": True,
}
def test_return_to_today_closes_insights_and_finishes_the_detour_once():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/today'}};
const calls=[];
const detour={{beginDetour(reason){{calls.push('begin:'+reason);}},finishDetour(){{calls.push('finish');}}}};
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},back(){{}}}};
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(){{}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start(); launcher.listeners.click(); returnButton.listeners.click();
process.stdout.write(JSON.stringify({{calls,current:controller.current(),hash:location.hash}}));
"""
assert run_node(script) == {
"calls": ["begin:insights", "finish"],
"current": False,
"hash": "#/my-work/today",
}
def test_direct_insights_route_retries_detour_after_identity_restores():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/insights'}};
let attempts=0; let retry;
global.setTimeout=callback=>{{retry=callback;}};
const detour={{beginDetour(){{attempts+=1;return attempts > 1 ? {{identity:'issue:r:42:'}} : null;}},finishDetour(){{}}}};
const history={{state:null,replaceState(){{}},pushState(){{}},back(){{}}}};
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(){{}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start(); retry();
process.stdout.write(JSON.stringify({{attempts,current:controller.current()}}));
"""
assert run_node(script) == {"attempts": 2, "current": True}
@pytest.mark.anyio
async def test_mobile_home_progressively_discloses_secondary_panels_as_insights():
html = await dashboard()
@@ -157,6 +221,11 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
assert 'id="open-insights"' in html
assert 'id="insights-sheet"' in html
assert 'id="close-insights"' in html
insights = html[html.index('id="insights-sheet"'):html.index('</section>', html.index('id="insights-sheet"'))]
assert 'data-today-detour' in insights
assert 'data-return-from-detour' in insights
assert "detour:() => timerView" in html
assert html.index("const timerView = createTodayTimerView({") < html.index("mobileInsights.start();")
assert html.index('id="my-work"') < html.index('id="insights-sheet"')
assert html.index('id="insights-sheet"') < html.index('data-panel-key="context"')
assert html.index('data-panel-key="markdown"') < html.index('</section>\n</main>')
@@ -174,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v117" in worker
assert "stackchain-dashboard-shell-v121" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
+57 -1
View File
@@ -110,6 +110,42 @@ process.stdout.write(JSON.stringify({{blocked, handed, opened, handoffs}}));
assert output["handoffs"] == ["agenda"]
def test_prepare_today_counts_each_work_identity_in_only_its_highest_priority_phase():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const agendaUpdate = {{kind:'issue', key:'lab#1'}};
const attentionUpdate = {{kind:'pull', key:'lab#2'}};
const filed = {{kind:'issue', key:'lab#3'}};
const delivery = {{outbox_id:'draft-4'}};
const controller = createStartDay({{
getCounts: () => ({{today:2}}),
getPhaseItems: () => ({{
delivery:[delivery],
agenda:[agendaUpdate],
attention:[agendaUpdate, attentionUpdate],
update:[agendaUpdate, attentionUpdate],
filed:[filed],
}}),
openQueue: () => {{}},
}});
process.stdout.write(JSON.stringify(controller.briefing()));
"""
output = run_node(script)
assert output == {
"total": 4,
"next": "delivery",
"label": "Review Delivery",
"summary": "1 delivery needs action before Today · 3 other items · 2 planned",
"phases": [
{"name": "delivery", "label": "Delivery recovery", "count": 1},
{"name": "agenda", "label": "Agenda", "count": 1},
{"name": "attention", "label": "Attention", "count": 1},
{"name": "filed", "label": "Filed", "count": 1},
],
}
def test_start_day_view_renders_refreshed_phases_and_launches_primary_action():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
@@ -322,4 +358,24 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v117" in service_worker
assert "stackchain-dashboard-shell-v121" in service_worker
@pytest.mark.anyio
async def test_primary_mobile_work_action_opens_a_unique_item_prepare_today_briefing():
html = await dashboard()
assert "getPreparationState: () => mobileStartDay.state()" in html
assert "prepareToday: openMobileStartDay" in html
assert "function openMobileStartDay()" in html
assert "qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';" in html
assert "qs('#mobile-queue-sheet').showModal();" in html
assert "qs('#mobile-start-day-action').focus();" in html
assert "let mobilePreparationItems = {};" in html
assert "getPhaseItems: () => mobilePreparationItems" in html
assert "mobilePreparationItems = {" in html
assert "delivery:draftInbox.partition(lastDrafts).deliveries" in html
assert "agenda:agendaMyWork(activeMyWork)" in html
assert "attention:activeMyWork.filter(item => item.needs_attention)" in html
assert "update:activeMyWork.filter(item => item.has_update)" in html
assert "filed:activeMyWork.filter(item => item.is_filed)" in html
+114 -4
View File
@@ -85,6 +85,37 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
}
def test_mobile_work_entry_opens_prepare_today_before_direct_queue_work():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
const calls = [];
const state = {{active:true, preparation:{{active:false, next:'attention'}}}};
const entry = createEntry({{
isTodayActive: () => state.active,
isTodayResumable: () => false,
getTodayCount: () => 0,
getEligibleCount: () => 3,
getPreparationState: () => state.preparation,
queueLauncher: {{recommend: () => ({{name:'attention'}}), open: name => calls.push('queue:' + name)}},
continueToday: () => calls.push('continue'),
prepareToday: () => calls.push('prepare'),
planToday: () => calls.push('plan'),
findWork: () => calls.push('find'),
}});
const modes = [entry.open()];
state.active = false; modes.push(entry.open());
state.preparation.active = true; 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", "prepare", "prepare-resume"],
"calls": ["continue", "prepare", "prepare"],
}
def test_mobile_work_entry_plans_eligible_work_before_find_fallback():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
@@ -178,6 +209,60 @@ process.stdout.write(JSON.stringify({{
}
def test_mobile_task_dock_pauses_before_queues_and_distinguishes_close_from_selection():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
class FakeElement {{
constructor() {{ this.listeners = {{}}; this.open = false; this.attributes = {{}}; }}
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
setAttribute(name, value) {{ this.attributes[name] = value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
showModal() {{ this.open = true; }}
close() {{ this.open = false; this.listeners.close?.(); }}
focus() {{}}
}}
const nav = new FakeElement();
nav.hidden = false;
const queues = new FakeElement();
const close = new FakeElement();
const row = new FakeElement();
const button = new FakeElement();
const calls = [];
const dock = createDock({{
nav, buttons:{{queues:button}}, queueSheet:queues, queueClose:close, queueRows:{{update:row}}, overlays:[],
detour:()=>({{beginDetour:reason=>calls.push('pause:' + reason), finishDetour:()=>calls.push('return')}}),
onSelectQueue:name=>calls.push('select:' + name),
observe:()=>({{disconnect(){{}}}}),
}});
dock.start();
button.listeners.click({{currentTarget:button}});
close.listeners.click();
button.listeners.click({{currentTarget:button}});
row.listeners.click();
process.stdout.write(JSON.stringify({{calls, open:queues.open}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": ["pause:queues", "return", "pause:queues", "select:update"],
"open": False,
}
@pytest.mark.anyio
async def test_find_and_queue_detours_are_visible_and_wired_to_today_timing():
html = await dashboard()
assert html.count('data-today-detour role="status"') == 6
assert html.count('data-return-from-detour type="button"') == 6
assert "detour:() => timerView" in html
timer_source = TIMER.read_text()
assert "createTodayDetourInterruption" in timer_source
assert "queryAll('#find-work-sheet')" in timer_source
assert '.today-detour-interruption button { min-height:44px;' in html
def test_mobile_task_dock_programmatically_selects_destination_without_running_action():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
@@ -651,6 +736,28 @@ process.stdout.write(JSON.stringify(labels));
}
def test_mobile_task_dock_labels_prepare_today_as_the_primary_action():
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 ['prepare','prepare-resume']) {{
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) == {
"prepare": ["Prepare", "Prepare Today"],
"prepare-resume": ["Resume prep", "Resume preparation"],
}
@pytest.mark.anyio
async def test_dashboard_renders_accessible_mobile_queue_completion_handoff():
html = await dashboard()
@@ -762,7 +869,7 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert "find: () => qs('#find-work').click()" in html
assert "new: () => qs('#new-issue').click()" in html
assert "search: () => qs('#open-palette').click()" in html
assert "queues: () => {}" in html
assert "detour:() => timerView" in html
assert "mobileTaskDock.updateQueues(counts)" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
@@ -802,10 +909,13 @@ async def test_dashboard_renders_phone_safe_today_session_hud_above_task_dock():
assert "completeTodayItem(item)" in html
assert "workSession.reopen(item)" in html
assert '.mobile-today-hud { display:none;' in html
assert 'grid-template-areas:' in html
assert '[data-mobile-today-complete] { grid-area:complete;' in html
assert 'data-mobile-today-more' in html
assert 'class="mobile-today-actions"' in html
assert 'grid-template-columns:repeat(6,minmax(0,1fr));' in html
assert '[data-mobile-today-complete] { grid-column:1 / 3;' in html
assert 'bottom:calc(56px + env(safe-area-inset-bottom))' in html
assert '.mobile-today-hud button { min-height:44px;' in html
assert '.mobile-today-hud button { min-width:0; min-height:44px;' in html
assert 'var(--mobile-today-clearance, 166px)' in html
assert '.mobile-task-action[hidden] { display:none;' in html
assert 'max-width:100%;' in html
assert 'overflow:hidden;' in html
+158
View File
@@ -0,0 +1,158 @@
import json
import subprocess
from pathlib import Path
SOURCE = Path(__file__).parents[1] / "frontend" / "mobile-today-command-bar.js"
FRONTEND = SOURCE.parent
def run_node(body: str) -> dict:
script = f"const createCommandBar = require({json.dumps(str(SOURCE))});\n" + body
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(completed.stdout)
def test_more_sheet_opens_modally_and_restores_focus_after_close():
result = run_node(r"""
function element() {
const listeners = {};
return {
listeners, open:false, hidden:false, attributes:{}, focusCount:0,
addEventListener:(type, listener) => { (listeners[type] ||= []).push(listener); },
dispatch(type, event={}) { (listeners[type] || []).forEach(listener => listener({preventDefault(){}, target:this, ...event})); },
setAttribute(name, value) { this.attributes[name] = value; },
showModal() { this.open = true; },
close() { this.open = false; this.dispatch('close'); },
focus() { this.focusCount += 1; },
};
}
const more = element();
const sheet = element();
const close = element();
const firstAction = element();
createCommandBar({more, sheet, close, firstAction});
more.dispatch('click');
const opened = {open:sheet.open, expanded:more.attributes['aria-expanded'], firstFocused:firstAction.focusCount};
close.dispatch('click');
process.stdout.write(JSON.stringify({opened, closed:!sheet.open, restored:more.focusCount}));
""")
assert result == {
"opened": {"open": True, "expanded": "true", "firstFocused": 1},
"closed": True,
"restored": 1,
}
def test_end_session_action_reuses_existing_session_control_and_closes_sheet():
result = run_node(r"""
function element() {
const listeners = {};
return {
open:false, clicks:0, attributes:{},
addEventListener:(type, listener) => { (listeners[type] ||= []).push(listener); },
dispatch(type, event={}) { (listeners[type] || []).forEach(listener => listener({preventDefault(){}, target:this, ...event})); },
setAttribute(name, value) { this.attributes[name] = value; },
showModal() { this.open = true; },
close() { this.open = false; this.dispatch('close'); },
click() { this.clicks += 1; this.dispatch('click'); },
focus() {},
};
}
const more = element();
const sheet = element();
const endAction = element();
const existingEndControl = element();
createCommandBar({more, sheet, endAction, existingEndControl});
more.dispatch('click');
endAction.dispatch('click');
process.stdout.write(JSON.stringify({open:sheet.open, endClicks:existingEndControl.clicks}));
""")
assert result == {"open": False, "endClicks": 1}
def test_secondary_action_closes_command_sheet_before_existing_handler_runs():
result = run_node(r"""
function element() {
const listeners = {};
return {
open:false, attributes:{}, observedOpen:null,
addEventListener:(type, listener) => { (listeners[type] ||= []).push(listener); },
dispatch(type) { (listeners[type] || []).forEach(listener => listener({preventDefault(){}, target:this})); },
setAttribute(name, value) { this.attributes[name] = value; },
showModal() { this.open = true; }, close() { this.open = false; this.dispatch('close'); }, focus() {},
};
}
const more = element();
const sheet = element();
const update = element();
createCommandBar({more, sheet, actionButtons:[update]});
update.addEventListener('click', () => { update.observedOpen = sheet.open; });
more.dispatch('click');
update.dispatch('click');
process.stdout.write(JSON.stringify({open:sheet.open, handlerSawOpen:update.observedOpen}));
""")
assert result == {"open": False, "handlerSawOpen": False}
def test_rendered_hud_and_dock_height_define_scroll_clearance():
result = run_node(r"""
const listeners = {};
const element = () => ({
open:false, attributes:{},
addEventListener:(type, listener) => { (listeners[type] ||= []).push(listener); },
setAttribute(name, value) { this.attributes[name] = value; },
focus() {}, showModal() { this.open = true; }, close() { this.open = false; },
});
const values = {};
let resize;
const ResizeObserverImpl = class {
constructor(callback) { resize = callback; }
observe() {}
disconnect() {}
};
const more = element();
const sheet = element();
createCommandBar({
more, sheet,
hud:{offsetHeight:96}, dock:{offsetHeight:56},
style:{setProperty:(name, value) => { values[name] = value; }},
ResizeObserverImpl,
});
resize();
process.stdout.write(JSON.stringify(values));
""")
assert result == {"--mobile-today-clearance": "168px"}
def test_mobile_command_bar_keeps_primary_actions_visible_and_secondary_actions_modal():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
hud = html[html.index('<section class="mobile-today-hud"'):html.index('</section>', html.index('<section class="mobile-today-hud"'))]
sheet = html[html.index('<dialog class="mobile-today-actions"'):html.index('</dialog>', html.index('<dialog class="mobile-today-actions"'))]
assert "data-mobile-today-open" in hud
assert "data-mobile-today-complete" in hud
assert "data-mobile-today-toggle" in hud
assert "data-mobile-today-more" in hud
for secondary in (
"data-work-session-previous",
"data-work-session-next",
"data-mobile-today-update",
"data-mobile-today-blocked",
"data-today-break-open",
"data-work-session-adjust-plan",
"data-mobile-today-end",
):
assert secondary not in hud
assert secondary in sheet
assert 'aria-modal="true"' in sheet
assert "body.mobile-today-active { padding-bottom:calc(var(--mobile-today-clearance" in css
assert "createMobileTodayCommandBar({" in dashboard
assert '<script src="static/mobile-today-command-bar.js"></script>' in html
+3 -2
View File
@@ -4771,8 +4771,9 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
assert 'id="start-work-session"' in html
assert html.count('class="work-session-nav"') == 4
assert html.count('<span class="small" aria-live="polite" data-work-session-progress>') == 4
assert html.count('<button type="button" data-work-session-previous>') == 4
assert html.count('<button type="button" data-work-session-next>') == 4
assert html.count('<button type="button" data-work-session-previous>') == 5
assert html.count('<button type="button" data-work-session-next>') == 5
assert 'class="mobile-today-actions"' in html
assert '.work-session-nav button { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
assert 'aria-live="polite" data-work-session-progress' in html
+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-v117" in source
assert "stackchain-dashboard-shell-v121" 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
+1 -1
View File
@@ -84,7 +84,7 @@ def test_private_sqlite_connection_rejects_a_symlink_before_opening_target(tmp_p
TodayStore,
LaterStore,
SavedSearchStore,
UnfiledDraftStore,
lambda path: UnfiledDraftStore(path, encryption_key=b"d" * 32),
CompletedFiledReviewStore,
PushSubscriptionStore,
LiveSnapshotStore,
+225
View File
@@ -4,6 +4,7 @@ import httpx
import pytest
from src import gitea_proxy, main
from src.security_event_store import SecurityEventStoreError
@pytest.mark.anyio
@@ -312,11 +313,98 @@ async def test_assigned_pull_merge_requires_current_eligible_head(monkeypatch):
assert calls == [("stackchain/api", 7, "abc123")]
@pytest.mark.anyio
async def test_assigned_pull_merge_fails_before_gitea_when_audit_reservation_fails(
monkeypatch,
):
merge_calls = []
class UnavailableJournal:
def reserve(self, *_args, **_kwargs):
raise SecurityEventStoreError("unavailable")
async def assigned(*_args):
return True
async def merge(*args):
merge_calls.append(args)
return {"number": 7, "merged": True, "state": "closed"}
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 503
assert response.json() == {
"error": "Security activity is temporarily unavailable. Nothing was merged."
}
assert merge_calls == []
@pytest.mark.anyio
async def test_assigned_pull_merge_reports_success_and_retains_pending_audit_when_finalize_fails(
monkeypatch,
):
lifecycle = []
class InterruptedJournal:
def reserve(self, kind, *, target):
lifecycle.append(("reserve", kind, target))
return "merge-operation"
def finalize(self, operation_id):
lifecycle.append(("finalize", operation_id))
raise SecurityEventStoreError("unavailable")
def discard(self, operation_id):
lifecycle.append(("discard", operation_id))
async def assigned(*_args):
return True
async def merge(*_args):
return {"number": 7, "merged": True, "state": "closed"}
monkeypatch.setattr(main, "_security_event_store", lambda: InterruptedJournal())
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 200
assert response.json() == {"number": 7, "merged": True, "state": "closed"}
assert lifecycle == [
("reserve", "pull_merged", "stackchain/api#7"),
("finalize", "merge-operation"),
]
@pytest.mark.anyio
async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypatch):
calls = []
merged = False
class LifecycleJournal:
def reserve(self, kind, *, target):
calls.append(("reserve", kind, target))
return "merge-operation"
def finalize(self, operation_id):
calls.append(("finalize", operation_id))
def discard(self, operation_id):
calls.append(("discard", operation_id))
async def assigned(repository, number):
return True
@@ -332,6 +420,7 @@ async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypa
return merged
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main, "_security_event_store", lambda: LifecycleJournal())
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
monkeypatch.setattr(main.gitea_proxy, "is_pull_merged_at_head", confirm, raising=False)
@@ -345,8 +434,10 @@ async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypa
assert response.status_code == 200
assert response.json() == {"number": 7, "merged": True, "state": "closed"}
assert calls == [
("reserve", "pull_merged", "stackchain/api#7"),
("merge", "stackchain/api", 7, "abc123"),
("confirm", "stackchain/api", 7, "abc123"),
("finalize", "merge-operation"),
]
@@ -435,6 +526,47 @@ async def test_assigned_pull_merge_returns_conflict_without_mutating_stale_head(
assert "New commits" in response.json()["error"]
@pytest.mark.anyio
@pytest.mark.parametrize(
"rejection",
[gitea_proxy.StalePullError("changed"), gitea_proxy.PullNotMergeableError("unsafe")],
)
async def test_assigned_pull_merge_discards_audit_reservation_after_definite_rejection(
monkeypatch, rejection
):
lifecycle = []
class LifecycleJournal:
def reserve(self, kind, *, target):
lifecycle.append(("reserve", kind, target))
return "merge-operation"
def discard(self, operation_id):
lifecycle.append(("discard", operation_id))
async def assigned(*_args):
return True
async def merge(*_args):
raise rejection
monkeypatch.setattr(main, "_security_event_store", lambda: LifecycleJournal())
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 409
assert lifecycle == [
("reserve", "pull_merged", "stackchain/api#7"),
("discard", "merge-operation"),
]
@pytest.mark.anyio
async def test_gitea_merge_rejects_failed_ci_without_upstream_mutation():
requests = []
@@ -601,3 +733,96 @@ async def test_gitea_pull_release_removes_current_login_case_insensitively():
await gitea_proxy.stop_client()
assert released["assignees"] == ["sam"]
@pytest.mark.anyio
async def test_gitea_merge_returns_the_exact_merge_commit_for_release_tracking():
async def handler(request):
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7, "state": "open", "draft": False, "mergeable": True,
"merged": False, "head": {"sha": "abc123"},
})
if request.url.path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={"state": "success"})
if request.url.path.endswith("/pulls/7/merge"):
return httpx.Response(200, json={"merged": True, "sha": "merge456"})
raise AssertionError(request.url.path)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.merge_assigned_pull("stackchain/api", 7, "abc123")
finally:
await gitea_proxy.stop_client()
assert result == {
"number": 7, "merged": True, "state": "closed", "merge_commit_sha": "merge456"
}
@pytest.mark.anyio
async def test_release_receipt_matches_only_the_captured_commit_and_reports_checks(monkeypatch):
async def receipt(repository, commit_sha):
assert (repository, commit_sha) == ("stackchain/api", "merge456")
return {
"commit_sha": "merge456",
"ci_state": "pending",
"checks": [{"name": "browser", "state": "pending", "url": ""}],
"release": None,
}
monkeypatch.setattr(main.gitea_proxy, "release_receipt_status", receipt, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/repos/stackchain/api/release-receipt/merge456"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["checks"] == [
{"name": "browser", "state": "pending", "url": ""}
]
assert response.json()["release"] is None
@pytest.mark.anyio
async def test_gitea_release_receipt_ignores_other_commits_and_normalizes_matching_assets():
forge = gitea_proxy.GITEA_URL.rstrip("/")
async def handler(request):
if request.url.path.endswith("/commits/merge456/status"):
return httpx.Response(200, json={
"state": "success",
"statuses": [{"context": "browser", "status": "success", "target_url": ""}],
})
if request.url.path.endswith("/releases"):
return httpx.Response(200, json=[
{"tag_name": "newer", "target_commitish": "other789", "html_url": f"{forge}/stackchain/api/releases/tag/newer"},
{
"tag_name": "rc-42", "target_commitish": "merge456",
"html_url": f"{forge}/stackchain/api/releases/tag/rc-42",
"assets": [
{"name": "manifest.json", "browser_download_url": f"{forge}/stackchain/api/releases/download/rc-42/manifest.json"},
],
},
])
raise AssertionError(request.url.path)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.release_receipt_status("stackchain/api", "merge456")
finally:
await gitea_proxy.stop_client()
assert result["commit_sha"] == "merge456"
assert result["ci_state"] == "success"
assert result["checks"] == [{"name": "browser", "state": "success", "url": ""}]
assert result["release"] == {
"tag": "rc-42",
"url": f"{forge}/stackchain/api/releases/tag/rc-42",
"assets": [{
"name": "manifest.json",
"url": f"{forge}/stackchain/api/releases/download/rc-42/manifest.json",
}],
}
+70 -1
View File
@@ -22,6 +22,9 @@ const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callba
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 deadlineSnooze = {hidden:true};
const deadlineSnoozeStatus = {set textContent(value) { state.snoozeText = value; }, get textContent() { return state.snoozeText; }};
const deadlineSnoozeReview = {disabled:false, addEventListener:(_name, callback) => state.reviewSnooze = callback};
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
const registration = {pushManager:{
getSubscription: async () => state.current || null,
@@ -29,9 +32,11 @@ const registration = {pushManager:{
}};
const feature = createPushNotifications({
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
onReviewDeadlines:() => { state.reviewed = true; },
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'}; },
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); if (state.failResume && options.method === 'DELETE' && url.endsWith('/deadlines/snooze')) throw new Error('offline'); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
});
(async () => { __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
@@ -158,6 +163,52 @@ process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:
assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"]
def test_active_deadline_snooze_is_visible_in_agenda_with_a_local_resume_time():
result = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,snoozed_until:1765003600,public_key:'AQID'};
state.current = existing;
await feature.init();
process.stdout.write(JSON.stringify({hidden:deadlineSnooze.hidden,text:state.snoozeText}));
""")
assert result["hidden"] is False
assert result["text"].startswith("Deadline reminders snoozed until ")
def test_review_now_resumes_this_device_and_opens_the_deadline_review_flow():
result = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,snoozed_until:1765003600,public_key:'AQID'};
state.current = existing;
await feature.init();
await state.reviewSnooze();
process.stdout.write(JSON.stringify({hidden:deadlineSnooze.hidden,disabled:deadlineSnoozeReview.disabled,reviewed:state.reviewed,requests:state.requests}));
""")
assert result["requests"][-1][0:2] == [
"api/v1/push-subscription/deadlines/snooze", "DELETE",
]
assert result["hidden"] is True
assert result["disabled"] is False
assert result["reviewed"] is True
def test_failed_resume_keeps_the_snooze_visible_and_retryable():
result = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,snoozed_until:1765003600,public_key:'AQID'};
state.current = existing;
await feature.init();
state.failResume = true;
await state.reviewSnooze();
process.stdout.write(JSON.stringify({hidden:deadlineSnooze.hidden,disabled:deadlineSnoozeReview.disabled,text:state.snoozeText,reviewed:state.reviewed}));
""")
assert result == {
"hidden": False,
"disabled": False,
"text": "Could not resume deadline reminders. Check your connection and try again.",
}
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
@@ -181,3 +232,21 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
assert "pywebpush==" in requirements
assert "STACKCHAIN_VAPID_PUBLIC_KEY" in readme
assert "STACKCHAIN_VAPID_PRIVATE_KEY" in readme
def test_mobile_agenda_mounts_a_touch_safe_snooze_recovery_flow():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
assert 'id="deadline-snooze"' in html
assert 'id="deadline-snooze-status"' in html
assert 'id="review-snoozed-deadlines"' in html
assert "deadlineSnooze:qs('#deadline-snooze')" in dashboard
assert "onReviewDeadlines:" in dashboard
assert "window.location.hash = '#/my-work/agenda'" in dashboard
assert "qs('#protect-today')" in dashboard
assert ".deadline-snooze" in css
assert ".deadline-snooze button { min-height:44px;" in css
assert "overflow-wrap:anywhere" in css
+287 -1
View File
@@ -7,9 +7,15 @@ from types import SimpleNamespace
import httpx
import pytest
import requests
from src import dashboard_auth, gitea_proxy, main
from src.push_notifications import PushConfiguration, dispatch_unread_updates
from src.push_notifications import (
PushConfiguration,
dispatch_unread_updates,
send_web_push,
)
from src import push_subscription_store as push_store_module
from src.push_subscription_store import PushSubscriptionStore
from src.push_endpoint_policy import UnsafePushEndpoint
@@ -48,6 +54,133 @@ def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
assert database.stat().st_mode & 0o777 == 0o600
def test_subscription_store_encrypts_device_credentials_and_reopens_with_same_key(tmp_path):
database = tmp_path / "push.sqlite3"
key = b"p" * 32
subscription = {
"endpoint": "https://push.example/private-device-canary",
"keys": {"p256dh": "public-key-canary", "auth": "auth-secret-canary"},
}
PushSubscriptionStore(database, encryption_key=key).upsert("session-a", subscription)
persisted = database.read_bytes()
assert b"private-device-canary" not in persisted
assert b"public-key-canary" not in persisted
assert b"auth-secret-canary" not in persisted
reopened = PushSubscriptionStore(database, encryption_key=key)
assert reopened.claim_unseen({42: "r1"})[0].subscription == subscription
def test_subscription_store_migrates_legacy_plaintext_without_resetting_checkpoints(tmp_path):
database = tmp_path / "push.sqlite3"
subscription = {
"endpoint": "https://push.example/legacy-device-canary",
"keys": {"p256dh": "legacy-public-canary", "auth": "legacy-auth-canary"},
}
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_deliveries (
session_id TEXT NOT NULL,
thread_id INTEGER NOT NULL,
revision TEXT NOT NULL DEFAULT '',
PRIMARY KEY (session_id, thread_id)
);
"""
)
connection.execute(
"INSERT INTO push_subscriptions VALUES (?, ?, ?)",
("session-a", subscription["endpoint"], json.dumps(subscription)),
)
connection.execute(
"INSERT INTO push_deliveries VALUES (?, ?, ?)",
("session-a", 42, "r1"),
)
store = PushSubscriptionStore(database, encryption_key=b"m" * 32)
assert store.claim_unseen({42: "r1"}) == []
updated = store.claim_unseen({42: "r2"})
assert updated[0].subscription == subscription
persisted = database.read_bytes()
assert b"legacy-device-canary" not in persisted
assert b"legacy-public-canary" not in persisted
assert b"legacy-auth-canary" not in persisted
def test_push_store_factory_only_requires_its_key_when_push_is_enabled(
tmp_path, monkeypatch
):
monkeypatch.delenv("STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY", raising=False)
factory = getattr(push_store_module, "build_push_subscription_store", None)
assert callable(factory), "push store factory is missing"
disabled = factory(tmp_path / "disabled.sqlite3", push_enabled=False)
assert disabled.is_subscribed("session-a") is False
assert disabled.deadline_preferences("session-a")["enabled"] is False
assert not (tmp_path / "disabled.sqlite3").exists()
with pytest.raises(RuntimeError, match="encryption key"):
factory(tmp_path / "enabled.sqlite3", push_enabled=True)
def test_subscription_store_fails_closed_for_wrong_key_or_tampered_payload(tmp_path):
database = tmp_path / "push.sqlite3"
subscription = {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
}
PushSubscriptionStore(database, encryption_key=b"a" * 32).upsert(
"session-a", subscription
)
with pytest.raises(RuntimeError, match="could not be decrypted"):
PushSubscriptionStore(database, encryption_key=b"b" * 32)
with sqlite3.connect(database) as connection:
payload = connection.execute(
"SELECT subscription_json FROM push_subscriptions WHERE session_id = ?",
("session-a",),
).fetchone()[0]
replacement = "A" if payload[-1] != "A" else "B"
connection.execute(
"UPDATE push_subscriptions SET subscription_json = ? WHERE session_id = ?",
(payload[:-1] + replacement, "session-a"),
)
with pytest.raises(RuntimeError, match="could not be decrypted"):
PushSubscriptionStore(database, encryption_key=b"a" * 32)
def test_subscription_ciphertext_cannot_be_substituted_between_sessions(tmp_path):
database = tmp_path / "push.sqlite3"
key = b"s" * 32
store = PushSubscriptionStore(database, encryption_key=key)
for session_id in ("session-a", "session-b"):
store.upsert(
session_id,
{
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": f"key-{session_id}", "auth": f"auth-{session_id}"},
},
)
with sqlite3.connect(database) as connection:
payloads = connection.execute(
"SELECT session_id, subscription_json FROM push_subscriptions ORDER BY session_id"
).fetchall()
connection.executemany(
"UPDATE push_subscriptions SET subscription_json = ? WHERE session_id = ?",
((payloads[1][1], payloads[0][0]), (payloads[0][1], payloads[1][0])),
)
with pytest.raises(RuntimeError, match="could not be decrypted"):
PushSubscriptionStore(database, encryption_key=key)
def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
subscription = {
@@ -692,6 +825,75 @@ async def test_dispatch_removes_an_endpoint_that_rebinds_private_without_contact
assert store.is_subscribed("unsafe-device") is False
@pytest.mark.anyio
async def test_web_push_transport_dials_the_address_validated_for_the_original_tls_host():
endpoint = "https://push.example/device-a"
resolved = SimpleNamespace(
endpoint=endpoint,
hostname="push.example",
port=443,
addresses=("93.184.216.34",),
)
resolutions = []
connection = {}
async def resolve_once(value):
resolutions.append(value)
return resolved
def inspect_transport(**kwargs):
session = kwargs["requests_session"]
assert session.trust_env is False
request = session.prepare_request(requests.Request("POST", endpoint))
adapter = session.get_adapter(endpoint)
adapter.add_headers(request)
host, tls = adapter.build_connection_pool_key_attributes(request, True)
connection.update(host=host, tls=tls, headers=dict(request.headers))
await send_web_push(
{"endpoint": endpoint, "keys": {"p256dh": "key", "auth": "secret"}},
"{}",
PushConfiguration("public", "private", "mailto:ops@example.com"),
endpoint_resolver=resolve_once,
webpush_sender=inspect_transport,
)
assert resolutions == [endpoint]
assert connection["host"] == {
"scheme": "https",
"host": "93.184.216.34",
"port": 443,
}
assert connection["tls"]["assert_hostname"] == "push.example"
assert connection["tls"]["server_hostname"] == "push.example"
assert connection["headers"]["Host"] == "push.example"
@pytest.mark.anyio
async def test_connect_time_rebinding_rejection_removes_only_the_unsafe_device(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("unsafe-device", "healthy-device"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
sent = []
async def unread():
return {"items": [{"id": 8}]}
async def send(subscription, _payload):
if subscription["endpoint"].endswith("unsafe-device"):
raise UnsafePushEndpoint("Endpoint rebound before connection")
sent.append(subscription["endpoint"])
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_unread_updates(store, config, unread, send) == 1
assert store.is_subscribed("unsafe-device") is False
assert store.is_subscribed("healthy-device") is True
assert sent == ["https://push.example/healthy-device"]
@pytest.mark.anyio
async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -981,6 +1183,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
"timezone": "UTC",
"reminder_hour": 9,
"reminder_days": 2,
"snoozed_until": None,
}
assert await main.subscribe_push(payload, request) == {"subscribed": True}
assert (await main.push_status(request))["subscribed"] is True
@@ -1016,6 +1219,89 @@ async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monk
assert store.deadline_preferences("session-a")["reminder_days"] == 7
@pytest.mark.anyio
async def test_authenticated_device_can_snooze_its_enabled_deadline_reminder(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("session-a", "session-b"):
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
)
monkeypatch.setattr(main, "_push_subscription_store", store)
monkeypatch.setattr(main.time, "time", lambda: 1_765_000_000)
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()))
assert await main.snooze_deadline_reminder(request) == {
"snoozed": True,
"snoozed_until": 1_765_003_600,
}
devices = {device.session_id: device for device in store.deadline_reminder_devices()}
assert devices["session-a"].snoozed_until == 1_765_003_600
assert devices["session-b"].snoozed_until is None
@pytest.mark.anyio
async def test_push_status_exposes_only_the_current_devices_active_deadline_snooze(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("session-a", "session-b"):
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
)
store.snooze_deadline_reminder("session-a", now=1_765_000_000)
store.snooze_deadline_reminder("session-b", now=1_765_001_000)
monkeypatch.setattr(main, "_push_subscription_store", store)
monkeypatch.setattr(main.time, "time", lambda: 1_765_000_100)
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()))
status = await main.push_status(request)
assert status["snoozed_until"] == 1_765_003_600
assert store.deadline_preferences("session-a", now=1_765_004_000)["snoozed_until"] is None
@pytest.mark.anyio
async def test_authenticated_device_can_resume_only_its_own_deadline_reminders(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("session-a", "session-b"):
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
)
store.snooze_deadline_reminder(session_id, now=1_765_000_000)
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()))
assert await main.resume_deadline_reminders(request) == {"snoozed": False}
devices = {device.session_id: device for device in store.deadline_reminder_devices()}
assert devices["session-a"].snoozed_until is None
assert devices["session-b"].snoozed_until == 1_765_003_600
@pytest.mark.anyio
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
+69 -12
View File
@@ -14,13 +14,32 @@ VERIFIER = ROOT / "scripts" / "verify_release.py"
def _fixture(root: Path) -> None:
(root / "src").mkdir(parents=True)
(root / "frontend").mkdir()
(root / "scripts").mkdir()
(root / "src" / "main.py").write_text("print('ready')\n")
(root / "frontend" / "index.html").write_text("<h1>Stackchain</h1>\n")
(root / "requirements.txt").write_text("fastapi==1.0\n")
(root / "README.md").write_text("# Stackchain\n")
(root / "scripts" / "rotate_unfiled_drafts.py").write_text("print('rotate')\n")
def _build(source: Path, output: Path) -> subprocess.CompletedProcess[str]:
def _commit_fixture(source: Path) -> str:
subprocess.run(["git", "init", "-q", str(source)], check=True)
subprocess.run(["git", "-C", str(source), "config", "user.name", "Release Test"], check=True)
subprocess.run(
["git", "-C", str(source), "config", "user.email", "release-test@example.invalid"],
check=True,
)
subprocess.run(["git", "-C", str(source), "add", "."], check=True)
subprocess.run(["git", "-C", str(source), "commit", "-qm", "fixture"], check=True)
return subprocess.run(
["git", "-C", str(source), "rev-parse", "HEAD"],
text=True,
capture_output=True,
check=True,
).stdout.strip()
def _build(source: Path, output: Path, commit: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
sys.executable,
@@ -30,7 +49,7 @@ def _build(source: Path, output: Path) -> subprocess.CompletedProcess[str]:
"--output-dir",
str(output),
"--commit",
"a" * 40,
commit,
"--source-date-epoch",
"1720000000",
],
@@ -40,14 +59,20 @@ def _build(source: Path, output: Path) -> subprocess.CompletedProcess[str]:
)
def test_release_bundle_is_reproducible(tmp_path):
def test_release_bundle_is_reproducible_from_declared_commit(tmp_path):
source = tmp_path / "source"
_fixture(source)
commit = _commit_fixture(source)
first = _build(source, tmp_path / "first")
second = _build(source, tmp_path / "second")
first = _build(source, tmp_path / "first", commit)
assert first.returncode == 0, first.stderr
(source / "src" / "__pycache__").mkdir()
(source / "src" / "__pycache__" / "main.cpython-311.pyc").write_bytes(b"bytecode")
(source / "src" / "untracked-secret.txt").write_text("must not ship")
(source / "src" / "main.py").write_text("print('dirty worktree')\n")
second = _build(source, tmp_path / "second", commit)
assert second.returncode == 0, second.stderr
first_archive = next((tmp_path / "first").glob("*.tar.gz"))
second_archive = next((tmp_path / "second").glob("*.tar.gz"))
@@ -55,13 +80,14 @@ def test_release_bundle_is_reproducible(tmp_path):
manifest = json.loads(next((tmp_path / "first").glob("*.manifest.json")).read_text())
checksum = next((tmp_path / "first").glob("*.sha256")).read_text().split()[0]
assert manifest["commit"] == "a" * 40
assert manifest["commit"] == commit
assert manifest["artifact"]["sha256"] == checksum
assert checksum == hashlib.sha256(first_archive.read_bytes()).hexdigest()
assert sorted(manifest["files"]) == [
"README.md",
"frontend/index.html",
"requirements.txt",
"scripts/rotate_unfiled_drafts.py",
"src/main.py",
]
with tarfile.open(first_archive, "r:gz") as archive:
@@ -72,21 +98,43 @@ def test_release_bundle_is_reproducible(tmp_path):
"frontend/index.html",
"release-manifest.json",
"requirements.txt",
"scripts/rotate_unfiled_drafts.py",
"src/main.py",
]
assert embedded["commit"] == "a" * 40
assert embedded["commit"] == commit
assert embedded["files"] == manifest["files"]
def test_release_bundle_rejects_unknown_commit(tmp_path):
source = tmp_path / "source"
_fixture(source)
_commit_fixture(source)
built = _build(source, tmp_path / "dist", "a" * 40)
assert built.returncode != 0
assert "declared commit is unavailable" in built.stderr
def test_release_bundle_verifier_enforces_integrity(tmp_path):
source = tmp_path / "source"
output = tmp_path / "dist"
_fixture(source)
built = _build(source, output)
commit = _commit_fixture(source)
built = _build(source, output, commit)
assert built.returncode == 0, built.stderr
valid = subprocess.run(
[sys.executable, str(VERIFIER), "--input-dir", str(output), "--commit", "a" * 40],
[
sys.executable,
str(VERIFIER),
"--input-dir",
str(output),
"--commit",
commit,
"--repository",
str(source),
],
text=True,
capture_output=True,
check=False,
@@ -96,9 +144,18 @@ def test_release_bundle_verifier_enforces_integrity(tmp_path):
archive = next(output.glob("*.tar.gz"))
archive.write_bytes(archive.read_bytes() + b"tampered")
tampered = subprocess.run(
[sys.executable, str(VERIFIER), "--input-dir", str(output), "--commit", "a" * 40],
[
sys.executable,
str(VERIFIER),
"--input-dir",
str(output),
"--commit",
commit,
"--repository",
str(source),
],
text=True,
capture_output=True,
check=False,
)
assert tampered.returncode != 0
assert tampered.returncode != 0
+221
View File
@@ -0,0 +1,221 @@
import json
import subprocess
from pathlib import Path
SOURCE = Path(__file__).parents[1] / "frontend" / "release-receipt.js"
def run_node(body: str) -> dict:
script = f"const createReleaseReceipt=require({json.dumps(str(SOURCE))});\n" + body
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(completed.stdout)
def test_receipt_survives_reload_for_confirmed_account_only_and_tracks_exact_commit():
output = run_node(r"""
const values=new Map();
const storage={getItem:key=>values.has(key)?values.get(key):null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let login='timmy'; const calls=[];
const first=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:async path=>{calls.push(path);return {commit_sha:'merge456',ci_state:'pending',checks:[{name:'browser',state:'pending',url:''}],release:null};}});
const captured=first.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
const restored=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:first.fetchJson}).restore();
restored && first.refresh().then(status=>{
login='alex';
const other=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:first.fetchJson}).restore();
process.stdout.write(JSON.stringify({captured,restored,status,other,calls,keys:[...values.keys()]}));
});
""")
assert output["captured"]["commit_sha"] == "merge456"
assert output["restored"][0]["repository"] == "stackchain/api"
assert output["status"][0]["label"] == "Checks running"
assert output["status"][0]["checks"] == ["browser"]
assert output["other"] == []
assert output["calls"] == ["api/v1/repos/stackchain/api/release-receipt/merge456"]
assert output["keys"] == ["stackchain.release-receipt.v1:timmy"]
def test_receipt_distinguishes_failed_checks_waiting_for_release_and_exact_release():
output = run_node(r"""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const responses=[
{commit_sha:'merge456',ci_state:'failure',checks:[{name:'unit',state:'success'},{name:'browser',state:'failure'}],release:null},
{commit_sha:'merge456',ci_state:'success',checks:[{name:'browser',state:'success'}],release:null},
{commit_sha:'merge456',ci_state:'success',checks:[{name:'browser',state:'success'}],release:{tag:'rc-42',url:'https://forge.example/git/x/releases/tag/rc-42',assets:[{name:'manifest.json',url:'https://forge.example/manifest'}]}},
];
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async()=>responses.shift()});
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
(async()=>{
const failed=await receipt.refresh(); const waiting=await receipt.refresh(); const released=await receipt.refresh();
receipt.dismiss();
process.stdout.write(JSON.stringify({failed,waiting,released,restored:receipt.restore()}));
})();
""")
assert output["failed"][0]["label"] == "Checks failed"
assert output["failed"][0]["checks"] == ["browser"]
assert output["waiting"][0]["label"] == "Checks passed · waiting for release"
assert output["released"][0]["label"] == "Released · rc-42"
assert output["released"][0]["release"]["assets"][0]["name"] == "manifest.json"
assert output["restored"] == []
def test_watchlist_preserves_distinct_merges_deduplicates_and_dismisses_one():
output = run_node(r"""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async()=>{throw new Error('unused')}});
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
receipt.capture({repository:'stackchain/web',number:8,key:'stackchain/web#8'}, {merge_commit_sha:'merge789'});
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
const before=receipt.restore();
receipt.dismiss('stackchain/api', 'merge456');
const after=receipt.restore();
process.stdout.write(JSON.stringify({before,after,stored:JSON.parse([...values.values()][0])}));
""")
assert [item["commit_sha"] for item in output["before"]] == ["merge456", "merge789"]
assert [item["commit_sha"] for item in output["after"]] == ["merge789"]
assert output["stored"]["version"] == 2
assert len(output["stored"]["entries"]) == 1
def test_watchlist_refreshes_every_exact_commit_in_one_single_flight():
output = run_node(r"""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const calls=[];
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async path=>{
calls.push(path); await new Promise(resolve=>setTimeout(resolve, 5));
const sha=path.split('/').pop();
return sha === 'merge456'
? {commit_sha:sha,ci_state:'failure',checks:[{name:'browser',state:'failure'}],release:null}
: {commit_sha:sha,ci_state:'success',checks:[],release:{tag:'rc-9',url:'https://forge.example/rc-9'}};
}});
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
receipt.capture({repository:'stackchain/web',number:8,key:'stackchain/web#8'}, {merge_commit_sha:'merge789'});
(async()=>{
const [first,second]=await Promise.all([receipt.refresh(),receipt.refresh()]);
process.stdout.write(JSON.stringify({first,second,calls,restored:receipt.restore()}));
})();
""")
assert output["calls"] == [
"api/v1/repos/stackchain/api/release-receipt/merge456",
"api/v1/repos/stackchain/web/release-receipt/merge789",
]
assert [status["label"] for status in output["first"]] == ["Checks failed", "Released · rc-9"]
assert output["second"] == output["first"]
assert [item["status"]["commit_sha"] for item in output["restored"]] == ["merge456", "merge789"]
def test_watchlist_renders_failed_merges_first_and_dismisses_individually():
output = run_node(r"""
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
global.document={createElement:tag=>node(tag)};
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const launcher=node('button'), listNode=node();
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',launcher,listNode,fetchJson:async path=>{
const sha=path.split('/').pop();
return sha === 'failed' ? {commit_sha:sha,ci_state:'failure',checks:[{name:'browser',state:'failure'}]}
: {commit_sha:sha,ci_state:'pending',checks:[{name:'unit',state:'pending'}]};
}});
receipt.capture({repository:'stackchain/api',number:1,key:'stackchain/api#1'}, {merge_commit_sha:'pending'});
receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'failed'});
(async()=>{
await receipt.refresh();
const before=listNode.children.map(row=>({key:row.children[0].children[0].textContent,state:row.children[0].children[1].textContent,label:row.children[1]['aria-label']}));
const beforeLauncher=launcher.textContent;
listNode.children[0].children[1].click();
process.stdout.write(JSON.stringify({before,launcher:beforeLauncher,after:receipt.restore()}));
})();
""")
assert output["before"] == [
{"key": "stackchain/web#2", "state": "Checks failed", "label": "Dismiss stackchain/web#2 from release tracking"},
{"key": "stackchain/api#1", "state": "Checks running", "label": "Dismiss stackchain/api#1 from release tracking"},
]
assert output["launcher"] == "1 release failure · 2 tracked"
assert [item["commit_sha"] for item in output["after"]] == ["pending"]
def test_watchlist_polls_pending_merges_only_while_foregrounded():
output = run_node(r"""
const values=new Map(), scheduled=[], calls=[];
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const documentRef={hidden:false,addEventListener(type,fn){this[type]=fn}};
const windowRef={addEventListener(type,fn){this[type]=fn}};
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',documentRef,windowRef,pollMs:25000,
setTimer:(fn,ms)=>{scheduled.push({fn,ms});return scheduled.length},clearTimer:()=>{},
fetchJson:async path=>{calls.push(path);const sha=path.split('/').pop();return {commit_sha:sha,ci_state:'success',checks:[],release:{tag:'rc-'+sha,url:'https://forge.example/'+sha}};}});
receipt.bind();
receipt.capture({repository:'stackchain/api',number:1,key:'stackchain/api#1'}, {merge_commit_sha:'one'});
receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'two'});
(async()=>{
const first=scheduled.at(-1); await first.fn();
documentRef.hidden=true; documentRef.visibilitychange();
process.stdout.write(JSON.stringify({delay:first.ms,calls,scheduled:scheduled.length,restored:receipt.restore()}));
})();
""")
assert output["delay"] == 25000
assert output["calls"] == [
"api/v1/repos/stackchain/api/release-receipt/one",
"api/v1/repos/stackchain/web/release-receipt/two",
]
assert output["scheduled"] == 2
assert all(item["status"]["release"] for item in output["restored"])
def test_watchlist_keeps_refreshing_other_merges_when_one_status_request_fails():
output = run_node(r"""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async path=>{
const sha=path.split('/').pop();
if (sha === 'offline') throw new Error('Network unavailable');
return {commit_sha:sha,ci_state:'success',checks:[],release:{tag:'rc-ok',url:'https://forge.example/rc-ok'}};
}});
receipt.capture({repository:'stackchain/api',number:1,key:'stackchain/api#1'}, {merge_commit_sha:'offline'});
receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'released'});
(async()=>{ const statuses=await receipt.refresh(); process.stdout.write(JSON.stringify({statuses,entries:receipt.restore()})); })();
""")
assert output["statuses"][0]["label"] == "Status unavailable"
assert output["statuses"][1]["label"] == "Released · rc-ok"
assert output["entries"][0]["status"]["error"] == "Network unavailable"
assert output["entries"][1]["status"]["release"]["url"] == "https://forge.example/rc-ok"
def test_watchlist_exposes_release_link_on_the_matching_merge_row():
output = run_node(r"""
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(){}}; }
global.document={createElement:tag=>node(tag)};
const values=new Map(), listNode=node();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',listNode,fetchJson:async()=>({commit_sha:'released',ci_state:'success',checks:[],release:{tag:'rc-12',url:'https://forge.example/rc-12'}})});
receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'released'});
(async()=>{ await receipt.refresh(); const link=listNode.children[0].children[0].children[2]; process.stdout.write(JSON.stringify({tag:link.tag,href:link.href,text:link.textContent})); })();
""")
assert output == {"tag": "a", "href": "https://forge.example/rc-12", "text": "Open release rc-12"}
def test_mobile_release_receipt_is_wired_into_the_merge_flow_and_phone_safe():
root = Path(__file__).parents[1]
html = (root / "frontend" / "index.html").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
assert '<script src="static/release-receipt.js"></script>' in html
assert 'id="release-receipt-launcher"' in html
assert 'id="release-receipt-sheet"' in html
assert 'id="release-watchlist"' in html
assert 'releaseReceipt.capture(merging, mergeResult)' in dashboard
assert "listNode:qs('#release-watchlist')" in dashboard
assert "min-height:44px" in css[css.index(".release-receipt-sheet"):]
assert "overflow-x:hidden" in css[css.index(".release-receipt-sheet"):]
assert ".release-watchlist-item" in css
+2 -2
View File
@@ -237,7 +237,7 @@ async def test_gitea_review_submission_checks_head_then_maps_decision_upstream()
json={
"id": 91,
"state": "APPROVED",
"html_url": "https://forge.example/reviews/91",
"html_url": "http://127.0.0.1:3000/reviews/91",
},
)
@@ -257,7 +257,7 @@ async def test_gitea_review_submission_checks_head_then_maps_decision_upstream()
assert result == {
"id": 91,
"state": "APPROVED",
"url": "https://forge.example/reviews/91",
"url": "http://127.0.0.1:3000/reviews/91",
}
+56
View File
@@ -0,0 +1,56 @@
import json
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
from src.unfiled_draft_store import UnfiledDraftStore
SCRIPT = Path(__file__).parents[1] / "scripts" / "rotate_unfiled_drafts.py"
OLD_KEY = "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28="
NEW_KEY = "bm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm4="
def _draft(title):
return {"id": "draft", "title": title, "body": "private", "saved_at": 1}
def test_rotation_cli_migrates_readable_rows_and_fails_closed_on_unreadable_rows(tmp_path):
path = tmp_path / "drafts.sqlite3"
store = UnfiledDraftStore(path, encryption_key=b"o" * 32)
store.replace("timmy", 0, [_draft("Secret launch")])
with sqlite3.connect(path) as connection:
connection.execute(
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?)",
("broken", 1, "v2:missing:not-ciphertext"),
)
env = {
**os.environ,
"STACKCHAIN_UNFILED_DRAFT_DB": str(path),
"STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS": json.dumps(
{"legacy": OLD_KEY, "new": NEW_KEY}
),
"STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID": "new",
}
completed = subprocess.run(
[sys.executable, str(SCRIPT)],
cwd=SCRIPT.parents[1],
env=env,
text=True,
capture_output=True,
check=False,
)
assert completed.returncode == 1
assert json.loads(completed.stdout) == {
"current": 0,
"failed": 1,
"migrated": 1,
"total": 2,
}
assert "timmy" not in completed.stdout
assert "Secret launch" not in completed.stdout
assert completed.stderr == ""
+43
View File
@@ -230,6 +230,49 @@ const boundary={{
assert output["pushed"] == ["/dashboard/#security/activity", "/dashboard/#security/devices"]
def test_security_center_finishes_its_today_detour_only_when_the_sheet_closes():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const listeners={{}};
const element=()=>({{
hidden:true,textContent:'',children:[],attributes:{{}},
addEventListener(){{}},focus(){{}},replaceChildren(){{this.children=[];}},append(...values){{this.children.push(...values);}},
setAttribute(name,value){{this.attributes[name]=value;}},removeAttribute(name){{delete this.attributes[name];}},scrollIntoView(){{}},
}});
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
'security-activity-status','load-more-security-activity','close-active-devices',
'security-section-activity','security-section-devices','security-section-passkeys',
'security-activity-section','security-devices-section','security-passkeys-section'];
const ids=Object.fromEntries(names.map(id=>[id,element()]));
const root={{
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
history:{{state:null,pushState(state){{this.state=state;}}}},
location:{{pathname:'/dashboard/',search:''}},
addEventListener:(type,listener)=>listeners[type]=listener,
}};
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
let closes=0;
(async()=>{{
const controller=attachSecurityCenter({{root,boundary,onClose:()=>closes++}});
await controller.open();
listeners.popstate({{state:{{stackchainSecuritySection:'devices'}}}});
const afterSectionBack=closes;
listeners.popstate({{state:null}});
listeners.popstate({{state:null}});
console.log(JSON.stringify({{afterSectionBack,closes,hidden:ids['active-devices-sheet'].hidden}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"afterSectionBack": 0,
"closes": 1,
"hidden": True,
}
def test_close_uses_history_back_to_remove_security_center_route():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
+237 -22
View File
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, 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: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, 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',
@@ -73,7 +73,10 @@ const context = {{
matchAll: async () => state.clientList || [],
openWindow: async url => {{ state.opened.push(url); }},
}},
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
registration: {{
showNotification: async (title, options) => state.notifications.push({{title,options}}),
getNotifications: async () => [{{close:()=>{{state.closedNotifications += 1;}}}}],
}},
__issueSync: {{
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
purge: async () => {{ state.outboxPurges += 1; state.outboxLifecycle.push('purge'); }},
@@ -135,11 +138,11 @@ async function dispatchMessage(data, ports = []) {{
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null, url = null) {{
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null, url = null, actionToken = '') {{
let pending;
listeners.notificationclick({{
action,
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId, ...(url ? {{url}} : {{}})}}, close: () => {{ state.notificationClosed = true; }}}},
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId, ...(url ? {{url}} : {{}}), ...(actionToken ? {{actionToken}} : {{}})}}, close: () => {{ state.notificationClosed = true; }}}},
waitUntil: promise => {{ pending = promise; }},
}});
if (pending) await pending;
@@ -165,13 +168,13 @@ async function dispatchPush(payload) {{
def test_offline_activation_migration_rolls_the_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v117" in source
assert "stackchain-dashboard-shell-v121" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v117" in source
assert "stackchain-dashboard-shell-v121" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -180,7 +183,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v117" in source
assert "stackchain-dashboard-shell-v121" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@@ -189,7 +192,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v117" in source
assert "stackchain-dashboard-shell-v121" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@@ -197,14 +200,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v117" in source
assert "stackchain-dashboard-shell-v121" 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-v117" in source
assert "stackchain-dashboard-shell-v121" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -212,7 +215,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-v117" in source
assert "stackchain-dashboard-shell-v121" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -220,7 +223,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v117" in source
assert "stackchain-dashboard-shell-v121" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -230,14 +233,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v117" in source
assert "stackchain-dashboard-shell-v121" 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-v117" in source
assert "stackchain-dashboard-shell-v121" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -246,21 +249,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v117" in source
assert "stackchain-dashboard-shell-v121" 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-v117" in source
assert "stackchain-dashboard-shell-v121" 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-v117" in source
assert "stackchain-dashboard-shell-v121" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -501,6 +504,167 @@ def test_authenticated_resume_rearms_a_previously_purged_worker_before_flushing(
assert result["state"]["backgroundResumes"] == 1
def test_active_today_message_replaces_one_privacy_safe_lock_screen_notification():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:true,identity:'secret/repo#42',actionToken:'opaque-token-1234567890'});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"] == [
{
"title": "Today session running",
"options": {
"body": "Your active Today timer is running.",
"tag": "stackchain-today-session",
"renotify": False,
"silent": True,
"actions": [
{"action": "pause-today", "title": "Pause"},
{"action": "finish-today", "title": "Finish current"},
],
"data": {"route": "#/my-work/today", "actionToken": "opaque-token-1234567890"},
},
}
]
assert "secret" not in json.dumps(result["notifications"])
def test_live_today_break_shows_return_time_and_safe_resume_controls():
result = run_worker_scenario(
"""
const deadline = Date.now() + 5 * 60 * 1000;
await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:false,breakDeadlineAt:deadline,identity:'secret/repo#42',actionToken:'opaque-token-1234567890'});
process.stdout.write(JSON.stringify(state));
"""
)
notification = result["notifications"][0]
assert notification["title"] == "On a Today break"
assert notification["options"]["body"].startswith("Return at ")
assert notification["options"]["actions"] == [
{"action": "resume-today", "title": "Resume now"},
{"action": "open-today", "title": "Open Today"},
]
assert notification["options"]["data"]["actionToken"] == "opaque-token-1234567890"
assert "secret" not in json.dumps(notification)
def test_invalid_or_expired_break_deadline_falls_back_to_paused_controls():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:false,breakDeadlineAt:Date.now()-1,actionToken:'opaque-token-1234567890'});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"][0]["title"] == "Today session paused"
assert result["notifications"][0]["options"]["actions"][0] == {
"action": "resume-today", "title": "Resume"
}
def test_inactive_today_message_closes_the_lock_screen_notification():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-today-lock-screen',active:false,running:false});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"] == []
assert result["closedNotifications"] == 1
def test_private_data_purge_also_removes_the_today_lock_screen_notification():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:()=>{}}]);
process.stdout.write(JSON.stringify(state));
"""
)
assert result["outboxPurges"] == 1
assert result["closedNotifications"] == 1
def test_today_lock_screen_action_updates_an_open_dashboard_without_navigation():
result = run_worker_scenario(
"""
state.clientMessages=[];
state.clientList=[{
url:'https://forge.example/dashboard/#/my-work/today',
postMessage:message=>state.clientMessages.push(message),
focus:async()=>state.focused.push('today'),
}];
await dispatchNotificationClick('#/my-work/today','pause-today',null,'stackchain-today-session');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["clientMessages"] == [
{"type": "stackchain-today-timer-action", "action": "pause"}
]
assert result["focused"] == ["today"]
assert result["opened"] == []
def test_today_lock_screen_action_opens_a_validated_one_shot_route_when_closed():
result = run_worker_scenario(
"""
await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["opened"] == [
"https://forge.example/dashboard/?today_timer_action=resume#/my-work/today"
]
def test_finish_today_lock_screen_action_forwards_only_its_opaque_token():
result = run_worker_scenario(
"""
state.clientMessages=[];
state.clientList=[{
url:'https://forge.example/dashboard/#/my-work/today',
postMessage:message=>state.clientMessages.push(message),
focus:async()=>state.focused.push('today'),
}];
await dispatchNotificationClick('#/my-work/today','finish-today',null,'stackchain-today-session',null,'opaque-token-1234567890');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["clientMessages"] == [{
"type": "stackchain-today-timer-action",
"action": "complete",
"actionToken": "opaque-token-1234567890",
}]
def test_break_resume_action_forwards_opaque_token_to_reject_stale_breaks():
result = run_worker_scenario(
"""
state.clientMessages=[];
state.clientList=[{
url:'https://forge.example/dashboard/#/my-work/today',
postMessage:message=>state.clientMessages.push(message),
focus:async()=>state.focused.push('today'),
}];
await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session',null,'opaque-token-1234567890');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["clientMessages"] == [{
"type": "stackchain-today-timer-action",
"action": "resume",
"actionToken": "opaque-token-1234567890",
}]
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
result = run_worker_scenario(
"""
@@ -686,7 +850,7 @@ 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():
def test_deadline_digest_push_offers_protect_today_and_snooze_without_rendering_private_copy():
result = run_worker_scenario(
"""
await dispatchPush({
@@ -706,7 +870,7 @@ def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_
"tag": "stackchain-deadline-digest-2026-08-13",
"actions": [
{"action": "protect-today", "title": "Protect Today"},
{"action": "open-agenda", "title": "Open Agenda"},
{"action": "snooze-deadline", "title": "Remind in 1 hour"},
],
"data": {
"route": "#/my-work/agenda",
@@ -720,19 +884,62 @@ def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_
assert "must-not-render" not in json.dumps(result["notifications"])
def test_deadline_digest_open_agenda_action_preserves_browsing_flow():
def test_deadline_digest_snooze_action_records_device_bound_reminder_without_opening_app():
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');
const calls = [];
context.fetch = async (request, options = {}) => {
const url = String(request.url || request);
const headers = new Headers(options.headers || {});
calls.push({url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token')});
if (url.endsWith('/api/v1/session')) {
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
status:200, headers:{'Content-Type':'application/json'},
});
}
return new Response(JSON.stringify({snoozed:true}), {
status:200, headers:{'Content-Type':'application/json'},
});
};
await dispatchNotificationClick('#/my-work/agenda', 'snooze-deadline', null, 'stackchain-deadline-digest-2026-08-13');
process.stdout.write(JSON.stringify({state,calls}));
"""
)
assert result["calls"] == [
{
"url": "https://forge.example/dashboard/api/v1/session",
"method": "GET",
"csrf": None,
},
{
"url": "https://forge.example/dashboard/api/v1/push-subscription/deadlines/snooze",
"method": "PATCH",
"csrf": "session-proof",
},
]
assert result["state"]["notificationClosed"] is True
assert result["state"]["opened"] == []
def test_deadline_digest_failed_snooze_opens_agenda_instead_of_losing_reminder():
result = run_worker_scenario(
"""
context.fetch = async () => { throw new Error('offline'); };
await dispatchNotificationClick(
'#/my-work/agenda', 'snooze-deadline', null,
'stackchain-deadline-digest-2026-08-13'
);
process.stdout.write(JSON.stringify(state));
"""
)
assert result["opened"] == ["https://forge.example/dashboard/#/my-work/agenda"]
assert result["notificationClosed"] is True
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
@@ -930,7 +1137,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-v117" in source
assert "stackchain-dashboard-shell-v121" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -982,14 +1189,20 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/agenda-replan.js",
"/dashboard/static/agenda-calendar.js",
"/dashboard/static/protect-today.js",
"/dashboard/static/notification-undo.js",
"/dashboard/static/card-planning.js",
"/dashboard/static/work-selection.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-timer.js",
"/dashboard/static/today-break.js",
"/dashboard/static/today-progress.js",
"/dashboard/static/today-lock-screen.js",
"/dashboard/static/today-session-sync.js",
"/dashboard/static/today-recap.js",
"/dashboard/static/today-wrap-up.js",
"/dashboard/static/today-summary.js",
"/dashboard/static/today-handoff.js",
"/dashboard/static/today-completion.js",
"/dashboard/static/today-readiness.js",
@@ -1036,10 +1249,12 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/queue-today.js",
"/dashboard/static/pull-sheet.js",
"/dashboard/static/review-sheet.js",
"/dashboard/static/release-receipt.js",
"/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-today-command-bar.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-queue-launcher.js",
+251
View File
@@ -0,0 +1,251 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
TIMER = ROOT / "frontend" / "today-timer.js"
BREAK = ROOT / "frontend" / "today-break.js"
def run_node(source: str) -> dict:
completed = subprocess.run(
["node", "-e", source], capture_output=True, text=True
)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_timed_break_pauses_immediately_and_restores_absolute_deadline():
script = TIMER.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now = 100000;
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
timer.activate('issue:r:42:');
now = 112000;
const started = timer.startBreak(15);
const paused = timer.snapshot();
now = 200000;
const restored = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now}).breakSnapshot();
process.stdout.write(JSON.stringify({started,paused,restored}));
"""
assert run_node(script) == {
"started": {
"identity": "issue:r:42:",
"deadline_at": 1012000,
"expired": False,
},
"paused": {
"identity": "issue:r:42:", "elapsed_ms": 12000, "running": False,
"break_deadline_at": 1012000,
},
"restored": {
"identity": "issue:r:42:",
"deadline_at": 1012000,
"expired": False,
},
}
def test_timed_break_snapshot_carries_deadline_for_cross_device_sync():
script = TIMER.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>100000});
timer.activate('issue:r:42:');
timer.startBreak(15);
process.stdout.write(JSON.stringify(timer.snapshot()));
"""
assert run_node(script) == {
"identity": "issue:r:42:", "elapsed_ms": 0, "running": False,
"break_deadline_at": 1_000_000,
}
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
script = TIMER.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now = 0;
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
timer.activate('issue:r:42:');
const invalid = [timer.startBreak(0), timer.startBreak(121), timer.startBreak(1.5)];
const started = timer.startBreak(5);
now = 300001;
const expired = timer.breakSnapshot();
const stillPaused = timer.snapshot();
const resumed = timer.resumeBreak();
now = 305001;
const running = timer.snapshot();
const replay = timer.resumeBreak();
process.stdout.write(JSON.stringify({invalid,started,expired,stillPaused,resumed,running,replay,remaining:timer.breakSnapshot()}));
"""
result = run_node(script)
assert result["invalid"] == [False, False, False]
assert result["started"]["deadline_at"] == 300000
assert result["expired"]["expired"] is True
assert result["stillPaused"]["running"] is False
assert result["resumed"] is True
assert result["running"] == {
"identity": "issue:r:42:", "elapsed_ms": 5000, "running": True
}
assert result["replay"] is False
assert result["remaining"] is None
def test_mobile_break_sheet_starts_counts_down_and_requires_explicit_resume():
script = f"""
const createBreak = require({json.dumps(str(BREAK))});
class Element {{
constructor() {{ this.listeners={{}}; this.hidden=false; this.textContent=''; this.value=''; this.open=false; this.dataset={{}}; }}
addEventListener(name, fn) {{ this.listeners[name]=fn; }}
click() {{ return this.listeners.click?.({{preventDefault(){{}}}}); }}
showModal() {{ this.open=true; }}
close() {{ this.open=false; this.listeners.close?.(); }}
focus() {{}}
}}
let now=100000;
let pending=null;
const calls=[];
const timer={{
snapshot:()=>({{identity:'issue:r:42:',elapsed_ms:5000,running:!pending}}),
breakSnapshot:()=>pending && {{...pending,expired:now>=pending.deadline_at}},
startBreak:minutes=>{{calls.push(['start',minutes]);pending={{identity:'issue:r:42:',deadline_at:now+minutes*60000}};return {{...pending,expired:false}};}},
resumeBreak:()=>{{if(!pending)return false;calls.push(['resume']);pending=null;return true;}},
}};
const sheet=new Element(), opener=new Element(), cancel=new Element(), preset=new Element(), status=new Element(), resume=new Element();
preset.dataset.todayBreakMinutes='15';
let tick;
const controller=createBreak({{timer,sheet,openButtons:[opener],cancelButton:cancel,presetButtons:[preset],status,resumeButton:resume,now:()=>now,setIntervalRef:fn=>(tick=fn,1),clearIntervalRef:()=>{{}},onChange:()=>calls.push(['change']),onResume:identity=>calls.push(['return',identity])}});
opener.click();
const opened=sheet.open;
cancel.click();
const cancelled={{open:sheet.open,calls:[...calls]}};
opener.click(); preset.click();
const started={{open:sheet.open,text:status.textContent,statusHidden:status.hidden,resumeHidden:resume.hidden,openerHidden:opener.hidden}};
now=100000+15*60000+1; tick();
const expired={{text:status.textContent,resumeHidden:resume.hidden,running:timer.snapshot().running}};
resume.click();
process.stdout.write(JSON.stringify({{opened,cancelled,started,expired,final:{{calls,statusHidden:status.hidden,openerHidden:opener.hidden,pending}}}}));
"""
assert run_node(script) == {
"opened": True,
"cancelled": {"open": False, "calls": []},
"started": {
"open": False,
"text": "On break · resume in 15:00",
"statusHidden": False,
"resumeHidden": False,
"openerHidden": True,
},
"expired": {
"text": "Break over · ready to resume",
"resumeHidden": False,
"running": False,
},
"final": {
"calls": [["start", 15], ["change"], ["resume"], ["return", "issue:r:42:"], ["change"]],
"statusHidden": True,
"openerHidden": False,
"pending": None,
},
}
def test_timed_break_is_packaged_as_an_accessible_mobile_flow():
html = (ROOT / "frontend" / "index.html").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert 'data-today-break-open' in html
assert 'id="today-break-sheet"' in html
assert 'data-today-break-minutes="5"' in html
assert 'data-today-break-minutes="15"' in html
assert 'data-today-break-minutes="30"' in html
assert 'id="today-break-custom-minutes"' in html
assert 'min="1" max="120" step="1"' in html
assert 'id="today-break-status"' in html
assert 'id="resume-today-break"' in html
assert '<script src="static/today-break.js"></script>' in html
assert "createTodayBreak({" in (ROOT / "frontend" / "today-timer.js").read_text()
assert '"static/today-break.js"' in bundle
assert ".today-break-panel" in css
assert "overflow-x:hidden" in css
assert ".today-break-actions button" in css
assert "min-height:44px" in css
def test_custom_break_validation_is_side_effect_free_until_valid():
script = f"""
const createBreak = require({json.dumps(str(BREAK))});
class Element {{
constructor() {{this.listeners={{}};this.open=true;this.hidden=false;this.value='';this.textContent='';}}
addEventListener(name,fn){{this.listeners[name]=fn;}}
close(){{this.open=false;}}
focus(){{this.focused=true;}}
}}
const form=new Element(), input=new Element(), error=new Element(), status=new Element(), resume=new Element(), sheet=new Element();
const calls=[];
const timer={{snapshot:()=>({{identity:'issue:r:1:'}}),breakSnapshot:()=>null,startBreak:value=>{{calls.push(value);return value===45;}},resumeBreak:()=>false}};
createBreak({{timer,sheet,form,minutesInput:input,error,status,resumeButton:resume,setIntervalRef:()=>1,clearIntervalRef:()=>{{}}}});
input.value='2.5'; form.listeners.submit({{preventDefault(){{}}}});
const invalid={{open:sheet.open,error:error.textContent,focused:input.focused||false}};
input.value='45'; form.listeners.submit({{preventDefault(){{}}}});
process.stdout.write(JSON.stringify({{invalid,valid:{{open:sheet.open,error:error.textContent}},calls}}));
"""
assert run_node(script) == {
"invalid": {
"open": True,
"error": "Choose a whole number from 1 to 120 minutes.",
"focused": True,
},
"valid": {"open": False, "error": ""},
"calls": [2.5, 45],
}
def test_replacing_or_stopping_active_work_clears_an_old_break():
script = TIMER.read_text() + r"""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now=0;
const timer=createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
timer.activate('issue:r:1:'); timer.startBreak(5); timer.activate('issue:r:2:');
const replaced=timer.breakSnapshot();
timer.startBreak(5); timer.stop();
const stopped=timer.breakSnapshot();
timer.activate('issue:r:3:'); timer.startBreak(5); timer.adopt('issue:r:4:',1000,false);
process.stdout.write(JSON.stringify({replaced,stopped,adopted:timer.breakSnapshot()}));
"""
assert run_node(script) == {"replaced": None, "stopped": None, "adopted": None}
def test_browser_back_closes_break_sheet_without_starting_a_break():
script = f"""
const createBreak=require({json.dumps(str(BREAK))});
class Element{{constructor(){{this.listeners={{}};this.open=false;this.hidden=true;}}addEventListener(n,f){{this.listeners[n]=f;}}showModal(){{this.open=true;}}close(){{this.open=false;}}}}
const sheet=new Element(), opener=new Element(), status=new Element(), resume=new Element();
const listeners={{}}, pushes=[];
const historyRef={{state:{{route:'today'}},pushState:state=>{{historyRef.state=state;pushes.push(state);}},back:()=>listeners.popstate?.()}};
const windowRef={{addEventListener:(name,fn)=>listeners[name]=fn}};
const timer={{snapshot:()=>({{identity:'issue:r:1:'}}),breakSnapshot:()=>null,resumeBreak:()=>false}};
createBreak({{timer,sheet,openButtons:[opener],status,resumeButton:resume,historyRef,windowRef,queryAll:()=>[]}});
opener.listeners.click();
const opened={{open:sheet.open,state:historyRef.state,pushes}};
listeners.popstate();
process.stdout.write(JSON.stringify({{opened,closed:!sheet.open}}));
"""
assert run_node(script) == {
"opened": {
"open": True,
"state": {"route": "today", "todayBreak": True},
"pushes": [{"route": "today", "todayBreak": True}],
},
"closed": True,
}
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
ROOT = Path(__file__).parents[1]
DASHBOARD = ROOT / "frontend" / "dashboard.js"
INDEX = ROOT / "frontend" / "index.html"
TIMER = ROOT / "frontend" / "today-timer.js"
def test_quick_capture_wires_timer_interruption_to_all_exit_paths():
source = DASHBOARD.read_text()
timer_source = TIMER.read_text()
html = INDEX.read_text()
assert 'id="today-capture-interruption"' in html
assert 'id="today-capture-interruption-label"' in html
assert "view.beginCapture();" in timer_source
assert "timerView.transferCapture()" in source
assert "timerView.finishCapture()" in source
assert "result ? 'Save & return to Today' : 'Save to Drafts'" in timer_source
assert "if (!capture)" in source
def test_capture_save_returns_only_after_durable_admission():
source = DASHBOARD.read_text()
saved = source.index("const savedCapture = await unfiledCaptures.save(captureDraft);")
returned = source.index("timerView.finishCapture()", saved)
failure = source.index("} catch (error) {", saved)
assert saved < returned < failure
+183
View File
@@ -0,0 +1,183 @@
import json
import subprocess
from pathlib import Path
SOURCE = Path(__file__).parents[1] / "frontend" / "today-lock-screen.js"
ROOT = SOURCE.parents[1]
def run_scenario(scenario: str) -> dict:
script = SOURCE.read_text() + r"""
const values = new Map();
const messages = [];
const listeners = {};
const control = {checked:false, disabled:false, addEventListener:(name, fn)=>listeners[name]=fn};
const status = {textContent:''};
const serviceWorker = {
controller:{postMessage:message=>messages.push(message)},
ready:Promise.resolve({active:{postMessage:message=>messages.push(message)}}),
addEventListener:(name, fn)=>listeners['sw-' + name]=fn,
};
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let permission = 'default';
const NotificationRef = {get permission(){return permission;},requestPermission:async()=>{permission='granted';return permission;}};
const actions = [];
const locationRef = {href:'https://forge.example/dashboard/#/my-work/today'};
const historyRef = {replaceState:(_a,_b,url)=>{locationRef.href=new URL(url, locationRef.href).href;}};
let tokenCounter = 0;
const tokens = ['opaque-token-1234567890', 'new-opaque-token-0987654321'];
(async()=>{
const lockScreen = createTodayLockScreen({storage,getLogin:()=> 'Timmy',serviceWorker,NotificationRef,control,status,locationRef,historyRef,randomToken:()=> tokens[tokenCounter++],fingerprint:async (_token, identity)=>identity.includes('break:1000000') ? 'opaque-break-fingerprint' : identity.startsWith('issue:') ? 'opaque-issue-fingerprint' : 'opaque-pull-fingerprint',onAction:(action, identity)=>actions.push([action, identity])});
%SCENARIO%
})().catch(error=>{console.error(error);process.exit(1)});
""".replace("%SCENARIO%", scenario)
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(completed.stdout)
def test_operator_opt_in_syncs_one_privacy_safe_session_notification():
result = run_scenario(r"""
await lockScreen.enable();
await lockScreen.sync({identity:'issue:secret/repo:42:',running:true,elapsed_ms:125000}, true);
process.stdout.write(JSON.stringify({checked:control.checked,status:status.textContent,messages,stored:[...values.entries()]}));
""")
assert result["checked"] is True
assert result["status"] == "Lock-screen Today controls are on."
assert result["stored"] == [
["stackchain.today-lock-screen.v1.timmy", "1"],
["stackchain.today-lock-screen-action.v1.timmy", '{"token":"opaque-token-1234567890","fingerprint":"opaque-issue-fingerprint"}'],
]
assert result["messages"][-1] == {
"type": "stackchain-today-lock-screen",
"active": True,
"running": True,
"actionToken": "opaque-token-1234567890",
}
assert "secret" not in json.dumps(result)
def test_disabling_or_ending_session_removes_notification():
result = run_scenario(r"""
await lockScreen.enable();
await lockScreen.sync({identity:'issue:r:42:',running:false,elapsed_ms:0}, false);
await lockScreen.disable();
process.stdout.write(JSON.stringify({checked:control.checked,messages,stored:[...values.entries()]}));
""")
assert result["checked"] is False
assert result["stored"] == []
assert result["messages"][-2:] == [
{"type": "stackchain-today-lock-screen", "active": False, "running": False},
{"type": "stackchain-today-lock-screen", "active": False, "running": False},
]
def test_valid_notification_action_is_consumed_once_and_removed_from_url():
result = run_scenario(r"""
values.set('stackchain.today-lock-screen.v1.timmy','1');
locationRef.href='https://forge.example/dashboard/?today_timer_action=pause#/my-work/today';
const consumed = await lockScreen.consumeLaunchAction();
const second = await lockScreen.consumeLaunchAction();
listeners['sw-message']({data:{type:'stackchain-today-timer-action',action:'resume'}});
process.stdout.write(JSON.stringify({consumed,second,actions,href:locationRef.href}));
""")
assert result == {
"consumed": True,
"second": False,
"actions": [["pause", None], ["resume", None]],
"href": "https://forge.example/dashboard/#/my-work/today",
}
def test_finish_action_is_bound_to_the_exact_active_identity_and_consumed_once():
result = run_scenario(r"""
await lockScreen.enable();
await lockScreen.sync({identity:'issue:private/repo:42:',running:true}, true);
const first = await lockScreen.consumeAction('complete', 'opaque-token-1234567890');
const replay = await lockScreen.consumeAction('complete', 'opaque-token-1234567890');
await lockScreen.sync({identity:'pull:private/repo:9:',running:true}, true);
const stale = await lockScreen.consumeAction('complete', 'opaque-token-1234567890');
process.stdout.write(JSON.stringify({first,replay,stale,actions,messages,stored:[...values.entries()]}));
""")
assert result["first"] is True
assert result["replay"] is False
assert result["stale"] is False
assert result["actions"] == [["complete", "issue:private/repo:42:"]]
assert all("private/repo" not in json.dumps(message) for message in result["messages"])
def test_break_sync_carries_deadline_and_resume_is_bound_to_that_exact_break():
result = run_scenario(r"""
await lockScreen.enable();
await lockScreen.sync({identity:'issue:private/repo:42:',running:false,break_deadline_at:1000000}, true);
const resumed = await lockScreen.consumeAction('resume', 'opaque-token-1234567890');
await lockScreen.sync({identity:'issue:private/repo:42:',running:false,break_deadline_at:2000000}, true);
const stale = await lockScreen.consumeAction('resume', 'opaque-token-1234567890');
process.stdout.write(JSON.stringify({resumed,stale,actions,messages,stored:[...values.entries()]}));
""")
assert result["resumed"] is True
assert result["stale"] is False
assert result["actions"] == [["resume", "issue:private/repo:42:"]]
assert result["messages"][0] == {
"type": "stackchain-today-lock-screen",
"active": True,
"running": False,
"breakDeadlineAt": 1_000_000,
"actionToken": "opaque-token-1234567890",
}
assert "private/repo" not in json.dumps(result["messages"])
def test_cold_launch_finish_action_is_removed_from_history_before_completion():
result = run_scenario(r"""
await lockScreen.enable();
await lockScreen.sync({identity:'issue:private/repo:42:',running:true}, true);
locationRef.href='https://forge.example/dashboard/?today_timer_action=complete&today_action_token=opaque-token-1234567890#/my-work/today';
const consumed = await lockScreen.consumeLaunchAction();
process.stdout.write(JSON.stringify({consumed,actions,href:locationRef.href}));
""")
assert result == {
"consumed": True,
"actions": [["complete", "issue:private/repo:42:"]],
"href": "https://forge.example/dashboard/#/my-work/today",
}
def test_permission_denial_is_truthful_and_does_not_persist_opt_in():
result = run_scenario(r"""
NotificationRef.requestPermission=async()=>{permission='denied';return permission;};
const enabled = await lockScreen.enable();
process.stdout.write(JSON.stringify({enabled,checked:control.checked,status:status.textContent,stored:[...values.entries()]}));
""")
assert result == {
"enabled": False,
"checked": False,
"status": "Lock-screen controls are blocked in browser settings.",
"stored": [],
}
def test_lock_screen_flow_is_wired_into_the_packaged_today_journey():
index = (ROOT / "frontend" / "index.html").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert 'id="today-lock-screen"' in index
assert 'id="today-lock-screen-status"' in index
assert '<script src="static/today-lock-screen.js"></script>' in index
assert index.index('static/today-lock-screen.js') < index.index('static/dashboard.js')
assert 'createTodayLockScreen({' in dashboard
assert "action === 'complete'" in dashboard
assert "completeTodayItem(item)" in dashboard
assert "todayLockScreen.consumeLaunchAction()" in dashboard
assert dashboard.index("const completeTodayItem = createTodayCompletion({") < dashboard.index("todayLockScreen.consumeLaunchAction()")
assert "todayLockScreen.sync(snapshot, workSession.checkpointed())" in dashboard
assert "todayLockScreen.consumeLaunchAction()" in dashboard
assert '"static/today-lock-screen.js"' in bundle
+747
View File
@@ -0,0 +1,747 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
TODAY_PROGRESS = ROOT / "frontend" / "today-progress.js"
def run_node(script: str) -> dict:
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_progress_drafts_are_account_and_item_scoped_bounded_and_durable():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let login = 'Timmy';
const progress = createProgress({{storage, getLogin:() => login, admit:async () => ({{}}), maxLength:20}});
const issue = {{identity:'issue:stackchain/dashboard:12:', kind:'issue', repository:'stackchain/dashboard', number:12}};
const pull = {{identity:'pull:stackchain/dashboard:13:', kind:'pull', repository:'stackchain/dashboard', number:13}};
const saved = progress.save(issue.identity, ' Investigated latency ');
progress.save(pull.identity, 'Reviewing tests');
const restored = createProgress({{storage, getLogin:() => login, admit:async () => ({{}}), maxLength:20}});
login = 'alexander';
const otherAccount = restored.load(issue.identity);
login = 'timmy';
process.stdout.write(JSON.stringify({{
saved,
issue:restored.load(issue.identity),
pull:restored.load(pull.identity),
otherAccount,
tooLong:restored.save(issue.identity, 'x'.repeat(21)),
keys:[...values.keys()],
}}));
"""
output = run_node(script)
assert output["saved"] is True
assert output["issue"] == "Investigated latency"
assert output["pull"] == "Reviewing tests"
assert output["otherAccount"] == ""
assert output["tooLong"] is False
assert output["keys"] == ["stackchain.today-progress.v1.timmy"]
def test_post_admits_exact_comment_once_and_clears_only_after_durable_admission():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values = new Map();
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const calls=[]; let fail=true;
const progress=createProgress({{
storage, getLogin:()=> 'timmy', makeId:()=> 'progress-op-1',
admit:async message => {{ calls.push(message); if (fail) throw new Error('offline store unavailable'); return {{background:true,item:{{id:message.operationId}}}}; }},
}});
const target={{identity:'issue:stackchain/dashboard:12:',kind:'issue',repository:'stackchain/dashboard',number:12}};
progress.save(target.identity, 'Shipped the first slice');
let error='';
try {{ await progress.post(target); }} catch (caught) {{ error=caught.message; }}
const retained=progress.load(target.identity);
fail=false;
const admitted=await progress.post(target);
process.stdout.write(JSON.stringify({{error,retained,after:progress.load(target.identity),calls,admitted}}));
"""
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
assert output["error"] == "offline store unavailable"
assert output["retained"] == "Shipped the first slice"
assert output["after"] == ""
assert len(output["calls"]) == 2
assert output["calls"][0] == output["calls"][1] == {
"kind": "issue-comment",
"repository": "stackchain/dashboard",
"number": 12,
"body": "Shipped the first slice",
"operationId": "progress-op-1",
}
assert output["admitted"]["background"] is True
def test_photo_only_progress_is_durably_admitted_and_retained_until_success():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values = new Map();
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const photo = {{filename:'result.jpg',contentType:'image/jpeg',blob:{{size:42}},operationId:'photo-op-1'}};
const calls=[]; let fail=true;
const progress=createProgress({{
storage, getLogin:()=> 'timmy', makeId:()=> 'progress-photo-op-1',
admit:async message => {{ calls.push(message); if (fail) throw new Error('photo store unavailable'); return {{background:true}}; }},
}});
const target={{identity:'issue:stackchain/dashboard:12:',kind:'issue',repository:'stackchain/dashboard',number:12}};
let error='';
try {{ await progress.post(target, '', [photo]); }} catch (caught) {{ error=caught.message; }}
const retained=progress.load(target.identity);
fail=false;
await progress.post(target, undefined, [photo]);
process.stdout.write(JSON.stringify({{error,retained,after:progress.load(target.identity),calls}}));
"""
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
assert output["error"] == "photo store unavailable"
assert output["retained"] == ""
assert output["after"] == ""
assert len(output["calls"]) == 2
assert output["calls"][0] == output["calls"][1] == {
"kind": "issue-comment",
"repository": "stackchain/dashboard",
"number": 12,
"body": "",
"operationId": "progress-photo-op-1",
"attachments": [{
"filename": "result.jpg",
"contentType": "image/jpeg",
"blob": {"size": 42},
"operationId": "photo-op-1",
}],
}
def test_photo_payload_changes_rotate_the_progress_operation_identity():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values = new Map(); const ids = ['progress-1', 'progress-2', 'progress-3'];
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const progress=createProgress({{storage,getLogin:()=> 'timmy',makeId:()=>ids.shift(),admit:async()=>({{}})}});
const identity='issue:stackchain/dashboard:12:';
const before={{filename:'proof.jpg',contentType:'image/jpeg',note:'Before',operationId:'photo-1'}};
const replacement={{filename:'proof.jpg',contentType:'image/jpeg',note:'After',operationId:'photo-2'}};
progress.save(identity, 'Repair complete', [before]);
const first=JSON.parse(values.values().next().value).drafts[identity].operation_id;
progress.save(identity, 'Repair complete', [before]);
const unchanged=JSON.parse(values.values().next().value).drafts[identity].operation_id;
progress.save(identity, 'Repair complete', [replacement]);
const replaced=JSON.parse(values.values().next().value).drafts[identity].operation_id;
replacement.note='Redacted replacement';
progress.save(identity, 'Repair complete', [replacement]);
const recaptioned=JSON.parse(values.values().next().value).drafts[identity].operation_id;
process.stdout.write(JSON.stringify({{first,unchanged,replaced,recaptioned}}));
"""
output = run_node(script)
assert output == {
"first": "progress-1",
"unchanged": "progress-1",
"replaced": "progress-2",
"recaptioned": "progress-3",
}
def test_photo_cleanup_failure_resumes_cleanup_without_readmitting_delivery():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const calls=[]; let cleanupFails=true; let cleanupCalls=0;
const options={{storage,getLogin:()=> 'timmy',makeId:()=> 'stable-progress-op',admit:async message=>{{calls.push(message);return {{background:true}};}}}};
let progress=createProgress(options);
const target={{identity:'pull:stackchain/dashboard:22:',kind:'pull',repository:'stackchain/dashboard',number:22}};
const photo={{filename:'proof.webp',contentType:'image/webp',blob:{{size:9}},operationId:'photo-op'}};
const cleanup=async()=>{{cleanupCalls++;if(cleanupFails)throw new Error('draft cleanup blocked')}};
let error=''; let deliveryAdmitted=false;
try {{await progress.post(target,'',[photo],cleanup);}} catch(caught){{error=caught.message;deliveryAdmitted=caught.deliveryAdmitted===true;}}
progress=createProgress(options);
cleanupFails=false;
const recovered=await progress.post(target,'edited text must not be admitted',[{{...photo,operationId:'replacement'}}],cleanup);
process.stdout.write(JSON.stringify({{error,deliveryAdmitted,calls,cleanupCalls,recovered,after:progress.load(target.identity)}}));
"""
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
assert output["error"] == "Progress update is already queued; photo cleanup is pending."
assert output["deliveryAdmitted"] is True
assert len(output["calls"]) == 1
assert output["cleanupCalls"] == 2
assert output["recovered"]["alreadyAdmitted"] is True
assert output["after"] == ""
def test_blocker_retry_moves_on_without_admitting_the_comment_twice():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const admissions=[]; const transitions=[]; let transitionFails=true;
const options={{
storage,getLogin:()=> 'timmy',makeId:()=> 'blocker-op-1',
admit:async message=>{{admissions.push(message);return {{background:true}};}},
}};
let progress=createProgress(options);
const target={{identity:'issue:stackchain/dashboard:1066:',kind:'issue',repository:'stackchain/dashboard',number:1066}};
const until='2026-08-19T09:00:00.000Z';
const transition=async(value,wakeAt)=>{{transitions.push([value.identity,wakeAt]);return !transitionFails;}};
let firstError=''; let admitted=false;
try {{await progress.postBlocker(target,'Waiting for the API owner',[],null,until,transition);}}
catch(caught){{firstError=caught.message;admitted=caught.deliveryAdmitted===true;}}
progress=createProgress(options);
transitionFails=false;
const recovered=await progress.postBlocker(target,undefined,[],null,until,transition);
process.stdout.write(JSON.stringify({{
firstError,admitted,admissions,transitions,recovered,after:progress.load(target.identity),stored:[...values.values()],
}}));
"""
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
assert output["firstError"] == "Blocker was posted, but Today could not move on. Retry the planning step."
assert output["admitted"] is True
assert len(output["admissions"]) == 1
assert output["admissions"][0] == {
"kind": "issue-comment",
"repository": "stackchain/dashboard",
"number": 1066,
"body": "Waiting for the API owner",
"operationId": "blocker-op-1",
}
assert output["transitions"] == [
["issue:stackchain/dashboard:1066:", "2026-08-19T09:00:00.000Z"],
["issue:stackchain/dashboard:1066:", "2026-08-19T09:00:00.000Z"],
]
assert output["recovered"]["alreadyAdmitted"] is True
assert output["after"] == ""
assert output["stored"] == []
def test_admitted_blocker_recovery_restores_and_replaces_its_planning_time_without_readmission():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const admissions=[]; const transitions=[]; let transitionFails=true;
const options={{
storage,getLogin:()=> 'timmy',makeId:()=> 'stable-blocker-op',
admit:async message=>{{admissions.push(message);return {{background:true}};}},
}};
let progress=createProgress(options);
const target={{identity:'issue:stackchain/dashboard:1068:',kind:'issue',repository:'stackchain/dashboard',number:1068}};
const expired='2026-08-18T08:00:00.000Z';
const replacement='2026-08-19T09:30:00.000Z';
const transition=async(value,wakeAt)=>{{transitions.push([value.identity,wakeAt]);return !transitionFails;}};
try {{await progress.postBlocker(target,'Waiting for production access',[],null,expired,transition);}} catch (_error) {{}}
progress=createProgress(options);
const restored=progress.blockerRecovery(target.identity);
transitionFails=false;
const recovered=await progress.postBlocker(target,'changed text must not be admitted',[],null,replacement,transition);
process.stdout.write(JSON.stringify({{
restored,recovered,admissions,transitions,after:progress.blockerRecovery(target.identity),body:progress.load(target.identity),
}}));
"""
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
assert output["restored"] == {
"pending": True,
"until": "2026-08-18T08:00:00.000Z",
}
assert len(output["admissions"]) == 1
assert output["admissions"][0]["body"] == "Waiting for production access"
assert output["transitions"] == [
["issue:stackchain/dashboard:1068:", "2026-08-18T08:00:00.000Z"],
["issue:stackchain/dashboard:1068:", "2026-08-19T09:30:00.000Z"],
]
assert output["recovered"]["alreadyAdmitted"] is True
assert output["after"] is None
assert output["body"] == ""
def test_progress_rejects_inactive_or_unsupported_targets_without_mutation():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values=new Map(); let admitted=0;
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const progress=createProgress({{storage,getLogin:()=> 'timmy',admit:async()=>{{admitted++;}}}});
const results=[];
for (const target of [null, {{identity:'review:r:1:',kind:'review',repository:'r',number:1}}, {{identity:'issue:r:0:',kind:'issue',repository:'r',number:0}}]) {{
try {{ await progress.post(target, 'note'); results.push('accepted'); }} catch (error) {{ results.push(error.message); }}
}}
process.stdout.write(JSON.stringify({{results,admitted,keys:[...values.keys()]}}));
"""
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
assert output["admitted"] == 0
assert output["keys"] == []
assert output["results"] == [
"An active Today issue or pull request is required.",
"An active Today issue or pull request is required.",
"An active Today issue or pull request is required.",
]
def test_progress_sheet_scopes_voice_to_the_open_item_and_aborts_on_close():
script = f"""
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
class Element {{
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.listeners={{}}; }}
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
click() {{ return this.listeners.click?.(); }}
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
}}
const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
const target={{identity:'issue:stackchain/dashboard:1058:',kind:'issue',repository:'stackchain/dashboard',number:1058,label:'#1058',title:'Voice progress'}};
const calls=[];
const voice={{open:async identity=>calls.push(['open',identity]),cancel:()=>calls.push(['cancel'])}};
const activity={{open:async value=>calls.push(['activity-open',value.identity]),close:()=>calls.push(['activity-close'])}};
const view=createView({{
progress:{{load:()=> 'Saved text',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],voice,activity,
photos:{{open:async()=>calls.push(['photos'])}},
}});
(async()=>{{
await elements['[data-mobile-today-update]'].click();
const opened={{body:elements['#today-progress-body'].value,sheet:elements['#today-progress-sheet'].open,calls:[...calls]}};
await elements['#cancel-today-progress'].click();
process.stdout.write(JSON.stringify({{opened,closed:!elements['#today-progress-sheet'].open,calls}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"opened": {
"body": "Saved text",
"sheet": True,
"calls": [["open", "issue:stackchain/dashboard:1058:"], ["photos"], ["activity-open", "issue:stackchain/dashboard:1058:"]],
},
"closed": True,
"calls": [
["open", "issue:stackchain/dashboard:1058:"],
["photos"],
["activity-open", "issue:stackchain/dashboard:1058:"],
["cancel"],
["activity-close"],
],
}
def test_blocked_hud_opens_a_focused_flow_with_quick_return_choices():
script = f"""
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
class Element {{
constructor() {{
this.hidden=false;this.value='';this.textContent='';this.open=false;this.focused=false;this.listeners={{}};
const names=new Set();
this.classList={{add:name=>names.add(name),remove:name=>names.delete(name),contains:name=>names.has(name)}};
}}
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
click() {{ return this.listeners.click?.(); }}
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{ this.focused=true; }}
}}
const selectors=['#today-progress-sheet','.today-progress-panel','#today-progress-title','#today-progress-body',
'#today-progress-status','[data-mobile-today-update]','[data-mobile-today-blocked]',
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress',
'#today-blocker-return-at','#post-today-blocker','[data-today-blocker-preset="later-today"]',
'[data-today-blocker-preset="tomorrow"]','[data-today-blocker-custom]'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
const target={{identity:'issue:stackchain/dashboard:1070:',kind:'issue',repository:'stackchain/dashboard',number:1070,label:'#1070',title:'Focused blocker'}};
const calls=[];
const view=createView({{
progress:{{load:()=> 'Waiting for access',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],
voice:{{open:async()=>calls.push('voice')}},photos:{{open:async()=>calls.push('photos')}},
activity:{{open:async()=>calls.push('activity')}},now:()=>new Date('2026-08-18T10:15:00Z'),
}});
(async()=>{{
const availability={{update:elements['[data-mobile-today-update]'].hidden,blocked:elements['[data-mobile-today-blocked]'].hidden}};
await elements['[data-mobile-today-blocked]'].click();
const opened={{
title:elements['#today-progress-title'].textContent,target:elements['#today-progress-target'].textContent,
focused:elements['#today-progress-body'].focused,mode:elements['.today-progress-panel'].classList.contains('blocker-mode'),
calls:[...calls],sheet:elements['#today-progress-sheet'].open,
}};
await elements['[data-today-blocker-preset="later-today"]'].click();
const laterToday=elements['#today-blocker-return-at'].value;
await elements['[data-today-blocker-preset="tomorrow"]'].click();
const tomorrow=elements['#today-blocker-return-at'].value;
elements['#today-blocker-return-at'].focused=false;
await elements['[data-today-blocker-custom]'].click();
process.stdout.write(JSON.stringify({{availability,opened,laterToday,tomorrow,customFocused:elements['#today-blocker-return-at'].focused}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"availability": {"update": False, "blocked": False},
"opened": {
"title": "Report blocker",
"target": "#1070 · Focused blocker",
"focused": True,
"mode": True,
"calls": [],
"sheet": True,
},
"laterToday": "2026-08-18T14:15",
"tomorrow": "2026-08-19T09:00",
"customFocused": True,
}
def test_progress_sheet_dismisses_mentions_when_opened_switched_or_closed():
script = f"""
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
class Element {{
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.listeners={{}}; }}
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
click() {{ return this.listeners.click?.(); }}
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
}}
const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
const issue={{identity:'issue:stackchain/dashboard:1064:',kind:'issue',repository:'stackchain/dashboard',number:1064,label:'#1064',title:'Mentions'}};
const pull={{identity:'pull:stackchain/dashboard:77:',kind:'pull',repository:'stackchain/dashboard',number:77,label:'#77',title:'Other'}};
let target=issue; let dismissals=0;
const view=createView({{
progress:{{load:()=>'',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],
mentions:{{dismiss:()=>dismissals++}},
}});
(async()=>{{
await elements['[data-mobile-today-update]'].click();
const afterOpen=dismissals;
target=pull; view.update();
const afterSwitch=dismissals;
await elements['#cancel-today-progress'].click();
process.stdout.write(JSON.stringify({{afterOpen,afterSwitch,afterClose:dismissals,closed:!elements['#today-progress-sheet'].open}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"afterOpen": 1,
"afterSwitch": 2,
"afterClose": 3,
"closed": True,
}
def test_progress_sheet_posts_blocker_then_defers_and_continues_exact_active_item():
script = f"""
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
class Element {{
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.disabled=false;this.listeners={{}}; }}
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
click() {{ return this.listeners.click?.(); }}
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
}}
const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress',
'#today-blocker-return-at','#post-today-blocker'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
const item={{kind:'issue',repository:'stackchain/dashboard',number:1066,title:'Blocked flow'}};
const target={{identity:'issue:stackchain/dashboard:1066:',kind:'issue',repository:'stackchain/dashboard',number:1066,label:'#1066',title:'Blocked flow',item}};
const calls=[]; const announcements=[];
const progress={{
load:()=>'',save:()=>true,
postBlocker:async(...args)=>{{calls.push(args);return {{background:true}};}},
}};
createView({{
progress,currentTarget:()=>target,qs:selector=>elements[selector],
photos:{{open:async()=>{{}},checkpoint:async()=>{{}},serialize:async()=>[]}},
moveOn:async(value,until)=>{{calls.push(['move-on',value.identity,until]);return true;}},
announce:value=>announcements.push(value),
}});
(async()=>{{
await elements['[data-mobile-today-update]'].click();
elements['#today-progress-body'].value='Waiting for API access';
elements['#today-blocker-return-at'].value='2099-08-19T09:00';
await elements['#post-today-blocker'].click();
process.stdout.write(JSON.stringify({{
posted:calls[0].slice(0,3),transitionResult:await calls[0][5](target,calls[0][4]),
moveCall:calls[1],announcements,closed:!elements['#today-progress-sheet'].open,
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["posted"][0]["identity"] == "issue:stackchain/dashboard:1066:"
assert output["posted"][1] == "Waiting for API access"
assert output["posted"][2] == []
assert output["moveCall"][0:2] == ["move-on", "issue:stackchain/dashboard:1066:"]
assert output["moveCall"][2].startswith("2099-08-19T09:00")
assert output["transitionResult"] is True
assert output["announcements"] == ["Blocker queued, deferred to Later, and Today moved on."]
assert output["closed"] is True
def test_progress_sheet_restores_admitted_blocker_recovery_and_accepts_a_new_planning_time():
script = f"""
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
class Element {{
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.disabled=false;this.listeners={{}}; }}
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
click() {{ return this.listeners.click?.(); }}
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
}}
const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress',
'#today-blocker-return-at','#post-today-blocker','#today-progress-blocker-recovery'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
const target={{identity:'issue:stackchain/dashboard:1068:',kind:'issue',repository:'stackchain/dashboard',number:1068,label:'#1068',title:'Recover blocker'}};
const calls=[];
const progress={{
load:()=> 'Waiting for production access',save:()=>true,
blockerRecovery:()=>({{pending:true,until:'2000-01-02T03:04:00.000Z'}}),
postBlocker:async(...args)=>{{calls.push(args);return {{background:true,alreadyAdmitted:true}};}},
}};
createView({{
progress,currentTarget:()=>target,qs:selector=>elements[selector],
photos:{{open:async()=>{{}},checkpoint:async()=>{{}},serialize:async()=>[]}},moveOn:async()=>true,
}});
(async()=>{{
await elements['[data-mobile-today-update]'].click();
const restored={{
body:elements['#today-progress-body'].value,bodyDisabled:elements['#today-progress-body'].disabled,
until:elements['#today-blocker-return-at'].value,recoveryHidden:elements['#today-progress-blocker-recovery'].hidden,
recoveryText:elements['#today-progress-blocker-recovery'].textContent,button:elements['#post-today-blocker'].textContent,
saveDisabled:elements['#save-today-progress'].disabled,postDisabled:elements['#post-today-progress'].disabled,
}};
elements['#today-blocker-return-at'].value='2099-08-19T09:30';
await elements['#post-today-blocker'].click();
process.stdout.write(JSON.stringify({{restored,posted:{{body:calls[0][1],until:calls[0][4]}},closed:!elements['#today-progress-sheet'].open}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["restored"] == {
"body": "Waiting for production access",
"bodyDisabled": True,
"until": "2000-01-02T03:04",
"recoveryHidden": False,
"recoveryText": "Blocker queued—finish moving on. The posted update cannot be changed.",
"button": "Finish moving on",
"saveDisabled": True,
"postDisabled": True,
}
assert output["posted"]["body"] == "Waiting for production access"
assert output["posted"]["until"].startswith("2099-08-19T09:30")
assert output["closed"] is True
def test_recent_activity_loads_the_exact_issue_newest_page_before_rendering():
script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const calls=[]; const paints=[];
const page={{comments:[{{id:2,body:'Latest',user:{{login:'alex'}},created_at:'2026-08-18T01:00:00Z'}}],page:3,older_page:2,total:41}};
const activity=createActivity({{
fetchJson:async path=>{{calls.push(path);return page;}},
createPager:({{loadPage}})=>({{reset:value=>value,loadOlder:()=>loadPage(2)}}),
paint:value=>paints.push(value), setStatus:value=>calls.push('status:'+value),
}});
const target={{identity:'issue:stackchain/dashboard:1060:',kind:'issue',repository:'stackchain/dashboard',number:1060}};
(async()=>{{await activity.open(target);process.stdout.write(JSON.stringify({{calls,paints}}));}})()
.catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["calls"] == [
"status:Loading recent activity…",
"api/v1/repos/stackchain/dashboard/issues/1060/comments?limit=20",
"status:",
]
assert output["paints"] == [{
"comments": [{
"id": 2,
"body": "Latest",
"user": {"login": "alex"},
"created_at": "2026-08-18T01:00:00Z",
}],
"page": 3,
"older_page": 2,
"total": 41,
}]
def test_recent_activity_rejects_a_slow_response_after_close():
script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
let resolve; const paints=[]; const statuses=[];
const activity=createActivity({{
fetchJson:()=>new Promise(done=>{{resolve=done;}}),
createPager:()=>({{reset:value=>value}}), paint:value=>paints.push(value), setStatus:value=>statuses.push(value),
}});
const target={{identity:'pull:stackchain/dashboard:9:',kind:'pull',repository:'stackchain/dashboard',number:9}};
(async()=>{{const pending=activity.open(target);activity.close();resolve({{comments:[{{id:1}}]}});const result=await pending;process.stdout.write(JSON.stringify({{result,paints,statuses}}));}})()
.catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"result": False,
"paints": [],
"statuses": ["Loading recent activity…", ""],
}
def test_recent_activity_retry_recovers_the_same_pull_after_failure():
script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const calls=[]; const paints=[]; let fail=true;
const activity=createActivity({{
fetchJson:async path=>{{calls.push(path);if(fail)throw new Error('offline');return {{comments:[],page:1,older_page:null,total:0}};}},
createPager:()=>({{reset:value=>value}}), paint:value=>paints.push(value),
}});
const target={{identity:'pull:stackchain/dashboard:9:',kind:'pull',repository:'stackchain/dashboard',number:9}};
(async()=>{{const opened=await activity.open(target);fail=false;const retried=await activity.retry();process.stdout.write(JSON.stringify({{opened,retried,calls,paints}}));}})()
.catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["opened"] is False
assert output["retried"] is True
assert output["calls"] == [
"api/v1/repos/stackchain/dashboard/pulls/9/comments?limit=20",
"api/v1/repos/stackchain/dashboard/pulls/9/comments?limit=20",
]
assert output["paints"] == [{"comments": [], "page": 1, "older_page": None, "total": 0}]
def test_recent_activity_load_older_paints_the_deduplicated_page():
script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const calls=[]; const paints=[];
const activity=createActivity({{
fetchJson:async path=>{{calls.push(path);return {{comments:[],page:2,older_page:null,total:20}};}},
createPager:({{loadPage}})=>({{reset:value=>value,loadOlder:async()=>{{await loadPage(2);return {{comments:[{{id:1}}],page:2,older_page:null,total:21}};}}}}),
paint:value=>paints.push(value),
}});
const target={{identity:'issue:stackchain/dashboard:1060:',kind:'issue',repository:'stackchain/dashboard',number:1060}};
(async()=>{{await activity.open(target);const loaded=await activity.loadOlder();process.stdout.write(JSON.stringify({{loaded,calls,paints}}));}})()
.catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["loaded"] is True
assert output["calls"][-1] == "api/v1/repos/stackchain/dashboard/issues/1060/comments?page=2&limit=20"
assert output["paints"][-1]["comments"] == [{"id": 1}]
def test_recent_activity_mount_renders_owned_actions_on_the_exact_comment_card():
script = f"""
const {{mountActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const createPager=require({json.dumps(str(ROOT / 'frontend' / 'conversation.js'))});
class Element {{
constructor(){{this.innerHTML='';this.textContent='';this.hidden=false;this.listeners={{}};this.scrollHeight=0;this.scrollTop=0;}}
addEventListener(name,callback){{this.listeners[name]=callback;}}
}}
const selectors=['#today-progress-activity-list','#today-progress-activity-count','#load-older-today-progress-activity',
'#today-progress-activity-status','#retry-today-progress-activity'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
const actions={{actionHtml:comment=>comment.author==='timmy'?'<button data-comment-action="edit">Edit</button>':''}};
const activity=mountActivity(selector=>elements[selector],async()=>({{
comments:[{{id:17,author:'timmy',body:'Needs correction',created_at:'2026-08-18T00:00:00Z'}}],page:1,older_page:null,total:1,
}}),{{getActions:async()=>actions,createPager,escape:value=>String(value),render:value=>String(value)}});
(async()=>{{
await activity.open({{identity:'issue:stackchain/dashboard:1062:',kind:'issue',repository:'stackchain/dashboard',number:1062}});
process.stdout.write(JSON.stringify({{html:elements['#today-progress-activity-list'].innerHTML,count:elements['#today-progress-activity-count'].textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert 'class="today-progress-activity-item issue-comment"' in output["html"]
assert 'data-comment-id="17"' in output["html"]
assert 'data-comment-action="edit"' in output["html"]
assert output["count"] == "1 of 1 messages"
def test_recent_activity_edit_control_preserves_a_failed_correction_for_retry():
script = f"""
const {{mountActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const createPager=require({json.dumps(str(ROOT / 'frontend' / 'conversation.js'))});
const createActions=require({json.dumps(str(ROOT / 'frontend' / 'comment-actions.js'))});
class Element {{constructor(){{this.innerHTML='';this.textContent='';this.hidden=false;this.listeners={{}};this.scrollHeight=0;this.scrollTop=0;}}addEventListener(n,c){{this.listeners[n]=c;}}}}
const selectors=['#today-progress-activity-list','#today-progress-activity-count','#load-older-today-progress-activity','#today-progress-activity-status','#retry-today-progress-activity'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
let state={{comments:[{{id:17,author:'timmy',body:'Wrong'}}],page:1,older_page:null,total:1}};
let attempted='';
const actions=createActions({{
getLogin:()=> 'timmy',
fetchJson:async(_path,options)=>{{attempted=JSON.parse(options.body).body;throw new Error('network unavailable');}},
}});
const activity=mountActivity(selector=>elements[selector],async()=>state,{{getActions:async()=>actions,createPager,escape:value=>String(value),render:value=>String(value),isOffline:()=>false}});
const textarea={{value:'Corrected transcript',focuses:0,focus(){{this.focuses++;}}}};
const save={{disabled:false,listeners:{{}},addEventListener(n,c){{this.listeners[n]=c;}}}};
const cancel={{listeners:{{}},addEventListener(n,c){{this.listeners[n]=c;}}}};
const card={{dataset:{{commentId:'17'}},innerHTML:'',querySelector:selector=>({{'.comment-edit-textarea':textarea,'[data-comment-edit-save]':save,'[data-comment-edit-cancel]':cancel}})[selector]}};
const button={{dataset:{{commentAction:'edit'}},closest:selector=>selector==='[data-comment-action]'?button:card}};
(async()=>{{
await activity.open({{identity:'issue:stackchain/dashboard:1062:',kind:'issue',repository:'stackchain/dashboard',number:1062}});
await elements['#today-progress-activity-list'].listeners.click({{target:button}});
await save.listeners.click({{currentTarget:save}});
process.stdout.write(JSON.stringify({{attempted,value:textarea.value,focuses:textarea.focuses,disabled:save.disabled,status:elements['#today-progress-activity-status'].textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output == {
"attempted": "Corrected transcript",
"value": "Corrected transcript",
"focuses": 2,
"disabled": False,
"status": "network unavailable",
}
def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
html = (ROOT / "frontend" / "index.html").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert 'data-mobile-today-update' in html
assert 'id="today-progress-sheet"' in html
assert 'aria-labelledby="today-progress-title"' in html
assert 'id="today-progress-body"' in html
assert 'id="today-progress-activity"' in html
assert 'id="today-progress-activity-list"' in html
assert 'id="today-progress-activity-status"' in html
assert 'id="retry-today-progress-activity"' in html
assert 'id="load-older-today-progress-activity"' in html
for control in (
"voice-today-progress",
"start-voice-today-progress",
"stop-voice-today-progress",
"voice-today-progress-review",
"voice-today-progress-transcript",
"append-voice-today-progress",
"replace-with-voice-today-progress",
"discard-voice-today-progress",
"voice-today-progress-status",
):
assert f'id="{control}"' in html
assert 'for="voice-today-progress-transcript"' in html
assert 'data-draft-label="progress update"' in html
assert 'id="take-today-progress-photo"' in html
assert 'id="today-progress-attachment"' in html
assert 'id="today-progress-attachment-preview"' in html
assert 'maxlength="2000"' in html
assert 'id="save-today-progress"' in html
assert 'id="post-today-progress"' in html
assert 'id="today-blocker-return-at"' in html
assert 'id="post-today-blocker"' in html
assert 'Post blocker &amp; move on' in html
assert '<script src="static/today-progress.js"></script>' in html
assert '"static/today-progress.js"' in bundle
assert "workSession.target('continue')" in dashboard
assert "authoredOutbox.enqueueDurably" in dashboard
assert "createTodayProgressPhotos" in dashboard
assert "scope:'today-progress'" in TODAY_PROGRESS.read_text()
assert "lanes:{ today:" in TODAY_PROGRESS.read_text()
assert "photos:todayProgressPhotos" in dashboard
assert "['today-progress', '#today-progress-body']" in dashboard
assert "voice:todayProgressVoice" in dashboard
assert "mountTodayProgressActivity" in dashboard
assert "activity:mountTodayProgressActivity" in dashboard
assert "moveOn:(target,until) => detailDefer.deferUntil(target.item,until)" in dashboard
assert "fetchReviewJson,actionHydrator" in dashboard
assert ".today-progress-activity-list" in css
assert ".today-progress-activity-actions button" in css
assert "min-height:44px" in css
assert "const controller = issueAttachment.mount" in TODAY_PROGRESS.read_text()
assert "const checkpointAttachments = await photos?.serialize?.() || [];" in TODAY_PROGRESS.read_text()
assert "progress.save(openedTarget.identity, body.value, checkpointAttachments)" in TODAY_PROGRESS.read_text()
assert "error.deliveryAdmitted" in TODAY_PROGRESS.read_text()
assert ".today-progress-panel" in css
assert "env(safe-area-inset-bottom)" in css
assert ".today-progress-actions button" in css and "min-height:44px" in css
assert ".today-progress-panel .voice-conversation-controls button" in css
+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-v117';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v121';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+2 -2
View File
@@ -880,9 +880,9 @@ async def test_dashboard_renders_mobile_today_recap_flow():
assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source
assert 'class="today-recap-variance"' in recap_source
assert "await recap.saveForReplan()" in recap_source
assert "openWrapUp(handoff.actual_minutes)" in recap_source
assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap_source
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
assert "todayWrapUpView.open(todayMyWork, actualMinutes)" in dashboard
assert "todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)" in dashboard
assert ".today-recap-row { grid-template-columns:1fr; }" in css
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more