Compare commits

...
Author SHA1 Message Date
timmy 1b448fef23 docs: add disk capacity incident runbook (Closes #1287)
CI / browser-journey (pull_request) Successful in 6m55s
CI / build-release (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 3m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 01:10:16 +00:00
timmy 881653eef1 Merge pull request 'Make calendar-import performance gates deterministic' (#1286) from timmy/1285-calendar-import-performance-gate into main
CI / browser-journey (push) Failing after 12m8s
CI / build-release (push) Successful in 19s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 5m17s
2026-08-23 00:32:32 +00:00
timmy d8476c6f7b perf: reuse calendar timezone formatters (Closes #1285)
CI / browser-journey (pull_request) Successful in 5m51s
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 4m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 00:19:34 +00:00
timmy 6eb2709477 Merge pull request 'Keep the installed app badge current from background Updates' (#1283) from timmy/1281-background-updates-app-badge into main
CI / browser-journey (push) Failing after 9m59s
CI / build-release (push) Successful in 11s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 4m14s
2026-08-22 22:36:24 +00:00
timmy 05405bf2e8 feat: keep background Updates badge current (Closes #1281)
CI / browser-journey (pull_request) Successful in 7m5s
CI / build-release (pull_request) Successful in 15s
CI / lint (pull_request) Successful in 4m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 22:11:52 +00:00
rockachopa b18a50fec5 Merge pull request 'Show unread Updates on the installed app icon' (#1279) from timmy/1278-unread-updates-app-icon-badge into main
CI / browser-journey (push) Successful in 5m39s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m34s
2026-08-22 21:04:51 +00:00
timmy be9de6596f feat: show unread Updates on app icon (Closes #1278)
CI / browser-journey (pull_request) Successful in 6m10s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 20:39:09 +00:00
rockachopa 71631c6731 Merge pull request 'Preserve complete Today timing ledger across device handoff' (#1277) from timmy/1276-complete-today-ledger-handoff into main
CI / browser-journey (push) Successful in 5m51s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m41s
2026-08-22 19:49:04 +00:00
timmy 548b95ddb6 feat: preserve Today timing ledger across handoff (Closes #1276)
CI / browser-journey (pull_request) Successful in 5m42s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 19:38:30 +00:00
rockachopa e0d8ab9f10 Merge pull request 'Recover active Today breaks after queue recovery' (#1275) from timmy/1274-recovered-today-break-status into main
CI / browser-journey (push) Successful in 5m58s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 10s
CI / lint (push) Successful in 3m53s
2026-08-22 18:39:57 +00:00
timmy a629b5a93e fix: recover server-confirmed Today breaks (Closes #1274)
CI / browser-journey (pull_request) Successful in 6m8s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 4m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 18:28:29 +00:00
rockachopa ce9301f145 Merge pull request 'feat: CI regression: packaged mobile first-task coach remains hidden' (#1273) from timmy/1272-ci-regression-packaged-mobile-first-task-coach-r into main
CI / browser-journey (push) Failing after 6m19s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m58s
Fix packaged first-task coach visibility (Closes #1272)
2026-08-22 17:57:05 +00:00
timmy d0e8378a02 fix: keep first-task coach host visible (Closes #1272)
CI / browser-journey (pull_request) Successful in 5m43s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 17:28:29 +00:00
rockachopa 424ab2bb6d Merge pull request 'Persist exact Week Ahead task times' (#1271) from timmy/1270-persist-week-task-times into main
CI / browser-journey (push) Failing after 6m23s
CI / build-release (push) Successful in 10s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 4m11s
2026-08-22 16:57:39 +00:00
timmy 5fb4da30a4 feat: persist exact Week Ahead task times (Closes #1270)
CI / browser-journey (pull_request) Successful in 6m22s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 4m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 16:39:24 +00:00
rockachopa f78da3dfc9 Merge pull request 'feat: CI regression: Plan Today remains visible after Save & start' (#1269) from timmy/1268-ci-regression-plan-today-remains-visible-after-s into main
CI / browser-journey (push) Successful in 6m29s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 4m12s
2026-08-22 16:06:12 +00:00
timmy 14b3fb4048 fix: close rollover planner before Today handoff (Closes #1268)
CI / browser-journey (pull_request) Successful in 6m38s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 4m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 15:34:05 +00:00
rockachopa d27a481d96 Merge pull request 'Refresh Week Ahead calendar availability with atomic reflow' (#1267) from timmy/1266-refresh-week-calendar-atomic-reflow into main
CI / browser-journey (push) Failing after 8m1s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 4m31s
2026-08-22 14:45:03 +00:00
timmy 24eb4d1e79 feat: refresh Week capacity with atomic reflow (Closes #1266)
CI / browser-journey (pull_request) Successful in 7m11s
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 4m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 14:32:08 +00:00
rockachopa 3c35aa31c1 Merge pull request 'Guard the Week Ahead reschedule journey from hidden issue sheets' (#1265) from timmy/1264-visible-week-reschedule-sheet into main
CI / browser-journey (push) Successful in 5m57s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 4m7s
2026-08-22 13:35:20 +00:00
timmy 363a97aa5e test: guard hidden Week Ahead journey sheet (Closes #1264)
CI / browser-journey (pull_request) Successful in 6m48s
CI / build-release (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 5m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 13:22:21 +00:00
rockachopa 306a20c37f Merge pull request 'feat: CI regression: mobile Today break status is lost after recovery reload' (#1263) from timmy/1262-ci-regression-mobile-today-break-status-is-lost- into main
CI / browser-journey (push) Failing after 7m7s
CI / build-release (push) Successful in 10s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m52s
2026-08-22 12:26:36 +00:00
timmy e06bd8435d fix: restore timed break after unchanged fresh context (Closes #1262)
CI / browser-journey (pull_request) Successful in 6m4s
CI / build-release (pull_request) Successful in 11s
CI / lint (pull_request) Successful in 3m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 12:04:23 +00:00
rockachopa 76f89df609 Merge pull request 'Export a saved Week Ahead plan directly from the overview' (#1261) from timmy/1260-export-saved-week-calendar into main
CI / browser-journey (push) Failing after 5m58s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 4m2s
2026-08-22 11:31:03 +00:00
timmy f0a966d3a3 feat: export saved Week Ahead from overview (Closes #1260)
CI / browser-journey (pull_request) Successful in 6m0s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 4m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 11:15:10 +00:00
rockachopa 9eac3bb958 Merge pull request 'Persist privacy-safe calendar free windows with Week Ahead' (#1259)
CI / browser-journey (push) Successful in 6m53s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Successful in 10s
CI / lint (push) Successful in 4m16s
Closes #1258
2026-08-22 10:04:09 +00:00
timmy fc93620118 feat: persist calendar free windows with Week Ahead (Closes #1258)
CI / browser-journey (pull_request) Successful in 5m58s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 09:49:04 +00:00
rockachopa 15739f7370 Merge pull request 'Persist weekly availability defaults for rolling Week Ahead dates' (#1257) from timmy/1256-weekly-availability-defaults into main
CI / browser-journey (push) Successful in 6m16s
CI / build-release (push) Successful in 11s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 4m26s
Merge pull request 'Persist weekly availability defaults for rolling Week Ahead dates' (#1257)
2026-08-22 08:20:35 +00:00
timmy 1ae8533702 feat: persist weekly availability defaults (Closes #1256)
CI / browser-journey (pull_request) Successful in 5m47s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 08:10:24 +00:00
timmy ea3204c78c Merge pull request 'Recover conflicted Week-to-Today transfers' (#1255) from timmy/1254-recover-conflicted-week-pulls into main
CI / browser-journey (push) Successful in 5m28s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m39s
Recover conflicted Week-to-Today transfers (#1255)
2026-08-22 07:28:12 +00:00
timmy 6e72f59fcf feat: recover conflicted Week-to-Today transfers (Closes #1254)
CI / browser-journey (pull_request) Successful in 5m22s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 07:06:33 +00:00
timmy 24cadf88f8 Merge pull request 'Encrypt completed Filed review history at rest' (#1253) from timmy/1252-encrypt-completed-filed-reviews into main
CI / browser-journey (push) Failing after 5m14s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m21s
2026-08-22 06:31:00 +00:00
timmy 4b645d8460 feat: encrypt completed Filed review history (Closes #1252)
CI / browser-journey (pull_request) Successful in 5m27s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 06:09:06 +00:00
rockachopa a4498fdbbc Merge pull request 'Queue multiple offline Week-to-Today pulls' (#1250) from timmy/1249-queue-offline-week-pulls into main
CI / browser-journey (push) Failing after 7m15s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m46s
2026-08-22 04:52:47 +00:00
timmy 2e8efdb2bb test: cover fallback lease for Week replay
CI / browser-journey (pull_request) Successful in 6m49s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 04:38:26 +00:00
timmy e92e476f49 test: exercise real cross-tab replay coordinator
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 04:36:02 +00:00
timmy 4a6a829401 fix: serialize Week-to-Today replay across tabs
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 3s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 04:25:01 +00:00
timmy c03d0a091f Revert "ci: pin checkout and Python setup actions"
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 12s
CI / release-candidate (pull_request) Has been skipped
This reverts commit 59c87f06a2.
2026-08-22 03:41:03 +00:00
timmy 2f5df33020 Revert "ci: prepare act runner action mount"
This reverts commit 5d517861f0.
2026-08-22 03:41:03 +00:00
timmy 5d517861f0 ci: prepare act runner action mount
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 3s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 03:40:21 +00:00
timmy 59c87f06a2 ci: pin checkout and Python setup actions
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 03:38:27 +00:00
timmy f76b2f8289 refactor: simplify pull queue admission
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 03:33:34 +00:00
timmy 7c4e798776 feat: queue offline Week-to-Today pulls (Closes #1249)
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 33s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 03:31:34 +00:00
timmy 317fc7371a Merge pull request 'Keep Week-to-Today pulls through offline reloads' (#1247) from timmy/1246-durable-week-today-pulls into main
CI / browser-journey (push) Successful in 4m56s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m10s
2026-08-22 02:50:55 +00:00
timmy c7d38593dd feat: keep Week-to-Today pulls through reloads (Closes #1246)
CI / browser-journey (pull_request) Successful in 5m2s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 02:41:29 +00:00
rockachopa a13a13d90c Merge pull request 'Confirm Today capacity before pulling Week Ahead work' (#1245) from timmy/1244-capacity-confirm-week-pull into main
CI / browser-journey (push) Successful in 5m34s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m21s
2026-08-22 01:43:15 +00:00
timmy 666671ee59 feat: confirm capacity before Week-to-Today pulls (Closes #1244)
CI / browser-journey (pull_request) Successful in 5m18s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 01:33:04 +00:00
rockachopa d01f502e70 Merge pull request 'Pull one Week Ahead item into an active Today plan' (#1243) from timmy/1242-week-item-to-today into main
CI / browser-journey (push) Successful in 5m26s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m25s
2026-08-22 00:24:40 +00:00
timmy 3fd5983df6 feat: pull Week Ahead work into Today (Closes #1242)
CI / browser-journey (pull_request) Successful in 5m11s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 00:10:39 +00:00
rockachopa 6afdad6fe4 Merge pull request 'Remove active work from Week Ahead with Undo' (#1241) from timmy/1240-week-ahead-unplan-undo into main
CI / browser-journey (push) Successful in 5m10s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m11s
2026-08-21 22:59:20 +00:00
timmy 449ab78991 feat: unplan Week Ahead work with undo (Closes #1240)
CI / browser-journey (pull_request) Successful in 5m20s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 22:45:15 +00:00
timmy 95887d7493 Merge pull request 'Rotate shared private-state encryption keys without downtime' (#1238)
CI / browser-journey (push) Successful in 5m17s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m19s
Closes #1237
2026-08-21 21:37:25 +00:00
timmy 965a73ab49 feat: rotate shared private-state keys (Closes #1237)
CI / browser-journey (pull_request) Successful in 5m2s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 21:12:54 +00:00
rockachopa 067fb76ebd Merge pull request 'Retire completed work from Week Ahead' (#1236) from timmy/1235-retire-completed-week-work into main
CI / browser-journey (push) Successful in 5m17s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m26s
2026-08-21 20:30:03 +00:00
timmy d47205a561 test: verify mobile Week Ahead retirement
CI / browser-journey (pull_request) Successful in 5m4s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 20:19:20 +00:00
timmy 1be9da3685 feat: retire completed Week Ahead work (Closes #1235) 2026-08-21 20:12:48 +00:00
timmy a8c1329676 Merge pull request 'Queue multiple offline Today-to-Week moves' (#1234) from timmy/1233-queue-offline-reschedules into main
CI / browser-journey (push) Successful in 5m4s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m8s
2026-08-21 19:12:01 +00:00
timmy d63765a9b1 feat: queue offline Today-to-Week moves (Closes #1233)
CI / browser-journey (pull_request) Successful in 5m10s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 19:02:30 +00:00
rockachopa c87d56c939 Merge pull request 'Keep Today-to-Week rescheduling through offline reloads' (#1232)
CI / browser-journey (push) Successful in 5m20s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m10s
2026-08-21 18:27:28 +00:00
timmy e234d6b6c3 feat: keep Today-to-Week moves through offline reloads (Closes #1231)
CI / browser-journey (pull_request) Successful in 4m57s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m21s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 18:18:14 +00:00
rockachopa edaeba4a4e Merge pull request 'Reschedule an active Today item into Week Ahead' (#1229) from timmy/1228-reschedule-today-to-week into main
CI / browser-journey (push) Successful in 4m47s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m16s
2026-08-21 17:13:00 +00:00
timmy 9d8a362583 feat: reschedule active Today work into Week Ahead (Closes #1228)
CI / browser-journey (pull_request) Successful in 4m57s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 16:59:40 +00:00
rockachopa c63df3c46d Merge pull request 'Reflow overloaded Week Ahead plans' (#1227) from timmy/1226-week-ahead-reflow into main
CI / browser-journey (push) Successful in 5m38s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m31s
2026-08-21 15:49:07 +00:00
timmy ffba6f1212 feat: reflow overloaded Week Ahead plans (Closes #1226)
CI / browser-journey (pull_request) Successful in 5m47s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 15:34:31 +00:00
timmy b7dd9757aa Merge pull request 'Start the next Week Ahead day early from mobile' (#1225) from timmy/1224-start-week-day-early into main
CI / browser-journey (push) Successful in 5m26s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 10s
CI / lint (push) Successful in 3m49s
Start the next Week Ahead day early from mobile (#1225)
2026-08-21 14:48:16 +00:00
timmy f64ce3acc4 feat: start Week Ahead days early (Closes #1224)
CI / browser-journey (pull_request) Successful in 5m38s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 14:37:50 +00:00
timmy 25597d1f05 Merge pull request 'Open Week Ahead planned work and return to the overview' (#1223) from timmy/1222-week-ahead-item-detail into main
CI / browser-journey (push) Successful in 5m38s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 10s
CI / lint (push) Successful in 3m50s
2026-08-21 13:50:37 +00:00
timmy 9f5baaef6e feat: open Week Ahead planned work (Closes #1222)
CI / browser-journey (pull_request) Successful in 5m53s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 13:38:48 +00:00
rockachopa a3861da125 Preserve Search-planned work details in offline Week Ahead (#1221)
CI / browser-journey (push) Successful in 5m31s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m52s
Closes #1220
2026-08-21 12:27:32 +00:00
timmy 2b874d8427 feat: preserve Search work details offline (Closes #1220)
CI / browser-journey (pull_request) Successful in 5m16s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 12:03:25 +00:00
rockachopa ba50784022 Merge pull request 'Keep the confirmed Week Ahead overview available offline' (#1219) from timmy/1218-offline-week-overview into main
CI / browser-journey (push) Successful in 5m6s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m39s
2026-08-21 11:08:47 +00:00
timmy 37950bd0ed feat: keep Week Ahead available offline (Closes #1218)
CI / browser-journey (pull_request) Successful in 5m26s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 10:43:59 +00:00
rockachopa bb6a4c4305 Merge pull request 'Make Week Ahead a read-first mobile overview' (#1217) from timmy/1216-week-ahead-overview into main
CI / browser-journey (push) Successful in 4m57s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m44s
2026-08-21 09:31:56 +00:00
timmy 42e97b4a79 feat: add read-first Week Ahead overview (Closes #1216)
CI / browser-journey (pull_request) Successful in 5m19s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 09:07:27 +00:00
timmy 45e2798d37 Merge pull request 'Update repeated Week Ahead calendar exports without duplicate blocks' (#1215)
CI / browser-journey (push) Successful in 4m59s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m37s
Closes #1214
2026-08-21 07:39:04 +00:00
timmy 218dd43764 feat: update repeated Week Ahead calendar exports (Closes #1214)
CI / browser-journey (pull_request) Successful in 5m2s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 07:29:23 +00:00
timmy 4d9c936b86 Merge pull request 'Make Week Ahead calendar import timezone-correct and range-bounded' (#1213) from timmy/1212-week-calendar-timezone into main
CI / browser-journey (push) Successful in 5m2s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m30s
2026-08-21 06:57:16 +00:00
timmy 69a3a2c568 feat: make calendar import timezone-correct and bounded (Closes #1212)
CI / browser-journey (pull_request) Successful in 5m8s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 06:43:44 +00:00
rockachopa 7a9347bd2a Merge pull request 'Avoid imported busy time in Week Ahead calendar export' (#1211) from timmy/1210-week-calendar-availability into main
CI / browser-journey (push) Successful in 5m35s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Successful in 12s
CI / lint (push) Successful in 3m54s
2026-08-21 06:00:10 +00:00
timmy 4e275b1518 feat: avoid imported busy time in Week Ahead export (Closes #1210)
CI / browser-journey (pull_request) Successful in 4m38s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 05:50:23 +00:00
rockachopa 865083a835 Merge pull request 'Make Week Ahead calendar import recurrence-aware and truthful' (#1209) from timmy/1208-week-calendar-recurrence into main
CI / browser-journey (push) Successful in 5m3s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m42s
2026-08-21 05:03:41 +00:00
timmy 15be0ec026 test: cover private recurrence additions on mobile
CI / browser-journey (pull_request) Successful in 4m55s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 04:53:57 +00:00
timmy ab74426cbc test: validate truthful mobile calendar import gating
CI / browser-journey (pull_request) Failing after 5m18s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 04:42:06 +00:00
timmy f1adffef6b feat: make calendar capacity recurrence-aware (Closes #1208)
CI / browser-journey (pull_request) Failing after 5m15s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 4m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 04:27:11 +00:00
rockachopa 359d4337eb Merge pull request 'Set Week Ahead capacity from a private calendar import' (#1207) from timmy/1206-week-calendar-capacity into main
CI / browser-journey (push) Successful in 5m37s
CI / build-release (push) Successful in 10s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 4m10s
2026-08-21 03:28:00 +00:00
timmy e77ad7c809 feat: import private calendar capacity for Week Ahead (Closes #1206)
CI / browser-journey (pull_request) Successful in 5m19s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 4m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 03:16:40 +00:00
rockachopa aa46196d1e Merge pull request 'fix: restore Today break after sync recovery' (#1205) from timmy/1204-today-break-recovery into main
CI / browser-journey (push) Successful in 4m48s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m37s
2026-08-21 02:17:28 +00:00
timmy e5ccfb0c5e fix: restore Today break after sync recovery (Closes #1204)
CI / browser-journey (pull_request) Successful in 5m17s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 01:56:04 +00:00
rockachopa e3c7df25c8 Merge pull request 'feat: CI regression: mobile Today handoff content is obscured on 568px viewport' (#1203) from timmy/1202-ci-regression-mobile-today-handoff-content-is-ob into main
CI / browser-journey (push) Failing after 4m57s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m30s
2026-08-21 01:03:10 +00:00
timmy f11fc17a2c Fix mobile Today handoff clearance (#1202)
CI / browser-journey (pull_request) Successful in 4m37s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 00:42:07 +00:00
rockachopa ecc79ce50f Merge pull request 'Reconcile unfinished Today with due Week Ahead work' (#1201) from timmy/1200-reconcile-week-today into main
CI / browser-journey (push) Failing after 4m23s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m17s
2026-08-21 00:18:01 +00:00
timmy a10592c349 feat: reconcile Today with due Week Ahead work (Closes #1200)
CI / browser-journey (pull_request) Successful in 4m51s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m32s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 00:08:43 +00:00
rockachopa 5ac90d01e6 Merge pull request 'Export Week Ahead as timed calendar blocks' (#1199) from timmy/1198-week-calendar-blocks into main
CI / browser-journey (push) Successful in 4m41s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m18s
2026-08-20 23:24:36 +00:00
timmy dd3016b96b feat: export Week Ahead as calendar blocks (Closes #1198)
CI / browser-journey (pull_request) Successful in 4m38s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 23:12:28 +00:00
rockachopa 875f4eea29 Merge pull request 'Batch-plan Search results into Week Ahead' (#1197)
CI / browser-journey (push) Successful in 4m36s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m24s
2026-08-20 22:11:15 +00:00
timmy 78839a50fe feat: batch-plan Search results into Week Ahead (Closes #1196)
CI / browser-journey (pull_request) Successful in 4m51s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 22:01:58 +00:00
rockachopa f557f06bc5 Merge pull request 'Plan Search results directly into Week Ahead' (#1195) from timmy/1194-search-week-ahead into main
CI / browser-journey (push) Successful in 4m47s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m19s
2026-08-20 21:07:52 +00:00
timmy c8998eb1ba test: require Search Week Ahead browser coverage
CI / browser-journey (pull_request) Successful in 4m55s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 20:58:36 +00:00
timmy b5b1a3b0d7 ci: exercise Search Week Ahead mobile sheet
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 3m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 20:51:34 +00:00
timmy ea1b5a77f5 feat: plan Search results into Week Ahead (Closes #1194)
CI / browser-journey (pull_request) Failing after 4m48s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 20:39:53 +00:00
rockachopa 5c74051aab Merge pull request 'Remind from due Week Ahead plans' (#1193) from timmy/1192-week-ahead-start-day-reminder into main
CI / browser-journey (push) Successful in 4m22s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m19s
2026-08-20 19:38:32 +00:00
timmy daffa894d8 feat: remind from due Week Ahead plans (Closes #1192)
CI / browser-journey (pull_request) Successful in 4m28s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 19:28:32 +00:00
rockachopa 67a3285412 Merge pull request 'Make Week Ahead review readable and editable' (#1191) from timmy/1190-readable-editable-week-review into main
CI / browser-journey (push) Successful in 4m48s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m37s
2026-08-20 18:42:52 +00:00
timmy 9f40a4c5ac feat: make Week Ahead review readable and editable (Closes #1190)
CI / browser-journey (pull_request) Successful in 4m38s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m21s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 18:33:37 +00:00
rockachopa 356f11b9c1 Merge pull request 'Review and rebalance Week Ahead before confirmation' (#1189) from timmy/1188-review-rebalance-week-ahead into main
CI / browser-journey (push) Successful in 5m27s
CI / build-release (push) Successful in 10s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m22s
Merge pull request 'Review and rebalance Week Ahead before confirmation' (#1189)
2026-08-20 17:46:31 +00:00
timmy d5f13c4afb feat: review and rebalance Week Ahead (Closes #1188)
CI / browser-journey (pull_request) Successful in 4m48s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 17:36:02 +00:00
timmy 69532bdc1f Merge pull request 'Plan Week Ahead in one continuous mobile pass' (#1187) from timmy/1186-continuous-week-ahead into main
CI / browser-journey (push) Successful in 4m36s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m31s
2026-08-20 16:45:31 +00:00
timmy 55e1c3d6dd feat: plan Week Ahead in one mobile pass (Closes #1186)
CI / browser-journey (pull_request) Successful in 4m39s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 4m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 16:30:45 +00:00
timmy 6718160381 Merge pull request 'Reconcile concurrent Week Ahead edits per day' (#1185) from timmy/1184-reconcile-week-ahead-per-day into main
CI / browser-journey (push) Successful in 4m35s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m17s
2026-08-20 15:54:53 +00:00
timmy e0f7c711ed feat: reconcile Week Ahead edits per day (Closes #1184)
CI / browser-journey (pull_request) Successful in 5m36s
CI / build-release (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 3m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 15:43:35 +00:00
timmy 43066a0822 Merge pull request 'Preserve Today before Week Ahead rollover' (#1183) from timmy/1182-preserve-today-before-week-rollover into main
CI / browser-journey (push) Successful in 4m31s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m29s
2026-08-20 14:44:23 +00:00
timmy f0706e2e6f feat: preserve Today before Week Ahead rollover (Closes #1182)
CI / browser-journey (pull_request) Successful in 5m37s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 14:33:15 +00:00
timmy 3edc4e36b3 Merge pull request 'Keep Week Ahead plans through offline saves' (#1181) from timmy/1180-offline-week-ahead-sync into main
CI / browser-journey (push) Successful in 4m44s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m35s
2026-08-20 13:57:04 +00:00
timmy 47cad77312 feat: keep Week Ahead plans through offline saves (Closes #1180)
CI / browser-journey (pull_request) Successful in 4m19s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 13:40:33 +00:00
rockachopa 99a2f28440 Merge pull request 'feat: Plan the next seven days from mobile' (#1179) from timmy/1177-plan-the-next-seven-days-from-mobile into main
CI / browser-journey (push) Successful in 4m24s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m29s
2026-08-20 13:01:21 +00:00
timmy 53c4340d76 fix: tolerate invalid device timezones in Week Ahead (Closes #1177)
CI / browser-journey (pull_request) Successful in 4m30s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m19s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 12:26:35 +00:00
timmy 5a9cce4a08 test: surface Week Ahead browser launch errors (Refs #1177)
CI / browser-journey (pull_request) Failing after 4m33s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 12:07:09 +00:00
timmy 4fbc9c62d9 fix: allow Week Ahead while identity finishes restoring (Refs #1177)
CI / browser-journey (pull_request) Failing after 4m52s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 11:53:45 +00:00
timmy a1b4698988 fix: open Week Ahead before account refresh (Refs #1177)
CI / browser-journey (pull_request) Failing after 4m41s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 11:41:04 +00:00
timmy e01681af09 feat: plan the next seven days on mobile (Closes #1177)
CI / browser-journey (pull_request) Failing after 4m21s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 11:28:58 +00:00
rockachopa 880d1c4a1f Merge pull request 'Stabilize the release gate for the Tomorrow wrap-up handoff' (#1176) from timmy/1174-today-wrap-up-tomorrow-handoff-rc into main
CI / browser-journey (push) Failing after 4m10s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m28s
2026-08-20 10:24:38 +00:00
timmy 7c1292faa3 test: isolate first-task release probe (Refs #1174)
CI / browser-journey (pull_request) Successful in 4m4s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 10:12:57 +00:00
rockachopa 286e691fd2 Merge pull request 'Carry unfinished Today work into the Tomorrow plan' (#1175) from timmy/1174-today-wrap-up-tomorrow-handoff into main
CI / browser-journey (push) Failing after 4m5s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m27s
2026-08-20 09:58:30 +00:00
timmy c99d03b9bc feat: carry wrap-up work into Tomorrow (Closes #1174)
CI / browser-journey (pull_request) Successful in 3m56s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 09:49:08 +00:00
timmy 58c2d3f5d8 Merge pull request 'Hand off Today lock-screen actions without URL capabilities' (#1173) from timmy/1172-private-today-action-handoff into main
CI / browser-journey (push) Successful in 3m55s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m18s
2026-08-20 08:16:45 +00:00
timmy 988f6646c2 feat: hand off Today lock-screen actions privately (Closes #1172)
CI / browser-journey (pull_request) Successful in 4m2s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 08:02:45 +00:00
rockachopa f56ca6373c Merge pull request 'Gate packaged mobile Tomorrow conflict recovery' (#1171) from timmy/1170-tomorrow-release-gate into main
CI / browser-journey (push) Successful in 3m41s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m56s
2026-08-20 07:36:50 +00:00
timmy 3cfe6b9f75 ci: gate packaged Tomorrow conflict recovery (Closes #1170)
CI / browser-journey (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 07:28:40 +00:00
rockachopa 65180cbb94 Merge pull request 'Make start-day reminders lease-safe under fan-out' (#1169) from timmy/1168-start-day-lease-safe-fanout into main
CI / browser-journey (push) Successful in 3m44s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m12s
Bound start-day reminder fan-out with configured concurrency and renew lease ownership before each admitted send.

Closes #1168
2026-08-20 06:46:16 +00:00
timmy 928184c581 feat: make start-day reminders lease-safe (Closes #1168)
CI / browser-journey (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 06:38:13 +00:00
rockachopa e8b78de1a5 Merge pull request 'Launch planned days with a private morning reminder' (#1167) from timmy/1166-start-day-reminder into main
CI / browser-journey (push) Successful in 3m52s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m30s
2026-08-20 06:01:50 +00:00
timmy 6fb547e8ce fix: preserve synchronous Today reconciliation
CI / browser-journey (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 05:48:08 +00:00
timmy d86e84253a feat: launch planned days with morning reminders (Closes #1166)
CI / browser-journey (pull_request) Failing after 3m39s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 05:32:50 +00:00
rockachopa 99b60e5f5f Merge pull request 'Resolve offline Tomorrow plan conflicts on mobile' (#1165) from timmy/1164-tomorrow-conflict-recovery into main
CI / browser-journey (push) Successful in 3m58s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m28s
2026-08-20 04:12:33 +00:00
timmy 059722de6c fix: bind Tomorrow conflict recovery to operator account
CI / browser-journey (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 04:01:25 +00:00
timmy 16a6ee1136 feat: resolve mobile Tomorrow plan conflicts (Closes #1164)
CI / browser-journey (pull_request) Failing after 3m49s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 03:46:22 +00:00
rockachopa f9c4938401 Merge pull request 'feat: CI regression: mobile handoff break status assertion fails on main' (#1163) from timmy/1162-ci-regression-mobile-handoff-break-status-assert into main
CI / browser-journey (push) Successful in 3m54s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m14s
2026-08-20 03:09:59 +00:00
timmy bdf1ddaada fix: restore break status after account confirmation
CI / browser-journey (pull_request) Successful in 4m9s
CI / build-release (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 3m32s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 02:48:56 +00:00
rockachopa cf94624aa9 Merge pull request 'Keep Tomorrow plans through offline saves' (#1161) from timmy/1160-offline-tomorrow-sync into main
CI / browser-journey (push) Failing after 4m29s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 4m11s
2026-08-20 02:19:59 +00:00
timmy f42f89ca21 feat: keep Tomorrow plans through offline saves (Closes #1160)
CI / browser-journey (pull_request) Successful in 4m5s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 02:10:37 +00:00
rockachopa ae632e3562 Merge pull request 'Make Tomorrow planning a first-class mobile queue' (#1159) from timmy/1158-mobile-tomorrow-queue into main
CI / browser-journey (push) Successful in 4m0s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m26s
2026-08-20 01:29:18 +00:00
timmy 0fd6314391 feat: make Tomorrow a first-class mobile queue (Closes #1158)
CI / browser-journey (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 01:20:27 +00:00
timmy 1ae49df1b0 Merge pull request 'feat: CI: browser journey leaves first-task completion sync pending on main' (#1157) from timmy/1156-ci-browser-journey-leaves-first-task-completion- into main
CI / browser-journey (push) Successful in 4m1s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m22s
2026-08-20 00:46:35 +00:00
timmy 16841f18e1 fix: drain first-task completion during Today refresh (Closes #1156)
CI / browser-journey (pull_request) Successful in 3m49s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 00:19:50 +00:00
rockachopa 80863182d4 Merge pull request 'Recover Tomorrow plans after missed midnight and interrupted promotion' (#1155) from timmy/1154-tomorrow-rollover-recovery into main
CI / browser-journey (push) Failing after 4m30s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m14s
2026-08-19 23:56:19 +00:00
timmy 7c2e9861d7 fix: tolerate unavailable browser timezone data
CI / browser-journey (pull_request) Successful in 4m2s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 23:39:34 +00:00
timmy 7deb1c1de9 fix: recover Tomorrow promotion after missed rollover (Closes #1154)
CI / browser-journey (pull_request) Failing after 3m50s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 23:27:07 +00:00
rockachopa 559fd2dc89 Merge pull request 'Plan tomorrow without disrupting active Today work' (#1153) from timmy/1152-tomorrow-plan into main
CI / browser-journey (push) Successful in 4m6s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m14s
2026-08-19 22:56:33 +00:00
timmy 0cb7328aa1 feat: plan tomorrow independently (Closes #1152)
CI / browser-journey (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m21s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 22:46:56 +00:00
rockachopa 664e5e999f Merge pull request 'Make Done for Today completion durably atomic' (#1151) from timmy/1150-atomic-today-completion into main
CI / browser-journey (push) Successful in 3m29s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m47s
2026-08-19 22:00:26 +00:00
timmy 37e919f43f fix: preserve first-task completion delivery
CI / browser-journey (pull_request) Successful in 3m36s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 21:52:50 +00:00
timmy d0780a72a7 feat: make Today completion durably atomic (Closes #1150)
CI / browser-journey (pull_request) Failing after 3m30s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 21:39:03 +00:00
rockachopa f785134dfe Merge pull request 'Keep first-task completion across devices' (#1149) from timmy/1148-cross-device-first-task into main
CI / browser-journey (push) Successful in 3m48s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m17s
2026-08-19 20:51:35 +00:00
timmy de405efdbc test: verify first-task completion on a replacement phone
CI / browser-journey (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 20:44:03 +00:00
timmy fa47ded3ab feat: sync first-task completion across devices (Closes #1148)
CI / browser-journey (pull_request) Failing after 3m27s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m6s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 20:34:10 +00:00
rockachopa e2be14d7c7 Merge pull request 'Guide first mobile task through a completed Today outcome' (#1147) from timmy/1146-first-task-completed-outcome into main
CI / browser-journey (push) Successful in 3m55s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m9s
Merge pull request 'Guide first mobile task through a completed Today outcome' (#1147)
2026-08-19 19:44:43 +00:00
timmy e2b5f6042c feat: coach first mobile task to completion (Closes #1146)
CI / browser-journey (pull_request) Successful in 3m34s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 19:34:31 +00:00
timmy 941a508d4b Finish first-task onboarding with an active Today session
CI / browser-journey (push) Successful in 3m31s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m6s
Closes #1144
2026-08-19 18:59:13 +00:00
timmy ebb7a78cab feat: finish onboarding with active Today (Closes #1144)
CI / browser-journey (pull_request) Successful in 4m3s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 18:51:15 +00:00
rockachopa b4a5401b22 Merge pull request 'Preserve timed break across reload' (#1143)
CI / browser-journey (push) Successful in 4m2s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m3s
Closes #1142
2026-08-19 18:32:30 +00:00
timmy 870edda123 Preserve timed break when active work reopens
CI / browser-journey (pull_request) Successful in 3m34s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 18:08:35 +00:00
rockachopa 156d4b0ffc Merge pull request 'Guide new mobile operators to their first task' (#1141) from timmy/1140-first-task-activation into main
CI / browser-journey (push) Failing after 3m36s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 2m54s
2026-08-19 17:41:31 +00:00
timmy 304e4a8a24 feat: guide first mobile task activation (Closes #1140)
CI / browser-journey (pull_request) Successful in 3m29s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 17:33:26 +00:00
rockachopa 8731682090 Merge pull request 'Pause Today while checking Live Data Status' (#1139) from timmy/1138-live-data-status-detour into main
CI / browser-journey (push) Successful in 3m29s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m13s
2026-08-19 16:44:43 +00:00
timmy 348f43f1bc feat: pause Today for live data status (Closes #1138)
CI / browser-journey (pull_request) Successful in 3m23s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 16:35:09 +00:00
rockachopa 161563d6f5 Merge pull request 'Contain focus inside mobile Security Center' (#1137)
CI / browser-journey (push) Successful in 3m25s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m10s
Closes #1136
2026-08-19 15:45:00 +00:00
timmy 931c82361b feat: contain Security Center focus (Closes #1136)
CI / browser-journey (pull_request) Successful in 3m22s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 15:34:47 +00:00
rockachopa 61b332e814 Merge pull request 'Contain focus inside mobile Device Setup' (#1135) from timmy/1134-device-setup-focus-containment into main
CI / browser-journey (push) Successful in 3m21s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m59s
2026-08-19 14:44:26 +00:00
timmy 4b3e53f4d0 feat: contain mobile Device Setup focus (Closes #1134)
CI / browser-journey (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m54s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 14:31:38 +00:00
rockachopa f3c9e89920 Merge pull request 'Recover mobile notification setup after permission is blocked' (#1133) from timmy/1132-notification-permission-recovery into main
CI / browser-journey (push) Failing after 3m23s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 2m58s
2026-08-19 13:43:48 +00:00
timmy 1248647aa4 feat: recover blocked notification setup (Closes #1132)
CI / browser-journey (pull_request) Successful in 3m22s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 13:36:20 +00:00
rockachopa b3832d107f Merge pull request 'Post reviewed Today summaries to a Gitea status thread' (#1131) from timmy/1130-post-today-summary into main
CI / browser-journey (push) Successful in 3m23s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m56s
2026-08-19 12:45:00 +00:00
timmy a0da8e9fb2 feat: post reviewed Today summaries to Gitea (Closes #1130)
CI / browser-journey (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 12:36:46 +00:00
timmy 8794200c2a Merge pull request 'Protect private work before mobile sign-out' (#1129) from timmy/1128-safe-sign-out-review into main
CI / browser-journey (push) Successful in 3m20s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m55s
2026-08-19 11:45:23 +00:00
timmy c2526969b1 feat: protect private work before sign out (Closes #1128)
CI / browser-journey (pull_request) Successful in 3m16s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 11:37:27 +00:00
rockachopa 3b4f6b39a5 Merge pull request 'Encrypt synchronized Saved Searches at rest' (#1127) from timmy/1126-encrypt-saved-searches into main
CI / browser-journey (push) Successful in 3m2s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m43s
2026-08-19 11:03:52 +00:00
timmy a9cf88b3ff security: encrypt synchronized Saved Searches (Closes #1126)
CI / browser-journey (pull_request) Successful in 3m12s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 2m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 10:57:01 +00:00
rockachopa 3d4be0a913 Merge pull request 'Recover mobile workspace loading in place' (#1125) from timmy/1124-workspace-load-recovery into main
CI / browser-journey (push) Successful in 2m58s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m36s
2026-08-19 10:21:01 +00:00
timmy 9252807364 fix: recover mobile workspace loading in place (Closes #1124)
CI / browser-journey (pull_request) Successful in 3m1s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 10:10:52 +00:00
rockachopa db85bfa5f9 Merge pull request 'Encrypt authored-action idempotency ledger at rest' (#1123)
CI / browser-journey (push) Successful in 2m55s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m44s
Closes #1122
2026-08-19 09:35:55 +00:00
timmy 3822ec4536 security: encrypt idempotency ledger at rest (Closes #1122)
CI / browser-journey (pull_request) Successful in 2m57s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 09:29:16 +00:00
rockachopa b278367e3b Merge pull request 'Verify Web Push delivery health per device' (#1121) from timmy/1120-push-delivery-health into main
CI / browser-journey (push) Successful in 3m10s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m47s
2026-08-19 09:06:55 +00:00
timmy 13e73bd342 feat: verify per-device Push delivery health (Closes #1120)
CI / browser-journey (pull_request) Successful in 3m1s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 09:00:06 +00:00
rockachopa 1047f3ecdc Merge pull request 'Encrypt Security activity journal at rest' (#1119) from timmy/1118-encrypt-security-activity into main
CI / browser-journey (push) Successful in 2m53s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m39s
2026-08-19 07:49:14 +00:00
timmy f09c922c46 test: model admitted blocker receipt deterministically
CI / browser-journey (pull_request) Successful in 2m56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 07:42:46 +00:00
timmy be9ce8ede6 security: remove plaintext journal metadata columns 2026-08-19 07:37:35 +00:00
timmy 7f2681c1c6 security: encrypt Security activity journal (Closes #1118)
CI / browser-journey (pull_request) Failing after 3m7s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 07:25:49 +00:00
rockachopa 91fdd958c2 Merge pull request 'Fetch the mobile workspace concurrently with the core runtime' (#1117) from timmy/1116-parallel-workspace-fetch into main
CI / browser-journey (push) Successful in 2m55s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m51s
2026-08-19 06:52:25 +00:00
timmy b70a2fb607 test: accept queued or delivered blocker receipt
CI / browser-journey (pull_request) Successful in 3m5s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 06:46:05 +00:00
timmy 950d52d147 perf: parallelize mobile workspace fetch (Closes #1116)
CI / browser-journey (pull_request) Failing after 2m56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 06:25:26 +00:00
timmy d054cd23ed Merge pull request 'Encrypt synchronized Later plans at rest' (#1115) from timmy/1114-encrypt-later-state into main
CI / browser-journey (push) Successful in 2m39s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m45s
2026-08-19 05:38:47 +00:00
timmy f4d8d64b9d security: encrypt synchronized Later state (Closes #1114)
CI / browser-journey (pull_request) Successful in 2m51s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 05:32:20 +00:00
timmy 9763bb410b Merge pull request 'Encrypt synchronized Today journeys at rest' (#1113) from timmy/1112-encrypt-today-state into main
CI / browser-journey (push) Successful in 2m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m33s
2026-08-19 04:41:16 +00:00
timmy db3ca83f29 security: encrypt synchronized Today state (Closes #1112)
CI / browser-journey (pull_request) Successful in 2m43s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 04:34:55 +00:00
rockachopa c219f130c8 Merge pull request 'Protect offline work from browser storage eviction' (#1111) from timmy/1110-protect-offline-storage into main
CI / browser-journey (push) Successful in 2m54s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m52s
2026-08-19 03:43:54 +00:00
timmy dc4f457b21 feat: protect offline work from storage eviction (Closes #1110)
CI / browser-journey (pull_request) Successful in 2m52s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 03:36:48 +00:00
rockachopa 9a471b7903 Merge pull request 'Encrypt Web Push subscriptions at rest' (#1109) from timmy/1108-encrypt-push-subscriptions into main
CI / browser-journey (push) Successful in 2m46s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m38s
2026-08-19 02:41:03 +00:00
timmy 67d3242f37 security: encrypt Web Push subscriptions at rest (Closes #1108)
CI / browser-journey (pull_request) Successful in 2m47s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 2m48s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m52s
2026-08-19 02:04:52 +00:00
timmy 9d9b28af8e security: encrypt worker-shared snapshots (Closes #1106)
CI / browser-journey (pull_request) Successful in 2m55s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m50s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m49s
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 / browser-journey (pull_request) Successful in 2m47s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m45s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m39s
2026-08-19 00:08:03 +00:00
timmy 2bd20130b6 feat: snooze mobile deadline reminders (Closes #1102)
CI / browser-journey (pull_request) Successful in 2m58s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m42s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m31s
2026-08-18 23:11:07 +00:00
timmy fc9ef1f8ae feat: rotate synchronized Draft encryption keys (Closes #1100)
CI / browser-journey (pull_request) Successful in 2m35s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 3m2s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m46s
2026-08-18 22:27:53 +00:00
timmy e79c457774 test: inject draft encryption key into release journey
CI / browser-journey (pull_request) Successful in 2m56s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (pull_request) Failing after 2m53s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 2m54s
CI / build-release (push) Successful in 7s
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 / browser-journey (pull_request) Successful in 2m57s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 3m17s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m8s
2026-08-18 20:49:38 +00:00
timmy 3cf33869d6 feat: track multiple mobile releases (Closes #1094)
CI / browser-journey (pull_request) Successful in 3m14s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 3m2s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m5s
2026-08-18 19:49:08 +00:00
timmy 6db0802461 feat: track exact post-merge release progress (Closes #1092)
CI / browser-journey (pull_request) Successful in 3m28s
CI / build-release (pull_request) Successful in 9s
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 / browser-journey (push) Successful in 3m17s
CI / build-release (push) Successful in 7s
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 / browser-journey (pull_request) Successful in 3m7s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (pull_request) Failing after 3m9s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m55s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m2s
2026-08-18 17:48:38 +00:00
timmy 1c17fcf337 feat: export Agenda calendar snapshots (Closes #1088)
CI / browser-journey (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 2m42s
CI / build-release (push) Successful in 7s
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 / browser-journey (pull_request) Successful in 2m59s
CI / build-release (pull_request) Successful in 8s
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 / browser-journey (push) Successful in 2m41s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 10s
CI / lint (push) Successful in 3m13s
2026-08-18 15:39:39 +00:00
timmy 4a53a95b2d feat: pause Today during mobile device setup (Closes #1084)
CI / browser-journey (pull_request) Successful in 3m0s
CI / build-release (pull_request) Successful in 9s
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 / browser-journey (push) Successful in 3m0s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 10s
CI / lint (push) Successful in 3m15s
2026-08-18 14:54:49 +00:00
timmy 472371d5a8 feat: pause Today during mobile Insights (Closes #1082)
CI / browser-journey (pull_request) Successful in 2m45s
CI / build-release (pull_request) Successful in 8s
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 / browser-journey (push) Successful in 2m47s
CI / build-release (push) Successful in 10s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m8s
Closes #1080
2026-08-18 14:02:05 +00:00
timmy 58cee5ea16 feat: pause Today during mobile work detours (Closes #1080)
CI / browser-journey (pull_request) Successful in 2m45s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 2m48s
CI / build-release (push) Successful in 8s
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 / browser-journey (pull_request) Successful in 2m21s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 2m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m13s
2026-08-18 11:15:52 +00:00
timmy f8c2541d30 feat: pause Today during quick capture (Closes #1076)
CI / browser-journey (pull_request) Successful in 2m35s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 2m14s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m56s
Closes #1074
2026-08-18 09:43:07 +00:00
timmy b77be4f5b0 feat: compact active Today controls on mobile (Closes #1074)
CI / browser-journey (pull_request) Successful in 2m17s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m9s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m41s
2026-08-18 09:04:35 +00:00
timmy 79f6cecab4 fix: keep Gitea links on configured forge (Closes #1072)
CI / browser-journey (pull_request) Successful in 2m8s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m12s
CI / build-release (push) Successful in 5s
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 / browser-journey (pull_request) Successful in 2m14s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m7s
CI / build-release (push) Successful in 6s
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 / browser-journey (pull_request) Successful in 2m6s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m7s
CI / build-release (push) Successful in 7s
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 / browser-journey (pull_request) Successful in 2m7s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (push) Successful in 2m2s
CI / build-release (push) Successful in 7s
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 / browser-journey (pull_request) Successful in 2m0s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m35s
2026-08-18 04:55:04 +00:00
timmy 8b4bd480cc feat: correct recent Today updates in place (Closes #1062)
CI / browser-journey (pull_request) Successful in 1m56s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m32s
2026-08-18 04:06:40 +00:00
timmy 47626338ba feat: review activity before Today updates (Closes #1060)
CI / browser-journey (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m4s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m44s
2026-08-18 03:11:36 +00:00
timmy 688eadf17a feat: dictate active Today progress updates (Closes #1058)
CI / browser-journey (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m3s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 2m39s
2026-08-18 02:35:23 +00:00
timmy 1410a66a36 fix: bind Today progress retries to evidence (Closes #1056)
CI / browser-journey (pull_request) Successful in 2m2s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m1s
CI / build-release (push) Successful in 6s
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 / browser-journey (pull_request) Successful in 2m5s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m58s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m40s
2026-08-18 00:52:44 +00:00
timmy bed9bb4270 fix: keep Today completion undo above progress HUD
CI / browser-journey (pull_request) Successful in 2m2s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (pull_request) Failing after 2m5s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 2m7s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 2m41s
2026-08-18 00:05:53 +00:00
timmy ed15392097 feat: show timed Today breaks on lock screen (Closes #1050)
CI / browser-journey (pull_request) Successful in 2m15s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m37s
2026-08-17 23:38:24 +00:00
timmy e8817056f7 feat: verify authenticated deployment readiness (Closes #1048)
CI / browser-journey (pull_request) Successful in 2m2s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m51s
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 / browser-journey (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 1m47s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m29s
2026-08-17 22:33:47 +00:00
timmy 2082fbd5eb ci: build releases from declared Git commits (Closes #1044)
CI / browser-journey (pull_request) Successful in 2m24s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m20s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m47s
2026-08-17 22:00:18 +00:00
timmy 5bf0f97df8 fix: preserve truthful queue recovery status
CI / browser-journey (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (pull_request) Failing after 2m23s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (pull_request) Failing after 2m24s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (pull_request) Failing after 2m21s
CI / build-release (pull_request) Successful in 8s
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 / browser-journey (push) Successful in 2m18s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m55s
2026-08-17 20:58:12 +00:00
timmy 442d0a157d fix: preserve Today sync enqueue order
CI / browser-journey (pull_request) Successful in 2m9s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (push) Failing after 2m10s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 2m44s
2026-08-17 20:23:06 +00:00
timmy dfd33aee1c fix: reject contradictory running break sessions
CI / browser-journey (pull_request) Successful in 2m5s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (pull_request) Failing after 2m8s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 2m1s
CI / build-release (push) Successful in 7s
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 / browser-journey (pull_request) Successful in 2m4s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (pull_request) Failing after 2m7s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (push) Successful in 2m14s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m52s
2026-08-17 18:12:56 +00:00
timmy 6e677f8857 feat: undo accidental Today completion (Closes #1034)
CI / browser-journey (pull_request) Successful in 2m13s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m13s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 12s
CI / lint (push) Successful in 3m5s
2026-08-17 17:09:29 +00:00
timmy 11395b655a feat: finish Today from the lock screen (Closes #1032)
CI / browser-journey (pull_request) Successful in 2m15s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 2m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 11s
CI / lint (push) Successful in 3m3s
2026-08-17 16:09:21 +00:00
timmy 25dda21811 fix: converge Today session conflicts across devices (Closes #1030)
CI / browser-journey (pull_request) Successful in 2m17s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m9s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m51s
2026-08-17 15:33:56 +00:00
timmy 6729359ba2 fix: make pull merges durably auditable (Closes #1028)
CI / browser-journey (pull_request) Successful in 2m8s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m2s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m46s
2026-08-17 14:53:43 +00:00
timmy a7f4f9714f feat: control Today from mobile lock screen (Closes #1026)
CI / browser-journey (pull_request) Successful in 2m21s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m35s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m4s
2026-08-17 13:41:24 +00:00
timmy b845f8ba95 feat: make Prepare Today the primary mobile entry (Closes #1024)
CI / browser-journey (pull_request) Successful in 2m27s
CI / build-release (pull_request) Successful in 8s
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 / browser-journey (push) Successful in 2m24s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m58s
2026-08-17 12:51:50 +00:00
timmy 8d12a0384b feat: hand off active Today sessions across devices (Closes #1022)
CI / browser-journey (pull_request) Successful in 2m22s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 12:44:01 +00:00
172 changed files with 28029 additions and 1006 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_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_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_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_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
+170 -11
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
@@ -75,6 +75,23 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
Eligible open issue previews also expose **Plan ahead**: choose one of the next seven local dates, review
that day's current load and capacity, and enter a required estimate without leaving Search. Existing Week
Ahead placement is moved only after an explicit day change, never duplicated; full days are blocked and
over-capacity admission requires a second confirmation. The action claims unassigned work only after these
checks, stages the canonical issue through the account-bound Week Ahead outbox, and preserves the Search
preview through cancel or browser Back. Offline saves report **sync pending**; if assignment succeeds but
local planning fails, the dashboard reports **Assigned, not planned** and leaves the issue recoverable in My Work.
The live mobile Week Ahead overview also offers **Remove from week** for active planned work. It removes only the
private planning placement—not assignment, issue state, or Gitea content—and immediately recalculates the day's
load. A 10-second **Undo** receipt restores the exact day, list position, and estimate. Both transitions use the
account-bound Week Ahead outbox, remain **sync pending** after delivery failure, and stay unavailable in a read-only
offline snapshot.
Search selection mode extends that flow across several open issues with **Plan Week Ahead**. One phone-safe
review assigns each issue a future day and estimate, validates five-item limits and daily capacity before any
claim, and requires a second confirmation for overload. Confirmed rows are staged into one canonical Week
Ahead update and flushed together; partial assignment or offline sync is reported without discarding the
selection, so claimed work remains recoverable from My Work.
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
can queue photo-only, text-only, or mixed replies without leaving their Search pass, including while offline.
@@ -117,8 +134,17 @@ 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. Rescheduling Today work into
Week Ahead uses a bounded account-scoped FIFO on the device: multiple offline moves survive reload,
deliver oldest-first with stable operation IDs, and rebase each later move on the confirmed plan revisions
without concurrent requests. 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. Cross-device **Continue here** handoff transfers the bounded per-item timing ledger—not only the active item—so the receiving phone keeps the complete recap and optional time-log durations; legacy single-item sessions migrate automatically. **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
@@ -143,7 +169,46 @@ failure preserves both the reply draft and checkpoint. Finishing or choosing **E
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
offline operations. Planning edits can remain offline for up to 30 days. After that, the
offline operations. **Plan Tomorrow** remains independent from active Today work and durably admits the exact
ordered plan, capacity, estimates, next local date, timezone, and server revision to account-bound browser
storage before closing on a phone. The Queues summary marks it **sync pending** while offline; reconnect,
foreground, and midnight lifecycle checks share one delivery flight. A successful account receipt removes
the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for
review. Unsynced Tomorrow work is never promoted into Today.
**Week Ahead** opens as a read-first seven-day mobile overview, so operators can inspect the next planned day,
work titles and references, load versus capacity, overloads, and pending sync without staging a change. When Today
is empty and no work session is active, **Start this day early** confirms the next planned date, item count, minutes,
and capacity before atomically moving only that day into Today; offline, pending, stale, or non-empty plans remain
unchanged. **Edit day** enters one date and returns to the refreshed overview; **Edit week** starts the continuous
planning pass. Closing an issue from its Week Ahead detail retires that identity and estimate from every future
week day in one durable update while preserving sibling order and daily capacity. The open overview recalculates
immediately; an unavailable save remains visibly **sync pending** and retries through the existing lifecycle.
**Plan Week Ahead** continues through seven local dates and now finishes on a mobile review step instead of
closing after the seventh save. The review shows planned minutes against each day’s capacity, marks overloads,
and flags work assigned to more than one date. Operators can move an item to another date without copying it;
the estimate follows the item and the server rejects duplicate cross-day assignments without advancing the
week revision. When capacity changes create overload, **Reflow remaining week** previews a deterministic,
order-preserving redistribution across the seven visible dates. No day exceeds its capacity or five-item limit;
work that cannot fit is named and remains in My Work, while missing estimates block apply. Cancel performs no
write, and apply stages one durable whole-week transition through the existing conflict-safe sync path.
Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
are not stranded behind an earlier request. From the same review, **Refresh from calendar** reads a local
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and compares every current
capacity with refreshed availability and planned load. If refreshed availability creates an overload, the review
previews the deterministic destination of moved work and reports anything that cannot fit. One explicit
**Apply capacities & reflow** action stages the complete seven-day plan once—there is no transient overloaded
save—and preserves consented free windows alongside the redistributed work. A refresh that already fits keeps
the direct one-write capacity path. IANA `TZID` values from Google and Outlook calendars are converted
to the device's local workday, including daylight-saving transitions. Daily and weekly recurrence (`COUNT`,
`UNTIL`, `INTERVAL`, and weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated with work bounded
to the seven review dates; cancelled and transparent events do not consume capacity. If a time zone is unknown,
or a recurring event that could affect the week uses another frequency or unsupported rule part, the review
reports only an affected-event count and disables apply rather than understating busy time. Export a simpler
seven-day calendar to proceed. Raw calendar data and event
metadata are never persisted, rendered in diagnostics, or sent; only the reviewed capacity-minute totals use
the existing encrypted, account-bound Week Ahead sync.
Planning edits can remain offline for up to 30 days. After that, the
expired edit is discarded visibly and the account plan is kept rather than replaying stale
intent. The server retains no more than 4,096 operation receipts per account and removes
receipts older than the same 30-day window; client base revisions keep a pruned replay from
@@ -185,8 +250,26 @@ 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. The authored-action
idempotency ledger encrypts both request fingerprints and confirmed upstream responses, authenticating
each envelope to its operation key and field purpose so rows and fields cannot be substituted.
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
freshness, revisions, ordering, replay, or conflict semantics. 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. Synchronized Saved Search collections and completed Filed review
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
startup without changing order or acknowledgement state. Missing, wrong, or modified key material
returns no saved-view or receipt content. 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 +335,44 @@ 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/Find Work snapshots, synchronized Today/Later
# planning, Saved Search state, completed Filed review receipts, and the Security activity journal. 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 state fails closed without
# returning content. Legacy Today/Later, Saved Search, and Security activity rows
# migrate atomically on first use without changing logical revisions or journal IDs.
# The single-key setting remains the compatible first deployment and writes v1 envelopes.
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
# After all workers run keyring-capable code, configure at most four named keys and
# select the active write key. Keep the old key present during the rolling deployment.
# Key IDs use 1-32 letters, digits, underscores, or hyphens.
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS='{"legacy":"<old-base64-key>","2026-08":"<new-base64-key>"}'
export STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID='2026-08'
# New writes use authenticated v2 envelopes carrying the active key ID. Rewrap every
# shared store with the restart-safe command; output contains store-level counts only.
# Run it again until every store reports failed=0, migrated=0, and current=total,
# then remove the old key from every worker. Retain all keys and investigate if it exits 1.
python3 scripts/rotate_private_state.py
# 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 +388,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 +411,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
@@ -317,8 +466,13 @@ place, their live sessions remain valid, and their idle clock starts at migratio
The same sheet includes **Security activity**, a reverse-chronological journal of
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
revocations, issue closures, and pull-request merges. The separate SQLite journal
retains at most 10,000 events for 90 days and stores only bounded device labels and
action targets. It never stores access tokens, cookies, session/CSRF values,
retains at most 10,000 events for 90 days. Its bounded event kind, authentication
method, device label, and action target are sealed at rest with the private-state
encryption key and authenticated against the immutable event ID. Legacy plaintext
journals migrate atomically without changing IDs, timestamps, order, or pending
operations. Missing or wrong key material and modified ciphertext fail closed with
no partial activity response. The journal never stores access tokens, cookies,
session/CSRF values,
credential IDs, public keys, challenges, attestation data, raw network addresses,
request bodies, or comment content. Keep its database on the same class of persistent,
writable storage as the session registry. Before passkey credential creation, session
@@ -386,6 +540,11 @@ is assembled in source order into one content-addressed JavaScript response. Das
HTML and the offline worker reference that exact fingerprint, while the runtime receives
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
Device Setup also checks whether the browser has granted persistent storage without prompting.
Choose **Protect offline work** to request protection from automatic storage-pressure eviction;
Stackchain reports **Protected**, **Best effort**, **Denied**, or an unavailable/retryable state
truthfully. Offline work continues when protection is unavailable or denied, but the browser may
remove best-effort data, so persistence does not replace backups or device security.
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
removing unrelated forge preferences. Rotate either dashboard secret by replacing
+17
View File
@@ -23,3 +23,20 @@
- API contracts + versioning
- independent deployability
- observability: metrics, traces, structured logs
# Disk capacity incident
Root filesystem usage at or above 85% is an operations incident.
1. Capture the initial state with `df -h / /var/lib/gitea`.
2. Inspect directory sizes and active processes before cleanup. Reclaim only
disposable caches and abandoned temporary environments. Responders must not
delete Gitea data, repositories, databases, secrets, or active release
artifacts.
3. Re-run `df -h / /var/lib/gitea`; usage must be below 85%.
4. Confirm the release path with `systemctl is-active gitea act_runner`, then run
the repository test suite.
Record the before and after usage in the incident ticket. If safe cleanup cannot
restore capacity below 85%, keep the incident open and escalate storage
expansion rather than removing durable data.
+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;
+360 -11
View File
@@ -6,14 +6,36 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
.sign-out-review-sheet { position:fixed; inset:0; z-index:112; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.sign-out-review-sheet[hidden] { display:none; }
.sign-out-review-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 #b45309; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
.sign-out-review-panel h2 { margin:.25rem 0; }
.sign-out-review-warning { color:#fbbf24; }
.sign-out-review-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.sign-out-review-actions button { min-height:44px; width:100%; }
#confirm-sign-out { border-color:#b45309; }
@media (max-width:359px) { .sign-out-review-actions { grid-template-columns:1fr; } }
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.live-data-status-sheet[hidden] { display:none; }
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
.live-data-status-today-paused { padding:10px 12px; border:1px solid #4ade80; border-radius:10px; color:#bbf7d0; background:#10291e; }
.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 +50,25 @@ 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; }
.search-week-plan-sheet { position:fixed; inset:0; z-index:118; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.search-week-plan-sheet[hidden] { display:none; }
.search-week-plan-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 #60a5fa; border-radius:18px 18px 0 0; background:#0b1526; }
.search-week-plan-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.search-week-plan-panel h2 { margin:.2rem 0; }
.search-week-plan-panel fieldset { min-width:0; margin:12px 0; padding:0; border:0; }
.search-week-plan-days { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.search-week-plan-days button { display:grid; min-width:0; min-height:44px; text-align:left; overflow-wrap:anywhere; }
.search-week-plan-days button[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 1px #60a5fa inset; }
.search-week-plan-days small { color:#9fb3c8; }
.search-week-plan-estimate { display:grid; gap:6px; }
.search-week-plan-estimate input, #confirm-search-week-plan, #cancel-search-week-plan { box-sizing:border-box; min-height:44px; }
#confirm-search-week-plan { width:100%; }
@media (max-width:359px) { .search-week-plan-days { grid-template-columns:1fr; } }
.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 +173,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; }
@@ -171,6 +212,13 @@ textarea { resize: vertical; min-height: 120px; }
.search-batch-estimate-review input { min-height:44px; width:76px; }
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.search-batch-estimate-actions button { min-height:44px; }
.search-week-batch-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; overflow-x:hidden; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
.search-week-batch-review[hidden] { display:none; }
.search-week-batch-review > div:first-child, #search-week-batch-list { display:grid; gap:10px; }
.search-week-batch-row { display:grid; gap:8px; padding:10px 0; border-bottom:1px solid #2a496e; overflow-wrap:anywhere; }
.search-week-batch-row-controls { display:grid; grid-template-columns:minmax(0,1fr) minmax(96px,.45fr); gap:8px; }
.search-week-batch-row select, .search-week-batch-row input, .search-week-batch-actions button { min-height:44px; width:100%; }
.search-week-batch-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.search-release-review[hidden] { display:none; }
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
@@ -193,6 +241,8 @@ textarea { resize: vertical; min-height: 120px; }
.offline-status[hidden] { display:none; }
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
.app-badge-setting { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.app-badge-control { min-height:44px; }
.push-update-control { min-height:44px; }
.offline-work-controls input { width:20px; height:20px; }
.offline-work-controls button { min-height:44px; }
@@ -214,6 +264,179 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.plan-today-header h2, .plan-today-header p { margin-top:0; }
.plan-today-header button { min-width:44px; min-height:44px; }
.week-plan-progress { margin:8px 0 4px; color:#bfdbfe; font-weight:700; }
.week-plan-dates { display:flex; gap:8px; margin:8px 0 12px; padding:2px 0 8px; overflow-x:auto; overscroll-behavior-inline:contain; }
.week-plan-dates[hidden] { display:none; }
.week-plan-dates button { min-height:44px; min-width:86px; flex:0 0 auto; padding:6px 10px; }
.week-plan-dates button[aria-current="date"] { color:#bfdbfe; background:#17365a; border-color:#60a5fa; }
.today-week-reschedule { box-sizing:border-box; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:inherit; }
.today-week-reschedule::backdrop { background:rgba(5,12,21,.82); }
.today-week-reschedule-panel { width:min(100%,560px); box-sizing:border-box; min-height:100%; margin-left:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow-x:hidden; }
.today-week-reschedule-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-week-reschedule-panel h2, .today-week-reschedule-panel header p { margin-top:0; }
.today-week-reschedule-panel input { box-sizing:border-box; width:100%; min-height:44px; margin:6px 0 10px; font-size:16px; }
.today-week-reschedule-days { display:grid; gap:8px; margin:14px 0; }
.today-week-reschedule-days button { min-height:44px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; text-align:left; overflow-wrap:anywhere; }
.today-week-reschedule-days button[aria-checked="true"] { border-color:#60a5fa; background:#17365a; color:#eff6ff; }
.today-week-reschedule-days button:disabled { opacity:.58; }
#cancel-today-week-reschedule { min-height:44px; }
#confirm-today-week-reschedule { position:sticky; bottom:0; width:100%; min-height:48px; }
#today-week-reschedule-status { min-height:1.4em; color:#fde68a; }
.back-to-week-review { width:100%; min-height:44px; margin:4px 0 12px; }
.tomorrow-conflict-review { margin-top:14px; }
.tomorrow-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
.tomorrow-conflict-plans > section { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.tomorrow-conflict-plans h4 { margin:0 0 8px; }
.tomorrow-conflict-plan-summary { margin:0 0 8px; color:#bfdbfe; }
.tomorrow-conflict-plan-list { margin:0; padding-left:22px; overflow-wrap:anywhere; }
.tomorrow-conflict-plan-list li + li { margin-top:6px; }
.tomorrow-conflict-actions { display:flex; gap:8px; margin-top:14px; }
.tomorrow-conflict-actions button { min-height:44px; flex:1 1 0; }
.tomorrow-conflict-status { min-height:1.4em; margin-top:8px; }
.plan-today-sheet.tomorrow-conflict-mode .mobile-plan-today-nav,
.plan-today-sheet.tomorrow-conflict-mode #plan-today-fit,
.plan-today-sheet.tomorrow-conflict-mode #plan-today-selected,
.plan-today-sheet.tomorrow-conflict-mode #plan-today-available-work,
.plan-today-sheet.tomorrow-conflict-mode .plan-today-actions { display:none; }
@media (max-width:480px) {
.tomorrow-conflict-plans { grid-template-columns:1fr; }
.tomorrow-conflict-actions { flex-direction:column; }
}
.week-conflict-review { margin-top:14px; }
.week-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
.week-conflict-plans > section { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.week-conflict-plans h4 { margin:0 0 8px; }
.week-conflict-day { margin:0 0 10px; padding-bottom:10px; border-bottom:1px solid #31577f; }
.week-conflict-day:last-child { margin-bottom:0; padding-bottom:0; border-bottom:0; }
.week-conflict-day strong { display:block; color:#bfdbfe; }
.week-conflict-actions { display:flex; gap:8px; margin-top:14px; }
.week-conflict-actions button { min-height:44px; flex:1 1 0; }
.week-conflict-days { display:grid; gap:12px; }
.week-conflict-date { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.week-conflict-date h4 { margin:0 0 8px; color:#bfdbfe; }
.week-conflict-choice { min-height:44px; display:flex; align-items:center; gap:10px; padding:8px; border-radius:8px; }
.week-conflict-choice:has(input:checked) { background:#17365a; outline:1px solid #60a5fa; }
.week-conflict-choice input { min-width:20px; min-height:20px; }
.week-conflict-choice span { display:grid; min-width:0; }
.week-conflict-choice small { color:#a9bdd3; }
#save-merged-week { width:100%; min-height:44px; margin-top:14px; }
.week-conflict-status { min-height:1.4em; margin-top:8px; }
.plan-today-sheet.week-conflict-mode .week-plan-dates,
.plan-today-sheet.week-conflict-mode .mobile-plan-today-nav,
.plan-today-sheet.week-conflict-mode #plan-today-fit,
.plan-today-sheet.week-conflict-mode #plan-today-selected,
.plan-today-sheet.week-conflict-mode #plan-today-available-work,
.plan-today-sheet.week-conflict-mode .plan-today-actions { display:none; }
@media (max-width:480px) {
.week-conflict-plans { grid-template-columns:1fr; }
.week-conflict-actions { flex-direction:column; }
}
.week-pull-conflict-review { min-width:0; margin-top:14px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
.week-pull-conflict-item { margin-bottom:6px; color:#eff6ff; font-weight:700; }
.week-pull-conflict-actions { display:flex; gap:8px; margin-top:14px; }
.week-pull-conflict-actions button { min-height:44px; flex:1 1 0; }
.week-pull-conflict-status { min-height:1.4em; margin-top:8px; }
@media (max-width:480px) { .week-pull-conflict-actions { flex-direction:column; } }
.week-review { margin-top:14px; }
.week-review h2 { margin-bottom:6px; }
.week-review-days { display:grid; gap:12px; }
.week-review-day { padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.week-review-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
.week-review-day.is-next-up { border-color:#60a5fa; box-shadow:0 0 0 1px #60a5fa inset; }
.week-next-up { display:inline-block; margin-left:6px; padding:2px 7px; border-radius:999px; background:#1d4f7a; color:#dbeafe; font-size:11px; vertical-align:middle; }
.week-review-day header { display:flex; align-items:center; justify-content:space-between; gap:8px; }
.week-review-day header button { flex:0 0 auto; min-height:44px; }
.week-review-day h3, .week-review-day p { margin:0 0 8px; }
.week-review-load { color:#bfdbfe; font-weight:700; }
.week-review-day ul { display:grid; gap:10px; margin:0; padding:0; list-style:none; }
.week-review-day li { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; padding-top:8px; border-top:1px solid #31577f; overflow-wrap:anywhere; }
.week-review-item-copy { display:grid; min-width:0; gap:3px; }
.week-review-item-open { display:block; width:100%; min-width:0; min-height:44px; padding:8px; border:0; border-radius:8px; background:transparent; color:inherit; text-align:left; }
.week-review-item-open:hover { background:#173453; }
.week-review-item-open:focus-visible { outline:3px solid #93c5fd; outline-offset:2px; }
.week-review-unplan { width:100%; min-height:44px; border-color:#6b87a6; background:transparent; color:#d7e5f5; }
.week-review-pull { grid-column:1/-1; width:100%; min-height:44px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
.week-review-unplan { grid-column:1/-1; }
.week-unplan-receipt { margin:12px 0 6px; padding:10px 12px; border:1px solid #60a5fa; border-radius:10px; background:#112d4d; color:#dbeafe; }
#undo-week-unplan { width:100%; min-height:44px; margin-bottom:8px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
.week-start-early { display:block; width:100%; min-height:44px; margin-top:12px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
.week-start-early:focus-visible { outline:3px solid #bfdbfe; outline-offset:2px; }
.week-review-item-copy strong, .week-review-item-copy small { overflow-wrap:anywhere; }
.week-review-item-copy small { color:#a9bdd3; }
.week-review-move { display:flex; align-items:end; gap:8px; }
.week-review-day label { display:grid; gap:4px; font-size:12px; }
.week-review-day select, .week-review-day button { min-height:44px; }
.week-review-day button { min-height:44px; }
.week-review-duplicates { margin:12px 0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; }
.week-review-status { min-height:1.4em; margin:10px 0; }
.week-offline-snapshot { margin:10px 0 8px; padding:10px 12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; color:#fde68a; overflow-wrap:anywhere; }
#retry-week-live { min-height:44px; width:100%; margin-bottom:8px; border-color:#f59e0b; }
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
#edit-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
#export-saved-week-calendar { width:100%; min-height:48px; margin:0 0 8px; }
#open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; }
#open-week-reflow { width:100%; min-height:48px; margin:0 0 12px; border-color:#60a5fa; }
.week-reflow-review { box-sizing:border-box; width:100%; margin:0 0 14px; padding:14px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; overflow-x:hidden; }
.week-reflow-review h3 { margin:.25rem 0; }
.week-reflow-days { display:grid; gap:6px; margin:12px 0; }
.week-reflow-day { display:flex; justify-content:space-between; gap:10px; min-width:0; padding:8px; border-radius:8px; background:#0b1526; overflow-wrap:anywhere; }
.week-reflow-unscheduled { min-height:1.4em; color:#fde68a; overflow-wrap:anywhere; }
.week-reflow-actions { display:grid; grid-template-columns:1fr 2fr; gap:8px; margin-top:12px; }
.week-reflow-actions button { min-width:0; min-height:48px; }
.week-capacity-import { position:fixed; z-index:121; inset:0; box-sizing:border-box; width:100%; max-width:560px; margin-inline:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow:auto; overflow-x:hidden; }
.week-capacity-import[hidden] { display:none; }
.week-capacity-import > header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.week-capacity-import h2 { margin:.2rem 0; }
.week-capacity-import > label, .week-capacity-hours label { display:grid; min-width:0; gap:6px; margin:12px 0; }
.week-capacity-import input, .week-capacity-import button { box-sizing:border-box; min-width:0; min-height:44px; max-width:100%; }
.week-capacity-hours { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
.week-capacity-days { display:grid; gap:8px; margin:10px 0 14px; }
.week-capacity-day { display:grid; min-width:0; gap:3px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; overflow-wrap:anywhere; }
.week-capacity-day span { color:#bfdbfe; font-weight:700; }
.week-capacity-day small { color:#a9bdd3; }
.week-capacity-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
.week-capacity-day .week-capacity-over { color:#fde68a; font-weight:700; }
.week-free-times-consent { display:flex; align-items:flex-start; gap:10px; min-width:0; margin:12px 0; padding:10px; border:1px solid #31577f; border-radius:10px; }
.week-free-times-consent input { flex:0 0 44px; width:44px; margin:0; }
.week-free-times-consent span { display:grid; min-width:0; gap:4px; overflow-wrap:anywhere; }
.week-free-times-consent small { color:#a9bdd3; }
#apply-week-capacities { width:100%; min-height:48px; position:sticky; bottom:0; }
.week-availability-editor { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
#open-week-availability { min-height:44px; }
.week-availability-editor h3 { margin-top:0; }
.week-availability-grid { display:grid; grid-template-columns:1fr; gap:8px; }
.week-availability-grid label { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,120px); gap:10px; align-items:center; }
.week-availability-grid input { min-height:44px; min-width:0; box-sizing:border-box; }
.week-availability-actions { display:grid; grid-template-columns:1fr; gap:8px; margin-top:12px; }
.week-availability-actions button { min-height:44px; }
@media (min-width:600px) { .week-availability-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .week-availability-actions { grid-template-columns:repeat(3,minmax(0,1fr)); } }
@media (max-width:359px) { .week-capacity-hours { grid-template-columns:1fr; } }
.week-calendar-handoff h2 { margin-bottom:6px; }
.week-calendar-days { display:grid; gap:12px; margin:14px 0; }
.week-calendar-day { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.week-calendar-day header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.week-calendar-day h3 { margin:0; }
.week-calendar-day input[type="time"] { min-height:44px; font-size:16px; }
.week-calendar-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; min-width:0; padding:8px 0; overflow-wrap:anywhere; }
.week-calendar-choice { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; }
.week-calendar-choice input { width:22px; height:22px; }
.week-calendar-choice span { display:grid; gap:3px; }
.week-calendar-time { display:grid; gap:2px; font-size:12px; font-weight:700; }
.week-calendar-time input { box-sizing:border-box; width:7.5rem; min-height:44px; font-size:16px; }
.week-calendar-item small { color:#a9bdd3; }
.week-calendar-status { min-height:1.4em; margin:10px 0; }
.week-calendar-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#0b1526; }
.week-calendar-actions button { min-width:0; min-height:48px; }
@media(max-width:359px) { .week-calendar-item, .week-calendar-actions { grid-template-columns:1fr; } .week-calendar-time input { width:100%; } }
.plan-today-sheet.week-review-mode .week-plan-dates,
.plan-today-sheet.week-review-mode .mobile-plan-today-nav,
.plan-today-sheet.week-review-mode #plan-today-fit,
.plan-today-sheet.week-review-mode #plan-today-selected,
.plan-today-sheet.week-review-mode #plan-today-available-work,
.plan-today-sheet.week-review-mode .plan-today-actions { display:none; }
@media (max-width:480px) {
.week-review-day li { grid-template-columns:1fr; }
}
.mobile-plan-today-nav { position:sticky; top:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px; margin:0 -18px 10px; padding:4px 18px; background:rgba(11,21,38,.98); border-block:1px solid #2a496e; }
.mobile-plan-today-nav button { min-width:0; min-height:44px; padding:4px; border-color:transparent; font-size:12px; }
.mobile-plan-today-nav button[aria-current="location"] { color:#bfdbfe; background:#17365a; border-color:#31577f; }
@@ -252,6 +475,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 +560,27 @@ 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-destination { margin-top:14px; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#0d1c31; }
.today-summary-destination h3 { margin-top:0; }
.today-summary-destination-controls { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
.today-summary-destination-controls input, .today-summary-destination-controls button { box-sizing:border-box; min-width:0; min-height:44px; }
.today-summary-target { min-height:1.4em; margin-top:8px; }
.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,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%; }
@media (max-width:420px) { .today-summary-destination-controls, .today-summary-actions { grid-template-columns:1fr; } }
.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 +652,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); }
@@ -510,6 +826,7 @@ textarea { resize: vertical; min-height: 120px; }
.update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; }
.update-retry { min-height:44px; width:100%; margin-top:10px; }
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.task-overlay-open .issue-sheet.open, .task-overlay-open .pull-sheet.open { z-index:76; }
.issue-sheet.open { display:flex; }
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
@@ -648,6 +965,7 @@ textarea { resize: vertical; min-height: 120px; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.plan-tomorrow { min-height:44px; }
.start-work-session { min-height:44px; }
.resume-today-session, .end-today-session { min-height:44px; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
@@ -786,6 +1104,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 +1246,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 +1265,12 @@ 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)); }
body:has(.mobile-today-hud:not([hidden]):not([data-overlay-hidden="true"])) { 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; }
@@ -1033,6 +1368,15 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-first-task { box-sizing:border-box; width: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-first-task::backdrop { background:rgba(3,9,18,.78); }
.mobile-first-task-panel { display:grid; gap:14px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-first-task-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.mobile-first-task-panel h2, .mobile-first-task-panel p { margin:0; }
.mobile-first-task-actions { display:grid; gap:10px; }
.mobile-first-task-actions button { min-height:48px; width:100%; }
.mobile-first-task-actions button:disabled { opacity:.55; }
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
@@ -1060,13 +1404,18 @@ 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-first-task-coach { grid-column:1 / -1; display:grid; gap:2px; padding:8px 10px; border-left:3px solid #60a5fa; border-radius:6px; background:#172f4d; }
.mobile-first-task-coach[hidden] { display:none; }
.mobile-first-task-receipt { position:fixed; left:12px; right:12px; bottom:calc(var(--mobile-today-clearance, 166px) + 8px + env(safe-area-inset-bottom)); z-index:46; padding:12px; border:1px solid #34d399; border-radius:10px; background:#0d3b35; font-weight:700; }
.mobile-first-task-receipt[hidden] { display:none; }
.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) {
+669 -138
View File
File diff suppressed because it is too large. Load diff
+42 -3
View File
@@ -21,6 +21,7 @@
let privateRecordCount = 0;
let inventoryUnavailable = false;
let fullClearArmed = false;
let persistence = { state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable' };
const ownedCaches = async () => (await options.caches?.keys?.() || [])
.filter(name => name.startsWith('stackchain-dashboard-'));
@@ -37,10 +38,48 @@
return Math.round((Number(value) || 0) / (1024 * 1024));
}
async function inspectPersistence() {
if (typeof options.storageManager?.persisted !== 'function' || typeof options.storageManager?.persist !== 'function') {
persistence = {state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable'};
return persistence;
}
try {
const protectedStorage = await options.storageManager.persisted();
persistence = protectedStorage
? {state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.', label:'Protected'}
: {state:'incomplete', detail:'Offline work uses best-effort browser storage and may be removed under storage pressure.', label:'Best effort'};
} catch (_error) {
persistence = {state:'incomplete', detail:'Storage protection could not be checked. Retry to protect offline work.', label:'Check failed'};
}
return persistence;
}
function persistenceReadiness() {
return {state:persistence.state, detail:persistence.detail};
}
async function requestPersistence() {
if (typeof options.storageManager?.persist !== 'function') return persistenceReadiness();
let granted = false;
try { granted = await options.storageManager.persist(); }
catch (_error) {
persistence = {state:'incomplete', detail:'Storage protection request failed. Offline work still uses best-effort storage; retry when ready.', label:'Request failed'};
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Request failed ');
return persistenceReadiness();
}
await refresh();
if (!granted && persistence.state !== 'complete') {
persistence = {state:'incomplete', detail:'The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.', label:'Denied'};
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Denied ');
}
return persistenceReadiness();
}
async function refresh() {
let estimate = null;
try { estimate = await options.storageManager?.estimate?.(); }
catch (_error) { /* Storage estimates are optional. */ }
await inspectPersistence();
options.summary.textContent = estimate?.quota
? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.`
: 'Browser storage usage is unavailable.';
@@ -52,7 +91,7 @@
privateRecordCount = inventory.recordCount;
inventoryUnavailable = inventory.unavailable;
const cacheCount = (await ownedCaches()).length;
options.detail.textContent = `${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
options.detail.textContent = `Storage protection: ${persistence.label} · ${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
@@ -84,7 +123,7 @@
privateItemCount = 0;
fullClearArmed = false;
options.clearAllButton.textContent = 'Clear all private data';
options.detail.textContent = '0 private browser items · 0 private work records · 0 cached app copies';
options.detail.textContent = `Storage protection: ${persistence.label} · 0 private browser items · 0 private work records · 0 cached app copies`;
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
} catch (error) {
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
@@ -99,5 +138,5 @@
return refresh();
}
return { refresh, start };
return { refresh, start, persistenceReadiness, requestPersistence };
});
+512 -8
View File
@@ -27,16 +27,29 @@
</div>
</details>
</header>
<div id="sign-out-review-sheet" class="sign-out-review-sheet" hidden>
<section class="sign-out-review-panel" role="dialog" aria-modal="true" aria-labelledby="sign-out-review-heading">
<h2 id="sign-out-review-heading">Review sign out</h2>
<p id="sign-out-review-summary" class="small" role="status" aria-live="polite">Checking private work…</p>
<p id="sign-out-review-warning" class="sign-out-review-warning"></p>
<div class="sign-out-review-actions">
<button id="cancel-sign-out" type="button">Cancel</button>
<button id="confirm-sign-out" type="button">Sign out</button>
</div>
</section>
</div>
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
<div class="live-data-status-header">
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
</div>
<p id="live-data-status-today-paused" class="live-data-status-today-paused" role="status" hidden>Today paused while you check live data.</p>
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
<p id="live-data-status-retry" class="small muted"></p>
<div class="live-data-status-actions">
<button id="refresh-live-data" type="button">Refresh now</button>
<button id="return-from-live-data-status" type="button">Return to Today</button>
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
</div>
</section>
@@ -51,6 +64,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 +112,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>
@@ -104,10 +125,18 @@
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
</li>
<li class="device-setup-step">
<div><strong>Protect offline work</strong><p class="small" id="device-setup-protection-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-protection" type="button">Protect</button>
</li>
<li class="device-setup-step">
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
</li>
<li class="device-setup-step" id="device-setup-app-badge-step">
<div><strong>Show unread Updates on the app icon</strong><p class="small" id="device-setup-app-badge-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-app-badge" type="button">Enable</button>
</li>
<li class="device-setup-step">
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
<div class="device-setup-deadline-controls">
@@ -118,6 +147,14 @@
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
</div>
</li>
<li class="device-setup-step">
<div><strong>Start my planned day</strong><p class="small" id="device-setup-start-day-status" role="status">Get one private reminder when Tomorrow is ready.</p></div>
<div class="device-setup-deadline-controls">
<label for="device-setup-start-day-hour">Local hour</label>
<select id="device-setup-start-day-hour" aria-label="Start-day reminder local hour"></select>
<button class="device-setup-action" id="device-setup-start-day" type="button">Enable</button>
</div>
</li>
</ol>
<section class="device-storage" aria-labelledby="device-storage-heading">
<div>
@@ -167,6 +204,7 @@
<button class="work-filter" data-work-filter="later" aria-pressed="false">Later <span data-work-count="later">0</span></button>
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
</div>
<button class="plan-tomorrow" id="plan-tomorrow" type="button">Plan Tomorrow</button>
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
<select class="work-milestone-filter" id="work-milestone-filter">
<option value="all">All milestones</option>
@@ -176,12 +214,22 @@
<div class="offline-work-controls">
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<span class="app-badge-setting" id="app-badge-setting">
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show unread Updates on the app icon</label>
<span class="small" id="app-badge-status" role="status" aria-live="polite"></span>
</span>
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-start-day"><input id="push-start-day" type="checkbox" /> Remind me when my planned day is ready</label>
<label for="push-start-day-hour">Start-day hour <select id="push-start-day-hour" aria-label="Start-day reminder local hour"></select></label>
<span class="small" id="push-start-day-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 +253,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 +298,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 +322,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 +347,11 @@
<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="retry-work-route" id="retry-workspace" type="button" hidden>Retry workspace</button>
<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 +366,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>
@@ -372,6 +468,143 @@
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
<button id="cancel-plan-today" type="button">Cancel</button>
</div>
<p class="week-plan-progress" id="week-plan-progress" aria-live="polite" hidden></p>
<nav class="week-plan-dates" id="week-plan-dates" aria-label="Week Ahead dates" hidden></nav>
<button class="back-to-week-review" id="back-to-week-review" type="button" hidden>Back to week review</button>
<section class="tomorrow-conflict-review" id="tomorrow-conflict-review" aria-labelledby="tomorrow-conflict-title" hidden>
<div class="small">Cross-device change</div>
<h3 id="tomorrow-conflict-title">Choose which Tomorrow plan to keep</h3>
<p class="small muted">Both plans are preserved until one choice succeeds. Today will not change.</p>
<div class="tomorrow-conflict-plans">
<section aria-labelledby="tomorrow-conflict-phone-title">
<h4 id="tomorrow-conflict-phone-title">This phone</h4>
<div id="tomorrow-conflict-phone-plan"></div>
</section>
<section aria-labelledby="tomorrow-conflict-server-title">
<h4 id="tomorrow-conflict-server-title">Saved on your account</h4>
<div id="tomorrow-conflict-server-plan"></div>
</section>
</div>
<div class="tomorrow-conflict-actions">
<button id="keep-phone-tomorrow" type="button">Keep this phone’s plan</button>
<button id="use-server-tomorrow" type="button">Use saved account plan</button>
</div>
<div class="small tomorrow-conflict-status" id="tomorrow-conflict-status" role="status" aria-live="assertive"></div>
</section>
<section class="week-conflict-review" id="week-conflict-review" aria-labelledby="week-conflict-title" hidden>
<div class="small">Cross-device change</div>
<h3 id="week-conflict-title">Resolve Week Ahead changes</h3>
<p class="small muted">Changes on different days are combined automatically. Choose only where both devices changed the same day.</p>
<div class="week-conflict-days" id="week-conflict-days" hidden></div>
<button id="save-merged-week" type="button" hidden disabled>Save combined week</button>
<div class="week-conflict-plans" id="week-conflict-legacy-plans">
<section aria-labelledby="week-conflict-phone-title">
<h4 id="week-conflict-phone-title">This phone</h4>
<div id="week-conflict-phone-plan"></div>
</section>
<section aria-labelledby="week-conflict-server-title">
<h4 id="week-conflict-server-title">Saved on your account</h4>
<div id="week-conflict-server-plan"></div>
</section>
</div>
<div class="week-conflict-actions" id="week-conflict-legacy-actions">
<button id="keep-phone-week" type="button">Keep this phone’s week</button>
<button id="use-server-week" type="button">Use saved account week</button>
</div>
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
</section>
<section class="week-pull-conflict-review" id="week-pull-conflict-review" aria-labelledby="week-pull-conflict-title" tabindex="-1" hidden>
<div class="small">Saved transfer needs review</div>
<h3 id="week-pull-conflict-title">Resolve Today transfer</h3>
<p id="week-pull-conflict-item" class="week-pull-conflict-item"></p>
<p id="week-pull-conflict-context" class="small muted"></p>
<div class="week-pull-conflict-actions">
<button id="rebase-week-pull" type="button">Add using current plans</button>
<button id="keep-week-pull" type="button">Keep in Week Ahead</button>
</div>
<div class="small week-pull-conflict-status" id="week-pull-conflict-status" role="status" aria-live="assertive"></div>
</section>
<section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden>
<div class="small">Seven-day overview</div>
<h2 id="week-review-title">Week Ahead</h2>
<p class="small muted">See what is next and check each day’s load. Edit only when you are ready.</p>
<p class="week-offline-snapshot" id="week-offline-snapshot" role="status" aria-live="polite" hidden></p>
<button id="retry-week-live" type="button" hidden>Retry live Week Ahead</button>
<button id="open-week-capacity-import" type="button">Refresh from calendar</button>
<button id="open-week-availability" type="button">Set weekly availability</button>
<section class="week-availability-editor" id="week-availability-editor" aria-labelledby="week-availability-title" hidden>
<h3 id="week-availability-title">Weekly availability</h3>
<p class="small muted">New Week Ahead dates use these defaults. Capacity set on a specific date remains an override.</p>
<div class="week-availability-grid">
<label>Monday <input data-weekday-capacity="0" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
<label>Tuesday <input data-weekday-capacity="1" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
<label>Wednesday <input data-weekday-capacity="2" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
<label>Thursday <input data-weekday-capacity="3" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
<label>Friday <input data-weekday-capacity="4" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
<label>Saturday <input data-weekday-capacity="5" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
<label>Sunday <input data-weekday-capacity="6" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
</div>
<div class="week-availability-actions">
<button id="cancel-week-availability" type="button">Cancel</button>
<button id="disable-week-availability" type="button">Disable defaults</button>
<button id="save-week-availability" type="button">Save weekly availability</button>
</div>
<div id="week-availability-status" class="small" role="status" aria-live="assertive"></div>
</section>
<button id="open-week-reflow" type="button" hidden>Reflow remaining week</button>
<section class="week-reflow-review" id="week-reflow-review" aria-labelledby="week-reflow-title" hidden>
<div class="small">Capacity-safe preview</div>
<h3 id="week-reflow-title">Reflow remaining week?</h3>
<p class="small muted">Work keeps its current order. Anything that cannot fit stays unscheduled in My Work.</p>
<div class="week-reflow-days" id="week-reflow-days"></div>
<div class="week-reflow-unscheduled" id="week-reflow-unscheduled" role="status"></div>
<div class="week-reflow-actions">
<button id="cancel-week-reflow" type="button">Cancel</button>
<button id="apply-week-reflow" type="button">Apply reflow</button>
</div>
</section>
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
<div class="week-review-days" id="week-review-days"></div>
<div class="week-unplan-receipt" id="week-unplan-receipt" role="status" aria-live="polite" hidden></div>
<button id="undo-week-unplan" type="button" hidden>Undo</button>
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
<button id="export-saved-week-calendar" type="button" hidden>Add saved week to calendar</button>
<button id="edit-week-plan" type="button" hidden>Edit week</button>
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
</section>
<section class="week-capacity-import" id="week-capacity-import" aria-labelledby="week-capacity-title" hidden>
<header><div><div class="small">Private calendar refresh</div><h2 id="week-capacity-title">Refresh Week Ahead availability</h2></div>
<button id="cancel-week-capacity-import" type="button">Cancel</button></header>
<p class="small muted">Compare refreshed availability with planned load before anything changes. Your calendar is read only on this device; event titles, people, locations, and the file are never saved or uploaded.</p>
<label>Calendar file
<input id="week-capacity-file" type="file" accept=".ics,text/calendar" />
</label>
<div class="week-capacity-hours">
<label>Workday starts <input id="week-capacity-start" type="time" value="09:00" required /></label>
<label>Workday ends <input id="week-capacity-end" type="time" value="17:00" required /></label>
</div>
<div id="week-capacity-status" class="small" role="status" aria-live="assertive"></div>
<section id="week-capacity-review" aria-labelledby="week-capacity-review-title" hidden>
<h3 id="week-capacity-review-title">Review available time</h3>
<div id="week-capacity-days" class="week-capacity-days"></div>
<label class="week-free-times-consent"><input id="keep-week-free-times" type="checkbox" />
<span><strong>Keep these free times with Week Ahead</strong><small>Only seven days of start/end times are encrypted and synced. Calendar events and the file stay on this device.</small></span>
</label>
<button id="apply-week-capacities" type="button">Apply seven capacities</button>
</section>
</section>
<section class="week-calendar-handoff" id="week-calendar-handoff" aria-labelledby="week-calendar-title" hidden>
<div class="small">Calendar handoff</div>
<h2 id="week-calendar-title">Add Week Ahead to calendar</h2>
<p class="small muted">Titles, references, dates, and times are included in a local calendar file; nothing is uploaded. Imported busy time is used only until this page reloads unless you consent to keep free times with the encrypted Week Ahead plan.</p>
<div class="week-calendar-days" id="week-calendar-days"></div>
<div class="small week-calendar-status" id="week-calendar-status" role="status" aria-live="assertive"></div>
<div class="week-calendar-actions">
<button id="back-to-week-review-from-calendar" type="button">Back to review</button>
<button id="save-week-calendar-times" type="button">Save exact times</button>
<button id="share-week-calendar" type="button">Share calendar blocks</button>
</div>
</section>
<nav class="mobile-plan-today-nav" aria-label="Plan Today sections">
<button type="button" data-plan-today-section="fit" aria-current="location">Fit</button>
<button type="button" data-plan-today-section="today">Today</button>
@@ -459,7 +692,7 @@
<div><div class="small">End your day intentionally</div><h2 id="today-wrap-up-title">Wrap up Today</h2></div>
<button id="close-today-wrap-up" type="button">Not now</button>
</div>
<p class="small muted">Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.</p>
<p class="small muted">Carry selected work into the ordered Tomorrow plan. Unselected work stays in Today.</p>
<div id="today-wrap-up-status" class="small" role="status" aria-live="polite"></div>
<div id="today-wrap-up-items" class="today-wrap-up-items"></div>
<div class="today-wrap-up-actions">
@@ -468,6 +701,40 @@
</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>
<section class="today-summary-destination" aria-labelledby="today-summary-destination-title">
<h3 id="today-summary-destination-title">Post to Gitea</h3>
<p class="small muted">Enter the exact issue or pull request that should receive this reviewed comment.</p>
<label for="today-summary-destination">Destination</label>
<div class="today-summary-destination-controls">
<input id="today-summary-destination" type="text" inputmode="text" autocomplete="off" maxlength="260" placeholder="owner/repo#42">
<button id="validate-today-summary-destination" type="button">Review destination</button>
</div>
<div id="today-summary-target" class="today-summary-target small" aria-live="polite"></div>
</section>
<div class="today-summary-actions">
<button id="post-today-summary" type="button" disabled>Post to Gitea</button>
<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 +785,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>
@@ -558,6 +829,7 @@
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
<button id="cancel-search-selection" type="button">Cancel</button>
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
<button id="week-selected-search-results" type="button" disabled>Plan Week Ahead</button>
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
<button id="queue-selected-search-results" type="button" disabled>Assign &amp; add to Today</button>
</div>
@@ -571,6 +843,16 @@
<button id="confirm-search-batch-estimates" type="button">Confirm &amp; assign</button>
</div>
</section>
<section class="search-week-batch-review" id="search-week-batch-review" role="dialog" aria-modal="true"
aria-labelledby="search-week-batch-heading" hidden>
<div><strong id="search-week-batch-heading">Plan selected issues into Week Ahead</strong>
<span class="small" id="search-week-batch-summary" aria-live="polite"></span></div>
<div id="search-week-batch-list"></div>
<div class="search-week-batch-actions">
<button id="cancel-search-week-batch" type="button">Back</button>
<button id="confirm-search-week-batch" type="button">Assign &amp; plan</button>
</div>
</section>
<section class="search-release-review" id="search-release-review"
aria-labelledby="search-release-heading" hidden>
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
@@ -595,6 +877,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>
@@ -669,6 +955,7 @@
<button id="claim-search-result" type="button" hidden>Assign to me</button>
<button id="defer-search-result" type="button" hidden>Assign &amp; defer</button>
<button id="queue-search-result" type="button" hidden>Assign &amp; add to Today</button>
<button id="plan-search-result" type="button" hidden>Plan ahead</button>
<button id="start-search-result" type="button" hidden>Assign &amp; start</button>
</div>
@@ -678,6 +965,23 @@
</section>
</div>
<section class="search-week-plan-sheet" id="search-week-plan-sheet" role="dialog" aria-modal="true"
aria-labelledby="search-week-plan-title" hidden>
<div class="search-week-plan-panel">
<header>
<div><div class="small">Week Ahead</div><h2 id="search-week-plan-title">Plan this issue</h2></div>
<button id="cancel-search-week-plan" type="button">Cancel</button>
</header>
<p id="search-week-plan-copy" class="muted"></p>
<fieldset><legend>Choose a day</legend><div class="search-week-plan-days" id="search-week-plan-days"></div></fieldset>
<label class="search-week-plan-estimate" for="search-week-plan-estimate">Estimate in minutes
<input id="search-week-plan-estimate" type="number" inputmode="numeric" min="1" max="1440" step="5" required />
</label>
<p id="search-week-plan-status" class="small" aria-live="polite"></p>
<button id="confirm-search-week-plan" type="button">Plan</button>
</div>
</section>
<div id="whiteboard-modal">
<div class="modal">
<div class="modal-actions">
@@ -898,6 +1202,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 +1269,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 +1617,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 +1832,168 @@
</form>
</dialog>
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
<button data-mobile-today-complete type="button" hidden>Done &amp; next</button>
<div class="small" data-work-session-progress aria-live="polite"></div>
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
<section 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>
<div class="mobile-first-task-coach small" data-mobile-first-task-coach hidden>
<strong>Complete your first task</strong>
<span>Open it, then use Done &amp; next after a real outcome.</span>
</div>
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></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-mobile-today-more type="button" aria-controls="mobile-today-actions" aria-haspopup="dialog">More</button>
</section>
<div class="mobile-first-task-receipt" id="mobile-first-task-receipt" role="status" aria-live="polite" hidden>
First task completed · continue Today
</div>
<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-work-session-reschedule-week type="button">Reschedule to Week Ahead</button>
<button data-mobile-today-end type="button">End session</button>
</div>
</section>
</dialog>
<dialog class="today-week-reschedule" id="today-week-reschedule" aria-labelledby="today-week-reschedule-title">
<section class="today-week-reschedule-panel">
<header>
<div><p class="small muted">Active Today item</p><h2 id="today-week-reschedule-title">Reschedule to Week Ahead</h2></div>
<button id="cancel-today-week-reschedule" type="button">Cancel</button>
</header>
<p class="small">Choose a future day after reviewing its current planned load. Today will continue with the next ready item.</p>
<div class="today-week-reschedule-days" id="today-week-reschedule-days" role="radiogroup" aria-label="Week Ahead destination"></div>
<label for="today-week-reschedule-estimate">Estimate in minutes</label>
<input id="today-week-reschedule-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" required />
<p id="today-week-reschedule-status" class="small" role="status" aria-live="assertive"></p>
<button id="confirm-today-week-reschedule" type="button" disabled>Move to Week Ahead &amp; continue</button>
</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>
@@ -1541,6 +2010,8 @@
</section>
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="week" type="button"><span><strong>Week Ahead</strong><small id="mobile-week-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
@@ -1568,6 +2039,22 @@
</section>
</dialog>
<dialog class="mobile-first-task" id="mobile-first-task" aria-labelledby="mobile-first-task-title">
<section class="mobile-first-task-panel">
<header>
<div><p class="small muted">Welcome to Work</p><h2 id="mobile-first-task-title">Start your first task</h2></div>
<button id="close-mobile-first-task" type="button">Close</button>
</header>
<p class="small">Choose work, start it, then return to Work whenever you want to continue.</p>
<p id="mobile-first-task-status" class="small" role="status" aria-live="polite"></p>
<div class="mobile-first-task-actions">
<button id="mobile-first-task-find" type="button">Find &amp; start</button>
<button id="mobile-first-task-create" type="button">Create &amp; start</button>
<button id="mobile-first-task-setup" type="button">Make this phone work-ready</button>
</div>
</section>
</dialog>
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
@@ -1579,6 +2066,8 @@
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<script src="static/private-data-registry.js"></script>
<script src="static/private-data-inventory.js"></script>
<script src="static/sign-out-review.js"></script>
<script src="static/session.js"></script>
<script src="static/feature-loader.js"></script>
<script src="static/conversation-action-hydrator.js"></script>
@@ -1610,14 +2099,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>
@@ -1626,6 +2121,12 @@
<script src="static/plan-today.js"></script>
<script src="static/plan-today-readiness.js"></script>
<script src="static/plan-today-preview.js"></script>
<script src="static/tomorrow-plan.js"></script>
<script src="static/week-calendar.js"></script>
<script src="static/week-calendar-import.js"></script>
<script src="static/week-plan.js"></script>
<script src="static/today-week-reschedule.js"></script>
<script src="static/search-week-plan.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/today-rollover.js"></script>
<script src="static/update-ownership.js"></script>
@@ -1656,12 +2157,15 @@
<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-first-task.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/mobile-delivery-recovery.js"></script>
@@ -1677,8 +2181,8 @@
<script src="static/mobile-launch.js"></script>
<script src="static/mobile-insights.js"></script>
<script src="static/mobile-app-shortcuts.js"></script>
<script src="static/mobile-app-badge.js"></script>
<script src="static/install-app.js"></script>
<script src="static/private-data-inventory.js"></script>
<script src="static/private-device-data.js"></script>
<script src="static/device-storage.js"></script>
<script src="static/mobile-device-setup.js"></script>
+1
View File
@@ -572,6 +572,7 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
headers: { Accept: 'application/json' },
}).then(result => {
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
globalThis.dispatchEvent?.(new CustomEvent('stackchain:issue-closed',{detail:item}));
return result;
}).finally(() => { closeRequest = null; });
return closeRequest;
+108 -1
View File
@@ -74,5 +74,112 @@
return { run };
}
return { describe, createRefreshController };
function createSheetController(options) {
let ownsDetour = false;
let backgroundState = null;
function focusableControls() {
return [...options.sheet.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])')]
.filter(control => !control.hidden);
}
function containBackground() {
if (backgroundState) return;
backgroundState = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
backgroundState.forEach((_wasInert, element) => { element.inert = true; });
}
function releaseBackground() {
if (!backgroundState) return;
backgroundState.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundState = null;
}
function finishClose() {
if (options.sheet.hidden) return;
options.sheet.hidden = true;
options.trigger.setAttribute('aria-expanded', 'false');
if (options.pausedStatus) options.pausedStatus.hidden = true;
releaseBackground();
if (ownsDetour) options.timerView?.finishDetour?.();
ownsDetour = false;
options.trigger.focus?.();
}
function close() {
if (options.history?.state?.liveDataStatus) {
options.history.back();
return;
}
finishClose();
}
function open() {
if (!options.sheet.hidden) return;
ownsDetour = options.timerView?.beginDetour?.('live-data-status')?.reason === 'live-data-status';
if (options.pausedStatus) options.pausedStatus.hidden = !ownsDetour;
if (options.returnButton) options.returnButton.hidden = !ownsDetour;
containBackground();
options.sheet.hidden = false;
options.trigger.setAttribute('aria-expanded', 'true');
if (options.history && !options.history.state?.liveDataStatus) {
options.history.pushState({ ...options.history.state, liveDataStatus:true }, '');
}
options.closeButton.focus();
}
function start() {
options.trigger.addEventListener('click', open);
options.closeButton.addEventListener('click', close);
options.returnButton?.addEventListener('click', close);
options.sheet.addEventListener('click', event => {
if (event.target === options.sheet) close();
});
options.escapeTarget?.addEventListener('keydown', event => {
if (options.sheet.hidden) return;
if (event.key === 'Escape') {
event.preventDefault?.();
close();
return;
}
if (event.key !== 'Tab') return;
const controls = focusableControls();
if (!controls.length) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && event.target === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && event.target === last) {
event.preventDefault();
first.focus();
}
});
options.historyTarget?.addEventListener('popstate', event => {
if (!options.sheet.hidden && !event.state?.liveDataStatus) finishClose();
});
}
return { start, open, close };
}
function mount(options) {
const document = options.document;
const qs = selector => document.querySelector(selector);
const trigger = qs('#open-live-data-status');
trigger.addEventListener('click', options.onOpen);
return createSheetController({
sheet:qs('#live-data-status-sheet'), trigger,
closeButton:qs('#close-live-data-status'),
returnButton:qs('#return-from-live-data-status'),
pausedStatus:qs('#live-data-status-today-paused'),
timerView:options.timerView,
history:options.window.history,
historyTarget:options.window,
escapeTarget:document,
backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')].filter(Boolean),
});
}
return { describe, createRefreshController, createSheetController, mount };
});
+103
View File
@@ -0,0 +1,103 @@
(function(root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileAppBadge = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileAppBadge({
control,
status,
container,
navigator,
storage,
serviceWorker,
}) {
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
let enabled = false;
let confirmedCount = 0;
let renderedCount = null;
function available() {
return typeof navigator?.setAppBadge === 'function'
&& typeof navigator?.clearAppBadge === 'function';
}
async function syncPreference() {
try {
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({type:'stackchain-app-badge-preference', enabled});
return Boolean(registration?.active);
} catch (_error) {
return false;
}
}
async function render() {
if (!enabled || !available() || renderedCount === confirmedCount) return true;
try {
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
else await navigator.clearAppBadge();
renderedCount = confirmedCount;
if (status) status.textContent = 'App icon badge is on and up to date.';
return true;
} catch (_error) {
if (status) status.textContent = 'Could not update the app icon badge. Stackchain still has the confirmed count.';
return false;
}
}
async function change() {
enabled = Boolean(control?.checked);
if (enabled) storage?.setItem(ENABLED_KEY, 'true');
else storage?.removeItem(ENABLED_KEY);
await syncPreference();
if (!enabled && available()) {
try {
await navigator.clearAppBadge();
renderedCount = null;
if (status) status.textContent = 'App icon badge is off.';
return true;
} catch (_error) {
if (status) status.textContent = 'App icon badge is off, but the browser could not clear the old count.';
return false;
}
}
return render();
}
function start() {
if (!available()) {
if (container) container.hidden = true;
if (control) control.disabled = true;
if (status) status.textContent = 'App icon badges are unavailable in this browser.';
return false;
}
enabled = storage?.getItem(ENABLED_KEY) === 'true';
if (enabled) void syncPreference();
if (control) {
control.checked = enabled;
control.addEventListener('change', change);
}
if (status) status.textContent = enabled ? 'App icon badge is on.' : 'App icon badge is off.';
return true;
}
function readiness() {
if (!available()) return {state:'unavailable', detail:'App icon badges are unavailable in this browser.'};
return enabled
? {state:'complete', detail:'The app icon shows the confirmed unread Updates count.'}
: {state:'incomplete', detail:'Show the confirmed unread Updates count without opening Stackchain.'};
}
async function enable() {
if (!available()) return false;
if (control) control.checked = true;
await change();
return true;
}
async function reconcile(count, {authoritative = false} = {}) {
if (!authoritative || !Number.isSafeInteger(count) || count < 0) return false;
confirmedCount = count;
return render();
}
return {start, change, reconcile, readiness, enable};
});
+91 -13
View File
@@ -5,12 +5,35 @@
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
const steps = [
['install', options.installButton, options.installStatus, options.install],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
['push', options.pushButton, options.pushStatus, options.enablePush],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
];
['install', options.installButton, options.installStatus, options.install, 'Install'],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable'],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
].filter(([_name, button, status, action]) => button && status && action);
let trigger = options.launcher;
let ownsDetour = false;
let backgroundInert = null;
let pendingResume = true;
function focusableControls() {
return [...options.sheet.querySelectorAll(
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)].filter(control => !control.hidden);
}
function containBackground() {
if (backgroundInert) return;
backgroundInert = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
}
function releaseBackground() {
if (!backgroundInert) return;
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundInert = null;
}
function readinessCounts(readiness) {
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
@@ -33,9 +56,10 @@
const readiness = await options.getReadiness();
let available = 0;
let complete = 0;
steps.forEach(([name, button, status]) => {
steps.forEach(([name, button, status, _action, defaultActionLabel]) => {
const step = readiness[name];
status.textContent = step.detail;
button.textContent = step.actionLabel || defaultActionLabel;
button.hidden = step.state === 'complete' || step.state === 'unavailable';
button.disabled = step.state === 'pending';
if (step.state !== 'unavailable') available += 1;
@@ -50,16 +74,35 @@
async function open(event) {
trigger = event?.currentTarget || options.launcher;
if (options.isMobile?.()) {
ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup';
}
await render();
containBackground();
options.sheet.hidden = false;
if (options.history && !options.history.state?.deviceSetup) {
options.history.pushState({...options.history.state, deviceSetup:true}, '');
}
options.closeButton.focus();
}
function close() {
function finishClose(resume = true) {
options.sheet.hidden = true;
releaseBackground();
if (resume && ownsDetour) options.timerView?.finishDetour?.();
ownsDetour = false;
trigger?.focus?.();
}
function close(resume = true) {
if (options.history?.state?.deviceSetup) {
pendingResume = resume;
options.history.back();
return;
}
finishClose(resume);
}
async function start() {
options.launcher.addEventListener('click', open);
options.promptLauncher?.addEventListener('click', open);
@@ -70,11 +113,33 @@
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();
});
options.escapeTarget.addEventListener('keydown', event => {
if (event.key === 'Escape' && !options.sheet.hidden) close();
if (event.key === 'Escape' && !options.sheet.hidden) {
event.preventDefault?.();
close();
}
if (event.key !== 'Tab' || options.sheet.hidden) return;
const controls = focusableControls();
if (!controls.length) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && event.target === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && event.target === last) {
event.preventDefault();
first.focus();
}
});
options.historyTarget?.addEventListener('popstate', event => {
if (!options.sheet.hidden && !event.state?.deviceSetup) {
finishClose(pendingResume);
pendingResume = true;
}
});
steps.forEach(([_name, button, _status, action]) => {
button.addEventListener('click', async () => {
@@ -97,18 +162,31 @@ if (typeof module === 'object' && module.exports) {
function mountMobileDeviceSetup(options) {
const qs = selector => options.document.querySelector(selector);
const window = options.window || options.document.defaultView;
return createMobileDeviceSetup({
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
protectionButton:qs('#device-setup-protection'),
appBadgeButton:qs('#device-setup-app-badge'),
deadlineButton:qs('#device-setup-deadline'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
protectionStatus:qs('#device-setup-protection-status'),
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
appBadgeStatus:qs('#device-setup-app-badge-status'),
readyStatus:qs('#device-setup-ready-status'),
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
promptStorage:options.promptStorage,
returnButton:qs('#return-from-device-setup'),
timerView:options.timerView,
isMobile:options.isMobile || (() => innerWidth <= 600),
escapeTarget:options.document,
history:window?.history,
historyTarget:window,
backgroundElements:[
qs('header'), qs('main'), qs('#mobile-task-dock'),
].filter(Boolean),
getReadiness:() => ({
install:options.installApp.state(),
offline:!options.offlineAvailable()
@@ -116,16 +194,16 @@ function mountMobileDeviceSetup(options) {
: options.offlineEnabled()
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
push:qs('#push-updates').disabled
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
: qs('#push-updates').checked
? {state:'complete', detail:'New update notifications are enabled.'}
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
protection:options.storageProtectionReadiness(),
push:options.notificationReadiness(),
appBadge:options.appBadgeReadiness(),
deadline:options.deadlineReadiness(),
}),
install:() => options.installApp.install(),
enableOffline:options.enableOffline,
protectStorage:options.protectStorage,
enablePush:options.enablePush,
enableAppBadge:options.enableAppBadge,
enableDeadline:options.enableDeadline,
});
}
+136
View File
@@ -0,0 +1,136 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileFirstTask = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileFirstTask(options) {
const doc = typeof document !== 'undefined' ? document : null;
const win = typeof window !== 'undefined' ? window : null;
options = Object.assign({
storage: typeof localStorage !== 'undefined' ? localStorage : null,
isOnline: () => typeof navigator === 'undefined' || navigator.onLine,
mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false},
eventTarget: win,
setTimer: (...args) => globalThis.setTimeout(...args),
sheet: doc?.querySelector('#mobile-first-task'),
title: doc?.querySelector('#mobile-first-task-title'),
findButton: doc?.querySelector('#mobile-first-task-find'),
createButton: doc?.querySelector('#mobile-first-task-create'),
setupButton: doc?.querySelector('#mobile-first-task-setup'),
closeButton: doc?.querySelector('#close-mobile-first-task'),
status: doc?.querySelector('#mobile-first-task-status'),
coach: doc?.querySelector('[data-mobile-first-task-coach]'),
receipt: doc?.querySelector('#mobile-first-task-receipt'),
isTodayActive: () => false,
onFind: () => doc?.querySelector('#find-work')?.click(),
onCreate: () => doc?.querySelector('#new-issue')?.click(),
onSetup: () => doc?.querySelector('#open-device-setup')?.click(),
}, options);
const prefix = 'stackchain.first-task.v1:';
function account() {
return String(options.getLogin?.() || '').trim().toLowerCase();
}
function key() {
const login = account();
return login ? prefix + login : '';
}
function state() {
const currentKey = key();
if (!currentKey) return '';
try { return options.storage.getItem(currentKey) || ''; }
catch (_error) { return ''; }
}
function store(value) {
const currentKey = key();
const current = state();
if (!currentKey || current === value || current === 'complete') return false;
try {
options.storage.setItem(currentKey, value);
return true;
} catch (_error) { return false; }
}
function adoptRemote(value) {
return ['coaching', 'complete'].includes(value) && store(value);
}
function required() {
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !state());
}
function renderCoach() {
if (!options.coach) return;
const visible = options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive();
options.coach.hidden = !visible;
const host = options.coach.closest?.('[data-mobile-today-hud]');
if (visible && host) host.hidden = false;
}
function render() {
const online = options.isOnline();
const awaitingStart = options.hasWork();
options.findButton.disabled = !online;
if (options.title) options.title.textContent = awaitingStart ? 'Finish starting your first task' : 'Start your first task';
options.findButton.textContent = 'Find & start';
options.createButton.textContent = 'Create & start';
options.status.textContent = awaitingStart ?
'Your task is ready. Start it from Find or create and start another task.' : online ?
'Choose a task to claim and start, or create and start one of your own.' :
'You are offline. Create a task now and it will stay in Drafts until you reconnect.';
}
function open() {
if (!required()) return false;
render();
if (!options.sheet.open) options.sheet.showModal();
(options.findButton.disabled ? options.createButton : options.findButton).focus();
return true;
}
function refresh() {
if (account() && options.isTodayActive() && state() !== 'complete') {
store('coaching');
if (options.sheet.open) options.sheet.close();
renderCoach();
return 'coaching';
}
renderCoach();
if (options.sheet.open) render();
if (required() && options.hasWork()) return 'awaiting-start';
return required() ? 'required' : 'inactive';
}
function completeOutcome() {
if (state() !== 'coaching') return false;
store('complete');
options.eventTarget?.dispatchEvent?.(new CustomEvent('stackchain:first-task-complete'));
renderCoach();
if (options.receipt) {
options.receipt.hidden = false;
options.setTimer?.(() => { options.receipt.hidden = true; }, 6000);
}
return true;
}
function handoff(callback, requiresOnline = false) {
if (requiresOnline && !options.isOnline()) return;
if (options.sheet.open) options.sheet.close();
callback();
}
function start() {
options.findButton.addEventListener('click', () => handoff(options.onFind, true));
options.createButton.addEventListener('click', () => handoff(options.onCreate));
options.setupButton.addEventListener('click', () => handoff(options.onSetup));
options.closeButton.addEventListener('click', () => {
if (options.sheet.open) options.sheet.close();
});
options.eventTarget?.addEventListener('online', render);
options.eventTarget?.addEventListener('offline', render);
}
globalThis.stackchainFirstTaskCompleting = () => state() === 'coaching';
return {required, open, refresh, render, start, completeOutcome, adoptRemote};
});
+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() };
});
+6
View File
@@ -4,20 +4,26 @@
})(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';
if (options.getTodayCount() > 0) return 'start';
if (options.getEligibleCount() > 0) return 'plan';
if (options.shouldActivate?.()) return 'activate';
return 'find';
}
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();
else if (current === 'activate') options.openActivation();
else if (current === 'find') options.findWork();
else options.queueLauncher.open(current);
return current;
+2
View File
@@ -6,6 +6,8 @@
'stackchain-voice-transcripts-v1',
'stackchain-search-reply-drafts-v1',
'stackchain-conversation-reply-drafts-v1',
'stackchain-today-action-mailbox-v1',
'stackchain-app-badge-preference-v1',
]);
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
else root.stackchainPrivateDatabases = databases;
+177 -8
View File
@@ -2,9 +2,69 @@
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, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
notification, serviceWorker, fetchJson,
}) {
let configuration = null;
let pendingIntent = null;
let recoveryPromise = null;
function blockedReadiness() {
return {
state:'blocked',
detail:'Notifications are blocked. Allow them in browser settings, then check again.',
actionLabel:'Check again',
};
}
function pendingRecoveryReadiness() {
return notification.permission === 'granted'
? {state:'blocked', detail:'Notification permission changed. Check again to finish setup.', actionLabel:'Check again'}
: blockedReadiness();
}
function notificationReadiness() {
if (!configuration?.available || control?.disabled) {
return {state:'unavailable', detail:status?.textContent || 'Update notifications are unavailable.'};
}
if (configuration.subscribed) {
return {state:'complete', detail:'New update notifications are enabled.'};
}
if (pendingIntent === 'updates') return pendingRecoveryReadiness();
if (notification.permission === 'denied') return blockedReadiness();
return {state:'incomplete', detail:status?.textContent || 'New update notifications are off.'};
}
function renderDeliveryHealth() {
const health = Object.values(configuration?.delivery_health || {});
const degraded = health.find(item => item?.state === 'degraded');
if (testControl) testControl.hidden = !configuration?.subscribed;
if (configuration?.subscribed && degraded) {
const count = Number(degraded.consecutive_failures || 1);
status.textContent = `Update notifications need attention after ${count} failed ${count === 1 ? 'delivery' : 'deliveries'}. Send a test notification.`;
return;
}
status.textContent = configuration?.subscribed
? 'New update notifications enabled for this device.'
: 'New update notifications are off for this device.';
}
async function testDelivery() {
if (!testControl) return;
testControl.disabled = true;
status.textContent = 'Sending a test notification…';
try {
await fetchJson('api/v1/push-subscription/test', {method:'POST'});
configuration.delivery_health = {unread:{state:'healthy',consecutive_failures:0}};
status.textContent = 'Test delivered. Update notifications are working on this device.';
} catch (error) {
status.textContent = 'Test delivery failed. Check your connection, then try again.';
} finally {
testControl.disabled = false;
}
}
function formattedHour(value) {
return `${String(Number(value)).padStart(2, '0')}:00`;
@@ -16,10 +76,37 @@
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.'};
}
if (pendingIntent === 'deadline') return pendingRecoveryReadiness();
return configuration.deadline_enabled
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
@@ -39,16 +126,24 @@
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
await subscription?.unsubscribe?.();
control.checked = false;
if (testControl) testControl.hidden = true;
if (deadlineControl) deadlineControl.checked = false;
if (startDayControl) startDayControl.checked = false;
configuration.subscribed = false;
configuration.deadline_enabled = false;
configuration.start_day_enabled = false;
pendingIntent = null;
status.textContent = 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
}
async function ensureSubscription() {
const permission = await notification.requestPermission();
const permission = notification.permission === 'granted'
? 'granted'
: await notification.requestPermission();
if (permission !== 'granted') {
control.checked = false;
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
status.textContent = blockedReadiness().detail;
return null;
}
const registration = await serviceWorker.ready;
@@ -67,11 +162,15 @@
control.checked = true;
status.textContent = 'New update notifications enabled for this device.';
configuration.subscribed = true;
if (testControl) testControl.hidden = false;
return subscription;
}
async function enable() {
await ensureSubscription();
pendingIntent = 'updates';
const subscription = await ensureSubscription();
if (subscription) pendingIntent = null;
return subscription;
}
async function change() {
@@ -94,6 +193,7 @@
try {
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (deadlineControl.checked) pendingIntent = 'deadline';
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
if (deadlineControl.checked && !subscription) {
deadlineControl.checked = false;
@@ -112,6 +212,7 @@
configuration.reminder_hour = reminderHour;
configuration.reminder_days = reminderDays;
configuration.timezone = timezone;
pendingIntent = null;
deadlineStatus.textContent = deadlineControl.checked
? enabledDeadlineText(reminderHour, reminderDays)
: 'Deadline reminders are off for this device.';
@@ -127,33 +228,101 @@
}
}
async function changeStartDay() {
startDayControl.disabled = true;
if (startDayHour) startDayHour.disabled = true;
try {
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (startDayControl.checked) pendingIntent = 'start-day';
if (startDayControl.checked && !subscription) subscription = await ensureSubscription();
if (startDayControl.checked && !subscription) {
startDayControl.checked = false;
startDayStatus.textContent = status.textContent;
return false;
}
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const reminderHour = Number(startDayHour?.value ?? configuration?.start_day_reminder_hour ?? 9);
await fetchJson('api/v1/push-subscription/start-day', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:startDayControl.checked, timezone, reminder_hour:reminderHour}),
});
configuration.start_day_enabled = startDayControl.checked;
configuration.start_day_timezone = timezone;
configuration.start_day_reminder_hour = reminderHour;
pendingIntent = null;
startDayStatus.textContent = startDayControl.checked
? `Start-day reminder enabled for ${formattedHour(reminderHour)} local time.`
: 'Start-day reminders are off for this device.';
return true;
} catch (_error) {
startDayControl.checked = !startDayControl.checked;
startDayStatus.textContent = 'Could not change start-day reminders. Check your connection and try again.';
return false;
} finally {
startDayControl.disabled = false;
if (startDayHour) startDayHour.disabled = false;
}
}
async function enableDeadline() {
deadlineControl.checked = true;
return changeDeadline();
}
async function recoverPermission(intent = null) {
if (!pendingIntent && ['updates', 'deadline', 'start-day'].includes(intent)) pendingIntent = intent;
if (!pendingIntent || notification.permission !== 'granted') return false;
if (recoveryPromise) return recoveryPromise;
recoveryPromise = (async () => {
if (pendingIntent === 'deadline') {
deadlineControl.checked = true;
return changeDeadline();
}
if (pendingIntent === 'start-day') {
startDayControl.checked = true;
return changeStartDay();
}
return Boolean(await enable());
})();
try {
return await recoveryPromise;
} finally {
recoveryPromise = null;
}
}
async function init() {
if (!control || !notification || !serviceWorker) return;
control.addEventListener('change', change);
testControl?.addEventListener('click', testDelivery);
deadlineControl?.addEventListener('change', changeDeadline);
startDayControl?.addEventListener('change', changeStartDay);
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
configuration = await fetchJson('api/v1/push-subscription');
if (!configuration.available) {
control.disabled = true;
if (deadlineControl) deadlineControl.disabled = true;
if (startDayControl) startDayControl.disabled = true;
status.textContent = 'New update notifications are not available on this server.';
return;
}
control.checked = Boolean(configuration.subscribed);
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
status.textContent = configuration.subscribed
? 'New update notifications enabled for this device.'
: 'New update notifications are off for this device.';
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
renderDeliveryHealth();
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
: 'Deadline reminders are off for this device.';
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
: 'Start-day reminders are off for this device.';
renderDeadlineSnooze();
}
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
});
+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;
+171 -2
View File
@@ -64,10 +64,100 @@
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
}
function createWeekBatchPlan({
week,
prepare = item => Promise.resolve(item),
accept = item => item,
identity = item => String(item?.repository || '') + '#' + String(item?.number || ''),
maxItems = 5,
onProgress = () => {},
} = {}) {
let running = null;
const load = async () => {
await week.load();
return week.dates().map(day => ({ ...day, ...week.day(day.date) }));
};
function validate(items, assignments, days, confirmOverload = false) {
const allowed = new Set(days.map(day => day.date));
const errors = [];
const simulated = new Map(days.map(day => [day.date, {
ids:[...(day.ids || [])], estimates:{...(day.estimates || {})},
capacity_minutes:Number(day.capacity_minutes) || 0,
}]));
items.forEach(item => {
const id = identity(item), choice = assignments?.[id] || {};
const estimate = Number(choice.estimate);
if (!allowed.has(choice.date)) errors.push({ id, reason:'date-required' });
if (!Number.isFinite(estimate) || estimate <= 0) errors.push({ id, reason:'estimate-required' });
if (!allowed.has(choice.date) || !Number.isFinite(estimate) || estimate <= 0) return;
simulated.forEach((day, date) => {
if (day.ids.includes(id) && choice.date !== date) {
day.ids = day.ids.filter(value => value !== id); delete day.estimates[id];
}
});
const destination = simulated.get(choice.date);
if (!destination.ids.includes(id) && destination.ids.length >= maxItems) {
errors.push({ id, reason:'day-full', date:choice.date, limit:maxItems }); return;
}
if (!destination.ids.includes(id)) destination.ids.push(id);
destination.estimates[id] = estimate;
});
const overloads = [];
simulated.forEach((day, date) => {
const planned = day.ids.reduce((sum, id) => sum + (Number(day.estimates[id]) || 0), 0);
if (day.capacity_minutes > 0 && planned > day.capacity_minutes) {
overloads.push({ date, planned_minutes:planned, capacity_minutes:day.capacity_minutes });
}
});
if (errors.length) return { status:'invalid', errors, overloads:[] };
if (overloads.length && !confirmOverload) return { status:'overload-confirmation-required', errors:[], overloads };
return { status:'ready', errors:[], overloads };
}
async function preview(items) {
const days = await load();
return { days, placements:Object.fromEntries(items.map(item => [identity(item), week.placement(identity(item))])) };
}
function run(items, assignments, { confirmOverload = false } = {}) {
if (running) return running;
running = (async () => {
const selected = Array.isArray(items) ? items.slice() : [];
const days = await load();
const checked = validate(selected, assignments, days, confirmOverload);
if (checked.status !== 'ready') return checked;
const failed = [], planned = [];
for (let index = 0; index < selected.length; index += 1) {
const source = selected[index], originalId = identity(source), choice = assignments[originalId];
try {
const confirmed = accept(await prepare(source)) || source;
const id = identity(confirmed);
const existing = week.placement(id);
if (!week.place(id, choice.date, Number(choice.estimate), {move:Boolean(existing && existing.date !== choice.date)})) {
failed.push({id:originalId, reason:'assigned-not-planned'});
} else {
week.rememberPendingItem?.(id, confirmed);
planned.push(id);
}
} catch (error) {
failed.push({id:originalId, reason:error?.message || 'assignment failed'});
}
onProgress({status:'running',processed:index + 1,selected:selected.length});
}
if (planned.length) {
try { await week.flush(); }
catch (_error) { return {status:'planned-pending',planned:planned.length,failed}; }
}
return {status:failed.length?'partial':'planned',planned:planned.length,failed};
})().finally(() => { running = null; });
return running;
}
return { preview, validate, run, pending:() => Boolean(running) };
}
createSearchBatchPlan.createWeekBatchPlan = createWeekBatchPlan;
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
laterWork = null, laterPicker = null
laterWork = null, laterPicker = null, weekPlan = null, storage = null
) {
const get = selector => document.querySelector(selector);
let processor;
@@ -87,6 +177,7 @@
get('#queue-selected-search-results').disabled = state.count === 0;
get('#defer-selected-search-results').disabled = state.count === 0;
get('#plan-selected-search-results').disabled = state.count === 0;
if (get('#week-selected-search-results')) get('#week-selected-search-results').disabled = state.count === 0;
get('#search-selection-status').textContent = state.count ?
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
render();
@@ -194,6 +285,81 @@
}
},
});
const weekProcessor = weekPlan ? createWeekBatchPlan({
week:weekPlan,prepare:item=>plan.prepare(item),accept:acceptIssue,
identity:item=>todayWork.identity(item),
onProgress:progress=>{
if (progress.status === 'running') get('#search-week-batch-summary').textContent =
'Planning ' + progress.processed + ' of ' + progress.selected + '…';
},
storage,
}) : null;
let weekOverload = false;
function weekAssignments() {
const values = {};
document.querySelectorAll('[data-search-week-batch-date]').forEach(select => {
const id = select.dataset.searchWeekBatchDate;
const estimate = document.querySelector('[data-search-week-batch-estimate="' + id + '"]');
values[id] = {date:select.value,estimate:Number(estimate?.value)};
});
return values;
}
function closeWeekReview() {
if (!get('#search-week-batch-review')) return;
get('#search-week-batch-review').hidden = true;
get('#search-batch-actions').hidden = !plan.snapshot().active;
weekOverload = false;
get('#confirm-search-week-batch').textContent = 'Assign & plan';
}
async function openWeekReview() {
const items = plan.snapshot().items;
if (!weekProcessor || !items.length) return;
const button = get('#week-selected-search-results');
button.disabled = true;
get('#search-selection-status').textContent = 'Loading Week Ahead…';
try {
const preview = await weekProcessor.preview(items);
const options = selected => preview.days.map(day => '<option value="' + escapeAttribute(day.date) + '"' +
(day.date === selected ? ' selected' : '') + '>' + escapeHtml(day.label + ' · ' + (day.ids || []).length + ' planned') + '</option>').join('');
get('#search-week-batch-list').innerHTML = items.map(item => {
const id = todayWork.identity(item), existing = preview.placements[id];
return '<div class="search-week-batch-row"><div><span class="small">' + escapeHtml(id) + '</span><strong>' +
escapeHtml(item.title || 'Untitled work') + '</strong></div><div class="search-week-batch-row-controls"><label>Day<select data-search-week-batch-date="' +
escapeAttribute(id) + '">' + options(existing?.date || preview.days[0]?.date) + '</select></label><label>Minutes<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
escapeAttribute(existing?.estimate || '') + '" data-search-week-batch-estimate="' + escapeAttribute(id) +
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes"></label></div></div>';
}).join('');
get('#search-week-batch-summary').textContent = items.length + ' selected · review every day and estimate before assignment.';
get('#search-batch-actions').hidden = true;
get('#search-week-batch-review').hidden = false;
document.querySelector('[data-search-week-batch-date]')?.focus();
} catch (error) {
get('#search-selection-status').textContent = error?.message || 'Week Ahead is unavailable. Retry when connected.';
} finally { button.disabled = false; }
}
async function confirmWeekReview() {
if (!weekProcessor || weekProcessor.pending()) return;
const button = get('#confirm-search-week-batch');
button.disabled = true;
const outcome = await weekProcessor.run(plan.snapshot().items, weekAssignments(), {confirmOverload:weekOverload});
if (outcome.status === 'invalid') {
const reasons = [...new Set(outcome.errors.map(error => error.reason))];
get('#search-week-batch-summary').textContent = reasons.includes('day-full') ?
'A day already has five items. Choose another day.' : 'Choose a valid day and estimate for every issue.';
} else if (outcome.status === 'overload-confirmation-required') {
weekOverload = true;
button.textContent = 'Confirm over capacity';
get('#search-week-batch-summary').textContent = outcome.overloads.map(day =>
day.planned_minutes + ' of ' + day.capacity_minutes + ' min on ' + day.date).join(' · ') + '. Confirm to plan anyway.';
} else {
const pending = outcome.status === 'planned-pending';
get('#cmd-search-action-status').textContent = outcome.planned + ' planned' +
(pending ? ' · sync pending' : '') + (outcome.failed.length ? ' · ' + outcome.failed.length + ' need attention.' : '.');
closeWeekReview();
if (!outcome.failed.length) plan.cancel();
}
button.disabled = false;
}
function estimateValues() {
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
[input.dataset.searchBatchEstimate, Number(input.value)]
@@ -274,6 +440,9 @@
) }, event.currentTarget, 'search-batch');
});
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
get('#week-selected-search-results')?.addEventListener('click', openWeekReview);
get('#cancel-search-week-batch')?.addEventListener('click', closeWeekReview);
get('#confirm-search-week-batch')?.addEventListener('click', confirmWeekReview);
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
get('#confirm-search-release').addEventListener('click', () => {
const milestoneSelect = get('#search-release-milestone');
@@ -322,7 +491,7 @@
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
}
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
return { plan, processor, laterProcessor, releaseProcessor, weekProcessor, restore, resultHtml };
};
return createSearchBatchPlan;
+116
View File
@@ -0,0 +1,116 @@
function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}={}) {
let running=null;
const eligible=detail=>Boolean(detail&&detail.kind==='issue'&&detail.state==='open'&&
(detail.claimable||detail.assigned_to_me));
const loadDay=date=>{
const value=week.day(date),estimates=value.estimates||{};
return {...value,planned_minutes:(value.ids||[]).reduce((total,id)=>total+(Number(estimates[id])||0),0)};
};
async function preview(detail) {
if(!eligible(detail))return {eligible:false,days:[]};
await week.load();
const id=identity(detail),existing=week.placement(id);
return {eligible:true,existing,days:week.dates().map(item=>({...item,...loadDay(item.date)}))};
}
function plan(detail,options={}) {
if(running)return running;
const perform=async()=>{
if(!eligible(detail))return {status:'ineligible'};
const estimate=Number(options.estimate);
if(!Number.isFinite(estimate)||estimate<=0)return {status:'estimate-required'};
await week.load();
if(!week.dates().some(item=>item.date===options.date))return {status:'date-required'};
const originalId=identity(detail),existing=week.placement(originalId);
if(existing&&existing.date!==options.date&&!options.move)
return {status:'already-planned',date:existing.date,estimate:existing.estimate};
const destination=loadDay(options.date);
const alreadyThere=(destination.ids||[]).includes(originalId);
if(!alreadyThere&&(destination.ids||[]).length>=maxItems)return {status:'day-full',limit:maxItems};
const previous=alreadyThere?(Number(destination.estimates?.[originalId])||0):0;
const plannedMinutes=destination.planned_minutes-previous+estimate;
if(Number(destination.capacity_minutes)>0&&plannedMinutes>Number(destination.capacity_minutes)&&!options.confirmOverload)
return {status:'overload-confirmation-required',planned_minutes:plannedMinutes,
capacity_minutes:Number(destination.capacity_minutes)};
let item=detail,assigned=false;
if(detail.claimable){item=await claim(detail);assigned=true;}
item=accept(item)||item;
const id=identity(item);
if(!week.place(id,options.date,estimate,{move:Boolean(options.move)}))
return {status:assigned?'assigned-not-planned':'not-planned',item};
week.rememberPendingItem?.(id,item);
try {
await week.flush();
return {status:'planned',date:options.date,estimate,item};
} catch(_error) {
return {status:'planned-pending',date:options.date,estimate};
}
};
running=perform().finally(()=>{running=null;});
return running;
}
return {eligible,preview,plan,pending:()=>Boolean(running)};
}
function createSearchWeekPlanUI({planner,document,window,getDetail,onPlanned=()=>{},announce=()=>{},escapeHtml,escapeAttribute}={}) {
const qs=selector=>document.querySelector(selector);
let trigger=null,detail=null,preview=null,overload=false;
function close(navigate=false) {
if(navigate&&window.history.state?.searchWeekPlan){window.history.back();return;}
qs('#search-week-plan-sheet').hidden=true;
detail=null;preview=null;overload=false;trigger?.focus?.();trigger=null;
}
function selectDate(date) {
qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(button=>
button.setAttribute('aria-pressed',String(button.dataset.searchWeekDate===date)));
qs('#search-week-plan-days').dataset.selected=date;
overload=false;qs('#confirm-search-week-plan').textContent='Plan';
}
async function open(button) {
const selected=getDetail();
if(!selected||planner.pending())return false;
trigger=button;detail=selected;overload=false;
const sheet=qs('#search-week-plan-sheet'),status=qs('#search-week-plan-status');
sheet.hidden=false;status.textContent='Loading Week Ahead…';qs('#confirm-search-week-plan').disabled=true;
window.history.pushState({...window.history.state,searchWeekPlan:true},'',window.location.href);
qs('#search-week-plan-copy').textContent=detail.title||'Untitled issue';
try {
preview=await planner.preview(detail);
const existing=preview.existing;
qs('#search-week-plan-days').innerHTML=preview.days.map((day,index)=>
'<button type="button" data-search-week-date="'+escapeAttribute(day.date)+'" aria-pressed="'+String(index===0)+'"><strong>'+escapeHtml(day.label)+
'</strong><small>'+escapeHtml(day.planned_minutes+(day.capacity_minutes?' of '+day.capacity_minutes:'')+' min · '+day.ids.length+' planned')+'</small></button>').join('');
qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(dayButton=>
dayButton.addEventListener('click',()=>selectDate(dayButton.dataset.searchWeekDate)));
selectDate(existing?.date||preview.days[0].date);
qs('#search-week-plan-estimate').value=existing?.estimate||'';
status.textContent=existing?'Already planned for '+existing.date+'. Choose another day to move it.':'Choose a day and add an estimate.';
qs('#confirm-search-week-plan').disabled=false;qs('#search-week-plan-estimate').focus();return true;
} catch(error) {status.textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;}
}
async function confirm() {
if(!detail||planner.pending())return;
const date=qs('#search-week-plan-days').dataset.selected,estimate=Number(qs('#search-week-plan-estimate').value);
const existing=preview?.existing,move=Boolean(existing&&existing.date!==date);
const button=qs('#confirm-search-week-plan'),status=qs('#search-week-plan-status');button.disabled=true;
const outcome=await planner.plan(detail,{date,estimate,move,confirmOverload:overload});
if(outcome.status==='overload-confirmation-required'){
overload=true;button.textContent='Confirm over capacity';
status.textContent=outcome.planned_minutes+' min exceeds '+outcome.capacity_minutes+' min capacity. Confirm to plan anyway.';
} else if(outcome.status==='estimate-required')status.textContent='Enter an estimate in minutes.';
else if(outcome.status==='day-full')status.textContent='That day already has '+outcome.limit+' items. Choose another day.';
else if(outcome.status==='already-planned')status.textContent='Already planned for '+outcome.date+'. Choose Move to change the day.';
else if(outcome.status==='assigned-not-planned')status.textContent='Assigned, not planned. Find it in My Work and retry.';
else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');onPlanned(detail,true);close(true);}
else if(outcome.status==='planned'){announce('Planned for '+date+'.');onPlanned(detail,false);close(true);}
else status.textContent='Could not plan this issue. Retry.';
button.disabled=false;
}
qs('#plan-search-result').addEventListener('click',event=>open(event.currentTarget));
qs('#cancel-search-week-plan').addEventListener('click',()=>close(true));
qs('#confirm-search-week-plan').addEventListener('click',confirm);
window.addEventListener('popstate',()=>{if(!qs('#search-week-plan-sheet').hidden)close();});
document.addEventListener('keydown',event=>{
if(event.key==='Escape'&&!qs('#search-week-plan-sheet').hidden){event.preventDefault();close(true);}
});
return {open,close,confirm};
}
if(typeof module!=='undefined'&&module.exports){module.exports=createSearchWeekPlan;module.exports.UI=createSearchWeekPlanUI;}
+61 -7
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');
@@ -19,6 +19,31 @@
section, root.document.getElementById(`security-${section}-section`),
]));
let activityCursor = null;
let backgroundInert = null;
const backgroundElements = ['header', 'main', '#mobile-task-dock']
.map(selector => root.document.querySelector?.(selector))
.filter(Boolean);
const focusableControls = () => [...(devicesSheet?.querySelectorAll?.(
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
) || [])].filter(control => !control.hidden && !control.disabled);
const containBackground = () => {
if (!backgroundInert) {
backgroundInert = new Map(backgroundElements.map(element => [element, element.inert]));
}
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
};
const releaseBackground = () => {
if (!backgroundInert) return;
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundInert = null;
};
const finishClose = () => {
devicesSheet.hidden = true;
releaseBackground();
devicesButton?.focus();
onClose();
};
const navigate = (section, { history = true } = {}) => {
if (!sections[section]) section = 'activity';
@@ -43,15 +68,41 @@
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
if (root.history?.state?.stackchainSecuritySection) root.history.back();
});
root.addEventListener?.('popstate', event => {
const section = event.state?.stackchainSecuritySection;
const handleRouteChange = section => {
if (section && !devicesSheet.hidden) {
containBackground();
navigate(section, { history: false });
return;
}
if (!devicesSheet.hidden) {
devicesSheet.hidden = true;
devicesButton?.focus();
finishClose();
}
};
root.addEventListener?.('popstate', event => {
handleRouteChange(event.state?.stackchainSecuritySection);
});
root.addEventListener?.('hashchange', () => {
handleRouteChange(root.history?.state?.stackchainSecuritySection);
});
root.document.addEventListener?.('keydown', event => {
if (devicesSheet.hidden) return;
if (event.key === 'Escape') {
event.preventDefault();
if (root.history?.state?.stackchainSecuritySection) root.history.back();
else finishClose();
return;
}
if (event.key !== 'Tab') return;
const controls = focusableControls();
if (!controls.length) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && event.target === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && event.target === last) {
event.preventDefault();
first.focus();
}
});
@@ -80,7 +131,8 @@
revoke.type = 'button';
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
revoke.addEventListener('click', async () => {
if (device.current) await boundary.signOut();
if (device.current && boundary.requestSignOut) await boundary.requestSignOut(revoke);
else if (device.current) await boundary.signOut();
else if (await boundary.revokeActiveDevice(device)) {
await Promise.all([renderDevices(), renderSecurityActivity()]);
}
@@ -222,6 +274,8 @@
const open = () => {
if (!devicesSheet) return;
onOpen();
containBackground();
devicesSheet.hidden = false;
root.document.getElementById('close-active-devices')?.focus();
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
+289 -12
View File
@@ -1,12 +1,118 @@
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-v130';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
const TODAY_ACTION_TTL_MS = 2 * 60 * 1000;
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
function createAppBadgePreference() {
const dbName = 'stackchain-app-badge-preference-v1';
const storeName = 'preferences';
const open = () => new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
});
return {
async get() {
const database = await open();
const enabled = await new Promise((resolve, reject) => {
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('enabled');
request.onsuccess = () => resolve(request.result === true);
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
});
database.close();
return enabled;
},
async set(enabled) {
const database = await open();
await new Promise((resolve, reject) => {
const transaction = database.transaction(storeName, 'readwrite');
transaction.objectStore(storeName).put(enabled === true, 'enabled');
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error || new Error('App badge preference could not be saved.'));
transaction.onabort = transaction.onerror;
});
database.close();
},
};
}
const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference();
let renderedBackgroundBadgeCount = null;
async function reconcileBackgroundAppBadge(count) {
if (!Number.isSafeInteger(count) || count < 0 || count > 9999
|| typeof self.registration.setAppBadge !== 'function'
|| typeof self.registration.clearAppBadge !== 'function') return false;
let enabled = false;
try { enabled = await appBadgePreference.get(); } catch (_error) { return false; }
if (!enabled || renderedBackgroundBadgeCount === count) return false;
try {
if (count > 0) await self.registration.setAppBadge(count);
else await self.registration.clearAppBadge();
renderedBackgroundBadgeCount = count;
return true;
} catch (_error) { return false; }
}
function createTodayActionStore() {
const dbName = 'stackchain-today-action-mailbox-v1';
const storeName = 'commands';
const open = () => new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => request.result.createObjectStore(storeName, {keyPath:'clientId'});
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Today action mailbox unavailable.'));
});
return {
async put(clientId, command) {
const database = await open();
await new Promise((resolve, reject) => {
const transaction = database.transaction(storeName, 'readwrite');
transaction.objectStore(storeName).put({...command, clientId});
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error || new Error('Today action could not be saved.'));
transaction.onabort = transaction.onerror;
});
database.close();
},
async claim(clientId, now) {
const database = await open();
let command = null;
await new Promise((resolve, reject) => {
const transaction = database.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.get(clientId);
request.onsuccess = () => {
command = request.result || null;
if (command) store.delete(clientId);
};
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error || new Error('Today action could not be claimed.'));
transaction.onabort = transaction.onerror;
});
database.close();
return command && command.expiresAt > now ? command : null;
},
async purge() {
const database = await open();
await new Promise((resolve, reject) => {
const transaction = database.transaction(storeName, 'readwrite');
transaction.objectStore(storeName).clear();
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error || new Error('Today actions could not be purged.'));
transaction.onabort = transaction.onerror;
});
database.close();
},
};
}
const todayActionStore = self.__STACKCHAIN_TODAY_ACTION_STORE || createTodayActionStore();
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
@@ -15,6 +121,7 @@ const SHELL = [
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/sign-out-review.js',
BASE + 'static/feature-loader.js',
BASE + 'static/workspace-bootstrap.js',
BASE + 'static/conversation-action-hydrator.js',
@@ -45,14 +152,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',
@@ -61,6 +174,12 @@ const SHELL = [
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-readiness.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/tomorrow-plan.js',
BASE + 'static/week-calendar.js',
BASE + 'static/week-calendar-import.js',
BASE + 'static/week-plan.js',
BASE + 'static/today-week-reschedule.js',
BASE + 'static/search-week-plan.js',
BASE + 'static/today-sync.js',
BASE + 'static/today-rollover.js',
BASE + 'static/update-ownership.js',
@@ -99,11 +218,14 @@ 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-first-task.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/mobile-delivery-recovery.js',
@@ -119,6 +241,7 @@ const SHELL = [
BASE + 'static/mobile-launch.js',
BASE + 'static/mobile-insights.js',
BASE + 'static/mobile-app-shortcuts.js',
BASE + 'static/mobile-app-badge.js',
BASE + 'static/install-app.js',
BASE + 'static/private-data-inventory.js',
BASE + 'static/private-device-data.js',
@@ -418,7 +541,53 @@ 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-app-badge-preference') {
event.waitUntil((async () => {
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
const enabled = event.data.enabled === true;
try {
await appBadgePreference.set(enabled);
renderedBackgroundBadgeCount = null;
if (!enabled && typeof self.registration.clearAppBadge === 'function') {
await self.registration.clearAppBadge();
}
} catch (_error) { /* Page preference remains authoritative on next launch. */ }
})());
}
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
await issueSync.resume();
await flushAndNotify();
@@ -429,12 +598,43 @@ self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
await todayActionStore.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((async () => {
const active = event.data.active === true;
if (!active) await todayActionStore.purge();
await updateTodayLockScreen(
active,
event.data.running === true,
String(event.data.actionToken || ''),
Number(event.data.breakDeadlineAt || 0)
);
})());
}
if (event.data?.type === 'stackchain-claim-today-action') {
event.waitUntil((async () => {
const source = event.source;
if (!source?.id || !String(source.url || '').startsWith(self.location.origin + BASE)) return;
let command;
try { command = await todayActionStore.claim(source.id, Date.now()); }
catch (_error) { return; }
if (!command || !['pause', 'resume', 'complete'].includes(command.action)) return;
if (command.actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(command.actionToken)) return;
if (command.action === 'complete' && !command.actionToken) return;
source.postMessage?.({
type:'stackchain-today-timer-action',
action:command.action,
...(command.actionToken ? {actionToken:command.actionToken} : {}),
});
})());
}
});
self.addEventListener('push', event => {
@@ -446,7 +646,22 @@ self.addEventListener('push', event => {
const tag = String(payload.tag || '');
const notificationId = Number(payload.notification_id);
const updateCount = Number(payload.update_count);
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
const deadlineCount = Number(payload.deadline_count);
const planDate = String(payload.plan_date || '');
if (
route === '#/my-work/start-day'
&& /^\d{4}-\d{2}-\d{2}$/.test(planDate)
&& tag === 'stackchain-start-day-' + planDate
) {
event.waitUntil(self.registration.showNotification('Your planned day is ready', {
body: 'Open Stackchain to prepare Today.',
tag,
actions: [{ action: 'prepare-today', title: 'Prepare Today' }],
data: {route},
}));
return;
}
if (
route === '#/my-work/agenda'
&& protectRoute === '#/my-work/agenda/protect-today'
@@ -462,7 +677,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},
}
@@ -476,11 +691,12 @@ self.addEventListener('push', event => {
&& updateCount > 0
&& updateCount <= 50
) {
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
body: 'Tap to review them in Stackchain.',
tag,
data: {route},
}));
event.waitUntil(Promise.all([
reconcileBackgroundAppBadge(unreadCount),
self.registration.showNotification(updateCount + ' new work updates', {
body: 'Tap to review them in Stackchain.', tag, data: {route},
}),
]));
return;
}
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
@@ -500,7 +716,10 @@ self.addEventListener('push', event => {
];
options.data.notificationId = notificationId;
}
event.waitUntil(self.registration.showNotification('New work update', options));
event.waitUntil(Promise.all([
reconcileBackgroundAppBadge(unreadCount),
self.registration.showNotification('New work update', options),
]));
});
async function openWorkRoute(route) {
@@ -527,9 +746,51 @@ 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 target = new URL(BASE + route, self.location.origin).href;
const opened = await self.clients.openWindow(target);
if (!opened?.id) return opened;
try {
await todayActionStore.put(opened.id, {
action,
...(actionToken ? {actionToken} : {}),
expiresAt:Date.now() + TODAY_ACTION_TTL_MS,
});
} catch (_error) { /* The clean Today route remains safe; fail the action closed. */ }
return opened;
}
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 +806,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') {
+38 -15
View File
@@ -38,35 +38,53 @@
boundary.startActivityHeartbeat();
boundary.startReconnectResume();
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
const allDevicesButton = root.document.getElementById('sign-out-all');
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
const allButton = root.document.getElementById('sign-out-all');
let review;
const features = root.createFeatureLoader({
document: root.document,
urls: {
'sign-out': root.document.querySelector('meta[name="stackchain-feature-sign-out"]')?.content || '',
'security-center': root.document.querySelector('meta[name="stackchain-feature-security-center"]')?.content || '',
},
});
const openReview = (mode, source) => features.run(
'sign-out', { trigger: source }, () => {
if (!review) review = root.createSignOutReview.mount(boundary);
review.show(mode, source);
},
);
[[button, 'current'], [allButton, 'all']].forEach(([source, mode]) =>
source?.addEventListener('click', () => openReview(mode, source))
);
boundary.requestSignOut = source => openReview('current', source || button);
const devicesButton = root.document.getElementById('active-devices');
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 securityFeatures = root.createFeatureLoader({
document: root.document,
urls: {
'security-center': root.document.querySelector(
'meta[name="stackchain-feature-security-center"]'
)?.content || '',
},
});
const returnFromSecurity = root.document.getElementById('return-from-security-center');
const beginSecurityDetour = () => {
if (root.matchMedia?.('(max-width: 600px)')?.matches) {
root.stackchainTodayTimerView?.beginDetour?.('security-center');
}
};
let loadingSecurityCenter = false;
const openSecurityCenter = async () => {
if (loadingSecurityCenter) return;
loadingSecurityCenter = true;
beginSecurityDetour();
devicesSheet.hidden = false;
closeDevices?.focus();
try {
await securityFeatures.run('security-center', {
await features.run('security-center', {
trigger: devicesButton,
status: devicesStatus,
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 +96,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();
@@ -592,8 +615,8 @@
}
}
async function signOutAllDevices() {
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
async function signOutAllDevices({ reviewed = false } = {}) {
const confirmed = reviewed || confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
if (!confirmed) return false;
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
if (!response.ok) throw new Error('Could not sign out all devices');
+131
View File
@@ -0,0 +1,131 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else {
root.createSignOutReview = factory;
root.createSignOutReview.mount = boundary => {
const document = root.document;
const review = factory({
sheet: document.getElementById('sign-out-review-sheet'),
heading: document.getElementById('sign-out-review-heading'),
summary: document.getElementById('sign-out-review-summary'),
warning: document.getElementById('sign-out-review-warning'),
cancelButton: document.getElementById('cancel-sign-out'),
confirmButton: document.getElementById('confirm-sign-out'),
escapeTarget: document,
historyTarget: root,
history: root.history,
backgroundTargets: Array.from(document.querySelectorAll('body > :not(#sign-out-review-sheet)')),
getActiveElement: () => document.activeElement,
localStorage: root.localStorage,
sessionStorage: root.sessionStorage,
privateDatabases: root.stackchainPrivateDatabases,
inspectPrivateDatabases: root.inspectStackchainPrivateDatabases,
onConfirm: mode => mode === 'all'
? boundary.signOutAllDevices({ reviewed: true })
: boundary.signOut(),
});
review.start();
return review;
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function createSignOutReview(options) {
let mode = 'current';
let launcher = null;
let open = false;
let historyEntry = false;
function ownedItemCount(storage) {
if (!storage) return 0;
let count = 0;
for (let index = 0; index < storage.length; index += 1) {
if (storage.key(index)?.startsWith('stackchain.')) count += 1;
}
return count;
}
async function show(nextMode, source) {
mode = nextMode;
launcher = source;
const itemCount = ownedItemCount(options.localStorage)
+ (options.sessionStorage === options.localStorage ? 0 : ownedItemCount(options.sessionStorage));
let inventory = { recordCount: 0, unavailable: true };
try {
inventory = await options.inspectPrivateDatabases?.(options.privateDatabases) || inventory;
} catch (_error) { /* Unknown private work must use the guarded confirmation path. */ }
const recordCount = Math.max(0, Number(inventory.recordCount) || 0);
options.heading.textContent = mode === 'all' ? 'Review sign out on every device' : 'Review sign out';
options.summary.textContent = inventory.unavailable
? `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'}; private work status is unknown.`
: `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'} and ${recordCount} private work record${recordCount === 1 ? '' : 's'}.`;
const atRisk = itemCount > 0 || recordCount > 0 || inventory.unavailable;
if (mode === 'all') {
options.warning.textContent = atRisk
? 'Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.'
: 'Every device will need to sign in again. This device will be cleared after its session is revoked.';
} else {
options.warning.textContent = atRisk
? 'Private drafts or queued work may not be synced. Signing out erases them from this device.'
: 'Signing out clears this device after the session is revoked.';
}
options.confirmButton.textContent = atRisk
? 'Sign out and erase private work'
: (mode === 'all' ? 'Sign out all devices' : 'Sign out');
options.sheet.hidden = false;
(options.backgroundTargets || []).forEach(target => { target.inert = true; });
open = true;
options.history?.pushState?.({ stackchainSignOutReview: true }, '');
historyEntry = Boolean(options.history?.back);
options.cancelButton.focus();
}
function close({ restoreFocus = true } = {}) {
if (!open) return;
options.sheet.hidden = true;
(options.backgroundTargets || []).forEach(target => { target.inert = false; });
open = false;
if (restoreFocus) launcher?.focus?.();
}
function dismiss() {
if (historyEntry) {
historyEntry = false;
options.history.back();
} else close();
}
async function confirm() {
options.confirmButton.disabled = true;
try {
await options.onConfirm(mode);
close({ restoreFocus: false });
} catch (_error) {
options.warning.textContent = 'Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.';
} finally {
options.confirmButton.disabled = false;
}
}
function start() {
options.launcher?.addEventListener('click', event => show('current', event.currentTarget || options.launcher));
options.allLauncher?.addEventListener('click', event => show('all', event.currentTarget || options.allLauncher));
options.cancelButton?.addEventListener('click', dismiss);
options.confirmButton?.addEventListener('click', confirm);
options.escapeTarget?.addEventListener('keydown', event => {
if (!open) return;
if (event.key === 'Escape') { event.preventDefault(); dismiss(); return; }
if (event.key !== 'Tab') return;
const focusable = Array.from(options.sheet.querySelectorAll('button:not([disabled])'));
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = options.getActiveElement?.();
if (!event.shiftKey && active === last) { event.preventDefault(); first?.focus(); }
if (event.shiftKey && active === first) { event.preventDefault(); last?.focus(); }
});
options.historyTarget?.addEventListener('popstate', () => {
historyEntry = false;
close();
});
}
return { start, show, close };
});
+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;
+78 -4
View File
@@ -1,17 +1,91 @@
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,
completeActivation = () => globalThis.stackchainFirstTaskCompleting?.(),
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;
}
todaySync.enqueue('remove', todayWork.identity(item));
const operations = [
{action:'remove', item_id:identity},
...(completeActivation() ? [{action:'activate', item_id:'first-task', activation_state:'complete'}] : []),
];
const admitted = todaySync.enqueueBatch ? todaySync.enqueueBatch(operations) :
todaySync.enqueue('remove', identity);
if (!admitted) {
todayWork.restore?.(snapshot);
announce(options.admissionFailureMessage ||
'Device storage is full. Free space and try again.');
return false;
}
todaySync.flush();
refresh();
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;
+171
View File
@@ -0,0 +1,171 @@
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() {
if (!enabled()) return false;
await post({type:'stackchain-claim-today-action'});
return true;
},
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();
+7 -1
View File
@@ -4,7 +4,13 @@ function createTodayRollover(options = {}) {
Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
function timeZone() {
return options.timeZone?.() || resolvedTimeZone();
const candidate = options.timeZone?.() || resolvedTimeZone();
try {
new Intl.DateTimeFormat('en', { timeZone: candidate }).format(now());
return candidate;
} catch (_error) {
return 'UTC';
}
}
function localDate() {
+234
View File
@@ -0,0 +1,234 @@
function createTodaySessionSync({
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
onOwnedRestore = () => {},
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;
if (Number.isFinite(session.break_deadline_at) &&
timer?.restoreBreak?.(session.identity, session.break_deadline_at)) onOwnedRestore(session);
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, entries:current.entries, 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, session.entries);
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)),
entries:Array.isArray(snapshot.entries) ? snapshot.entries : undefined,
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?.sessionSnapshot?.() || 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));
if (Array.isArray(session?.entries) && session.entries.length > 1) {
const total = session.entries.reduce((sum, entry) => sum + Math.max(0, Number(entry?.elapsed_ms) || 0), 0);
return `${session.entries.length} tracked items · ${Math.floor(total / 60000)} min total`;
}
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,
onOwnedRestore:renderTimer,
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,
};
+359
View File
@@ -0,0 +1,359 @@
function createTodaySummary({
storage = null, getLogin = () => '', share = null, copy = null,
resolveTarget = null, enqueueDurably = null,
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
} = {}) {
let draft = null;
let draftKey = '';
let postPromise = null;
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),
target:draft.target ? { ...draft.target } : null,
} : 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');
const validTarget = Boolean(saved) && (saved.target === null || saved.target === undefined || (saved.target &&
typeof saved.target.repository === 'string' && /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(saved.target.repository) &&
Number.isInteger(saved.target.number) && saved.target.number > 0 &&
['issue', 'pull'].includes(saved.target.kind) && typeof saved.target.title === 'string' &&
typeof saved.target.state === 'string'));
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000 ||
!['string', 'undefined'].includes(typeof saved.destination) || !validTarget ||
!['string', 'undefined'].includes(typeof saved.operation_id)) {
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:'',
destination:'',
target:null,
operation_id:'',
};
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;
},
setDestination(value) {
syncAccount();
if (!draft) return null;
const match = String(value || '').trim().match(/^([a-z0-9_.-]+)\s*\/\s*([a-z0-9_.-]+)\s*#\s*([1-9][0-9]*)$/i);
draft.destination = String(value || '').trim().slice(0, 260);
draft.target = null;
draft.operation_id = '';
persist();
if (!match) return null;
const repository = (match[1] + '/' + match[2]).toLowerCase();
return { repository, number:Number(match[3]), label:repository + '#' + Number(match[3]) };
},
async validateDestination() {
syncAccount();
const parsed = this.setDestination(draft?.destination || '');
if (!parsed) throw new Error('Enter a destination like owner/repo#42.');
if (typeof resolveTarget !== 'function') throw new Error('Destination validation is unavailable.');
const resolved = await resolveTarget({ repository:parsed.repository, number:parsed.number });
if (!resolved || resolved.repository !== parsed.repository || Number(resolved.number) !== parsed.number ||
!['issue', 'pull'].includes(resolved.kind)) throw new Error('Choose an exact visible issue or pull request.');
draft.target = {
repository:parsed.repository, number:parsed.number, kind:resolved.kind,
title:String(resolved.title || '').slice(0, 180), state:String(resolved.state || ''),
};
draft.operation_id = String(createOperationId()).slice(0, 128);
persist();
return { ...draft.target };
},
postSummary() {
if (postPromise) return postPromise;
syncAccount();
const body = this.text();
if (!body) throw new Error('Select at least one summary item or add a note.');
if (!draft?.target || !draft.operation_id) throw new Error('Validate an exact Gitea destination first.');
if (typeof enqueueDurably !== 'function') throw new Error('Gitea posting is unavailable.');
const target = { ...draft.target };
const message = {
kind:'search-reply', repository:target.repository, number:target.number,
targetKind:target.kind, body, operationId:draft.operation_id,
};
postPromise = (async () => {
try {
const admission = await enqueueDurably(message);
discard();
return { status:'queued', durability:admission?.durability || 'foreground-only', target };
} finally {
postPromise = null;
}
})();
return postPromise;
},
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-destination').value = draft.destination || '';
qs('#today-summary-target').textContent = draft.target ?
(draft.target.repository + '#' + draft.target.number + ' · ' + draft.target.kind + ' · ' + draft.target.title) : '';
qs('#post-today-summary').disabled = !draft.target;
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;
}
}
async function validateDestination(button) {
button.disabled = true;
qs('#today-summary-status').textContent = 'Checking destination…';
try {
await summary.validateDestination();
render();
qs('#today-summary-status').textContent = 'Destination confirmed. Review it, then post once.';
} catch (error) {
qs('#today-summary-status').textContent = error.message || 'Destination could not be confirmed.';
} finally {
button.disabled = false;
}
}
async function postDraft(button) {
button.disabled = true;
qs('#today-summary-status').textContent = 'Saving comment for delivery…';
try {
const result = await summary.postSummary();
qs('#my-work-action-status').textContent = 'Today summary queued for ' +
result.target.repository + '#' + result.target.number + '.';
close();
} catch (error) {
qs('#today-summary-status').textContent = error.message || 'Summary could not be queued. Your review is unchanged.';
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('#today-summary-destination').addEventListener('input', event => {
summary.setDestination(event.currentTarget.value);
qs('#today-summary-target').textContent = '';
qs('#post-today-summary').disabled = true;
});
qs('#validate-today-summary-destination').addEventListener('click', event => validateDestination(event.currentTarget));
qs('#post-today-summary').addEventListener('click', event => postDraft(event.currentTarget));
qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
}
return {
open, close, render, bind, shareDraft, validateDestination, postDraft,
resume() { if (summary.restore()) show(); },
};
}
function setupTodaySummary({ qs, escapeHtml, getLogin, resolveTarget, enqueueDurably, fetchJson }) {
const targetResolver = resolveTarget || (async target => {
if (typeof fetchJson !== 'function') throw new Error('Destination validation is unavailable.');
const item = await fetchJson('api/v1/repos/' + target.repository + '/issues/' + target.number +
'/preview?kind=issue');
if (String(item.repository || '').toLowerCase() !== target.repository ||
Number(item.number) !== target.number || !['issue', 'pull'].includes(item.kind)) {
throw new Error('That exact issue or pull request is not visible to this account.');
}
return { repository:target.repository, number:target.number, kind:item.kind,
title:item.title || '', state:item.state || '' };
});
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,
resolveTarget:targetResolver,
enqueueDurably,
});
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;
}
+97 -25
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() {
@@ -59,12 +61,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
revision: plan.revision, ids: plan.ids,
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
};
if (['coaching', 'complete'].includes(plan.first_task_state)) {
snapshot.first_task_state = plan.first_task_state;
}
if (plan.plan_date) {
snapshot.plan_date = plan.plan_date;
snapshot.timezone = plan.timezone || null;
}
try {
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
const login = String(getLogin?.() || '').trim().toLowerCase();
const activationKey = login && 'stackchain.first-task.v1:' + login;
const current = activationKey && storage?.getItem(activationKey);
const rank = {'': 0, coaching: 1, complete: 2};
if (activationKey && rank[snapshot.first_task_state] > (rank[current] || 0)) {
storage?.setItem(activationKey, snapshot.first_task_state);
}
} catch (_error) {
// A storage quota failure must not prevent the current tab from using server truth.
}
@@ -108,10 +120,31 @@ 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 operations = Array.isArray(record?.operations) ? record.operations : [record?.operation];
const valid = operations.length && operations.every(operation => operation &&
typeof operation.operation_id === 'string' &&
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) &&
typeof operation.item_id === 'string') && Number.isFinite(Number(record.queued_at));
if (valid) operations.forEach((operation, index) => {
records.push({operation, queued_at:Number(record.queued_at) + index / 1000, 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 => {
@@ -131,7 +164,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
? record.operation.base_revision : Math.max(0, savedRevision()),
}))
.filter(operation => operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) && typeof operation.item_id === 'string');
} catch (_error) {
return [];
}
@@ -156,7 +189,25 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function enqueue(action, itemId, direction = null) {
function saveOperations(operations, queuedAt = now()) {
const storageKey = key();
if (!storageKey || !storage) return false;
const batched = operations.length > 1;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operations[0].operation_id);
try {
storage.setItem(recordKey, JSON.stringify(batched ?
{operations, queued_at:queuedAt} : {operation:operations[0], queued_at:queuedAt}));
knownOperationKeys.add(recordKey);
coordinator?.notify('today');
onStatus?.('pending');
return true;
} catch (_error) {
onStatus?.('error');
return false;
}
}
function enqueue(action, itemId, direction = null, fields = {}) {
const operations = pending();
if (action === 'remove' && operations.some(operation =>
operation.action === 'remove' && operation.item_id === itemId
@@ -166,20 +217,17 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
}
const operation = {
operation_id: operationId(), action, item_id: itemId, direction,
base_revision: Math.max(0, savedRevision()),
base_revision: Math.max(0, savedRevision()), ...fields,
};
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false;
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('today');
} catch (_error) { /* Report the persistence failure below. */ }
onStatus?.(saved ? 'pending' : 'error');
return saved;
return saveOperations([operation], now() + operations.length);
}
function enqueueBatch(specifications) {
const operations = specifications.map(specification => ({
operation_id: operationId(), direction: specification.direction ?? null,
base_revision: Math.max(0, savedRevision()), ...specification,
}));
return saveOperations(operations);
}
function enqueueConfiguration(capacityMinutes, estimates) {
@@ -203,6 +251,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
}
}
function enqueueActivation(state) {
if (!['coaching', 'complete'].includes(state)) return false;
const rank = {coaching: 1, complete: 2};
const queued = pending().filter(operation => operation.action === 'activate');
if (queued.some(operation => rank[operation.activation_state] >= rank[state])) return true;
queued.forEach(operation => removeOperation(operation.operation_id));
return enqueue('activate', 'first-task', null, {activation_state: state});
}
function enqueueRollover(proposed) {
if (!proposed || proposed.action !== 'rollover') return false;
const operation = {
@@ -246,7 +303,10 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
let operations = pending();
let plan;
let hadConflict = false;
if (!operations.length) plan = await fetchJson('api/v1/today');
if (!operations.length) {
plan = await fetchJson('api/v1/today');
operations = pending();
}
while (operations.length) {
if (key() !== ownerKey) return false;
const batch = operations.slice(0, 50);
@@ -272,14 +332,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;
}
}
@@ -294,6 +363,9 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
function startLifecycle({ window: windowObject, document: documentObject }) {
windowObject?.addEventListener?.('online', flush);
windowObject?.addEventListener?.('stackchain:first-task-complete', () => {
if (enqueueActivation('complete')) flush();
});
documentObject?.addEventListener?.('visibilitychange', () =>
documentObject.hidden ? false : flush()
);
@@ -303,7 +375,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
if (change.queue === 'today' && pending().length) flush();
});
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
return { enqueue, enqueueBatch, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
+403 -10
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', 'live-data-status'].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;
@@ -50,20 +75,65 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
entry.running = false;
return state;
};
const snapshot = (identity = '') => {
const snapshot = (identity = '', includeEntries = false) => {
const state = read();
const sampledAt = now();
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, sampledAt - Number(entry.started_at ?? sampledAt)) : 0),
running:Boolean(entry.running),
};
const timedBreak = validBreak(state);
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
if (includeEntries) {
result.entries = Object.entries(state.entries).slice(0, 20).map(([entryIdentity, value]) => ({
identity:entryIdentity,
elapsed_ms:Math.max(0, Math.floor(Number(value.elapsed_ms) || 0) + (value.running ?
Math.max(0, sampledAt - Number(value.started_at ?? sampledAt)) : 0)),
}));
}
return result;
};
return {
adopt(identity, elapsedMs, running, entries = null) {
if (!key() || typeof identity !== 'string' || !identity ||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
const incoming = entries === null ? [{identity, elapsed_ms:elapsedMs}] : entries;
if (!Array.isArray(incoming) || incoming.length > 20) return false;
const seen = new Set();
const ledger = {};
for (const entry of incoming) {
if (typeof entry?.identity !== 'string' || !entry.identity || entry.identity.length > 500 ||
seen.has(entry.identity) || !Number.isFinite(Number(entry.elapsed_ms)) || Number(entry.elapsed_ms) < 0) return false;
seen.add(entry.identity);
ledger[entry.identity] = {
elapsed_ms:Math.floor(Number(entry.elapsed_ms)), started_at:null, running:false,
};
}
if (!ledger[identity] || ledger[identity].elapsed_ms !== Math.floor(Number(elapsedMs))) return false;
const state = read();
state.active_identity = identity;
state.entries = ledger;
state.entries[identity].started_at = running ? now() : null;
state.entries[identity].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();
if (state.active_identity === identity && state.entries[identity]?.running) return true;
if (state.active_identity === identity &&
(state.entries[identity]?.running || validBreak(state)?.identity === identity)) return true;
settle(state);
state.active_identity = identity;
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
@@ -73,6 +143,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 +162,41 @@ 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;
},
restoreBreak(identity, deadlineAt) {
const deadline = Number(deadlineAt);
if (typeof identity !== 'string' || !identity || !Number.isFinite(deadline) || deadline <= now()) return false;
const state = read();
const entry = state.entries[identity];
if (state.active_identity !== identity || !entry || entry.running || validBreak(state)) return false;
state.timed_break = { identity, deadline_at:Math.floor(deadline) };
return write(state);
},
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 +205,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 +240,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', 'live-data-status'].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];
@@ -197,12 +415,32 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
}, 0);
},
snapshot,
sessionSnapshot:(identity = '') => snapshot(identity, true),
};
}
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 +486,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 +541,49 @@ 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(); },
restore(recovery) {
render();
return Promise.resolve(recovery).catch(() => false).then(() => { render(); return !!timer.breakSnapshot?.(); });
},
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 +635,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;
}
+329
View File
@@ -0,0 +1,329 @@
function createTodayWeekReschedule({
week,
api,
getToday,
adoptToday = () => {},
operationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()),
storage = null,
getLogin = () => '',
now = () => Date.now(),
} = {}) {
let current = null;
let flushing = null;
const storagePrefix = 'stackchain.today-week-reschedule.v1.';
function storageKey() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? storagePrefix + encodeURIComponent(login) : '';
}
function validRecord(value) {
return typeof value?.body?.operation_id === 'string' &&
Array.isArray(value?.today?.ids) && Array.isArray(value?.week?.days);
}
function readQueue() {
const key = storageKey();
if (!key || !storage) return [];
try {
const value = JSON.parse(storage.getItem(key) || 'null');
if (Array.isArray(value?.records)) return value.records.filter(validRecord);
return validRecord(value) ? [value] : [];
} catch (_error) { return []; }
}
function saveQueue(records) {
const key = storageKey();
if (!key || !storage) return false;
try {
if (!records.length) storage.removeItem(key);
else storage.setItem(key, JSON.stringify({version:2, records}));
return true;
}
catch (_error) { return false; }
}
function persist(record) { return saveQueue([...readQueue(), record]); }
function pending() {
const key = storageKey();
if (!key || !storage) return null;
return readQueue()[0] || null;
}
function resume() {
const records = readQueue();
const record = records[records.length - 1];
if (!record) return null;
adoptToday(record.today);
week.adopt(record.week);
return {...record, sync_pending:true, pending_count:records.length};
}
async function restoreConflict(error) {
if (error?.status !== 409) throw error;
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
adoptToday(today);
week.adopt(weekState);
const conflict = new Error('Plans changed on another device. Review and retry this saved move.');
conflict.status = 409;
throw conflict;
}
function flush() {
if (flushing) return flushing;
if (!pending()) return Promise.resolve(false);
flushing = (async () => {
let result = null;
while (true) {
const records = readQueue();
const record = records[0];
if (!record) return result ? {...result, sync_pending:false, pending_count:0} : false;
try {
result = await api('api/v1/week/reschedule', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body),
});
} catch (error) { await restoreConflict(error); }
adoptToday(result.today);
week.adopt(result.week);
const latest = readQueue();
if (latest[0]?.body?.operation_id !== record.body.operation_id) continue;
const remaining = latest.slice(1).map((entry, index) => index ? entry : ({
...entry,
body:{...entry.body, today_revision:result.today.revision, week_revision:result.week.revision},
}));
if (!saveQueue(remaining)) throw new Error('Move synced, but its local queue could not be updated.');
}
})().finally(() => { flushing = null; });
return flushing;
}
function optimisticSnapshots(planDate, estimate) {
const today = {
...current.today,
ids: current.today.ids.filter(id => id !== current.identity),
estimates: {...(current.today.estimates || {})},
};
delete today.estimates[current.identity];
const days = (current.week.days || []).map(day => {
const estimates = {...(day.estimates || {})}; delete estimates[current.identity];
return {...day, ids:(day.ids || []).filter(id => id !== current.identity), estimates};
});
let destination = days.find(day => day.plan_date === planDate);
if (!destination) {
destination = {plan_date:planDate, ids:[], capacity_minutes:null, estimates:{}};
days.push(destination);
}
destination.ids.push(current.identity);
destination.estimates[current.identity] = estimate;
return {today, week:{revision:current.week_revision, timezone:current.week.timezone || null, days}};
}
async function open(identity) {
let today;
const queued = readQueue().length;
let offline = Boolean(queued);
if (queued) {
today = getToday?.();
} else {
try { today = await api('api/v1/today'); }
catch (error) {
if (error?.status === 401 || error?.status === 403) throw error;
today = getToday?.(); offline = true;
}
}
if (!identity || !today?.ids?.includes(identity)) {
throw new Error('The active Today item changed. Reopen rescheduling.');
}
const loaded = queued ? week.state?.() : await week.load();
if (!loaded) throw new Error('Reload Today before rescheduling another saved move.');
if (loaded?.sync_pending) {
throw new Error('Reconnect before rescheduling Today into Week Ahead.');
}
offline = offline || Boolean(loaded?.offline_snapshot);
const estimate = Number(today.estimates?.[identity]);
const estimateMinutes = Number.isFinite(estimate) && estimate > 0 ? estimate : null;
const days = week.review().days.map(day => {
const ids = (day.ids || []).filter(id => id !== identity);
const planned = Number(day.planned_minutes) || 0;
const existing = Number(day.estimates?.[identity]) || 0;
return {
...day,
ids,
planned_minutes: Math.max(0, planned - existing),
load: `${Math.max(0, planned - existing)} / ${Number(day.capacity_minutes) || 0} min`,
eligible: ids.length < 5,
};
});
current = {
identity,
today_revision: today.revision,
week_revision: loaded.revision,
operation_id: operationId(),
estimate_minutes: estimateMinutes,
days,
today,
week: loaded,
offline,
};
return {...current, days:days.map(day => ({...day, ids:[...day.ids]}))};
}
async function confirm(planDate, estimateMinutes, {allowOverload = false} = {}) {
if (!current) throw new Error('Open rescheduling before choosing a day.');
const day = current.days.find(value => value.plan_date === planDate);
if (!day) throw new Error('Choose one of the next seven days.');
const estimate = Number(estimateMinutes);
if (!Number.isFinite(estimate) || estimate < 5 || estimate > 1440) {
throw new Error('Estimate must be between 5 and 1440 minutes.');
}
if (!day.eligible) throw new Error('That Week Ahead day already has five items.');
const capacity = Number(day.capacity_minutes) || 0;
if (capacity && day.planned_minutes + estimate > capacity && !allowOverload) {
throw new Error("That move exceeds the day's capacity. Confirm overload before rescheduling.");
}
const body = {
operation_id:current.operation_id,
identity:current.identity,
estimate_minutes:estimate,
plan_date:planDate,
today_revision:current.today_revision,
week_revision:current.week_revision,
allow_over_capacity:allowOverload,
};
const optimistic = optimisticSnapshots(planDate, estimate);
const durable = Boolean(storageKey() && storage);
const admitted = durable && persist({body, ...optimistic, queued_at:now()});
if (durable && !admitted) {
throw new Error('Could not save this move on this device. Nothing changed.');
}
if (admitted) {
adoptToday(optimistic.today);
week.adopt(optimistic.week);
}
if (current.offline) {
current = null;
return {...optimistic, sync_pending:true, pending_count:readQueue().length};
}
let result;
try {
result = admitted ? await flush() : await api('api/v1/week/reschedule', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
});
} catch(error) {
if (admitted && error?.status === 409) await restoreConflict(error);
if (admitted && error?.status !== 401 && error?.status !== 403) {
current = null;
return {...optimistic, sync_pending:true};
}
throw error;
}
adoptToday(result.today);
week.adopt(result.week);
current = null;
return result;
}
function cancel() { current = null; }
return {open, confirm, cancel, state:() => current, pending, resume, flush};
}
function mountTodayWeekReschedule({
qs, document=globalThis.document, window=globalThis.window, week, api, getToday, adoptToday, currentTarget, closeActions,
refresh, warm, continueToday, announce, storage=null, getLogin=()=>'', schedule=callback=>requestAnimationFrame(callback),
}={}) {
const dialog=qs('#today-week-reschedule'),daysRoot=qs('#today-week-reschedule-days');
const estimate=qs('#today-week-reschedule-estimate'),status=qs('#today-week-reschedule-status');
const confirm=qs('#confirm-today-week-reschedule'),launcher=qs('[data-work-session-reschedule-week]');
let selectedDate=null,allowOverload=false;
const controller=createTodayWeekReschedule({week,api,getToday,adoptToday,storage,getLogin});
async function flushPending() {
if(!controller.pending())return false;
try {
const result=await controller.flush();
announce('Today-to-Week move synced to your account.');
await refresh();warm();
return result;
} catch(error) {
announce(`${error.message||'Sync unavailable.'} Move saved on this device · sync pending.`);
return false;
}
}
function resumePending() {
const restored=controller.resume();
if(!restored)return false;
announce('Today-to-Week move saved on this device · sync pending.');
flushPending();
return restored;
}
window?.addEventListener('online', flushPending);
document?.addEventListener('visibilitychange',()=>document.hidden?false:flushPending());
let restoreAttempts=0;
function restoreWhenOwned(){
if(resumePending()||getLogin()||restoreAttempts++>=20)return;
window?.setTimeout(restoreWhenOwned,250);
}
restoreWhenOwned();
function close() {
controller.cancel();selectedDate=null;allowOverload=false;
if(dialog.open)dialog.close();schedule(()=>{
qs('[data-mobile-today-more]').click();launcher.focus();
});
}
function render(opened) {
daysRoot.replaceChildren(...opened.days.map(day=>{
const button=document.createElement('button');
button.type='button';button.dataset.planDate=day.plan_date;button.setAttribute('role','radio');
button.setAttribute('aria-checked','false');button.disabled=!day.eligible;
const label=document.createElement('strong');label.textContent=day.label;
const load=document.createElement('span');load.className='small';
load.textContent=day.eligible?day.load:'5 items · full';button.append(label,load);
button.addEventListener('click',()=>{
selectedDate=day.plan_date;allowOverload=false;
daysRoot.querySelectorAll('button').forEach(choice=>choice.setAttribute(
'aria-checked',String(choice===button)
));
status.textContent='';confirm.disabled=false;confirm.textContent='Move to Week Ahead & continue';
});
return button;
}));
}
launcher.addEventListener('click',async()=>{
const target=currentTarget();
if(!target){announce('Start or resume a checkpointed Today item before rescheduling.');return;}
closeActions();status.textContent='Loading Week Ahead…';confirm.disabled=true;dialog.showModal();
try{
const opened=await controller.open(target.identity);render(opened);
estimate.value=opened.estimate_minutes||'';
status.textContent=opened.estimate_minutes?'Choose a future day.':'Add an estimate, then choose a future day.';
schedule(()=>daysRoot.querySelector('button:not(:disabled)')?.focus());
}catch(error){status.textContent=error.message;}
});
qs('#cancel-today-week-reschedule').addEventListener('click',close);
dialog.addEventListener('cancel',event=>{event.preventDefault();close();});
confirm.addEventListener('click',async()=>{
if(!selectedDate)return;
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';
try{
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
dialog.close();
if(result.sync_pending){
announce('Moved locally. Saved on this device · sync pending.');warm();
}else{
await refresh();warm();announce('Moved to Week Ahead. Continuing Today.');
}
await continueToday();
}catch(error){
const overload=error.message.includes('Confirm overload');allowOverload=overload;
status.textContent=error.message;
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
confirm.disabled=false;
}
});
return {controller,close,flushPending,resumePending};
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createTodayWeekReschedule;
module.exports.mount = mountTodayWeekReschedule;
}
+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;
+51 -14
View File
@@ -1,4 +1,4 @@
function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
function createTodayWrapUp({ todayWork, tomorrowPlan, todaySync, limit = 5 }) {
let items = [];
const selected = new Set();
@@ -28,16 +28,39 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
}
async function finish() {
const wake = laterWork.presetUntil('tomorrow');
let scheduled = 0;
for (const item of items) {
const loaded = await tomorrowPlan.load();
const currentIds = Array.isArray(loaded?.ids) ? [...loaded.ids] : [];
const carried = items.filter(item => selected.has(todayWork.identity(item)) && todayWork.contains(item));
if (!carried.length) {
return {
scheduled:0,
left:items.filter(item => todayWork.contains(item)).length,
plan_count:currentIds.length,
plan_date:loaded?.plan_date,
sync_pending:Boolean(loaded?.sync_pending),
};
}
const combinedIds = [...currentIds];
for (const item of carried) {
const identity = todayWork.identity(item);
if (!combinedIds.includes(identity)) combinedIds.push(identity);
}
if (combinedIds.length > limit) {
throw new Error(`Tomorrow can hold ${limit} items. Keep ${combinedIds.length - limit} in Today or edit Tomorrow first.`);
}
const estimates = {...(loaded?.estimates || {})};
const capacityMinutes = loaded?.capacity_minutes ?? null;
const plannedMinutes = combinedIds.reduce((total, identity) => total + (Number(estimates[identity]) || 0), 0);
if (capacityMinutes !== null && plannedMinutes > capacityMinutes) {
throw new Error('Tomorrow is over capacity. Edit the plan before finishing wrap-up.');
}
const staged = tomorrowPlan.stage({ids:combinedIds, capacity_minutes:capacityMinutes, estimates});
if (!staged) throw new Error('Tomorrow could not be saved on this device. Your Today plan is unchanged.');
let scheduled = 0;
for (const item of carried) {
const identity = todayWork.identity(item);
if (!selected.has(identity) || !todayWork.contains(item)) continue;
const deferred = laterWork.defer(item, wake, {handoff:'today'});
if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.');
if (!todaySync.enqueue('remove', identity)) {
laterWork.restore?.(item);
throw new Error('Today sync could not be queued. Your Today plan is unchanged.');
throw new Error('Today sync could not be queued. The item remains in Today.');
}
if (todayWork.contains(item) && !todayWork.remove(item)) {
throw new Error('Today could not be updated. Retry wrap-up.');
@@ -45,7 +68,13 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
scheduled += 1;
}
await todaySync.flush();
return { scheduled, left:items.length - scheduled, wake_at:wake.toISOString() };
return {
scheduled,
left:items.length - scheduled,
plan_count:combinedIds.length,
plan_date:staged.plan_date,
sync_pending:Boolean(staged.sync_pending),
};
}
return { open, choose, snapshot, finish };
@@ -53,6 +82,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;
@@ -68,15 +98,16 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
'> Tomorrow 09:00</label></div>';
'> Carry to Tomorrow</label></div>';
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
});
}
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 +120,16 @@ 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.';
qs('#my-work-action-status').textContent = result.scheduled + ' carried to Tomorrow · ' + result.left +
' left in Today' + (result.sync_pending ? ' · sync pending.' : '.');
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 {
+171
View File
@@ -0,0 +1,171 @@
function createTomorrowPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
let plan={revision:0,ids:[],capacity_minutes:null,estimates:{}};
let flushing=null;
let lastConflict=null;
const storagePrefix='stackchain.tomorrow-sync.v1.';
const state=()=>({...plan,ids:[...plan.ids],estimates:{...plan.estimates}});
function storageKey() {
const login=String(getLogin?.()||'').trim().toLowerCase();
return login?storagePrefix+encodeURIComponent(login):'';
}
function pending() {
const key=storageKey();
if(!key||!storage) return false;
try {
const value=JSON.parse(storage.getItem(key)||'null');
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.ids)?
{...value,ids:[...value.ids],estimates:{...(value.estimates||{})},sync_pending:true}:false;
} catch (_error) { return false; }
}
function adopt(value) {
if (!Number.isInteger(value?.revision)||!Array.isArray(value?.ids)) return false;
plan={revision:value.revision,ids:[...value.ids],capacity_minutes:value.capacity_minutes??null,
estimates:{...(value.estimates||{})},...(value.plan_date?{plan_date:value.plan_date,timezone:value.timezone||null}:{})};
return state();
}
function nextLocalDate() {
const [y,m,d]=localDate().split('-').map(Number);
return new Date(Date.UTC(y,m-1,d+1)).toISOString().slice(0,10);
}
async function load(){
const queued=pending();
return queued?(plan={...queued},state()):adopt(await fetchJson('api/v1/tomorrow'));
}
function stage(value) {
const key=storageKey();
if(!key||!storage) return false;
const queued={base_revision:plan.revision,revision:plan.revision,ids:[...(value.ids||[])],
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
plan_date:nextLocalDate(),timezone:timeZone(),sync_pending:true};
try { storage.setItem(key,JSON.stringify(queued)); }
catch (_error) { return false; }
lastConflict=null;
plan=queued;
return state();
}
function deliveryBody(value) {
return {base_revision:value.base_revision,ids:[...value.ids],
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
plan_date:value.plan_date,timezone:value.timezone};
}
function flush() {
if(flushing) return flushing;
const queued=pending();
const key=storageKey();
if(!queued||!key) return Promise.resolve(false);
const body=deliveryBody(queued);
flushing=fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify(body)}).then(saved=>{
const current=pending();
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
if(!pending()) adopt(saved);
return saved;
}).catch(async error=>{
if(error?.status===409) {
const remote=await fetchJson('api/v1/tomorrow');
lastConflict={key,local:pending(),remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}};
}
throw error;
}).finally(()=>{flushing=null;});
return flushing;
}
async function keepLocal() {
if(!lastConflict||lastConflict.key!==storageKey()) return false;
const conflictKey=lastConflict.key;
const local={...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}};
const body=deliveryBody({...local,base_revision:lastConflict.remote.revision});
try {
const saved=await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify(body)});
const current=pending();
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
if(!pending()) adopt(saved);
lastConflict=null;
return saved;
} catch(error) {
if(error?.status===409) {
const remote=await fetchJson('api/v1/tomorrow');
lastConflict={key:conflictKey,local,remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}};
}
throw error;
}
}
function useRemote() {
if(!lastConflict||lastConflict.key!==storageKey()) return false;
const remote=lastConflict.remote;
const key=storageKey();
if(key&&storage) storage.removeItem(key);
const adopted=adopt(remote);
lastConflict=null;
return adopted;
}
async function save(value) {
const body={base_revision:plan.revision,ids:[...(value.ids||[])],
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
plan_date:nextLocalDate(),timezone:timeZone()};
return adopt(await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
}
async function promote(today_revision) {
if (pending()||!plan.plan_date||plan.plan_date>localDate()||!plan.ids.length) return false;
const promotion_id=`tomorrow-${plan.plan_date}-r${plan.revision}`;
return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({promotion_id,tomorrow_revision:plan.revision,today_revision})});
}
function millisecondsUntilNextDay() {
const now=Date.now();
const zone=timeZone();
let formatter;
try {
formatter=new Intl.DateTimeFormat('en-CA',{timeZone:zone,year:'numeric',month:'2-digit',day:'2-digit',
hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'});
} catch (_error) {
const next=new Date(now);
next.setHours(24,0,0,250);
return Math.max(1,next.getTime()-now);
}
const zoned=value=>Object.fromEntries(formatter.formatToParts(new Date(value))
.filter(part=>part.type!=='literal').map(part=>[part.type,Number(part.value)]));
const current=zoned(now);
const targetWall=Date.UTC(current.year,current.month-1,current.day+1);
let candidate=targetWall;
for(let attempt=0;attempt<2;attempt+=1){
const parts=zoned(candidate);
const offset=Date.UTC(parts.year,parts.month-1,parts.day,parts.hour,parts.minute,parts.second)-candidate;
candidate=targetWall-offset;
}
return Math.max(1,candidate-now+250);
}
function summary(value=plan) {
const ids=Array.isArray(value?.ids)?value.ids:[];
const suffix=value?.sync_pending?' · sync pending':'';
if(!ids.length) return 'Nothing planned'+suffix;
const label=`${ids.length} planned`;
const estimates=value.estimates||{};
const estimated=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0);
const capacity=Number(value.capacity_minutes)||0;
return (estimated&&capacity?`${label} · ${estimated} of ${capacity} min`:label)+suffix;
}
function startLifecycle({windowObject,documentObject,check,setTimer=setTimeout,clearTimer=clearTimeout,
nextDelay=millisecondsUntilNextDay}={}) {
let flight=null;
let timer=null;
const schedule=()=>{
if(timer!==null) clearTimer(timer);
timer=setTimer(run,nextDelay());
};
function run(){
if(!flight) flight=Promise.resolve().then(check).finally(()=>{flight=null;schedule();});
return flight;
}
windowObject?.addEventListener?.('online',run);
documentObject?.addEventListener?.('visibilitychange',()=>documentObject.hidden?false:run());
run();
return {run,stop(){if(timer!==null)clearTimer(timer);timer=null;}};
}
function conflict() {
return lastConflict?.key===storageKey()?{local:{...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}},
remote:{...lastConflict.remote,ids:[...lastConflict.remote.ids],estimates:{...lastConflict.remote.estimates}}}:null;
}
return {adopt,load,save,stage,pending,flush,conflict,keepLocal,useRemote,promote,state,summary,nextLocalDate,startLifecycle};
}
if(typeof module!=='undefined'&&module.exports)module.exports=createTomorrowPlan;
+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};
+298
View File
@@ -0,0 +1,298 @@
function createWeekCalendarImport() {
const maxBytes=1024*1024;
const zonedInstantFormatters=new Map(),zonePartFormatters=new Map(),supportedTimeZones=new Map();
function clock(value) {
const match=/^(\d{2}):(\d{2})$/.exec(String(value||''));
if(!match)return null;
const minutes=Number(match[1])*60+Number(match[2]);
return Number(match[1])<24&&Number(match[2])<60?minutes:null;
}
function instant(value) {
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value||'');
if(!match)return null;
const parts=match.slice(1,7).map(Number);
const milliseconds=match[7]?Date.UTC(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]):
new Date(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]).getTime();
return Number.isFinite(milliseconds)?milliseconds:null;
}
function zonedInstant(value,timeZone) {
if(!timeZone)return instant(value);
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/.exec(value||'');
if(!match)return null;
try {
const wanted=match.slice(1).map(Number);
if(!zonedInstantFormatters.has(timeZone))zonedInstantFormatters.set(timeZone,new Intl.DateTimeFormat('en-CA',{
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23',
}));
const formatter=zonedInstantFormatters.get(timeZone);
let result=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5]);
for(let attempt=0;attempt<2;attempt+=1) {
const shown=Object.fromEntries(formatter.formatToParts(new Date(result)).map(part=>[part.type,part.value]));
const represented=Date.UTC(Number(shown.year),Number(shown.month)-1,Number(shown.day),Number(shown.hour),Number(shown.minute),Number(shown.second));
result+=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5])-represented;
}
return result;
} catch(error) { return null; }
}
function supportedTimeZone(timeZone) {
if(!timeZone)return true;
if(supportedTimeZones.has(timeZone))return supportedTimeZones.get(timeZone);
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);supportedTimeZones.set(timeZone,true);return true;}
catch(error) {supportedTimeZones.set(timeZone,false);return false;}
}
function zoneParts(value,timeZone) {
const date=new Date(value);
if(!timeZone)return {year:date.getFullYear(),month:date.getMonth()+1,day:date.getDate(),weekday:date.getDay(),
hour:date.getHours(),minute:date.getMinutes(),second:date.getSeconds()};
if(!zonePartFormatters.has(timeZone))zonePartFormatters.set(timeZone,new Intl.DateTimeFormat('en-US',{
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23',
}));
const formatter=zonePartFormatters.get(timeZone);
const parts=Object.fromEntries(formatter.formatToParts(date).map(part=>[part.type,part.value]));
return {year:Number(parts.year),month:Number(parts.month),day:Number(parts.day),
weekday:['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(parts.weekday),hour:Number(parts.hour),
minute:Number(parts.minute),second:Number(parts.second)};
}
function addCalendarDays(value,days,timeZone) {
if(!timeZone) {const date=new Date(value);date.setDate(date.getDate()+days);return date.getTime();}
const parts=zoneParts(value,timeZone),day=new Date(Date.UTC(parts.year,parts.month-1,parts.day+days));
const wall=[day.getUTCFullYear(),String(day.getUTCMonth()+1).padStart(2,'0'),String(day.getUTCDate()).padStart(2,'0')].join('')+
'T'+[parts.hour,parts.minute,parts.second].map(part=>String(part).padStart(2,'0')).join('');
return zonedInstant(wall,timeZone);
}
function dateInstant(value) {
const match=/^(\d{4})(\d{2})(\d{2})$/.exec(value||'');
return match?new Date(Number(match[1]),Number(match[2])-1,Number(match[3])).getTime():null;
}
function events(source) {
if(typeof source!=='string'||!source.includes('BEGIN:VCALENDAR'))throw new Error('Choose a valid .ics calendar file.');
if(new TextEncoder().encode(source).length>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
const lines=source.replace(/\r\n[ \t]/g,'').split(/\r?\n/);
const result=[];let current=null;
lines.forEach(line=>{
if(line==='BEGIN:VEVENT'){current={};return;}
if(line==='END:VEVENT'){
if((current?.unsupportedTimezone||(current?.start!=null&&current?.end>current.start))&&
current.status!=='CANCELLED'&&current.transparency!=='TRANSPARENT')result.push(current);
current=null;return;
}
if(!current)return;
const separator=line.indexOf(':');if(separator<0)return;
const property=line.slice(0,separator),parts=property.split(';'),name=parts[0],value=line.slice(separator+1);
const parameters=Object.fromEntries(parts.slice(1).map(part=>part.split('=')));
if(parameters.TZID&&!supportedTimeZone(parameters.TZID))current.unsupportedTimezone=true;
const allDay=parameters.VALUE==='DATE',parsed=allDay?dateInstant(value):zonedInstant(value,parameters.TZID);
if(name==='DTSTART'){current.start=parsed;current.timeZone=parameters.TZID||null;}
if(name==='DTEND')current.end=parsed;
if(name==='RRULE')current.rule=value;
if(name==='RDATE')current.rdates=(current.rdates||[]).concat(value.split(',').map(item=>allDay?dateInstant(item):zonedInstant(item,parameters.TZID)).filter(value=>value!=null));
if(name==='EXDATE')current.exdates=(current.exdates||[]).concat(value.split(',').map(item=>allDay?dateInstant(item):zonedInstant(item,parameters.TZID)).filter(value=>value!=null));
if(name==='STATUS')current.status=value.toUpperCase();
if(name==='TRANSP')current.transparency=value.toUpperCase();
});
return result;
}
function recurrenceRule(value) {
return Object.fromEntries(String(value||'').split(';').filter(Boolean).map(part=>part.split('=')));
}
function supportedRecurrence(value) {
const rule=recurrenceRule(value),keys=Object.keys(rule);
if(!['DAILY','WEEKLY'].includes(rule.FREQ))return false;
const allowed=new Set(['FREQ','COUNT','INTERVAL','UNTIL',...(rule.FREQ==='WEEKLY'?['BYDAY']:[])]);
if(keys.some(key=>!allowed.has(key)))return false;
if(rule.COUNT&&(!/^\d+$/.test(rule.COUNT)||Number(rule.COUNT)<1))return false;
if(rule.INTERVAL&&(!/^\d+$/.test(rule.INTERVAL)||Number(rule.INTERVAL)<1))return false;
if(rule.COUNT&&rule.UNTIL)return false;
if(rule.UNTIL&&instant(rule.UNTIL)==null&&dateInstant(rule.UNTIL)==null)return false;
return !rule.BYDAY||rule.BYDAY.split(',').every(day=>/^(MO|TU|WE|TH|FR|SA|SU)$/.test(day));
}
function occurrences(event,rangeStart,rangeEnd) {
const duration=event.end-event.start,excluded=new Set(event.exdates||[]);
if(!event.rule) {
return [event.start,...(event.rdates||[])].filter((start,index,all)=>start<rangeEnd&&!excluded.has(start)&&all.indexOf(start)===index)
.map(start=>({start,end:start+duration}));
}
const rule=recurrenceRule(event.rule);
if(!['DAILY','WEEKLY'].includes(rule.FREQ))return [event];
const count=Math.max(1,Math.min(Number(rule.COUNT)||10000,10000));
const result=[];
const weekdays={SU:0,MO:1,TU:2,WE:3,TH:4,FR:5,SA:6};
const selected=new Set((rule.BYDAY||'').split(',').map(day=>weekdays[day]).filter(day=>day!=null));
if(rule.FREQ==='WEEKLY'&&!selected.size)selected.add(zoneParts(event.start,event.timeZone).weekday);
const interval=Math.max(1,Number(rule.INTERVAL)||1),origin=zoneParts(event.start,event.timeZone);
const until=rule.UNTIL?(rule.UNTIL.endsWith('Z')?instant(rule.UNTIL):
(zonedInstant(rule.UNTIL,event.timeZone)??dateInstant(rule.UNTIL))):null;
const originDay=Date.UTC(origin.year,origin.month-1,origin.day);
let start=event.start,matched=0;
if(rule.FREQ==='DAILY'&&start+duration<=rangeStart) {
const target=zoneParts(rangeStart-duration,event.timeZone);
const targetDay=Date.UTC(target.year,target.month-1,target.day);
const elapsed=Math.max(0,Math.floor((targetDay-originDay)/(24*60*60*1000)));
const jumps=Math.floor(elapsed/interval);
if(jumps) {start=addCalendarDays(start,jumps*interval,event.timeZone);matched=jumps;}
while(start+duration<=rangeStart&&matched<count) {start=addCalendarDays(start,interval,event.timeZone);matched+=1;}
}
if(rule.FREQ==='WEEKLY'&&start+duration<=rangeStart) {
const target=zoneParts(rangeStart-duration,event.timeZone);
const targetDay=Date.UTC(target.year,target.month-1,target.day);
const elapsed=Math.max(0,Math.floor((targetDay-originDay)/(24*60*60*1000)));
const cycleDays=7*interval,cycles=Math.floor(elapsed/cycleDays),offset=elapsed-cycles*cycleDays;
matched=cycles*selected.size;
start=addCalendarDays(event.start,cycles*cycleDays+(offset>=7?offset:0),event.timeZone);
while(start+duration<=rangeStart&&matched<count) {
const cursor=zoneParts(start,event.timeZone),cursorDay=Date.UTC(cursor.year,cursor.month-1,cursor.day);
const weeks=Math.floor((cursorDay-originDay)/(7*24*60*60*1000));
if(weeks%interval===0&&selected.has(cursor.weekday))matched+=1;
start=addCalendarDays(start,1,event.timeZone);
}
}
while(start<rangeEnd&&matched<count&&(until==null||start<=until)) {
const cursor=zoneParts(start,event.timeZone),cursorDay=Date.UTC(cursor.year,cursor.month-1,cursor.day);
const weeks=Math.floor((cursorDay-originDay)/(7*24*60*60*1000));
const matches=rule.FREQ==='DAILY'||(weeks%interval===0&&selected.has(cursor.weekday));
if(matches) {
matched+=1;
if(!excluded.has(start))result.push({start,end:start+duration});
}
start=addCalendarDays(start,rule.FREQ==='DAILY'?interval:1,event.timeZone);
}
(event.rdates||[]).forEach(extra=>{
if(extra<rangeEnd&&!excluded.has(extra)&&!result.some(item=>item.start===extra))result.push({start:extra,end:extra+duration});
});
return result;
}
function dayBoundary(date,minutes) {
const [year,month,day]=date.split('-').map(Number);
return new Date(year,month-1,day,Math.floor(minutes/60),minutes%60).getTime();
}
function localClock(value) {
const date=new Date(value);
return String(date.getHours()).padStart(2,'0')+':'+String(date.getMinutes()).padStart(2,'0');
}
function review(source,{dates,workdayStart='09:00',workdayEnd='17:00'}={}) {
if(!Array.isArray(dates)||dates.length!==7)throw new Error('Week Ahead must contain seven dates.');
const startMinute=clock(workdayStart),endMinute=clock(workdayEnd);
if(startMinute==null||endMinute==null||endMinute<=startMinute)throw new Error('Working hours must end after they start.');
const parsedEvents=events(source),rangeStart=dayBoundary(dates[0],0),rangeEnd=dayBoundary(dates[6],24*60);
const unsupportedRecurrence=parsedEvents.filter(event=>event.rule&&!supportedRecurrence(event.rule)&&
event.start<rangeEnd&&(event.end>rangeStart||event.rule)).length;
const unsupportedTimezone=parsedEvents.filter(event=>event.unsupportedTimezone).length;
const unsupported_count=unsupportedRecurrence+unsupportedTimezone;
const calendarEvents=parsedEvents.filter(event=>!event.unsupportedTimezone).flatMap(event=>occurrences(event,rangeStart,rangeEnd));
const days=dates.map(plan_date=>{
const start=dayBoundary(plan_date,startMinute),end=dayBoundary(plan_date,endMinute);
const ranges=calendarEvents.map(event=>[Math.max(start,event.start),Math.min(end,event.end)])
.filter(range=>range[1]>range[0]).sort((left,right)=>left[0]-right[0]);
const merged=[];
ranges.forEach(range=>{
const previous=merged[merged.length-1];
if(previous&&range[0]<=previous[1])previous[1]=Math.max(previous[1],range[1]);
else merged.push([...range]);
});
const busy_minutes=Math.round(merged.reduce((total,range)=>total+range[1]-range[0],0)/60000);
const free_windows=[];let cursor=start;
merged.forEach(range=>{
if(range[0]>cursor)free_windows.push({start_time:localClock(cursor),end_time:localClock(range[0])});
cursor=Math.max(cursor,range[1]);
});
if(cursor<end)free_windows.push({start_time:localClock(cursor),end_time:localClock(end)});
return {plan_date,busy_minutes,capacity_minutes:endMinute-startMinute-busy_minutes,free_windows};
});
days.unsupported_count=unsupported_count;
days.unsupported_timezone_count=unsupportedTimezone;
return days;
}
function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
let reviewed=null,activeAvailability=null,needsReflow=false,reflowPreview=null;
const root=()=>qs('#week-capacity-import');
function clear() {
reviewed=null;needsReflow=false;reflowPreview=null;
qs('#week-capacity-file').value='';
qs('#week-capacity-review').hidden=true;
qs('#week-capacity-days').innerHTML='';
qs('#apply-week-capacities').disabled=false;
qs('#apply-week-capacities').textContent='Apply seven capacities';
}
function open() {
clear();root().hidden=false;qs('#week-capacity-status').textContent='';
qs('#week-capacity-file').focus?.();return true;
}
function cancel() {clear();root().hidden=true;return true;}
function reviewSource(source) {
const display=controller.dates();
reviewed=review(source,{dates:display.map(item=>item.date),workdayStart:qs('#week-capacity-start').value,
workdayEnd:qs('#week-capacity-end').value});
const current=controller.review?.().days||[];
needsReflow=reviewed.some((day,index)=>Number(current[index]?.planned_minutes)>day.capacity_minutes);
const previewValues=reviewed.map(day=>({plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
free_windows:day.free_windows.map(window=>({...window}))}));
reflowPreview=needsReflow?controller.previewCapacityReflow?.(previewValues):null;
const destinations=new Map();
(reflowPreview?.days||[]).forEach(day=>(day.ids||[]).forEach(id=>destinations.set(id,day.plan_date)));
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>{
const previous=current[index]||{},planned=Number(previous.planned_minutes)||0,over=Math.max(0,planned-day.capacity_minutes);
const moved=[...new Set((previous.ids||[]).map(id=>destinations.get(id)).filter(date=>date&&date!==day.plan_date))]
.map(date=>display.find(item=>item.date===date)?.label||date);
return '<article class="week-capacity-day'+(over?' is-overloaded':'')+'"><strong>'+display[index].label+'</strong><span>'+
(Number.isInteger(previous.capacity_minutes)?previous.capacity_minutes+' → ':'')+day.capacity_minutes+' min available</span><small>'+
planned+' min planned · '+day.busy_minutes+' min busy</small>'+(over?'<small class="week-capacity-over">'+over+
' min over refreshed capacity</small>':'')+(moved.length?'<small>Moves to '+moved.join(', ')+'</small>':'')+'</article>';
}).join('');
qs('#week-capacity-review').hidden=false;
const unsupported=reviewed.unsupported_count||0;
const reflowBlocked=Boolean(reflowPreview?.blockers?.length);
qs('#apply-week-capacities').disabled=unsupported>0||reflowBlocked;
qs('#apply-week-capacities').textContent=needsReflow?'Apply capacities & reflow':'Apply seven capacities';
const kind=reviewed.unsupported_timezone_count?'calendar event':'recurring event';
qs('#week-capacity-status').textContent=unsupported?
unsupported+' '+kind+(unsupported===1?'':'s')+' could not be counted. Apply is unavailable; export a simpler seven-day calendar and try again.':
(reflowBlocked?'Reflow needs an estimate for every planned item before refreshed capacity can be applied.':
(reflowPreview?.unscheduled?.length?reflowPreview.unscheduled.length+' planned item'+
(reflowPreview.unscheduled.length===1?'':'s')+' will return to My Work. Review before applying.':
'Review seven capacity totals. Calendar details stay on this device.'));
return reviewed.map(day=>({...day}));
}
async function apply() {
if(!reviewed||reviewed.unsupported_count)return false;
const keep=Boolean(qs('#keep-week-free-times')?.checked);
const values=reviewed.map(day=>({
plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
...(keep?{free_windows:day.free_windows.map(window=>({...window}))}:{}),
}));
const staged=needsReflow?controller.applyCapacityReflow?.(values):controller.stageCapacities(values);
if(!staged)return false;
qs('#apply-week-capacities').disabled=true;
try {await controller.flush();activeAvailability=reviewed.map(day=>({plan_date:day.plan_date,
free_windows:day.free_windows.map(window=>({...window}))}));cancel();onApplied();return true;}
finally {qs('#apply-week-capacities').disabled=false;}
}
qs('#week-capacity-file')?.addEventListener('change',async event=>{
const file=event.currentTarget.files?.[0];if(!file)return;
try {
if(file.size>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
reviewSource(await file.text());
} catch(error) {clear();qs('#week-capacity-status').textContent=error.message||'Calendar could not be read.';}
});
qs('#cancel-week-capacity-import')?.addEventListener('click',cancel);
qs('#apply-week-capacities')?.addEventListener('click',()=>apply().catch(error=>{
qs('#week-capacity-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Capacities remain saved on this phone.';
}));
function availability() {
const saved=(controller.state?.().days||[]).filter(day=>Array.isArray(day.free_windows));
const source=activeAvailability||saved;
return source.length?source.map(day=>({plan_date:day.plan_date,
free_windows:day.free_windows.map(window=>({...window}))})):null;
}
return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null,
availability};
}
function mount(controller,weekWorkflow,qs) {
const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview});
StackchainWeekCalendar.setAvailabilityProvider(workflow.availability);
qs('#open-week-capacity-import').addEventListener('click',workflow.open);
return workflow;
}
return {review,createWorkflow,mount};
}
const weekCalendarImport=createWeekCalendarImport();
if(typeof module!=='undefined'&&module.exports)module.exports=weekCalendarImport;
+161
View File
@@ -0,0 +1,161 @@
(function(root){
'use strict';
let availabilityProvider=()=>null;
function escapeText(value){
return String(value||'').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/,/g,'\\,').replace(/;/g,'\\;');
}
function compact(value){return String(value||'').replace(/[-:]/g,'');}
function addMinutes(value,minutes){
const [hours,mins]=String(value).split(':').map(Number),total=hours*60+mins+Number(minutes);
return String(Math.floor(total/60)%24).padStart(2,'0')+':'+String(total%60).padStart(2,'0');
}
function clockMinutes(value){const [hours,minutes]=String(value).split(':').map(Number);return hours*60+minutes;}
function stableId(value){return String(value||'work').replace(/[^a-z0-9]+/gi,'-').replace(/^-|-$/g,'').toLowerCase();}
function foldLine(line){
const encoder=new TextEncoder(),chunks=[];let chunk='',bytes=0,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 buildWeekSchedule(plan,startTimes,getItem,selected,availability){
const blocks=[],blockers=[],availableByDate=new Map((availability||[]).map(day=>[day.plan_date,day.free_windows||[]]));
(plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{
let cursor=startTimes?.[day.plan_date]||'09:00';
const windows=availableByDate.has(day.plan_date)?availableByDate.get(day.plan_date):(day.free_windows||null);
const candidates=[];
(day.ids||[]).forEach(id=>{
if(selected&&!selected.has(id))return;
const minutes=Number(day.estimates?.[id]),item=getItem?.(id);
if(!item||!Number.isFinite(minutes)||minutes<=0)return;
const exact=startTimes?.[id]||day.start_times?.[id];
let start=exact||cursor;
if(!exact&&windows){
const fit=windows.find(window=>{
const candidate=Math.max(clockMinutes(cursor),clockMinutes(window.start_time));
if(candidate+minutes>clockMinutes(window.end_time))return false;
start=addMinutes('00:00',candidate);return true;
});
if(!fit){blockers.push({id,title:item.title||'Untitled work',plan_date:day.plan_date,minutes});return;}
}
const startMinute=clockMinutes(start),endMinute=startMinute+minutes;
candidates.push({item,id,minutes,start,startMinute,endMinute,exact:Boolean(exact)});
if(!exact)cursor=addMinutes('00:00',endMinute);
});
const invalid=new Set();
candidates.forEach(candidate=>{
if(candidate.endMinute>1440||windows&&!windows.some(window=>clockMinutes(window.start_time)<=candidate.startMinute&&candidate.endMinute<=clockMinutes(window.end_time)))invalid.add(candidate.id);
});
candidates.forEach((left,index)=>candidates.slice(index+1).forEach(right=>{
if(left.startMinute<right.endMinute&&left.endMinute>right.startMinute){invalid.add(left.id);invalid.add(right.id);}
}));
candidates.forEach(candidate=>{
if(invalid.has(candidate.id)){
blockers.push({id:candidate.id,title:candidate.item.title||'Untitled work',plan_date:day.plan_date,minutes:candidate.minutes,reason:'invalid-exact-time'});return;
}
blocks.push({...candidate.item,id:candidate.id,plan_date:day.plan_date,start_time:candidate.start,
end_time:addMinutes('00:00',candidate.endMinute),minutes:candidate.minutes});
});
});
return {blocks,blockers};
}
function buildWeekBlocks(plan,startTimes,getItem,selected,availability){
return buildWeekSchedule(plan,startTimes,getItem,selected,availability).blocks;
}
function serializeWeekCalendar(blocks,{timezone='UTC',revision=0,generatedAt}={}){
const stamp=generatedAt||new Date().toISOString().replace(/[-:]/g,'').replace(/\.\d{3}/,'');
const lines=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Stackchain//Week Ahead//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','X-WR-CALNAME:Stackchain Week Ahead',`X-WR-TIMEZONE:${timezone}`,'BEGIN:VTIMEZONE',`TZID:${timezone}`,'END:VTIMEZONE'];
(blocks||[]).forEach(block=>{
const day=compact(block.plan_date),reference=block.repository+(block.number!=null?' #'+block.number:'');
lines.push('BEGIN:VEVENT',`UID:week-${stableId(block.id)}@stackchain`,`SEQUENCE:${Math.max(0,Math.floor(Number(revision)||0))}`,`DTSTAMP:${stamp}`,
`DTSTART;TZID=${timezone}:${day}T${compact(block.start_time)}00`,`DTEND;TZID=${timezone}:${day}T${compact(block.end_time)}00`,
`SUMMARY:${escapeText(block.title||'Untitled work')}`,`DESCRIPTION:${escapeText(reference+' · Stackchain Week Ahead')}`,
`URL:${String(block.url||'')}`,'TRANSP:OPAQUE','END:VEVENT');
});
lines.push('END:VCALENDAR');return lines.map(foldLine).join('\r\n')+'\r\n';
}
async function deliverWeekCalendar({text,filename,navigator,document,urlApi,FileCtor}){
const file=new FileCtor([text],filename,{type:'text/calendar;charset=utf-8'});
const payload={files:[file],title:'Stackchain Week Ahead',text:'Timed Week Ahead calendar blocks'};
if(typeof navigator?.share==='function'&&typeof navigator?.canShare==='function'&&navigator.canShare(payload)){
await navigator.share(payload);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 mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onSave,onDone,windowObject=root,navigatorObject=root.navigator,
documentObject=root.document,urlApi=root.URL,FileCtor=root.File}={}){
const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days');
let plan=null,focusReturn=null;
const selected=()=>new Set(Array.from(daysRoot.querySelectorAll('[data-week-calendar-item]')).filter(input=>input.checked).map(input=>input.value));
const starts=()=>Object.fromEntries([
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]),
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-exact]')).filter(input=>input.value).map(input=>[input.dataset.weekCalendarExact,input.value]),
]);
const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.());
const blocks=()=>schedule().blocks;
function update(){
const current=schedule(),byId=new Map(current.blocks.map(block=>[block.id,block])),blockedById=new Map(current.blockers.map(blocker=>[blocker.id,blocker]));
daysRoot.querySelectorAll('[data-week-calendar-preview]').forEach(node=>{
const id=node.dataset.weekCalendarPreview,block=byId.get(id),blocker=blockedById.get(id);
node.textContent=block?block.start_time+'–'+block.end_time+' · '+block.minutes+' min':
(blocker?'Does not fit imported free time':'Excluded from calendar');
});
qs('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0;
qs('#save-week-calendar-times').disabled=!current.blocks.length||current.blockers.length>0;
if(current.blockers.length){
const blocker=current.blockers[0];
qs('#week-calendar-status').textContent=blocker.title+' does not fit free time on '+blocker.plan_date+'. Adjust the start, estimate, or calendar import.';
}else qs('#week-calendar-status').textContent=(getAvailability?.()?'Planning around imported busy time · ':'Manual timing · ')+
current.blocks.length+' calendar block'+(current.blocks.length===1?'':'s')+' selected.';
}
function close({back=false}={}){
handoff.hidden=true;
if(back){review.hidden=false;const target=focusReturn||qs('#confirm-week-plan');focusReturn=null;target.focus?.();}
}
function open(value,{returnFocus=null}={}){
plan=value;if(returnFocus)focusReturn=returnFocus;review.hidden=true;handoff.hidden=false;
const labels=new Map((value.days||[]).map(day=>[day.plan_date,new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(new Date(day.plan_date+'T12:00:00Z'))]));
daysRoot.innerHTML=(value.days||[]).filter(day=>day.ids?.length).map(day=>'<article class="week-calendar-day"><header><h3>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
'</h3><label>Start <input type="time" value="09:00" data-week-calendar-start="'+escapeAttribute(day.plan_date)+'"></label></header>'+
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96),saved=day.start_times?.[id]||'';
return '<div class="week-calendar-item"><label class="week-calendar-choice"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label><label class="week-calendar-time">Start<input type="time" value="'+escapeAttribute(saved)+'" data-week-calendar-exact="'+escapeAttribute(id)+'" aria-label="Exact start for '+escapeAttribute(item?.title||'work')+'"></label></div>';}).join('')+'</article>').join('');
daysRoot.querySelectorAll('input').forEach(input=>input.addEventListener('change',update));
update();qs('#back-to-week-review-from-calendar').focus?.();return true;
}
qs('#back-to-week-review-from-calendar').addEventListener('click',()=>close({back:true}));
qs('#save-week-calendar-times').addEventListener('click',async()=>{
const button=qs('#save-week-calendar-times'),current=schedule();if(!current.blocks.length||current.blockers.length)return;
button.disabled=true;qs('#week-calendar-status').textContent='Saving exact task times…';
const byDate={};current.blocks.forEach(block=>(byDate[block.plan_date]||(byDate[block.plan_date]={}))[block.id]=block.start_time);
try{await onSave?.(byDate);plan={...plan,days:(plan.days||[]).map(day=>({...day,start_times:{...(byDate[day.plan_date]||{})}}))};
qs('#week-calendar-status').textContent='Exact task times saved to Week Ahead.';}
catch(error){qs('#week-calendar-status').textContent=(error?.message||'Exact times could not be saved.')+' Retry when connected.';}
finally{button.disabled=false;}
});
qs('#export-saved-week-calendar')?.addEventListener('click',event=>{focusReturn=event.currentTarget;qs('#confirm-week-plan').click();});
qs('#share-week-calendar').addEventListener('click',async()=>{
const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;
button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…';
const day=new Date().toISOString().slice(0,10);
try{
const text=serializeWeekCalendar(current,{timezone:plan.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone,revision:plan.revision});
const result=await deliverWeekCalendar({text,filename:'stackchain-week-ahead-'+day+'.ics',navigator:navigatorObject,
document:documentObject,urlApi,FileCtor});
close();onDone?.(result);
}catch(error){
qs('#week-calendar-status').textContent=error?.name==='AbortError'?'Share cancelled. Your Week Ahead is still ready.':'Calendar export failed. Retry without leaving Week Ahead.';
button.disabled=false;
}
});
return {open,close,blocks};
}
function setAvailabilityProvider(value){availabilityProvider=typeof value==='function'?value:()=>null;}
const api={buildWeekBlocks,buildWeekSchedule,deliverWeekCalendar,escapeText,foldLine,mountWeekCalendarHandoff,serializeWeekCalendar,setAvailabilityProvider};
if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.StackchainWeekCalendar=api;
})(typeof window!=='undefined'?window:globalThis);
File diff suppressed because it is too large. Load diff
+66 -30
View File
@@ -1,41 +1,77 @@
async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader }) {
async function loadWorkspace({
document,
window = null,
createLoader = createFeatureLoader,
schedule = callback => setTimeout(callback,750),
}) {
let cameOnline = false;
let replayed = false;
let retryInFlight = null;
const captureOnline = () => { cameOnline = true; };
window?.addEventListener('online', captureOnline);
const status = document.querySelector('#my-work-action-status');
const url = document.querySelector(
'meta[name="stackchain-feature-today-timer"]'
)?.content || '';
const loader = createLoader({
document,
urls: { 'today-timer': url },
});
if (status) status.textContent = 'Starting workspace…';
try {
await loader.load('today-timer');
if (status) status.textContent = '';
return {
replayOnline(callback) {
if (replayed) return;
replayed = true;
window?.removeEventListener('online', captureOnline);
if (cameOnline) callback();
},
const retryButton = document.querySelector('#retry-workspace');
const urls = Object.fromEntries(['today-timer', 'planning'].map(name => [name,
document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || ''
]));
const originalUrls = {...urls};
const loader = createLoader({document, urls});
let attempts = 0;
const load = () => {
if (attempts++) Object.keys(urls).forEach(name => {
urls[name] = originalUrls[name] + '?retry=' + attempts;
});
return Promise.all(Object.keys(urls).map(name => loader.load(name)));
};
const waitForRecovery = () => new Promise(resolve => {
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
if (retryButton) {
retryButton.hidden = retryButton.disabled = false;
}
const recover = () => {
if (retryInFlight) return retryInFlight;
if (retryButton) retryButton.disabled = true;
retryInFlight = load().then(() => {
window?.removeEventListener('online', recover);
resolve();
}).catch(() => {
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
if (retryButton) retryButton.disabled = false;
}).finally(() => {
retryInFlight = null;
});
return retryInFlight;
};
} catch (error) {
window?.removeEventListener('online', captureOnline);
if (window?.location?.reload) {
if (cameOnline) window.location.reload();
else window.addEventListener('online', () => window.location.reload(), { once: true });
window?.addEventListener('online', recover);
retryButton?.addEventListener('click', recover);
});
try {
await load();
} catch {
await new Promise(resolve => schedule(resolve));
try {
await load();
} catch {
await waitForRecovery();
}
if (status) {
status.textContent = window
? 'Workspace could not load. Reconnect to retry automatically, or reload now.'
: 'Workspace could not load. Check your connection, then reload to retry.';
}
throw error;
}
window?.removeEventListener('online', captureOnline);
if (retryButton) retryButton.hidden = true;
if (status) status.textContent = '';
return {
replayOnline(callback) {
if (replayed) return;
replayed = true;
window?.removeEventListener('online', captureOnline);
if (cameOnline) callback();
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
+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
+80 -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,93 @@ 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_private_state.py",
"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 +114,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 +159,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)
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Rewrap shared private-state envelopes under the configured active key."""
from __future__ import annotations
import json
import os
import sqlite3
import sys
from dataclasses import dataclass
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.private_state import connect_private_sqlite # noqa: E402
from src.state_encryption import ( # noqa: E402
PrivateStateCipher,
PrivateStateEncryptionError,
private_state_encryption_config,
)
@dataclass(frozen=True)
class Field:
column: str
binding: str
alias: str | None = None
plaintext_string: bool = False
@dataclass(frozen=True)
class Table:
name: str
context_columns: tuple[str, ...]
fields: tuple[Field, ...]
@dataclass(frozen=True)
class Store:
name: str
identity: str
env: str
filename: str
tables: tuple[Table, ...]
STORES = (
Store("today", "today", "STACKCHAIN_TODAY_DB", "today.sqlite3", (
Table("today_plans", ("login",), (Field("ids", "plan:{login}"),)),
Table("tomorrow_plans", ("login",), (Field("payload", "tomorrow:{login}"),)),
Table("tomorrow_promotions", ("login", "promotion_id"), (Field("result", "tomorrow-promotion:{login}:{promotion_id}"),)),
Table("week_plans", ("login",), (Field("payload", "week:{login}"),)),
Table("week_promotions", ("login", "promotion_id"), (Field("result", "week-promotion:{login}:{promotion_id}"),)),
Table("week_reschedules", ("login", "operation_id"), (Field("result", "week-reschedule:{login}:{operation_id}"),)),
Table("today_sessions", ("login",), (Field("device_id", "session:{login}"),)),
Table("today_recaps", ("login",), (
Field("session_id", "recap-id:{login}", "session_id", True),
Field("items", "recap-items:{login}:{session_id}"),
)),
Table("today_time_logs", ("login",), (
Field("session_id", "time-log-session:{login}", "session_id", True),
Field("identity", "time-log-identity:{login}:{session_id}", "identity", True),
Field("actual_minutes", "time-log-payload:{login}:{session_id}:{identity}"),
)),
)),
Store("later", "later", "STACKCHAIN_LATER_DB", "later.sqlite3", (
Table("later_plans", ("login",), (Field("records", "plan:{login}"),)),
Table("later_item_revisions", ("login",), (Field("item_id", "item-revision:{login}", plaintext_string=True),)),
)),
Store("live-snapshot", "live-snapshot", "STACKCHAIN_LIVE_SNAPSHOT_DB", "live-snapshot.sqlite3", (
Table("live_snapshot", ("generation",), (Field("value_json", "{generation}"),)),
)),
Store("available-issue-snapshot", "available-issue-snapshot", "STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB", "available-issue-snapshot.sqlite3", (
Table("available_issue_snapshot", (), (Field("items_json", "singleton"),)),
)),
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
)),
Store(
"completed-filed-reviews",
"completed-filed-reviews",
"STACKCHAIN_COMPLETED_FILED_REVIEW_DB",
"completed-filed-reviews.sqlite3",
(
Table(
"completed_filed_review_collections",
("login",),
(Field("receipts", "receipts:{login}"),),
),
),
),
Store("security-events", "security-events", "STACKCHAIN_SECURITY_EVENT_DB", "security-events.sqlite3", (
Table("security_events", ("id",), (Field("payload", "event:{id}"),)),
)),
Store("idempotency-ledger", "idempotency-ledger", "STACKCHAIN_IDEMPOTENCY_DB", "idempotency.sqlite3", (
Table("idempotency_operations", ("key",), (
Field("fingerprint", "{key}:fingerprint"),
Field("response_json", "{key}:response"),
)),
)),
)
def _tables(connection: sqlite3.Connection) -> set[str]:
return {
row[0]
for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
)
}
def rotate_store(path: Path, spec: Store, config) -> dict[str, int]:
counts = {"current": 0, "failed": 0, "migrated": 0, "total": 0}
if not path.exists():
return counts
cipher = PrivateStateCipher(config, store=spec.identity)
with connect_private_sqlite(path, timeout=1.0) as connection:
connection.row_factory = sqlite3.Row
available = _tables(connection)
for table in spec.tables:
if table.name not in available:
continue
columns = (*table.context_columns, *(field.column for field in table.fields))
query = f"SELECT rowid AS _rotation_rowid, {', '.join(columns)} FROM {table.name}"
for row in connection.execute(query).fetchall():
context = {name: row[name] for name in table.context_columns}
updates: dict[str, str] = {}
row_failed = False
row_counts = {"current": 0, "migrated": 0, "total": 0}
for field in table.fields:
payload = row[field.column]
if payload is None:
continue
row_counts["total"] += 1
try:
binding = field.binding.format(**context)
if isinstance(payload, str) and not payload.startswith(("v1:", "v2:")) and field.plaintext_string:
value, stale = payload, True
else:
value, stale = cipher.open(str(payload), binding=binding)
if field.alias:
context[field.alias] = value
if stale:
updates[field.column] = cipher.seal(value, binding=binding)
row_counts["migrated"] += 1
else:
row_counts["current"] += 1
except (KeyError, PrivateStateEncryptionError, ValueError):
row_failed = True
break
counts["total"] += row_counts["total"]
if row_failed:
counts["failed"] += 1
continue
if updates:
assignments = ", ".join(f"{name} = ?" for name in updates)
connection.execute(
f"UPDATE {table.name} SET {assignments} WHERE rowid = ?",
(*updates.values(), row["_rotation_rowid"]),
)
counts["migrated"] += row_counts["migrated"]
counts["current"] += row_counts["current"]
return counts
def main() -> int:
try:
config = private_state_encryption_config()
if isinstance(config, bytes):
raise PrivateStateEncryptionError("rotation requires a keyring")
state = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
report = {
spec.name: rotate_store(
Path(os.getenv(spec.env, str(state / spec.filename))), spec, config
)
for spec in STORES
}
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
print(json.dumps({"error": "Private-state rotation configuration is unavailable"}, sort_keys=True))
return 2
print(json.dumps(report, sort_keys=True))
return 1 if any(item["failed"] for item in report.values()) else 0
if __name__ == "__main__":
raise SystemExit(main())
+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_config,
)
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_config(),
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()
+93 -50
View File
@@ -1,4 +1,4 @@
"""Durable, account-scoped completed Filed review receipts."""
"""Durable, encrypted, account-scoped completed Filed review receipts."""
import re
import sqlite3
@@ -6,33 +6,68 @@ from datetime import datetime
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
class CompletedFiledReviewStore:
def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0):
def __init__(
self,
path: str | Path,
*,
limit: int = 200,
timeout: float = 1.0,
encryption_key: bytes | None = None,
):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="completed-filed-reviews",
)
self._initialize()
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA secure_delete=ON")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS completed_filed_reviews (
login TEXT NOT NULL,
repository TEXT NOT NULL,
issue_number INTEGER NOT NULL,
updated_at TEXT NOT NULL,
touched_at INTEGER NOT NULL,
PRIMARY KEY (login, repository, issue_number)
CREATE TABLE IF NOT EXISTS completed_filed_review_collections (
login TEXT PRIMARY KEY,
receipts TEXT NOT NULL
)
"""
)
legacy = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'completed_filed_reviews'"
).fetchone()
if legacy:
connection.execute("BEGIN IMMEDIATE")
rows = connection.execute(
"SELECT login, repository, issue_number, updated_at "
"FROM completed_filed_reviews ORDER BY login, touched_at"
).fetchall()
grouped: dict[str, list[dict]] = {}
for login, repository, number, updated_at in rows:
grouped.setdefault(login, []).append({
"repository": repository,
"number": int(number),
"updated_at": updated_at,
})
for login, receipts in grouped.items():
normalized = [self._receipt(item) for item in receipts][-self.limit:]
connection.execute(
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
(login, self._seal(login, normalized)),
)
connection.execute("DROP TABLE completed_filed_reviews")
with self._connect() as connection:
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
def _connect(self) -> sqlite3.Connection:
return connect_private_sqlite(self.path, timeout=self.timeout)
@@ -45,7 +80,7 @@ class CompletedFiledReviewStore:
return normalized
@staticmethod
def _receipt(raw: dict) -> tuple[str, int, str]:
def _receipt(raw: dict) -> dict:
if not isinstance(raw, dict):
raise ValueError("receipt must be an object")
repository = raw.get("repository")
@@ -63,23 +98,41 @@ class CompletedFiledReviewStore:
raise ValueError("updated_at is invalid") from error
if parsed.tzinfo is None:
raise ValueError("updated_at is invalid")
return repository, number, updated_at
return {"repository": repository, "number": number, "updated_at": updated_at}
def _open(self, login: str, payload: str | None) -> tuple[list[dict], bool]:
if payload is None:
return [], False
value, stale = self._cipher.open(payload, binding=f"receipts:{login}")
if not isinstance(value, list):
raise PrivateStateEncryptionError("private state could not be decrypted")
try:
return [self._receipt(item) for item in value], stale
except ValueError as error:
raise PrivateStateEncryptionError("private state could not be decrypted") from error
def _seal(self, login: str, receipts: list[dict]) -> str:
return self._cipher.seal(receipts, binding=f"receipts:{login}")
@staticmethod
def _snapshot(rows) -> dict:
return {"receipts": [
{"repository": row[0], "number": int(row[1]), "updated_at": row[2]}
for row in rows
]}
def _snapshot(receipts: list[dict]) -> dict:
return {"receipts": receipts}
def get(self, login: str) -> dict:
login = self._login(login)
with self._connect() as connection:
rows = connection.execute(
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
"WHERE login = ? ORDER BY touched_at",
(self._login(login),),
).fetchall()
return self._snapshot(rows)
row = connection.execute(
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
(login,),
).fetchone()
receipts, stale = self._open(login, row[0] if row else None)
if row is not None and stale:
connection.execute(
"UPDATE completed_filed_review_collections SET receipts = ? "
"WHERE login = ? AND receipts = ?",
(self._seal(login, receipts), login, row[0]),
)
return self._snapshot(receipts)
def merge(self, login: str, receipts: list[dict]) -> dict:
login = self._login(login)
@@ -88,35 +141,25 @@ class CompletedFiledReviewStore:
normalized = [self._receipt(receipt) for receipt in receipts]
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
touched = int(connection.execute(
"SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?",
row = connection.execute(
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
(login,),
).fetchone()[0])
for repository, number, updated_at in normalized:
current = connection.execute(
"SELECT updated_at FROM completed_filed_reviews "
"WHERE login = ? AND repository = ? AND issue_number = ?",
(login, repository, number),
).fetchone()
if current is not None and current[0] >= updated_at:
).fetchone()
current, _stale = self._open(login, row[0] if row else None)
for incoming in normalized:
match = next((
item for item in current
if item["repository"] == incoming["repository"] and item["number"] == incoming["number"]
), None)
if match is not None and match["updated_at"] >= incoming["updated_at"]:
continue
touched += 1
connection.execute(
"INSERT INTO completed_filed_reviews "
"(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(login, repository, issue_number) DO UPDATE SET "
"updated_at=excluded.updated_at, touched_at=excluded.touched_at",
(login, repository, number, updated_at, touched),
)
if match is not None:
current.remove(match)
current.append(incoming)
current = current[-self.limit:]
connection.execute(
"DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN ("
"SELECT rowid FROM completed_filed_reviews WHERE login = ? "
"ORDER BY touched_at DESC LIMIT ?)",
(login, login, self.limit),
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
(login, self._seal(login, current)),
)
rows = connection.execute(
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
"WHERE login = ? ORDER BY touched_at",
(login,),
).fetchall()
return self._snapshot(rows)
return self._snapshot(current)
+20 -8
View File
@@ -25,21 +25,27 @@ 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",),
"sign-out": ("static/private-data-inventory.js", "static/sign-out-review.js"),
"device-setup": (
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
"static/device-storage.js", "static/mobile-device-setup.js",
),
"security-center": ("static/security-center.js",),
"planning": (
"static/plan-today.js", "static/plan-today-readiness.js",
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.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/mobile-app-badge.js", "static/conversation.js", "static/widgets.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-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/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-first-task.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/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/search-batch-plan.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.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",
),
}
@@ -89,7 +95,7 @@ def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> Runti
digest = hashlib.sha256(content).hexdigest()[:16]
return RuntimeBundle(
runtime_bytes=content,
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
runtime_gzip_bytes=gzip.compress(content, compresslevel=9, mtime=0),
runtime_digest=digest,
runtime_name=f"{prefix}-{digest}.js",
)
@@ -114,7 +120,13 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
f'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
for name, bundle in feature_bundles.items()
)
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
workspace_preload = (
f'<link rel="preload" as="script" '
f'href="{feature_bundles["today-timer"].runtime_name}">'
)
dashboard_html = dashboard_html.replace(
"</head>", feature_metadata + "\n" + workspace_preload + "\n</head>"
)
dashboard_html = dashboard_html.replace(
"</body>",
f'<script src="{core.runtime_name}"></script>\n</body>',
+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")),
}
+51 -4
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any, Callable
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, private_state_encryption_config
@dataclass(frozen=True)
@@ -29,12 +30,17 @@ class IdempotencyLedger:
max_entries: int,
lock_timeout_seconds: float = 0.1,
clock: Callable[[], float] = time.time,
encryption_key: bytes | None = None,
) -> None:
self.path = Path(path)
self.ttl_seconds = ttl_seconds
self.max_entries = max_entries
self.lock_timeout_seconds = lock_timeout_seconds
self.clock = clock
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="idempotency-ledger",
)
self._initialize()
def _connect(self) -> sqlite3.Connection:
@@ -67,6 +73,17 @@ class IdempotencyLedger:
def _fingerprint(value: tuple[Any, ...]) -> str:
return json.dumps(value, separators=(",", ":"), sort_keys=True)
def _seal(self, value: str, *, key: str, field: str) -> str:
return self._cipher.seal(value, binding=f"{key}:{field}")
def _open(self, payload: str, *, key: str, field: str) -> tuple[str, bool]:
value, legacy = self._cipher.open(payload, binding=f"{key}:{field}")
if legacy:
return json.dumps(value, separators=(",", ":"), sort_keys=True), True
if not isinstance(value, str):
raise RuntimeError("idempotency ledger payload is invalid")
return value, legacy
def reserve(self, key: str, fingerprint: tuple[Any, ...]) -> Reservation:
encoded = self._fingerprint(fingerprint)
now = self.clock()
@@ -84,10 +101,40 @@ class IdempotencyLedger:
(key,),
).fetchone()
if row is not None:
if row[0] != encoded:
stored_fingerprint, legacy_fingerprint = self._open(
row[0], key=key, field="fingerprint"
)
if legacy_fingerprint:
connection.execute(
"UPDATE idempotency_operations SET fingerprint = ? "
"WHERE key = ? AND fingerprint = ?",
(
self._seal(
stored_fingerprint, key=key, field="fingerprint"
),
key,
row[0],
),
)
if stored_fingerprint != encoded:
return Reservation("conflict")
if row[1] == "completed":
return Reservation("completed", json.loads(row[2]))
stored_response, legacy_response = self._open(
row[2], key=key, field="response"
)
if legacy_response:
connection.execute(
"UPDATE idempotency_operations SET response_json = ? "
"WHERE key = ? AND response_json = ?",
(
self._seal(
stored_response, key=key, field="response"
),
key,
row[2],
),
)
return Reservation("completed", json.loads(stored_response))
if row[3] <= now - self.ttl_seconds:
return Reservation("uncertain")
return Reservation("pending")
@@ -110,7 +157,7 @@ class IdempotencyLedger:
connection.execute(
"INSERT INTO idempotency_operations "
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
(key, encoded, now),
(key, self._seal(encoded, key=key, field="fingerprint"), now),
)
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
@@ -129,7 +176,7 @@ class IdempotencyLedger:
connection.execute(
"UPDATE idempotency_operations SET status = 'completed', "
"response_json = ?, completed_at = ? WHERE key = ?",
(encoded, self.clock(), key),
(self._seal(encoded, key=key, field="response"), self.clock(), key),
)
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
+96 -23
View File
@@ -1,12 +1,12 @@
"""Durable, account-scoped Later deferrals."""
import json
import sqlite3
import time
from datetime import datetime
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
class LaterStore:
@@ -17,6 +17,7 @@ class LaterStore:
timeout: float = 1.0,
operation_limit: int = 4096,
operation_retention_seconds: float = 30 * 24 * 60 * 60,
encryption_key: bytes | None = None,
clock=time.time,
):
self.path = Path(path)
@@ -24,6 +25,10 @@ class LaterStore:
self.operation_limit = operation_limit
self.operation_retention_seconds = operation_retention_seconds
self.clock = clock
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="later",
)
self._initialize()
def _initialize(self) -> None:
@@ -99,11 +104,55 @@ class LaterStore:
raise ValueError("login is required")
return normalized
@staticmethod
def _snapshot(row) -> dict:
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None:
return {"revision": 0, "records": {}}
return {"revision": int(row[0]), "records": json.loads(row[1])}
return {"revision": 0, "records": {}}, False
records, legacy = self._cipher.open(row[1], binding=f"plan:{login}")
if not isinstance(records, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
return {"revision": int(row[0]), "records": records}, legacy
def _sealed_records(self, login: str, records: dict) -> str:
return self._cipher.seal(records, binding=f"plan:{login}")
def _item_revisions(
self, connection: sqlite3.Connection, login: str
) -> tuple[dict[str, int], dict[str, str]]:
revisions: dict[str, int] = {}
stored_ids: dict[str, str] = {}
for stored_item_id, revision in connection.execute(
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
(login,),
):
if stored_item_id.startswith(("v1:", "v2:")):
item_id, _legacy = self._cipher.open(
stored_item_id, binding=f"item-revision:{login}"
)
else:
item_id = stored_item_id
if not isinstance(item_id, str) or not item_id:
raise PrivateStateEncryptionError("private state could not be decrypted")
revisions[item_id] = int(revision)
stored_ids[item_id] = stored_item_id
return revisions, stored_ids
def _migrate_item_ids(
self, connection: sqlite3.Connection, login: str, stored_ids: dict[str, str]
) -> dict[str, str]:
migrated = dict(stored_ids)
for item_id, stored_item_id in stored_ids.items():
if stored_item_id.startswith(("v1:", "v2:")):
continue
sealed_item_id = self._cipher.seal(
item_id, binding=f"item-revision:{login}"
)
connection.execute(
"UPDATE later_item_revisions SET item_id = ? "
"WHERE login = ? AND item_id = ?",
(sealed_item_id, login, stored_item_id),
)
migrated[item_id] = sealed_item_id
return migrated
@staticmethod
def _validate_wake_at(wake_at: str | None) -> str:
@@ -116,12 +165,21 @@ class LaterStore:
return wake_at
def get(self, login: str) -> dict:
login = self._normalize_login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = ?",
(self._normalize_login(login),),
(login,),
).fetchone()
return self._snapshot(row)
snapshot, legacy_plan = self._snapshot(row, login)
_revisions, stored_item_ids = self._item_revisions(connection, login)
if row is not None and legacy_plan:
connection.execute(
"UPDATE later_plans SET records = ? WHERE login = ? AND records = ?",
(self._sealed_records(login, snapshot["records"]), login, row[1]),
)
self._migrate_item_ids(connection, login, stored_item_ids)
return snapshot
def apply(
self,
@@ -152,7 +210,7 @@ class LaterStore:
row = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
snapshot, legacy_plan = self._snapshot(row, login)
records = dict(snapshot["records"])
revision = snapshot["revision"]
accepted: list[str] = []
@@ -161,15 +219,19 @@ class LaterStore:
# Existing databases predate per-item revisions. Conservatively mark
# active deferrals as changed at the latest known plan revision.
item_revisions, stored_item_ids = self._item_revisions(connection, login)
stored_item_ids = self._migrate_item_ids(connection, login, stored_item_ids)
for item_id in records:
connection.execute(
"INSERT OR IGNORE INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
(login, item_id, revision),
)
item_revisions = dict(connection.execute(
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
(login,),
).fetchall())
if item_id not in item_revisions:
stored_item_id = self._cipher.seal(
item_id, binding=f"item-revision:{login}"
)
connection.execute(
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
(login, stored_item_id, revision),
)
item_revisions[item_id] = revision
stored_item_ids[item_id] = stored_item_id
batch_start_item_revisions = dict(item_revisions)
for operation in operations:
@@ -218,21 +280,32 @@ class LaterStore:
revision += 1 if changed else 0
if changed:
item_revisions[item_id] = revision
connection.execute(
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?) "
"ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision",
(login, item_id, revision),
)
stored_item_id = stored_item_ids.get(item_id)
if stored_item_id is None:
stored_item_id = self._cipher.seal(
item_id, binding=f"item-revision:{login}"
)
stored_item_ids[item_id] = stored_item_id
connection.execute(
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
(login, stored_item_id, revision),
)
else:
connection.execute(
"UPDATE later_item_revisions SET revision = ? "
"WHERE login = ? AND item_id = ?",
(revision, login, stored_item_id),
)
self._record_operation(connection, login, operation_id)
accepted.append(operation_id)
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
serialized = self._sealed_records(login, records)
if row is None:
connection.execute(
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
(login, revision, serialized),
)
elif accepted:
elif accepted or legacy_plan:
connection.execute(
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
(revision, serialized, login),
+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_config,
)
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_config(),
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=(",", ":")),
),
)
+659 -38
View File
@@ -3,6 +3,7 @@ import base64
import binascii
import hashlib
import hmac
import json
import math
import os
import re
@@ -54,17 +55,29 @@ from src.passkey_store import PasskeyStore
from src.push_notifications import (
PushConfiguration,
dispatch_deadline_reminders,
dispatch_start_day_reminders,
dispatch_unread_updates,
send_web_push,
)
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, TodayPromotionConflict, TodaySessionConflict, TodayStore,
TomorrowPlanConflict, TomorrowPlanNotDue, WeekPlanConflict,
)
from src.state_encryption import PrivateStateEncryptionError
from src.views import FRONTEND_BUILD, router as frontend_router
@@ -109,6 +122,14 @@ async def _push_channel_loop(dispatch, *, interval: float) -> None:
next_tick += elapsed_intervals * interval
async def _start_day_plan_snapshot() -> dict:
user = await current_user()
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
return {}
return await asyncio.to_thread(_today_store().get_start_day_plan, login)
async def _push_poll_loop() -> None:
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
deadline_interval = max(
@@ -151,11 +172,25 @@ async def _push_poll_loop() -> None:
max_concurrency=max_concurrency,
)
async def dispatch_start_day() -> None:
await dispatch_start_day_reminders(
_push_subscription_store,
_push_configuration(),
_start_day_plan_snapshot,
session_statuses=dashboard_auth.managed_session_statuses,
send_timeout_seconds=send_timeout,
lease_seconds=lease_seconds,
max_concurrency=max_concurrency,
)
channel_tasks = (
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
asyncio.create_task(
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
),
asyncio.create_task(
_push_channel_loop(dispatch_start_day, interval=deadline_interval)
),
)
try:
done, _pending = await asyncio.wait(
@@ -347,8 +382,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",
)
),
)
@@ -413,6 +456,23 @@ class DeadlineReminderPayload(BaseModel):
return value
class StartDayReminderPayload(BaseModel):
enabled: bool
timezone: str = Field(min_length=1, max_length=64)
reminder_hour: int = Field(default=9, ge=0, le=23)
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as error:
raise ValueError("Valid IANA timezone required") from error
return value
StepUpAction = Literal[
"merge_pull",
"submit_pull_review",
@@ -536,7 +596,7 @@ class NotificationReadBatch(BaseModel):
class TodayOperation(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
action: Literal["add", "remove", "move", "configure", "rollover"]
action: Literal["add", "remove", "move", "configure", "rollover", "activate"]
item_id: str = Field(min_length=1, max_length=500)
direction: Literal["up", "down"] | None = None
base_revision: int | None = Field(default=None, ge=0)
@@ -545,6 +605,7 @@ class TodayOperation(BaseModel):
plan_date: str | None = Field(default=None, min_length=10, max_length=10)
timezone: str | None = Field(default=None, min_length=1, max_length=100)
ids: list[str] = Field(default_factory=list, max_length=5)
activation_state: Literal["coaching", "complete"] | None = None
@model_validator(mode="after")
def validate_action_fields(self):
@@ -563,6 +624,11 @@ class TodayOperation(BaseModel):
raise ValueError("rollover IDs must be unique")
elif self.plan_date is not None or self.timezone is not None or self.ids:
raise ValueError("date, timezone, and IDs are only valid for rollover")
if self.action == "activate":
if self.item_id != "first-task" or self.activation_state is None:
raise ValueError("activate requires a first-task activation state")
elif self.activation_state is not None:
raise ValueError("activation state is only valid for activate")
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
for item_id, minutes in self.estimates.items()):
raise ValueError("estimates must use bounded item IDs and minutes")
@@ -574,6 +640,8 @@ class TodayOperation(BaseModel):
data.pop("plan_date", None)
data.pop("timezone", None)
data.pop("ids", None)
if self.action != "activate":
data.pop("activation_state", None)
return data
@@ -588,6 +656,36 @@ class TodayRecap(BaseModel):
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
class TodaySessionEntry(BaseModel):
identity: str = Field(min_length=1, max_length=500)
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
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)
entries: list[TodaySessionEntry] | None = Field(default=None, max_length=20)
running: bool
break_deadline_at: int | None = Field(default=None, ge=0, le=10_000_000_000_000)
@model_validator(mode="after")
def validate_session_state(self):
if self.running and self.break_deadline_at is not None:
raise ValueError("break must remain paused")
if self.entries is not None:
identities = [entry.identity for entry in self.entries]
if len(set(identities)) != len(identities):
raise ValueError("Today session entry identities must be unique")
active = [entry for entry in self.entries if entry.identity == self.identity]
if self.identity and (len(active) != 1 or active[0].elapsed_ms != self.elapsed_ms):
raise ValueError("active Today session must match its ledger entry")
if not self.identity and self.entries:
raise ValueError("inactive Today session ledger must be empty")
return self
class TodayRecapTimeLog(TodayRecap):
log_identities: list[str] = Field(min_length=1, max_length=20)
@@ -618,6 +716,97 @@ class TodayOperationBatch(BaseModel):
operations: list[TodayOperation] = Field(min_length=1, max_length=50)
class TomorrowPlanUpdate(BaseModel):
base_revision: int = Field(ge=0)
ids: list[str] = Field(max_length=5)
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
plan_date: str = Field(min_length=10, max_length=10)
timezone: str = Field(min_length=1, max_length=100)
@model_validator(mode="after")
def validate_plan(self):
if len(set(self.ids)) != len(self.ids):
raise ValueError("Tomorrow IDs must be unique")
if any(not item_id or len(item_id) > 500 for item_id in self.ids):
raise ValueError("Tomorrow IDs must be bounded")
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
for item_id, minutes in self.estimates.items()):
raise ValueError("estimates must use bounded item IDs and minutes")
return self
class TomorrowPromotion(BaseModel):
promotion_id: str = Field(min_length=1, max_length=100)
tomorrow_revision: int = Field(ge=0)
today_revision: int = Field(ge=0)
class WeekFreeWindow(BaseModel):
start_time: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
end_time: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
class WeekPlanDay(BaseModel):
plan_date: str = Field(min_length=10, max_length=10)
ids: list[str] = Field(max_length=5)
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
free_windows: list[WeekFreeWindow] | None = Field(default=None, max_length=16)
start_times: dict[str, str] = Field(default_factory=dict, max_length=5)
class WeekPlanUpdate(BaseModel):
base_revision: int = Field(ge=0)
days: list[WeekPlanDay] = Field(max_length=7)
timezone: str = Field(min_length=1, max_length=100)
availability_defaults: list[int] | None = Field(
default=None, min_length=7, max_length=7,
)
class WeekPromotion(BaseModel):
promotion_id: str = Field(min_length=1, max_length=100)
week_revision: int = Field(ge=0)
plan_date: str = Field(min_length=10, max_length=10)
today_revision: int = Field(ge=0)
class WeekReschedule(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
identity: str = Field(min_length=1, max_length=500)
estimate_minutes: int = Field(ge=5, le=1440)
plan_date: str = Field(min_length=10, max_length=10)
today_revision: int = Field(ge=0)
week_revision: int = Field(ge=0)
allow_over_capacity: bool = False
class WeekItemPull(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
identity: str = Field(min_length=1, max_length=500)
today_revision: int = Field(ge=0)
week_revision: int = Field(ge=0)
allow_over_capacity: bool = False
class WeekReconciliation(WeekPromotion):
ids: list[str] = Field(max_length=5)
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
@model_validator(mode="after")
def validate_selection(self):
if len(set(self.ids)) != len(self.ids):
raise ValueError("reconciliation IDs must be unique")
if any(not item_id or len(item_id) > 500 for item_id in self.ids):
raise ValueError("reconciliation IDs must be bounded")
if any(item_id not in self.ids or minutes < 5 or minutes > 1440
for item_id, minutes in self.estimates.items()):
raise ValueError("estimates must match selected IDs and be bounded")
return self
class LaterOperationBatch(BaseModel):
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
@@ -1322,7 +1511,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/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/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 +2316,13 @@ 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()
)
start_day_preferences = await asyncio.to_thread(
_push_subscription_store.start_day_preferences, device_id
)
delivery_health = await asyncio.to_thread(
_push_subscription_store.delivery_health, device_id
)
return {
"available": configuration.enabled,
@@ -2137,6 +2332,11 @@ async def push_status(request: Request):
"timezone": preferences["timezone"],
"reminder_hour": preferences["reminder_hour"],
"reminder_days": preferences["reminder_days"],
"snoozed_until": preferences["snoozed_until"],
"start_day_enabled": start_day_preferences["enabled"],
"start_day_timezone": start_day_preferences["timezone"],
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
"delivery_health": delivery_health,
}
@@ -2190,6 +2390,52 @@ async def unsubscribe_push(request: Request):
return {"subscribed": False}
@app.post("/api/v1/push-subscription/test")
async def test_push_notification(request: Request):
configuration = _push_configuration()
if not configuration.enabled:
raise HTTPException(status_code=503, detail="Push notifications are not configured")
device_id = await dashboard_auth.session_management_id(
request.state.dashboard_session
)
subscription = await asyncio.to_thread(
_push_subscription_store.subscription_for_session, device_id
)
if subscription is None:
raise HTTPException(status_code=409, detail="Enable device notifications first")
payload = json.dumps({
"title": "Stackchain notifications are working",
"body": "This device can receive private work alerts.",
"route": "#/device-setup",
"tag": "stackchain-push-test",
}, separators=(",", ":"))
try:
await send_web_push(subscription, payload, configuration)
except Exception as error:
status = getattr(getattr(error, "response", None), "status_code", None)
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
await asyncio.to_thread(_push_subscription_store.delete_session, device_id)
raise HTTPException(
status_code=409, detail="Re-enable notifications for this device"
) from error
reason = "timeout" if isinstance(error, (asyncio.TimeoutError, TimeoutError)) else "provider"
await asyncio.to_thread(
_push_subscription_store.mark_delivery_failed,
device_id,
"unread",
reason,
)
raise HTTPException(
status_code=503,
detail="Test notification delivery failed",
headers={"Retry-After": "1"},
) from error
await asyncio.to_thread(
_push_subscription_store.mark_delivery_succeeded, device_id, "unread"
)
return {"delivery_state": "healthy", "delivered": True}
@app.put("/api/v1/push-subscription/deadlines")
async def update_deadline_reminders(payload: DeadlineReminderPayload, request: Request):
device_id = await dashboard_auth.session_management_id(
@@ -2215,6 +2461,58 @@ async def update_deadline_reminders(payload: DeadlineReminderPayload, request: R
}
@app.put("/api/v1/push-subscription/start-day")
async def update_start_day_reminders(payload: StartDayReminderPayload, request: Request):
device_id = await dashboard_auth.session_management_id(
request.state.dashboard_session
)
if payload.enabled and not await asyncio.to_thread(
_push_subscription_store.is_subscribed, device_id
):
raise HTTPException(status_code=409, detail="Enable device notifications first")
await asyncio.to_thread(
_push_subscription_store.set_start_day_preferences,
device_id,
enabled=payload.enabled,
timezone=payload.timezone,
reminder_hour=payload.reminder_hour,
)
return {
"start_day_enabled": payload.enabled,
"start_day_timezone": payload.timezone,
"start_day_reminder_hour": payload.reminder_hour,
}
@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 +2567,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", "")
),
)
@@ -2294,7 +2606,7 @@ async def get_completed_filed_reviews(response: Response):
login = await _confirmed_login()
try:
snapshot = await asyncio.to_thread(_completed_filed_review_store().get, login)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Completed Filed review synchronization is unavailable",
@@ -2315,7 +2627,7 @@ async def merge_completed_filed_reviews(payload: CompletedFiledReviewBatch):
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Completed Filed review synchronization is unavailable",
@@ -2328,7 +2640,7 @@ async def get_saved_searches(response: Response):
login = await _confirmed_login()
try:
snapshot = await asyncio.to_thread(_saved_search_store().get, login)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Saved Search synchronization is unavailable",
@@ -2358,7 +2670,7 @@ async def replace_saved_searches(payload: SavedSearchCollection):
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Saved Search synchronization is unavailable",
@@ -2371,7 +2683,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 +2713,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",
@@ -2414,7 +2726,7 @@ async def get_today_plan():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_today_store().get, login)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Today synchronization is unavailable",
@@ -2448,7 +2760,7 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
)
except TodayPlanFull:
raise HTTPException(status_code=409, detail="Today is limited to 5 items")
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Today synchronization is unavailable",
@@ -2456,12 +2768,282 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
)
@app.get("/api/v1/tomorrow")
async def get_tomorrow_plan():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_today_store().get_tomorrow, login)
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Tomorrow synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.put("/api/v1/tomorrow")
async def replace_tomorrow_plan(payload: TomorrowPlanUpdate):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().replace_tomorrow, login, **payload.model_dump()
)
except TomorrowPlanConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "tomorrow_changed", "snapshot": error.snapshot},
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Tomorrow synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.post("/api/v1/tomorrow/promote")
async def promote_tomorrow_plan(payload: TomorrowPromotion):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().promote_tomorrow, login, **payload.model_dump()
)
except TomorrowPlanConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "tomorrow_changed", "snapshot": error.snapshot},
)
except TodayPromotionConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "today_changed", "today": error.today},
)
except TomorrowPlanNotDue as error:
raise HTTPException(
status_code=409,
detail={"code": "tomorrow_not_due", "plan_date": error.plan_date},
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Tomorrow promotion is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/week")
async def get_week_plan():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_today_store().get_week, login)
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Week Ahead synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.put("/api/v1/week")
async def replace_week_plan(payload: WeekPlanUpdate):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().replace_week, login,
base_revision=payload.base_revision,
days=[day.model_dump(exclude_none=True) for day in payload.days],
timezone=payload.timezone,
availability_defaults=payload.availability_defaults,
)
except WeekPlanConflict as error:
raise HTTPException(
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Week Ahead synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.post("/api/v1/week/promote")
async def promote_week_plan(payload: WeekPromotion):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().promote_week, login, **payload.model_dump()
)
except WeekPlanConflict as error:
raise HTTPException(
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
)
except TodayPromotionConflict as error:
if error.week is not None:
raise HTTPException(
status_code=409,
detail={"code": "week_today_in_progress", "today": error.today, "week": error.week},
)
raise HTTPException(
status_code=409, detail={"code": "today_changed", "today": error.today}
)
except TomorrowPlanNotDue as error:
raise HTTPException(
status_code=409, detail={"code": "week_not_due", "plan_date": error.plan_date}
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Week Ahead promotion is unavailable",
headers={"Retry-After": "1"},
)
@app.post("/api/v1/week/reschedule")
async def reschedule_today_to_week(payload: WeekReschedule):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().reschedule_today_to_week, login, **payload.model_dump()
)
except WeekPlanConflict as error:
raise HTTPException(
status_code=409, detail={"code": "week_changed", "week": error.snapshot}
)
except TodayPromotionConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "today_changed", "today": error.today, "week": error.week},
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Week Ahead rescheduling is unavailable",
headers={"Retry-After": "1"},
)
@app.post("/api/v1/week/pull-item")
async def pull_week_item_into_today(payload: WeekItemPull):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().pull_week_item, login, **payload.model_dump()
)
except WeekPlanConflict as error:
raise HTTPException(
status_code=409, detail={"code": "week_changed", "week": error.snapshot}
)
except TodayPromotionConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "today_changed", "today": error.today, "week": error.week},
)
except TodayPlanFull:
raise HTTPException(status_code=409, detail={"code": "today_full"})
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Adding Week Ahead work to Today is unavailable",
headers={"Retry-After": "1"},
)
@app.post("/api/v1/week/start-early")
async def start_week_day_early(payload: WeekPromotion):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().promote_week, login, **payload.model_dump(), allow_future=True
)
except WeekPlanConflict as error:
raise HTTPException(
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
)
except TodayPromotionConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "week_today_in_progress", "today": error.today, "week": error.week},
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Starting Week Ahead early is unavailable",
headers={"Retry-After": "1"},
)
@app.post("/api/v1/week/reconcile")
async def reconcile_week_plan(payload: WeekReconciliation):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().reconcile_week, login, **payload.model_dump()
)
except WeekPlanConflict as error:
raise HTTPException(
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
)
except TodayPromotionConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "week_today_changed", "today": error.today, "week": error.week},
)
except TomorrowPlanNotDue as error:
raise HTTPException(
status_code=409, detail={"code": "week_not_due", "plan_date": error.plan_date}
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Week Ahead reconciliation is unavailable",
headers={"Retry-After": "1"},
)
@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, PrivateStateEncryptionError):
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, PrivateStateEncryptionError):
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()
try:
recaps = await asyncio.to_thread(_today_store().list_recaps, login)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Today recap history is unavailable",
@@ -2483,7 +3065,7 @@ async def save_today_recap(payload: TodayRecap, response: Response):
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Today recap could not be saved",
@@ -2535,7 +3117,7 @@ async def save_today_recap_and_log_time(
raise ValueError("time log target must match the saved recap")
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(status_code=503, detail="Today recap could not be saved", headers={"Retry-After": "1"})
semaphore = asyncio.Semaphore(3)
@@ -2604,7 +3186,7 @@ async def get_later_plan():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_later_store().get, login)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Later synchronization is unavailable",
@@ -2634,7 +3216,7 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Later synchronization is unavailable",
@@ -3782,7 +4364,7 @@ async def _remove_notifications_from_live_snapshot(thread_ids: list[int]) -> Non
_live_snapshot_value = updated
try:
await asyncio.to_thread(_live_snapshot_store.remove_notifications, read_ids)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
# The upstream mutation already succeeded; keep process-local filtering.
pass
@@ -3962,7 +4544,7 @@ async def live_snapshot(
events_revision=events_revision,
notifications_revision=notifications_revision,
)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
return JSONResponse(
{"error": "Gitea live snapshot state is temporarily unavailable"},
status_code=503,
@@ -4257,7 +4839,7 @@ async def defer_notification(
status_code=503,
headers={"Retry-After": "1"},
)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
return JSONResponse(
{"error": "Later synchronization is unavailable. Please retry."},
status_code=503,
@@ -5879,31 +6461,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 +6530,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 +6548,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
+257 -16
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,65 @@ 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
def _delivery_failure_reason(error: Exception) -> str:
if isinstance(error, (asyncio.TimeoutError, TimeoutError)):
return "timeout"
return "provider"
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,
@@ -77,6 +133,7 @@ async def dispatch_unread_updates(
for item in page.get("items", [])
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
}
unread_count = min(len(thread_revisions), 9999)
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
if session_statuses is not None:
@@ -145,6 +202,7 @@ async def dispatch_unread_updates(
"route": f"#/my-work/update/{thread_id}",
"tag": f"stackchain-update-{thread_id}",
"notification_id": thread_id,
"unread_count": unread_count,
},
separators=(",", ":"),
)
@@ -160,14 +218,24 @@ 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
)
else:
await asyncio.to_thread(
store.mark_delivery_failed,
delivery.session_id,
"unread",
_delivery_failure_reason(error),
)
can_send_digest = False
# Leave this device's transient failures unseen for a later
# poll instead of paying the endpoint deadline repeatedly.
break
await asyncio.to_thread(
store.mark_delivery_succeeded, delivery.session_id, "unread"
)
await asyncio.to_thread(
store.mark_delivered,
delivery.session_id,
@@ -192,6 +260,7 @@ async def dispatch_unread_updates(
"route": "#/my-work/updates",
"tag": "stackchain-update-digest",
"update_count": len(overflow_revisions),
"unread_count": unread_count,
},
separators=(",", ":"),
)
@@ -207,17 +276,26 @@ 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
)
else:
await asyncio.to_thread(
store.mark_delivery_failed,
delivery.session_id,
"unread",
_delivery_failure_reason(error),
)
await asyncio.to_thread(
store.mark_digest_pending,
delivery.session_id,
overflow_revisions,
)
return count
await asyncio.to_thread(
store.mark_delivery_succeeded, delivery.session_id, "unread"
)
await asyncio.to_thread(
store.mark_delivered,
delivery.session_id,
@@ -229,10 +307,19 @@ async def dispatch_unread_updates(
async def dispatch_device_safely(delivery) -> int:
try:
return await dispatch_device(delivery)
except Exception:
except Exception as error:
# Device-specific validation, persistence, or provider failures
# must not cancel healthy siblings. Leave any uncheckpointed
# revisions unseen so a later poll can retry them.
try:
await asyncio.to_thread(
store.mark_delivery_failed,
delivery.session_id,
"unread",
_delivery_failure_reason(error),
)
except Exception:
pass
return 0
counts = await asyncio.gather(
@@ -272,6 +359,125 @@ async def dispatch_deadline_reminders(
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="deadline")
async def dispatch_start_day_reminders(
store: PushSubscriptionStore,
configuration: PushConfiguration,
tomorrow: Callable[[], Awaitable[dict]],
send: Callable[[dict, str], Awaitable[None]] | None = None,
*,
now: datetime | None = None,
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
send_timeout_seconds: float = 10.0,
lease_seconds: float = 60.0,
max_concurrency: int = 8,
) -> int:
if not configuration.enabled:
return 0
owner = secrets.token_urlsafe(18)
acquired = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="start-day",
now=time.time(),
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
)
if not acquired:
return 0
try:
devices = await asyncio.to_thread(store.start_day_reminder_devices)
if not devices:
return 0
current = now or datetime.now(timezone.utc)
due_devices = []
for device in devices:
try:
local_now = current.astimezone(ZoneInfo(device.timezone))
except ZoneInfoNotFoundError:
continue
if local_now.hour >= device.reminder_hour:
due_devices.append((device, local_now.date().isoformat()))
if not due_devices:
return 0
plan = await tomorrow()
ids = plan.get("ids") if isinstance(plan, dict) else None
plan_date = plan.get("plan_date") if isinstance(plan, dict) else None
if not isinstance(ids, list) or not ids or not isinstance(plan_date, str):
return 0
due_devices = [
(device, local_day) for device, local_day in due_devices
if local_day >= plan_date and device.delivered_plan_date != plan_date
]
if not due_devices:
return 0
if session_statuses is not None:
try:
statuses = await session_statuses(
[device.session_id for device, _local_day in due_devices]
)
except Exception:
return 0
for device, _local_day in due_devices:
if statuses.get(device.session_id) != "active":
await asyncio.to_thread(store.delete_session, device.session_id)
due_devices = [
pair for pair in due_devices
if statuses.get(pair[0].session_id) == "active"
]
payload = json.dumps({
"title": "Your planned day is ready",
"body": "Open Stackchain to prepare Today.",
"route": "#/my-work/start-day",
"tag": f"stackchain-start-day-{plan_date}",
"plan_date": plan_date,
}, separators=(",", ":"))
semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def dispatch_device(device) -> int:
async with semaphore:
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="start-day",
now=time.time(),
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
)
if not still_owner:
return 0
try:
operation = (
send(device.subscription, payload) if send is not None
else send_web_push(device.subscription, payload, configuration)
)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception as error:
status = getattr(getattr(error, "response", None), "status_code", None)
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
await asyncio.to_thread(store.delete_session, device.session_id)
else:
await asyncio.to_thread(
store.mark_delivery_failed,
device.session_id,
"start-day",
_delivery_failure_reason(error),
)
return 0
await asyncio.to_thread(
store.mark_delivery_succeeded, device.session_id, "start-day"
)
await asyncio.to_thread(
store.mark_start_day_reminder_delivered, device.session_id, plan_date
)
return 1
results = await asyncio.gather(
*(dispatch_device(device) for device, _local_day in due_devices),
return_exceptions=True,
)
return sum(result for result in results if isinstance(result, int))
finally:
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="start-day")
async def _dispatch_deadline_reminders_unlocked(
store: PushSubscriptionStore,
configuration: PushConfiguration,
@@ -298,10 +504,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 +531,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 +544,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 +580,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,8 +616,21 @@ 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:
status = getattr(getattr(error, "response", None), "status_code", None)
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
await asyncio.to_thread(store.delete_session, device.session_id)
else:
await asyncio.to_thread(
store.mark_delivery_failed,
device.session_id,
"deadline",
_delivery_failure_reason(error),
)
return 0
await asyncio.to_thread(
store.mark_delivery_succeeded, device.session_id, "deadline"
)
await asyncio.to_thread(
store.mark_deadline_reminder_delivered, device.session_id, local_day
)
+335 -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,108 @@ class DeadlineReminderDevice:
reminder_hour: int
reminder_days: int
delivered_local_day: str | None
snoozed_until: float | None
@dataclass(frozen=True)
class StartDayReminderDevice:
session_id: str
subscription: dict
timezone: str
reminder_hour: int
delivered_plan_date: str | None
class DisabledPushSubscriptionStore:
"""No-persistence store used when Web Push is not configured."""
def is_subscribed(self, session_id: str) -> bool:
return False
def subscription_for_session(self, session_id: str) -> dict | None:
return None
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 start_day_preferences(self, session_id: str) -> dict:
return {"enabled": False, "timezone": "UTC", "reminder_hour": 9}
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
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 set_start_day_preferences(self, *args, **kwargs) -> None:
return None
def mark_start_day_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 delivery_health(self, *args, **kwargs) -> dict:
return {}
def mark_delivery_failed(self, *args, **kwargs) -> None:
return None
def mark_delivery_succeeded(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 +167,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 +212,27 @@ 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
);
CREATE TABLE IF NOT EXISTS push_delivery_health (
session_id TEXT NOT NULL,
channel TEXT NOT NULL,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
last_attempted_at REAL NOT NULL,
last_succeeded_at REAL,
reason TEXT,
PRIMARY KEY (session_id, channel),
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_start_day_preferences (
session_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
timezone TEXT NOT NULL DEFAULT 'UTC',
reminder_hour INTEGER NOT NULL DEFAULT 9,
delivered_plan_date TEXT,
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
@@ -140,6 +279,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 +343,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:
@@ -198,6 +369,67 @@ class PushSubscriptionStore:
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
).fetchone() is not None
def subscription_for_session(self, session_id: str) -> dict | None:
with self._connect() as connection:
row = connection.execute(
"SELECT subscription_json FROM push_subscriptions WHERE session_id = ?",
(session_id,),
).fetchone()
return self._open_subscription(session_id, row[0]) if row else None
def delivery_health(self, session_id: str) -> dict:
with self._connect() as connection:
rows = connection.execute(
"""SELECT channel, consecutive_failures, last_attempted_at,
last_succeeded_at, reason
FROM push_delivery_health WHERE session_id = ? ORDER BY channel""",
(session_id,),
).fetchall()
return {
row[0]: {
"state": "degraded" if row[1] else "healthy",
"consecutive_failures": row[1],
"last_attempted_at": row[2],
"last_succeeded_at": row[3],
"reason": row[4],
}
for row in rows
}
def mark_delivery_failed(
self, session_id: str, channel: str, reason: str, *, now: float | None = None
) -> None:
attempted_at = time.time() if now is None else now
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_delivery_health(
session_id, channel, consecutive_failures, last_attempted_at, reason
) VALUES (?, ?, 1, ?, ?)
ON CONFLICT(session_id, channel) DO UPDATE SET
consecutive_failures = consecutive_failures + 1,
last_attempted_at = excluded.last_attempted_at,
reason = excluded.reason""",
(session_id, channel, attempted_at, reason),
)
def mark_delivery_succeeded(
self, session_id: str, channel: str, *, now: float | None = None
) -> None:
attempted_at = time.time() if now is None else now
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_delivery_health(
session_id, channel, consecutive_failures, last_attempted_at,
last_succeeded_at, reason
) VALUES (?, ?, 0, ?, ?, NULL)
ON CONFLICT(session_id, channel) DO UPDATE SET
consecutive_failures = 0,
last_attempted_at = excluded.last_attempted_at,
last_succeeded_at = excluded.last_succeeded_at,
reason = NULL""",
(session_id, channel, attempted_at, attempted_at),
)
def set_deadline_preferences(
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
reminder_days: int = 2,
@@ -215,42 +447,128 @@ 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),
)
def set_start_day_preferences(
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int
) -> None:
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_start_day_preferences(
session_id, enabled, timezone, reminder_hour
) VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
enabled = excluded.enabled,
timezone = excluded.timezone,
reminder_hour = excluded.reminder_hour""",
(session_id, int(enabled), timezone, reminder_hour),
)
def start_day_preferences(self, session_id: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"""SELECT enabled, timezone, reminder_hour
FROM push_start_day_preferences WHERE session_id = ?""",
(session_id,),
).fetchone()
return {
"enabled": bool(row[0]) if row else False,
"timezone": row[1] if row else "UTC",
"reminder_hour": row[2] if row else 9,
}
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
with self._connect() as connection:
rows = connection.execute(
"""SELECT s.session_id, s.subscription_json, p.timezone,
p.reminder_hour, p.delivered_plan_date
FROM push_subscriptions s
JOIN push_start_day_preferences p ON p.session_id = s.session_id
WHERE p.enabled = 1 ORDER BY s.session_id"""
).fetchall()
return [
StartDayReminderDevice(
row[0], self._open_subscription(row[0], row[1]), row[2], row[3], row[4]
)
for row in rows
]
def mark_start_day_reminder_delivered(
self, session_id: str, plan_date: str
) -> None:
with self._connect() as connection:
connection.execute(
"""UPDATE push_start_day_preferences SET delivered_plan_date = ?
WHERE session_id = ? AND enabled = 1""",
(plan_date, session_id),
)
def claim_unseen(
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
) -> list[PushDelivery]:
@@ -313,13 +631,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}))
+34 -11
View File
@@ -1,11 +1,11 @@
"""Durable, account-scoped saved Search views."""
import json
import re
import sqlite3
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
@@ -21,10 +21,21 @@ class SavedSearchConflict(ValueError):
class SavedSearchStore:
def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0):
def __init__(
self,
path: str | Path,
*,
limit: int = 20,
timeout: float = 1.0,
encryption_key: bytes | None = None,
):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="saved-searches",
)
self._initialize()
def _initialize(self) -> None:
@@ -50,19 +61,31 @@ class SavedSearchStore:
raise ValueError("login is required")
return normalized
@staticmethod
def _snapshot(row) -> dict:
return {"revision": 0, "views": []} if row is None else {
"revision": int(row[0]), "views": json.loads(row[1])
}
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None:
return {"revision": 0, "views": []}, False
views, legacy = self._cipher.open(row[1], binding=f"views:{login}")
if not isinstance(views, list):
raise PrivateStateEncryptionError("private state could not be decrypted")
return {"revision": int(row[0]), "views": views}, legacy
def _sealed_views(self, login: str, views: list[dict]) -> str:
return self._cipher.seal(views, binding=f"views:{login}")
def get(self, login: str) -> dict:
login = self._login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT revision, views FROM saved_searches WHERE login = ?",
(self._login(login),),
(login,),
).fetchone()
return self._snapshot(row)
snapshot, legacy = self._snapshot(row, login)
if row is not None and legacy:
connection.execute(
"UPDATE saved_searches SET views = ? WHERE login = ? AND views = ?",
(self._sealed_views(login, snapshot["views"]), login, row[1]),
)
return snapshot
def _normalize(self, views: list[dict]) -> list[dict]:
if not isinstance(views, list):
@@ -113,13 +136,13 @@ class SavedSearchStore:
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
raise ValueError("revision is invalid")
normalized = self._normalize(views)
serialized = json.dumps(normalized, separators=(",", ":"))
serialized = self._sealed_views(login, normalized)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
).fetchone()
current = self._snapshot(row)
current, _legacy = self._snapshot(row, login)
if current["revision"] != expected_revision:
raise SavedSearchConflict(current)
revision = expected_revision + 1
+137 -23
View File
@@ -7,6 +7,11 @@ from pathlib import Path
from typing import Callable
from src.private_state import connect_private_sqlite
from src.state_encryption import (
PrivateStateCipher,
PrivateStateEncryptionError,
private_state_encryption_config,
)
class SecurityEventStoreError(RuntimeError):
@@ -39,12 +44,22 @@ class SecurityEventStore:
max_events: int = 10_000,
retention_seconds: int = 90 * 24 * 60 * 60,
lock_timeout_seconds: float = 0.1,
encryption_key: bytes | None = None,
) -> None:
self.path = Path(path)
self.clock = clock
self.max_events = max(1, max_events)
self.retention_seconds = max(1, retention_seconds)
self.lock_timeout_seconds = lock_timeout_seconds
try:
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="security-events",
)
except PrivateStateEncryptionError as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
@staticmethod
def _bounded(value: str | None, limit: int) -> str | None:
@@ -60,10 +75,7 @@ class SecurityEventStore:
"""
CREATE TABLE IF NOT EXISTS security_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
method TEXT,
device_label TEXT,
target TEXT,
payload TEXT,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'completed',
operation_id TEXT
@@ -81,6 +93,11 @@ class SecurityEventStore:
connection.execute(
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
)
plaintext_columns = {"kind", "method", "device_label", "target"}
if "payload" not in columns or plaintext_columns.intersection(columns):
self._migrate_plaintext(
connection, has_payload="payload" in columns
)
connection.execute(
"CREATE INDEX IF NOT EXISTS security_events_created "
"ON security_events(created_at DESC, id DESC)"
@@ -95,6 +112,56 @@ class SecurityEventStore:
"Security activity is temporarily unavailable"
) from exc
def _migrate_plaintext(
self, connection: sqlite3.Connection, *, has_payload: bool
) -> None:
connection.execute("PRAGMA secure_delete = ON")
payload_column = "payload," if has_payload else "NULL AS payload,"
rows = connection.execute(
f"SELECT id, {payload_column} kind, method, device_label, target, "
"created_at, status, operation_id FROM security_events ORDER BY id"
).fetchall()
connection.execute(
"""
CREATE TABLE security_events_encrypted (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'completed',
operation_id TEXT
)
"""
)
for (
event_id,
payload,
kind,
method,
device_label,
target,
created_at,
status,
operation_id,
) in rows:
connection.execute(
"INSERT INTO security_events_encrypted "
"(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)",
(
event_id,
payload
or self._seal_event(
event_id, kind, method, device_label, target
),
created_at,
status,
operation_id,
),
)
connection.execute("DROP TABLE security_events")
connection.execute(
"ALTER TABLE security_events_encrypted RENAME TO security_events"
)
def _prune(self, connection: sqlite3.Connection, now: int) -> None:
connection.execute(
"DELETE FROM security_events WHERE created_at < ?",
@@ -106,6 +173,24 @@ class SecurityEventStore:
(self.max_events,),
)
def _seal_event(
self,
event_id: int,
kind: str,
method: str | None,
device_label: str | None,
target: str | None,
) -> str:
return self._cipher.seal(
{
"kind": self._bounded(kind, 48) or "security_event",
"method": self._bounded(method, 32),
"device_label": self._bounded(device_label, 64),
"target": self._bounded(target, 255),
},
binding=f"event:{event_id}",
)
def record(
self,
kind: str,
@@ -117,16 +202,17 @@ class SecurityEventStore:
now = int(self.clock())
try:
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO security_events(created_at, status) VALUES (?, 'completed')",
(now,),
)
event_id = cursor.lastrowid
payload = self._seal_event(
event_id, kind, method, device_label, target
)
connection.execute(
"INSERT INTO security_events(kind, method, device_label, target, created_at, status) "
"VALUES (?, ?, ?, ?, ?, 'completed')",
(
self._bounded(kind, 48) or "security_event",
self._bounded(method, 32),
self._bounded(device_label, 64),
self._bounded(target, 255),
now,
),
"UPDATE security_events SET payload = ? WHERE id = ?",
(payload, event_id),
)
self._prune(connection, now)
except (OSError, sqlite3.Error) as exc:
@@ -146,16 +232,19 @@ class SecurityEventStore:
operation_id = secrets.token_urlsafe(24)
try:
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO security_events(created_at, status, operation_id) "
"VALUES (?, 'pending', ?)",
(now, operation_id),
)
event_id = cursor.lastrowid
connection.execute(
"INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) "
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
"UPDATE security_events SET payload = ? WHERE id = ?",
(
self._bounded(kind, 48) or "security_event",
self._bounded(method, 32),
self._bounded(device_label, 64),
self._bounded(target, 255),
now,
operation_id,
self._seal_event(
event_id, kind, method, device_label, target
),
event_id,
),
)
self._prune(connection, now)
@@ -204,7 +293,7 @@ class SecurityEventStore:
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT id, kind, method, device_label, target, created_at, status "
"SELECT id, payload, created_at, status "
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
parameters,
).fetchall()
@@ -214,7 +303,32 @@ class SecurityEventStore:
) from exc
has_more = len(rows) > bounded_limit
visible = rows[:bounded_limit]
events = []
try:
for event_id, payload, created_at, status in visible:
value, _legacy = self._cipher.open(
payload, binding=f"event:{event_id}"
)
if not isinstance(value, dict) or not isinstance(value.get("kind"), str):
raise PrivateStateEncryptionError(
"private state could not be decrypted"
)
events.append(
SecurityEvent(
event_id,
value["kind"],
value.get("method"),
value.get("device_label"),
value.get("target"),
created_at,
status,
)
)
except PrivateStateEncryptionError as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
return SecurityEventPage(
events=[SecurityEvent(*row) for row in visible],
events=events,
next_cursor=visible[-1][0] if has_more else None,
)
+207
View File
@@ -0,0 +1,207 @@
"""Authenticated envelopes for retained private dashboard state."""
from __future__ import annotations
import base64
import binascii
import json
import os
import re
from collections.abc import Mapping
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 an 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:
"""Load the legacy single key setting."""
return decode_private_state_encryption_key(
os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", "")
)
_KEY_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
_MAX_KEYS = 4
def decode_private_state_encryption_keyring(
encoded: str, active_key_id: str
) -> tuple[dict[str, bytes], str]:
"""Decode a bounded key-ID-to-key JSON object and validate its active key."""
try:
raw = json.loads(encoded)
except (TypeError, json.JSONDecodeError) as error:
raise PrivateStateEncryptionError(
"private state encryption keyring is invalid"
) from error
if not isinstance(raw, dict) or not raw:
raise PrivateStateEncryptionError("private state encryption keyring is invalid")
if len(raw) > _MAX_KEYS:
raise PrivateStateEncryptionError(
f"private state encryption keyring supports at most {_MAX_KEYS} keys"
)
keys: dict[str, bytes] = {}
for key_id, value in raw.items():
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
raise PrivateStateEncryptionError("private state encryption key ID is invalid")
if not isinstance(value, str):
raise PrivateStateEncryptionError("private state encryption keyring is invalid")
keys[key_id] = decode_private_state_encryption_key(value)
if active_key_id not in keys:
raise PrivateStateEncryptionError(
"private state active key is missing from the keyring"
)
return keys, active_key_id
def private_state_encryption_config() -> bytes | tuple[dict[str, bytes], str]:
"""Load a rotation keyring when configured, otherwise the legacy single key."""
encoded = os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS", "")
active = os.getenv("STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID", "")
if encoded or active:
return decode_private_state_encryption_keyring(encoded, active)
return private_state_encryption_key()
class PrivateStateCipher:
"""Seal JSON values with store-specific authenticated context."""
def __init__(
self,
key: bytes | Mapping[str, bytes] | tuple[dict[str, bytes], str],
*,
store: str,
active_key_id: str | None = None,
):
if isinstance(key, tuple):
key, active_key_id = key
if isinstance(key, Mapping):
if not key or len(key) > _MAX_KEYS or active_key_id not in key:
raise PrivateStateEncryptionError(
"private state encryption keyring configuration is invalid"
)
ciphers: dict[str, AESGCM] = {}
for key_id, raw_key in key.items():
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
raise PrivateStateEncryptionError(
"private state encryption key ID is invalid"
)
if not isinstance(raw_key, bytes) or len(raw_key) != 32:
raise PrivateStateEncryptionError(
"private state encryption requires exactly 32 key bytes"
)
ciphers[key_id] = AESGCM(raw_key)
self._ciphers = ciphers
self._active_key_id = active_key_id
self._legacy_cipher = None
else:
if not isinstance(key, bytes) or len(key) != 32:
raise PrivateStateEncryptionError(
"private state encryption requires exactly 32 key bytes"
)
self._ciphers = {}
self._active_key_id = None
self._legacy_cipher = AESGCM(key)
if not store or "\0" in store:
raise ValueError("private state store identity is invalid")
self._store = store
def _aad(self, binding: str, *, version: str = "v1", key_id: str = "") -> bytes:
prefix = f"stackchain:private-state:{version}\0{self._store}\0{binding}"
if version == "v2":
prefix += f"\0{key_id}"
return prefix.encode()
def seal(self, value: object, *, binding: str = "singleton") -> str:
plaintext = json.dumps(value, separators=(",", ":")).encode()
nonce = os.urandom(12)
if self._active_key_id is not None:
key_id = self._active_key_id
sealed = nonce + self._ciphers[key_id].encrypt(
nonce, plaintext, self._aad(binding, version="v2", key_id=key_id)
)
return f"v2:{key_id}:" + base64.urlsafe_b64encode(sealed).decode()
sealed = nonce + self._legacy_cipher.encrypt(
nonce, plaintext, self._aad(binding)
)
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
@staticmethod
def _decode_sealed(payload: str) -> bytes:
sealed = base64.b64decode(payload, altchars=b"-_", validate=True)
if len(sealed) < 28:
raise ValueError("encrypted payload is too short")
return sealed
def open(self, payload: str, *, binding: str = "singleton") -> tuple[object, bool]:
"""Return the decoded value and whether active-key rewrapping is required."""
if not isinstance(payload, str):
raise PrivateStateEncryptionError("private state could not be decrypted")
if not payload.startswith(("v1:", "v2:")):
try:
return json.loads(payload), True
except (TypeError, json.JSONDecodeError) as error:
raise PrivateStateEncryptionError(
"private state could not be decrypted"
) from error
try:
if payload.startswith("v2:"):
_, key_id, encoded = payload.split(":", 2)
cipher = self._ciphers.get(key_id)
if cipher is None:
raise ValueError("encrypted payload uses an unavailable key")
sealed = self._decode_sealed(encoded)
plaintext = cipher.decrypt(
sealed[:12],
sealed[12:],
self._aad(binding, version="v2", key_id=key_id),
)
return json.loads(plaintext), key_id != self._active_key_id
sealed = self._decode_sealed(payload[3:])
candidates = (
[self._legacy_cipher]
if self._legacy_cipher is not None
else list(self._ciphers.values())
)
plaintext = None
for cipher in candidates:
try:
plaintext = cipher.decrypt(
sealed[:12], sealed[12:], self._aad(binding)
)
break
except InvalidTag:
continue
if plaintext is None:
raise InvalidTag
return json.loads(plaintext), self._active_key_id is not None
except (
binascii.Error,
InvalidTag,
UnicodeDecodeError,
ValueError,
json.JSONDecodeError,
) as error:
raise PrivateStateEncryptionError(
"private state could not be decrypted"
) from error
+1149 -62
View File
File diff suppressed because it is too large. Load diff
+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
+298 -5
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import re
import threading
from pathlib import Path
@@ -11,10 +12,108 @@ 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
@pytest.mark.parametrize(("width", "height"), [(390, 844)])
def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
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.assigned_issue_numbers = []
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_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"])
context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("First task 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")
expect(page.locator("#my-work-status")).to_contain_text("No assigned work")
page.locator('[data-mobile-task="work"]').click()
sheet = page.locator("#mobile-first-task")
expect(sheet).to_be_visible()
expect(page.locator("#mobile-first-task-find")).to_be_focused()
for selector in ("#mobile-first-task-find", "#mobile-first-task-create", "#mobile-first-task-setup"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#mobile-first-task-find").click()
expect(sheet).to_be_hidden()
expect(page.locator("#find-work-sheet")).to_be_visible()
page.locator("#close-find-work").click()
context.set_offline(True)
page.locator('[data-mobile-task="work"]').click()
expect(sheet).to_be_visible()
expect(page.locator("#mobile-first-task-find")).to_be_disabled()
expect(page.locator("#mobile-first-task-status")).to_contain_text("Create a task now")
expect(page.locator("#mobile-first-task-create")).to_be_enabled()
expect(page.locator("#mobile-first-task-create")).to_be_focused()
page.locator("#close-mobile-first-task").click()
context.set_offline(False)
page.evaluate(
"""() => {
document.querySelector('[data-mobile-today-hud]').hidden = false;
localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching');
const productionCoach = document.querySelector('[data-mobile-first-task-coach]');
const isolatedCoach = productionCoach.cloneNode(true);
productionCoach.replaceWith(isolatedCoach);
window.firstTaskOutcomeProbe = createMobileFirstTask({
getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true,
coach: isolatedCoach,
});
return window.firstTaskOutcomeProbe.refresh();
}"""
)
coach = page.locator("[data-mobile-first-task-coach]")
expect(coach).to_be_visible()
expect(coach).to_contain_text("Complete your first task")
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True
expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
expect(coach).to_be_hidden()
expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account")
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
fresh_context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
fresh_page = fresh_context.new_page()
fresh_page.goto(origin + "/", wait_until="networkidle")
fresh_page.locator('input[name="device_label"]').fill("Replacement release phone")
fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
fresh_page.locator("#submit-sign-in").click()
fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
assert fresh_page.evaluate(
"localStorage.getItem('stackchain.first-task.v1:timmy')"
) == "complete"
fresh_page.locator('[data-mobile-task="work"]').click()
expect(fresh_page.locator("#mobile-first-task")).to_be_hidden()
fresh_context.close()
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
tmp_path: Path, width: int, height: int
@@ -29,6 +128,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
browser_errors: list[str] = []
failed_responses: list[str] = []
workspace_requests: list[str] = []
launch_transfer_events: list[str] = []
live_requests: list[str] = []
try:
@@ -38,6 +138,18 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
cdp = context.new_cdp_session(page)
cdp.send("Network.enable")
cdp.send(
"Network.emulateNetworkConditions",
{
"offline": False,
"latency": 100,
"downloadThroughput": 48 * 1024,
"uploadThroughput": 48 * 1024,
"connectionType": "cellular3g",
},
)
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on(
"console",
@@ -53,9 +165,16 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
)
page.on(
"request",
lambda request: workspace_requests.append(request.url)
if "feature-today-timer-" in request.url
else None,
lambda request: (
workspace_requests.append(request.url),
launch_transfer_events.append("workspace-requested"),
)
if "feature-today-timer-" in request.url else None,
)
page.on(
"requestfinished",
lambda request: launch_transfer_events.append("core-finished")
if "/runtime-" in request.url else None,
)
page.on(
"request",
@@ -69,6 +188,9 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
assert launch_transfer_events.index("workspace-requested") < (
launch_transfer_events.index("core-finished")
), launch_transfer_events
expect(page.locator("#my-work-status")).to_contain_text("2")
initial_live_requests = len(live_requests)
@@ -101,6 +223,18 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#work-settings-toggle").click()
tomorrow = page.locator("#plan-tomorrow")
expect(tomorrow).to_be_visible()
tomorrow_bounds = tomorrow.bounding_box()
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
tomorrow.click()
expect(page.locator("#plan-today-sheet")).to_be_visible()
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
expect(page.locator("#save-and-start-today")).to_be_hidden()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#cancel-plan-today").click()
page.locator('[data-mobile-task="queues"]').click()
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
expect(delivery_queue).to_be_visible()
@@ -124,16 +258,47 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
expect(page.locator("#device-setup-sheet")).to_be_visible()
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
expect(page.locator("body > header")).to_have_attribute("inert", "")
expect(page.locator("main")).to_have_attribute("inert", "")
expect(dock).to_have_attribute("inert", "")
expect(page.locator("#close-device-setup")).to_be_focused()
page.keyboard.press("Shift+Tab")
expect(page.locator("#clear-private-device-data")).to_be_focused()
page.keyboard.press("Tab")
expect(page.locator("#close-device-setup")).to_be_focused()
for selector in ("#clear-device-caches", "#clear-private-device-data"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#close-device-setup").click()
page.go_back()
expect(page.locator("#device-setup-sheet")).to_be_hidden()
expect(page.locator("#open-device-setup")).to_be_focused()
expect(page.locator("body > header")).not_to_have_attribute("inert", "")
expect(page.locator("main")).not_to_have_attribute("inert", "")
expect(dock).not_to_have_attribute("inert", "")
page.locator("#active-devices").click()
expect(page.locator("#active-devices-sheet")).to_be_visible()
expect(page.locator("#security-activity-section")).to_be_visible()
expect(page.locator("body > header")).to_have_attribute("inert", "")
expect(page.locator("main")).to_have_attribute("inert", "")
expect(dock).to_have_attribute("inert", "")
expect(page.locator("#close-active-devices")).to_be_focused()
expect(page.locator("#active-devices-status")).to_contain_text("active device")
expect(page.locator("#enrolled-passkeys-status")).not_to_contain_text("Loading")
page.keyboard.press("Shift+Tab")
assert page.evaluate(
"""
() => {
const controls = [...document.querySelector('#active-devices-sheet').querySelectorAll(
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
)].filter(control => !control.hidden);
return document.activeElement === controls.at(-1);
}
"""
)
page.keyboard.press("Tab")
expect(page.locator("#close-active-devices")).to_be_focused()
security_nav = page.locator(".security-section-nav")
expect(security_nav).to_be_visible()
for control in security_nav.locator("button").all():
@@ -143,9 +308,15 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
page.go_back()
expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
expect(page.locator("body > header")).to_have_attribute("inert", "")
expect(page.locator("main")).to_have_attribute("inert", "")
expect(dock).to_have_attribute("inert", "")
page.go_back()
expect(page.locator("#active-devices-sheet")).to_be_hidden()
expect(page.locator("#active-devices")).to_be_focused()
expect(page.locator("body > header")).not_to_have_attribute("inert", "")
expect(page.locator("main")).not_to_have_attribute("inert", "")
expect(dock).not_to_have_attribute("inert", "")
assert len(workspace_requests) == 1, workspace_requests
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
@@ -158,6 +329,59 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
fake_thread.join(timeout=5)
def test_release_artifact_recovers_a_transient_workspace_request_in_place(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"
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}"
attempts: 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})
page.goto(origin + "/", wait_until="networkidle")
def interrupt_once(route):
attempts.append(route.request.url)
if len(attempts) == 1:
route.abort("failed")
else:
route.continue_()
page.route(re.compile(r"/feature-today-timer-[^/?]+\.js(?:\?.*)?$"), interrupt_once)
page.locator('input[name="device_label"]').fill("Workspace recovery 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.wait_for_timeout(1500)
resources = page.evaluate(
"performance.getEntriesByType('resource').map(entry => entry.name)"
)
state = {
"attempts": attempts,
"resources": resources,
"action": page.locator("#my-work-action-status").text_content(),
"retry_hidden": page.locator("#retry-workspace").is_hidden(),
"work": page.locator("#my-work-status").text_content(),
}
assert len(attempts) == 2, state
assert "2" in (state["work"] or ""), state
expect(page.locator("#retry-workspace")).to_be_hidden()
expect(page.locator("#my-work-action-status")).not_to_contain_text("Workspace unavailable")
assert any("feature-today-timer-" in url and "retry=" in url for url in resources), state
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(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"
@@ -250,3 +474,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")
+77
View File
@@ -0,0 +1,77 @@
from pathlib import Path
import pytest
sync_api = pytest.importorskip("playwright.sync_api")
FRONTEND = Path(__file__).parents[2] / "frontend"
@pytest.mark.parametrize("width,height", [(320, 568), (390, 844)])
def test_search_week_plan_sheet_fits_small_phones_with_touch_safe_controls(width, height):
html = (FRONTEND / "index.html").read_text()
with sync_api.sync_playwright() as playwright:
try:
browser = playwright.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium unavailable: {error}")
page = browser.new_page(viewport={"width": width, "height": height})
page.set_content(html)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.locator("#search-week-plan-days").evaluate(
"""node => node.innerHTML = Array.from({length:7}, (_, index) =>
`<button type="button" data-search-week-date="2026-08-${21+index}" aria-pressed="${index===0}">
<strong>Day ${index+1}</strong><small>${index*30} of 120 min · ${index} planned</small>
</button>`).join('')"""
)
page.locator("#search-week-plan-sheet").evaluate("node => node.hidden = false")
page.locator("#search-week-plan-estimate").fill("45")
expect = sync_api.expect
expect(page.locator("#search-week-plan-sheet")).to_be_visible()
expect(page.locator("#confirm-search-week-plan")).to_be_visible()
for button in page.locator("#search-week-plan-days button").all():
bounds = button.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.locator("#confirm-search-week-plan").bounding_box()["height"] >= 44
assert page.locator("#cancel-search-week-plan").bounding_box()["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert page.locator(".search-week-plan-panel").evaluate(
"node => node.scrollWidth <= node.clientWidth"
)
browser.close()
@pytest.mark.parametrize("width,height", [(320, 568), (390, 844)])
def test_search_week_batch_review_fits_small_phones_without_horizontal_overflow(width, height):
html = (FRONTEND / "index.html").read_text()
with sync_api.sync_playwright() as playwright:
try:
browser = playwright.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium unavailable: {error}")
page = browser.new_page(viewport={"width": width, "height": height})
page.set_content(html)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.locator("#search-week-batch-list").evaluate(
"""node => node.innerHTML = Array.from({length:4}, (_, index) =>
`<div class="search-week-batch-row"><div><span class="small">stackchain/dashboard#${1196+index}</span>
<strong>Substantial mobile planning issue ${index+1}</strong></div>
<div class="search-week-batch-row-controls"><label>Day<select><option>Fri, Aug 21 · 2 planned</option></select></label>
<label>Minutes<input type="number" value="45"></label></div></div>`).join('')"""
)
page.locator("#search-week-batch-review").evaluate("node => node.hidden = false")
page.locator("#cmd-palette").evaluate("node => node.classList.add('open')")
expect = sync_api.expect
expect(page.locator("#search-week-batch-review")).to_be_visible()
expect(page.locator("#confirm-search-week-batch")).to_be_visible()
for control in page.locator("#search-week-batch-review select, #search-week-batch-review input, #search-week-batch-review button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert page.locator("#search-week-batch-review").evaluate(
"node => node.scrollWidth <= node.clientWidth"
)
browser.close()
+76
View File
@@ -0,0 +1,76 @@
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 mobile sign-out 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
def test_release_artifact_reviews_private_work_before_mobile_sign_out(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"
fake = FakeGiteaServer(("127.0.0.1", 0))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
delete_requests: list[str] = []
browser_errors: list[str] = []
try:
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": 390, "height": 844}, ignore_https_errors=True
)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on(
"request",
lambda request: delete_requests.append(request.url)
if request.method == "DELETE" and "/api/v1/session" in request.url
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Sign-out review 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("localStorage.setItem('stackchain.release-test-draft', 'unsynced')")
launcher = page.locator("#sign-out")
page.locator("#app-menu-toggle").click()
launcher.click()
sheet = page.locator("#sign-out-review-sheet")
expect(sheet).to_be_visible()
expect(page.locator("#sign-out-review-summary")).to_contain_text("private browser item")
expect(page.locator("#sign-out-review-warning")).to_contain_text("may not be synced")
expect(page.locator("#confirm-sign-out")).to_have_text("Sign out and erase private work")
assert page.evaluate("document.querySelector('main').inert") is True
panel = page.locator(".sign-out-review-panel").bounding_box()
assert panel and panel["height"] <= 844
for selector in ("#cancel-sign-out", "#confirm-sign-out"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert delete_requests == []
page.keyboard.press("Escape")
expect(sheet).to_be_hidden()
expect(launcher).to_be_focused()
assert page.evaluate("document.querySelector('main').inert") is False
assert page.evaluate("localStorage.getItem('stackchain.release-test-draft')") == "unsynced"
assert delete_requests == []
assert browser_errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)
+521 -4
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)
@@ -78,7 +88,231 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
expect(page.locator("#send-issue-comment-next")).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
expect(page.locator("#plan-today-sheet")).to_be_hidden()
try:
expect(page.locator("#plan-today-sheet")).to_be_hidden()
except AssertionError as error:
raise AssertionError(page.evaluate("""() => ({
hidden:document.querySelector('#plan-today-sheet').hidden,
state:history.state,
bodyClass:document.body.className,
status:document.querySelector('#my-work-action-status').textContent,
error:document.querySelector('#plan-today-error').textContent,
})""")) from error
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.")
@@ -88,9 +322,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 +358,261 @@ 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("""() => {
const outbox = JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') ||
'{"version":2,"items":[]}');
outbox.items.push({
id: 'already-admitted-blocker', operationId: 'already-admitted-blocker',
kind: 'issue-comment', repository: 'acme/mobile', number: 41,
body: 'Blocked after reload waiting for the design owner', ownerLogin: 'timmy',
status: 'queued', queuedAt: Date.now()
});
localStorage.setItem('stackchain.authored-outbox.v1', JSON.stringify(outbox));
localStorage.setItem('stackchain.today-progress.v1.timmy', JSON.stringify({
version: 1,
drafts: {
'issue:acme/mobile:41:': {
body: 'Blocked after reload 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")
queued_comments = [
item for item in state["outbox"]["items"]
if item["kind"] == "issue-comment"
and item["number"] == 41
and item["body"] == "Blocked after reload waiting for the design owner"
]
delivered_comments = [
{"number": number, "body": body}
for number, body in fake.comments
if number == 41 and body == "Blocked after reload waiting for the design owner"
]
assert len(queued_comments) <= 1
assert len(delivered_comments) <= 1
assert queued_comments or delivered_comments
receipt = (delivered_comments or queued_comments)[0]
assert receipt["number"] == 41
assert receipt["body"] == "Blocked after reload 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,150 @@
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),
createOperationId:()=> 'release-summary-op',
resolveTarget:async target=>({...target,kind:'issue',title:'Daily status',state:'open'}),
enqueueDurably:async message=>{ window.__queuedSummary=message; return {item:message,durability:'background'}; },
});
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.locator("#today-summary-destination").fill("acme/mobile#41")
page.evaluate(
"""
() => {
window.__summaryTest.summary.setDestination(document.querySelector('#today-summary-destination').value);
return window.__summaryTest.view.validateDestination(document.querySelector('#validate-today-summary-destination'));
}
"""
)
expect(page.locator("#today-summary-target")).to_contain_text(
"acme/mobile#41 · issue · Daily status"
)
expect(page.locator("#post-today-summary")).to_be_enabled()
page.evaluate(
"window.__summaryTest.view.postDraft(document.querySelector('#post-today-summary'))"
)
expect(sheet).to_be_hidden()
assert page.evaluate("window.__queuedSummary") == {
"kind": "search-reply",
"repository": "acme/mobile",
"number": 41,
"targetKind": "issue",
"body": "Today\n- Ship mobile capture\n\nTomorrow\n- Review release",
"operationId": "release-summary-op",
}
page.evaluate(
"""
window.__summaryTest.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'}]
)
"""
)
expect(sheet).to_be_visible()
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)
@@ -0,0 +1,125 @@
from __future__ import annotations
import os
import threading
from datetime import date, timedelta
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Today to Week Ahead 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_reschedules_active_today_into_week_ahead(
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))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
try:
with release_server(
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(
viewport={"width": width, "height": height}, timezone_id="UTC"
)
page_errors: list[str] = []
page.on("pageerror", lambda error: page_errors.append(str(error)))
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Reschedule 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")
plan_date = (date.today() + timedelta(days=2)).isoformat()
saved_week = page.evaluate(
"""async ({planDate}) => {
const response=await fetch('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify({base_revision:0,timezone:'UTC',days:[{
plan_date:planDate,ids:[],capacity_minutes:90,estimates:{}
}]})});
return {status:response.status,body:await response.json()};
}""",
{"planDate": plan_date},
)
assert saved_week["status"] == 200
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]")
for _ in range(2):
missing.nth(0).fill("30")
missing.nth(0).press("Tab")
save_and_start = page.locator("#save-and-start-today")
expect(save_and_start).to_be_enabled()
save_and_start.click()
expect(page.locator("#plan-today-sheet")).to_be_hidden(timeout=10_000)
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
issue_sheet = page.locator("#issue-sheet")
if issue_sheet.is_visible():
page.locator("#close-issue-sheet").click()
expect(issue_sheet).to_be_hidden()
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
launcher = page.locator("[data-work-session-reschedule-week]")
page.locator("[data-mobile-today-more]").click()
expect(launcher).to_be_visible()
launcher_bounds = launcher.bounding_box()
assert launcher_bounds and launcher_bounds["height"] >= 44
launcher.click()
dialog = page.locator("#today-week-reschedule")
expect(dialog).to_be_visible()
days = page.locator("#today-week-reschedule-days button")
try:
expect(days).to_have_count(7)
except AssertionError as error:
raise AssertionError({
"status": page.locator("#today-week-reschedule-status").text_content(),
"page_errors": page_errors,
"today": page.evaluate("async()=>await (await fetch('api/v1/today')).json()"),
"week": page.evaluate("async()=>await (await fetch('api/v1/week')).json()"),
}) from error
for control in [*days.all(), page.locator("#cancel-today-week-reschedule"), page.locator("#confirm-today-week-reschedule")]:
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#cancel-today-week-reschedule").click()
expect(dialog).to_be_hidden()
expect(launcher).to_be_focused()
launcher.click()
destination = page.locator(
f'#today-week-reschedule-days button[data-plan-date="{plan_date}"]'
)
destination.click()
page.locator("#confirm-today-week-reschedule").click()
expect(dialog).to_be_hidden()
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
week = page.evaluate("async()=>await (await fetch('api/v1/week')).json()")
today = page.evaluate("async()=>await (await fetch('api/v1/today')).json()")
assert week["days"][0]["plan_date"] == plan_date
assert len(week["days"][0]["ids"]) == 1
assert len(today["ids"]) == 1
assert not page_errors
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=2)
+16 -7
View File
@@ -56,17 +56,19 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
{kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'},
];
const identity=item => `${item.kind}:${item.repository}:${item.number}:`;
const today=items.map(identity); const later={}; const operations=[];
const today=items.map(identity); const operations=[];
let tomorrow={revision:4,ids:['issue:acme/mobile:42:'],capacity_minutes:120,
estimates:{'issue:acme/mobile:42:':45},plan_date:'2026-08-18'};
const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{
const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true;
}};
const laterWork={presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:(item,wake)=>{
later[identity(item)]=wake.toISOString(); return 'deferred';
const tomorrowPlan={load:async()=>tomorrow,stage:value=>{
tomorrow={...tomorrow,...value,sync_pending:true}; return tomorrow;
}};
const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)};
const controller=createTodayWrapUp({todayWork,laterWork,todaySync});
const controller=createTodayWrapUp({todayWork,tomorrowPlan,todaySync});
const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
window.__wrapTest={view,today,later,operations};
window.__wrapTest={view,today,getTomorrow:()=>tomorrow,operations};
view.open(items,{});
}
"""
@@ -84,11 +86,18 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))")
expect(sheet).to_be_hidden()
result = page.evaluate(
"({today:window.__wrapTest.today,later:window.__wrapTest.later,operations:window.__wrapTest.operations})"
"({today:window.__wrapTest.today,tomorrow:window.__wrapTest.getTomorrow(),operations:window.__wrapTest.operations})"
)
assert result == {
"today": ["issue:acme/mobile:42:"],
"later": {"issue:acme/mobile:41:": "2026-08-17T09:00:00.000Z"},
"tomorrow": {
"revision": 4,
"ids": ["issue:acme/mobile:42:", "issue:acme/mobile:41:"],
"capacity_minutes": 120,
"estimates": {"issue:acme/mobile:42:": 45},
"plan_date": "2026-08-18",
"sync_pending": True,
},
"operations": [["remove", "issue:acme/mobile:41:"]],
}
assert browser_errors == []
@@ -0,0 +1,142 @@
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Tomorrow conflict 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_resolves_cross_device_tomorrow_conflicts_on_mobile(
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}"
put_bodies: list[dict] = []
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()
def tomorrow_route(route):
if route.request.method == "PUT":
body = json.loads(route.request.post_data or "{}")
put_bodies.append(body)
if body.get("base_revision") != 12:
route.fulfill(status=409, content_type="application/json", body='{"detail":"changed elsewhere"}')
return
route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"revision": 13, **body}),
)
return
route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(
{
"revision": 12,
"ids": ["issue:acme/mobile:server:"],
"capacity_minutes": 60,
"estimates": {"issue:acme/mobile:server:": 30},
"plan_date": "2026-08-21",
"timezone": "UTC",
}
),
)
page.route("**/api/v1/tomorrow", tomorrow_route)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Tomorrow conflict 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")
def stage_phone_plan():
page.evaluate(
"""() => {
localStorage.setItem('stackchain.tomorrow-sync.v1.timmy', JSON.stringify({
base_revision:11, revision:11,
ids:['issue:acme/mobile:phone:'], capacity_minutes:90,
estimates:{'issue:acme/mobile:phone:':45},
plan_date:'2026-08-21', timezone:'UTC', sync_pending:true,
}));
window.dispatchEvent(new Event('online'));
}"""
)
expect(page.locator("#mobile-tomorrow-summary")).to_have_text(
"Conflict · review required"
)
def open_conflict():
queue_sheet = page.locator("#mobile-queue-sheet")
if not queue_sheet.evaluate("element => element.open"):
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="tomorrow"]').click()
review = page.locator("#tomorrow-conflict-review")
expect(review).to_be_visible()
expect(page.locator("#plan-today-title")).to_have_text(
"Resolve Tomorrow conflict"
)
expect(page.locator("#tomorrow-conflict-phone-plan")).to_contain_text(
"issue:acme/mobile:phone:"
)
expect(page.locator("#tomorrow-conflict-phone-plan")).to_contain_text(
"45 min"
)
expect(page.locator("#tomorrow-conflict-server-plan")).to_contain_text(
"issue:acme/mobile:server:"
)
expect(page.locator("#tomorrow-conflict-server-plan")).to_contain_text(
"30 min"
)
expect(page.locator("#keep-phone-tomorrow")).to_be_focused()
for selector in ("#keep-phone-tomorrow", "#use-server-tomorrow"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
stage_phone_plan()
open_conflict()
initial_puts = len(put_bodies)
page.locator("#use-server-tomorrow").click()
expect(page.locator("#plan-today-sheet")).to_be_hidden()
expect(page.locator("#mobile-tomorrow-summary")).to_contain_text("1 planned")
assert len(put_bodies) == initial_puts
stage_phone_plan()
open_conflict()
page.locator("#keep-phone-tomorrow").click()
expect(page.locator("#plan-today-sheet")).to_be_hidden()
expect(page.locator("#mobile-tomorrow-summary")).to_contain_text("1 planned · 45 of 90 min")
assert put_bodies[-1]["base_revision"] == 12
assert put_bodies[-1]["ids"] == ["issue:acme/mobile:phone:"]
assert page.evaluate(
"localStorage.getItem('stackchain.tomorrow-sync.v1.timmy')"
) is None
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
+546
View File
@@ -0,0 +1,546 @@
from __future__ import annotations
import json
import os
import re
import threading
from datetime import date, timedelta
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Week Ahead 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_plans_seven_touch_safe_mobile_dates(
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))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
saved: list[dict] = []
try:
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC")
page_errors: list[str] = []
page.on("pageerror", lambda error: page_errors.append(str(error)))
pulled: list[dict] = []
page.route("**/api/v1/today", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps({
"revision": 3, "ids": ["issue:acme/mobile:99:"],
"capacity_minutes": 60, "estimates": {"issue:acme/mobile:99:": 30},
}),
))
def pull_item_route(route):
body = json.loads(route.request.post_data or "{}")
pulled.append(body)
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"today": {"revision": 4, "ids": ["issue:acme/mobile:99:", body["identity"]],
"capacity_minutes": 60,
"estimates": {"issue:acme/mobile:99:": 30, body["identity"]: 45}},
"week": {"revision": 99, "timezone": "UTC", "days": [{
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
"ids": [], "capacity_minutes": 60, "estimates": {},
}]},
}))
page.route("**/api/v1/week/pull-item", pull_item_route)
def week_route(route):
if route.request.method == "PUT":
body = json.loads(route.request.post_data or "{}")
saved.append(body)
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": body["base_revision"] + 1,
"timezone": body["timezone"], "days": body["days"],
**({"availability_defaults": body["availability_defaults"]}
if "availability_defaults" in body else {}),
}))
return
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": 0, "timezone": None, "days": [{
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
"ids": ["issue:acme/mobile:41:", "issue:acme/mobile:42:"], "capacity_minutes": 60,
"estimates": {"issue:acme/mobile:41:": 45, "issue:acme/mobile:42:": 45},
}],
}))
page.route("**/api/v1/week", week_route)
page.route(
"**/api/v1/repos/acme/mobile/issues/41/close",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"number":41,"state":"closed","updated_at":"2026-08-21T12:00:00Z"}',
),
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Week Ahead 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")
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="week"]').click()
page.wait_for_timeout(100)
assert not page_errors, f"Week Ahead launch raised: {page_errors}"
expect(page.locator("#plan-today-title")).to_have_text("Week Ahead")
overview = page.locator("#week-review")
expect(overview).to_be_visible()
cards = page.locator("#week-review-days .week-review-day")
expect(cards).to_have_count(7)
expect(cards.first).to_contain_text("Next up")
expect(cards.first).to_contain_text("Ship mobile capture")
expect(page.locator("#week-review-status")).to_contain_text("30 min over capacity")
expect(page.locator("#confirm-week-plan")).to_be_hidden()
blocked_export = page.locator("#export-saved-week-calendar")
expect(blocked_export).to_be_visible()
expect(blocked_export).to_be_disabled()
availability = page.locator("#open-week-availability")
expect(availability).to_be_visible()
availability_bounds = availability.bounding_box()
assert availability_bounds and availability_bounds["height"] >= 44
availability.click()
editor = page.locator("#week-availability-editor")
expect(editor).to_be_visible()
capacities = editor.locator("[data-weekday-capacity]")
expect(capacities).to_have_count(7)
for index, minutes in enumerate((480, 480, 480, 480, 360, 0, 0)):
capacities.nth(index).fill(str(minutes))
bounds = capacities.nth(index).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#save-week-availability").click()
expect(editor).to_be_hidden()
expect(page.locator("#week-review-status")).to_have_text("Weekly availability saved.")
assert saved[-1]["availability_defaults"] == [480, 480, 480, 480, 360, 0, 0]
availability.click()
page.locator("#disable-week-availability").click()
expect(editor).to_be_hidden()
expect(page.locator("#week-review-status")).to_have_text("Weekly availability disabled.")
assert "availability_defaults" not in saved[-1]
edit_week = page.locator("#edit-week-plan")
expect(edit_week).to_be_visible()
remove_actions = cards.first.locator("[data-week-unplan]")
expect(remove_actions).to_have_count(2)
remove_bounds = remove_actions.nth(1).bounding_box()
assert remove_bounds and remove_bounds["height"] >= 44
remove_actions.nth(1).click()
expect(cards.first).not_to_contain_text("Polish desktop filters")
expect(page.locator("#week-unplan-receipt")).to_have_text(
"Polish desktop filters removed from Week Ahead."
)
undo = page.locator("#undo-week-unplan")
expect(undo).to_be_visible()
undo_bounds = undo.bounding_box()
assert undo_bounds and undo_bounds["height"] >= 44
undo.click()
expect(cards.first).to_contain_text("Polish desktop filters")
expect(page.locator("#week-unplan-receipt")).to_be_hidden()
assert saved[-2]["days"][0]["ids"] == ["issue:acme/mobile:41:"]
assert saved[-1]["days"][0]["ids"] == [
"issue:acme/mobile:41:", "issue:acme/mobile:42:"
]
writes_before_reflow = len(saved)
reflow = page.locator("#open-week-reflow")
expect(reflow).to_be_visible()
bounds = reflow.bounding_box()
assert bounds and bounds["height"] >= 44
reflow.click()
preview = page.locator("#week-reflow-review")
expect(preview).to_be_visible()
expect(page.locator("#week-reflow-days")).to_contain_text("1 item · 45 of 60 min")
expect(page.locator("#week-reflow-unscheduled")).to_have_text(
"1 item cannot fit: Polish desktop filters. It will remain in My Work."
)
for control in (page.locator("#cancel-week-reflow"), page.locator("#apply-week-reflow")):
control_bounds = control.bounding_box()
assert control_bounds and control_bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#cancel-week-reflow").click()
expect(preview).to_be_hidden()
assert len(saved) == writes_before_reflow, "cancelling reflow must not write"
reflow.click()
page.locator("#apply-week-reflow").click()
expect(page.locator("#week-review-status")).to_have_text("Week Ahead reflowed and saved.")
expect(preview).to_be_hidden()
assert len(saved) == writes_before_reflow + 1
assert [day["ids"] for day in saved[-1]["days"]][:2] == [["issue:acme/mobile:41:"], []]
planned_item = cards.first.locator("[data-week-open-item]")
expect(planned_item).to_have_count(1)
planned_bounds = planned_item.bounding_box()
assert planned_bounds and planned_bounds["height"] >= 44
planned_item.click()
expect(page.locator("#issue-sheet")).to_have_class(re.compile(r"\bopen\b"))
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
expect(overview).to_be_visible()
page.locator("#close-issue-sheet").click()
expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b"))
expect(overview).to_be_visible()
expect(planned_item).to_be_focused()
assert len(saved) == writes_before_reflow + 1, "opening and inspecting Week Ahead must not add a write"
export_saved = page.locator("#export-saved-week-calendar")
expect(export_saved).to_be_visible()
expect(export_saved).to_be_enabled()
export_bounds = export_saved.bounding_box()
assert export_bounds and export_bounds["height"] >= 44
writes_before_export = len(saved)
export_saved.click()
expect(page.locator("#week-calendar-handoff")).to_be_visible()
expect(overview).to_be_hidden()
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text(
"09:00–09:45 · 45 min"
)
assert len(saved) == writes_before_export, "exporting a saved week must not add a write"
page.locator("#back-to-week-review-from-calendar").click()
expect(overview).to_be_visible()
expect(export_saved).to_be_focused()
for control in (cards.first.locator("[data-week-edit-day]"), edit_week):
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
edit_week.click()
expect(overview).to_be_hidden()
expect(page.locator("#plan-today-title")).to_have_text("Plan Week Ahead")
dates = page.locator("#week-plan-dates button")
expect(dates).to_have_count(7)
expect(dates.first).to_have_attribute("aria-current", "date")
for index in range(7):
bounds = dates.nth(index).bounding_box()
assert bounds and bounds["height"] >= 44
dates.nth(2).click()
expect(dates.nth(2)).to_have_attribute("aria-current", "date")
expect(page.locator("#week-plan-progress")).to_have_text("Day 3 of 7 · 1 planned")
expect(page.locator("#save-today-plan")).to_have_text("Save & next")
page.locator("#save-today-plan").click()
expect(page.locator("#plan-today-sheet")).to_be_visible()
expect(dates.nth(3)).to_have_attribute("aria-current", "date")
expect(page.locator("#week-plan-progress")).to_have_text("Day 4 of 7 · 1 planned")
dates.nth(6).click()
expect(page.locator("#save-today-plan")).to_have_text("Save week")
page.locator("#save-today-plan").click()
expect(page.locator("#plan-today-sheet")).to_be_visible()
expect(page.locator("#week-review")).to_be_visible()
expect(page.locator("#week-review-days .week-review-day")).to_have_count(7)
first_day = page.locator("#week-review-days .week-review-day").first
expect(first_day).to_contain_text("Ship mobile capture")
expect(first_day).to_contain_text("acme/mobile #41 · Issue · 45 min")
expect(first_day.locator("code")).to_have_count(0)
edit = first_day.locator("[data-week-edit-day]")
bounds = edit.bounding_box()
assert bounds and bounds["height"] >= 44
edit.click()
expect(page.locator("#week-review")).to_be_hidden()
expect(page.locator("#back-to-week-review")).to_be_visible()
expect(page.locator("#save-today-plan")).to_have_text("Save & review")
page.locator("#back-to-week-review").click()
expect(page.locator("#week-review")).to_be_visible()
expect(first_day).to_contain_text("Ship mobile capture")
page.locator("#open-week-capacity-import").click()
capacity_import = page.locator("#week-capacity-import")
expect(capacity_import).to_be_visible()
private_title = "Private customer planning"
tomorrow = (date.today() + timedelta(days=1)).strftime("%Y%m%d")
excluded = (date.today() + timedelta(days=2)).strftime("%Y%m%d")
added = (date.today() + timedelta(days=4)).strftime("%Y%m%d")
calendar = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
f"SUMMARY:{private_title}\r\nDTSTART;TZID=America/New_York:{tomorrow}T050000\r\n"
f"DTEND;TZID=America/New_York:{tomorrow}T060000\r\nRRULE:FREQ=DAILY;COUNT=3\r\n"
f"EXDATE;TZID=America/New_York:{excluded}T050000\r\n"
f"RDATE;TZID=America/New_York:{added}T050000\r\nEND:VEVENT\r\nEND:VCALENDAR"
)
unsupported_title = "Private monthly board review"
unsupported_calendar = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
f"SUMMARY:{unsupported_title}\r\nDTSTART:{tomorrow}T100000\r\n"
f"DTEND:{tomorrow}T110000\r\nRRULE:FREQ=MONTHLY;BYDAY=1FR\r\n"
"END:VEVENT\r\nEND:VCALENDAR"
)
page.locator("#week-capacity-file").set_input_files({
"name": "unsupported.ics", "mimeType": "text/calendar", "buffer": unsupported_calendar.encode()
})
expect(page.locator("#week-capacity-status")).to_contain_text(
"1 recurring event could not be counted"
)
expect(page.locator("#apply-week-capacities")).to_be_disabled()
expect(capacity_import).not_to_contain_text(unsupported_title)
page.locator("#week-capacity-file").set_input_files({
"name": "availability.ics", "mimeType": "text/calendar", "buffer": calendar.encode()
})
expect(page.locator("#week-capacity-days .week-capacity-day")).to_have_count(7)
expect(page.locator("#week-capacity-days .week-capacity-day").first).to_contain_text(
"420 min available"
)
expect(page.locator("#week-capacity-days .week-capacity-day").nth(1)).to_contain_text(
"480 min available"
)
expect(page.locator("#week-capacity-days .week-capacity-day").nth(2)).to_contain_text(
"420 min available"
)
expect(page.locator("#week-capacity-days .week-capacity-day").nth(3)).to_contain_text(
"420 min available"
)
expect(capacity_import).not_to_contain_text(private_title)
for control in (
page.locator("#cancel-week-capacity-import"),
page.locator("#week-capacity-file"),
page.locator("#week-capacity-start"),
page.locator("#week-capacity-end"),
page.locator("#keep-week-free-times"),
page.locator("#apply-week-capacities"),
):
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#keep-week-free-times").check()
page.locator("#apply-week-capacities").click()
expect(capacity_import).to_be_hidden()
expect(page.locator("#week-review")).to_be_visible()
expect(first_day).to_contain_text("45 of 420 min")
assert saved[-1]["days"][0]["free_windows"] == [
{"start_time": "10:00", "end_time": "17:00"}
]
page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled")
confirm = page.locator("#confirm-week-plan")
bounds = confirm.bounding_box()
assert bounds and bounds["height"] >= 44
confirm.click()
expect(page.locator("#week-calendar-handoff")).to_be_visible()
expect(page.locator("#week-review")).to_be_hidden()
start = page.locator("[data-week-calendar-start]").first
expect(start).to_have_value("09:00")
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:00–10:45 · 45 min")
expect(page.locator("#week-calendar-status")).to_contain_text("Planning around imported busy time")
exact = page.locator('[data-week-calendar-exact="issue:acme/mobile:41:"]')
expect(exact).to_have_value("")
exact.fill("14:00")
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("14:00–14:45 · 45 min")
save_times = page.locator("#save-week-calendar-times")
for control in (start, exact, page.locator("#back-to-week-review-from-calendar"), save_times, page.locator("#share-week-calendar")):
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
save_times.click()
expect(page.locator("#week-calendar-status")).to_have_text("Exact task times saved to Week Ahead.")
assert saved[-1]["days"][0]["start_times"] == {"issue:acme/mobile:41:": "14:00"}
page.locator("#back-to-week-review-from-calendar").click()
expect(page.locator("#week-review")).to_be_visible()
confirm.click()
with page.expect_download() as download_info:
page.locator("#share-week-calendar").click()
assert download_info.value.suggested_filename.startswith("stackchain-week-ahead-")
expect(page.locator("#plan-today-sheet")).to_be_hidden()
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
assert saved and len(saved[-1]["days"]) == 7
assert [day["plan_date"] for day in saved[-1]["days"]] == sorted(
day["plan_date"] for day in saved[-1]["days"]
)
before_retirement = len(saved)
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="week"]').click()
expect(overview).to_be_visible()
planned_item = cards.first.locator(
'[data-week-open-item="issue:acme/mobile:41:"]'
)
planned_item.click()
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
page.once("dialog", lambda dialog: dialog.accept())
page.locator("#close-issue").click()
expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b"))
expect(cards.first).not_to_contain_text("Ship mobile capture")
expect(cards.first).to_contain_text("Polish desktop filters")
expect(cards.first).to_contain_text("45 of 60 min")
expect(page.locator("#week-review-status")).to_have_text(
"Ship mobile capture removed and Week Ahead saved."
)
expect(edit_week).to_be_focused()
assert len(saved) == before_retirement + 1
assert saved[-1]["days"][0]["ids"] == ["issue:acme/mobile:42:"]
assert saved[-1]["days"][0]["capacity_minutes"] == 60
pull = cards.first.locator('[data-week-pull-item="issue:acme/mobile:42:"]')
expect(pull).to_have_text("Review Today · 75 of 60 min")
pull_bounds = pull.bounding_box()
assert pull_bounds and pull_bounds["height"] >= 44
page.once("dialog", lambda dialog: dialog.dismiss())
pull.click()
expect(cards.first).to_contain_text("Polish desktop filters")
assert pulled == [], "cancelling the overload review must not submit"
expect(pull).to_have_text("Review Today · 75 of 60 min")
page.once("dialog", lambda dialog: dialog.accept())
pull.click()
expect(cards.first).not_to_contain_text("Polish desktop filters")
expect(page.locator("#week-review-status")).to_have_text(
"Polish desktop filters added to Today."
)
assert pulled and pulled[-1]["identity"] == "issue:acme/mobile:42:"
assert pulled[-1]["today_revision"] == 3
assert pulled[-1]["allow_over_capacity"] is True
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_reconciles_only_the_week_day_changed_on_both_devices(
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))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
puts: list[dict] = []
try:
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": width, "height": height})
page_errors: list[str] = []
page.on("pageerror", lambda error: page_errors.append(str(error)))
def week_route(route):
if route.request.method == "PUT":
body = json.loads(route.request.post_data or "{}")
puts.append(body)
if len(puts) == 1:
route.fulfill(status=409, content_type="application/json", body='{"message":"changed"}')
return
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": 6, "timezone": body["timezone"], "days": body["days"],
}))
return
if puts:
changed_date = puts[0]["days"][0]["plan_date"]
following_date = (date.fromisoformat(changed_date) + timedelta(days=1)).isoformat()
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": 5, "timezone": puts[0]["timezone"], "days": [
{"plan_date": changed_date, "ids": [], "capacity_minutes": 90, "estimates": {}},
{"plan_date": following_date, "ids": [], "capacity_minutes": 30, "estimates": {}},
],
}))
return
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": 4, "timezone": None, "days": [],
}))
page.route("**/api/v1/week", week_route)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Week conflict 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="week"]').click()
page.locator("#edit-week-plan").click()
page.locator("#plan-today-available").fill("60")
page.locator("#save-today-plan").click()
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent.includes('Conflict')")
choices = page.locator("[data-week-conflict-choice]")
expect(choices).to_have_count(2)
for index in range(2):
bounds = choices.nth(index).locator("xpath=..").bounding_box()
assert bounds and bounds["height"] >= 44
page.locator('[data-week-conflict-choice="phone"]').check()
expect(page.locator("#save-merged-week")).to_be_enabled()
page.locator("#save-merged-week").click()
expect(page.locator("#plan-today-sheet")).to_be_hidden()
assert len(puts) == 2
assert [day["capacity_minutes"] for day in puts[-1]["days"]] == [60, 30]
assert not page_errors, f"Week conflict recovery raised: {page_errors}"
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_reads_the_confirmed_week_offline_then_retries_live(
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))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
online = True
try:
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC")
page_errors: list[str] = []
page.on("pageerror", lambda error: page_errors.append(str(error)))
def week_route(route):
if not online:
route.fulfill(status=503, content_type="application/json", body='{"message":"offline"}')
return
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": 7, "timezone": "UTC", "days": [{
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
"ids": ["issue:acme/mobile:41:"], "capacity_minutes": 60,
"estimates": {"issue:acme/mobile:41:": 30},
}],
}))
page.route("**/api/v1/week", week_route)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Offline Week Ahead 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="week"]').click()
expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture")
page.locator("#cancel-plan-today").click()
online = False
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="week"]').click()
notice = page.locator("#week-offline-snapshot")
expect(notice).to_be_visible()
expect(notice).to_contain_text("Offline snapshot")
expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture")
expect(page.locator("[data-week-edit-day]")).to_have_count(0)
expect(page.locator("#edit-week-plan")).to_be_hidden()
expect(page.locator("#open-week-capacity-import")).to_be_hidden()
retry = page.locator("#retry-week-live")
expect(retry).to_be_visible()
bounds = retry.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
online = True
retry.click()
expect(notice).to_be_hidden()
expect(page.locator("#edit-week-plan")).to_be_visible()
expect(page.locator("[data-week-edit-day]")).to_have_count(7)
assert not page_errors, f"offline Week Ahead recovery raised: {page_errors}"
browser.close()
finally:
fake.shutdown()
fake.server_close()
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):
+18 -2
View File
@@ -41,11 +41,14 @@ 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")
def test_release_promotion_waits_for_artifact_mobile_offline_journey():
def test_release_promotion_waits_for_packaged_mobile_journeys():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
release = text[text.index(" release-candidate:") :]
@@ -58,10 +61,23 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
assert (
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
"tests/e2e/test_mobile_search_preview_navigation.py "
"tests/e2e/test_mobile_search_week_plan.py "
"tests/e2e/test_mobile_find_work_release.py "
"tests/e2e/test_mobile_home_bootstrap_release.py "
"tests/e2e/test_mobile_sign_out_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_tomorrow_conflict_release.py "
"tests/e2e/test_mobile_week_ahead_release.py "
"tests/e2e/test_mobile_today_week_reschedule_release.py "
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
) in browser
assert "needs: [lint, build-release, browser-journey]" in release
def test_browser_job_runs_packaged_today_week_reschedule_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_today_week_reschedule_release.py" in browser
+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-v130" in worker
@@ -1,3 +1,5 @@
import sqlite3
import httpx
import pytest
@@ -21,6 +23,30 @@ def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path)
assert store.get("alexander") == {"receipts": []}
def test_receipt_history_is_encrypted_and_bound_to_its_account(tmp_path):
database = tmp_path / "completed-filed.sqlite3"
store = CompletedFiledReviewStore(database, encryption_key=b"f" * 32)
timmy = receipt("private-timmy/repository", 912)
alexander = receipt("private-alexander/repository", 427)
store.merge("timmy-private-login", [timmy])
store.merge("alexander-private-login", [alexander])
raw = database.read_bytes()
assert b"private-timmy/repository" not in raw
assert b"private-alexander/repository" not in raw
with sqlite3.connect(database) as connection:
rows = connection.execute(
"SELECT login, receipts FROM completed_filed_review_collections ORDER BY login"
).fetchall()
connection.execute(
"UPDATE completed_filed_review_collections SET receipts = ? WHERE login = ?",
(rows[1][1], rows[0][0]),
)
with pytest.raises(RuntimeError, match="private state could not be decrypted"):
store.get("alexander-private-login")
def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2)
newer = receipt(updated_at="2026-08-16T12:00:00Z")
@@ -32,6 +58,42 @@ def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]}
def test_legacy_plaintext_receipts_migrate_for_every_account_without_loss(tmp_path):
database = tmp_path / "completed-filed.sqlite3"
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TABLE completed_filed_reviews ("
"login TEXT NOT NULL, repository TEXT NOT NULL, issue_number INTEGER NOT NULL, "
"updated_at TEXT NOT NULL, touched_at INTEGER NOT NULL, "
"PRIMARY KEY (login, repository, issue_number))"
)
connection.executemany(
"INSERT INTO completed_filed_reviews VALUES (?, ?, ?, ?, ?)",
[
("timmy", "legacy-private/first", 1, "2026-08-14T12:00:00Z", 1),
("timmy", "legacy-private/second", 2, "2026-08-15T12:00:00Z", 2),
("alexander", "legacy-private/other", 3, "2026-08-16T12:00:00Z", 1),
],
)
store = CompletedFiledReviewStore(database, encryption_key=b"m" * 32)
assert store.get("timmy") == {"receipts": [
receipt("legacy-private/first", 1, "2026-08-14T12:00:00Z"),
receipt("legacy-private/second", 2, "2026-08-15T12:00:00Z"),
]}
assert store.get("alexander") == {"receipts": [
receipt("legacy-private/other", 3, "2026-08-16T12:00:00Z")
]}
raw = database.read_bytes()
assert b"legacy-private/first" not in raw
assert b"legacy-private/other" not in raw
with sqlite3.connect(database) as connection:
assert connection.execute(
"SELECT count(*) FROM sqlite_master WHERE name = 'completed_filed_reviews'"
).fetchone()[0] == 0
@pytest.mark.parametrize("candidate", [
{"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"},
{"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"},
@@ -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,
}
+18
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))});
@@ -351,6 +364,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [
@@ -378,6 +392,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
@@ -510,6 +525,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -534,6 +550,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -631,6 +648,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
+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);
""")
+109 -6
View File
@@ -17,6 +17,7 @@ class Target {
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
let purgeCalls=0, purgeError='';
let inventory={recordCount:0, unavailable:false};
let persistence={persisted:false, persistResult:false, persistedCalls:0, persistCalls:0, error:''};
const localStorage={
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
get length(){return this.values.size;},
@@ -27,11 +28,16 @@ const caches={
async keys(){return Array.from(this.values);},
async delete(key){return this.values.delete(key);},
};
const storageManager={
estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024}),
persisted:async()=>{persistence.persistedCalls++;if(persistence.error)throw new Error(persistence.error);return persistence.persisted;},
persist:async()=>{persistence.persistCalls++;persistence.persisted=persistence.persistResult;return persistence.persistResult;},
};
const controller=createDeviceStorage({
summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
inspectPrivateDatabases:async()=>inventory,
storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})},
storageManager,
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
});
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);});
@@ -49,7 +55,104 @@ process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.t
assert result == {
"summary": "2 MB of 10 MB browser storage used.",
"detail": "1 private browser item · 0 private work records · 1 cached app copy",
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 1 cached app copy",
}
def test_refresh_checks_storage_protection_without_requesting_permission():
result = run_scenario("""
await controller.refresh();
process.stdout.write(JSON.stringify({
readiness:await controller.persistenceReadiness(),
persistedCalls:persistence.persistedCalls,
persistCalls:persistence.persistCalls,
detail:detail.textContent,
}));
""")
assert result == {
"readiness": {
"state": "incomplete",
"detail": "Offline work uses best-effort browser storage and may be removed under storage pressure.",
},
"persistedCalls": 1,
"persistCalls": 0,
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 1 cached app copy",
}
def test_explicit_request_confirms_storage_is_protected_before_claiming_success():
result = run_scenario("""
await controller.refresh();
persistence.persistResult=true;
const readiness=await controller.requestPersistence();
process.stdout.write(JSON.stringify({
readiness,
persistedCalls:persistence.persistedCalls,
persistCalls:persistence.persistCalls,
detail:detail.textContent,
}));
""")
assert result == {
"readiness": {
"state": "complete",
"detail": "Offline work is protected from automatic browser storage cleanup.",
},
"persistedCalls": 2,
"persistCalls": 1,
"detail": "Storage protection: Protected · 1 private browser item · 0 private work records · 1 cached app copy",
}
def test_denied_request_remains_retryable_and_does_not_claim_protection():
result = run_scenario("""
await controller.refresh();
const readiness=await controller.requestPersistence();
process.stdout.write(JSON.stringify({readiness,persistCalls:persistence.persistCalls}));
""")
assert result == {
"readiness": {
"state": "incomplete",
"detail": "The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.",
},
"persistCalls": 1,
}
def test_denied_request_keeps_setup_and_storage_panel_in_the_same_state_when_recheck_fails():
result = run_scenario("""
await controller.refresh();
storageManager.persist=async()=>{persistence.persistCalls++;persistence.error='check blocked';return false;};
const readiness=await controller.requestPersistence();
process.stdout.write(JSON.stringify({readiness,detail:detail.textContent}));
""")
assert result == {
"readiness": {
"state": "incomplete",
"detail": "The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.",
},
"detail": "Storage protection: Denied · 1 private browser item · 0 private work records · 1 cached app copy",
}
def test_failed_request_is_retryable_and_updates_storage_status_without_data_loss():
result = run_scenario("""
await controller.refresh();
storageManager.persist=async()=>{persistence.persistCalls++;throw new Error('blocked');};
const readiness=await controller.requestPersistence();
process.stdout.write(JSON.stringify({readiness,detail:detail.textContent,persistCalls:persistence.persistCalls}));
""")
assert result == {
"readiness": {
"state": "incomplete",
"detail": "Storage protection request failed. Offline work still uses best-effort storage; retry when ready.",
},
"detail": "Storage protection: Request failed · 1 private browser item · 0 private work records · 1 cached app copy",
"persistCalls": 1,
}
@@ -69,7 +172,7 @@ process.stdout.write(JSON.stringify({
"caches": ["other-app"],
"storage": ["stackchain.draft.1", "other.preference"],
"status": "Cached app copies cleared. Private work was kept.",
"detail": "1 private browser item · 0 private work records · 0 cached app copies",
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 0 cached app copies",
}
@@ -90,7 +193,7 @@ process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.tex
},
"calls": 1,
"status": "All Stackchain private data was cleared from this device.",
"detail": "0 private browser items · 0 private work records · 0 cached app copies",
"detail": "Storage protection: Best effort · 0 private browser items · 0 private work records · 0 cached app copies",
}
@@ -112,7 +215,7 @@ process.stdout.write(JSON.stringify({
"calls": 0,
"label": "Confirm: clear private work",
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
"detail": "0 private browser items · 3 private work records · 1 cached app copy",
"detail": "Storage protection: Best effort · 0 private browser items · 3 private work records · 1 cached app copy",
}
@@ -132,7 +235,7 @@ process.stdout.write(JSON.stringify({
assert result == {
"calls": 0,
"label": "Confirm: clear private work",
"detail": "0 private browser items · private work status unknown · 1 cached app copy",
"detail": "Storage protection: Best effort · 0 private browser items · private work status unknown · 1 cached app copy",
}
+28 -6
View File
@@ -15,6 +15,7 @@ from src.views import dashboard, feature_bundle, runtime_bundle, service_worker
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
PAGE_SCRIPT = re.compile(r'<script src="(static/[^"]+\.js)"></script>')
SCRIPT_PRELOAD = re.compile(r'<link rel="preload" as="script" href="([^"]+)">')
def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
@@ -35,8 +36,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
(changed_frontend / "widgets.js").write_text(
(changed_frontend / "widgets.js").read_text() + "\n// changed\n"
(changed_frontend / "session.js").write_text(
(changed_frontend / "session.js").read_text() + "\n// changed\n"
)
changed = build_frontend(changed_frontend)
@@ -48,8 +49,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
first = build_frontend(FRONTEND)
assert set(first.feature_bundles) == {
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
"today-timer", "security-center",
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
"today-timer", "security-center", "planning",
}
assert first.dashboard_html.count("<script src=") == 1
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
@@ -70,14 +71,22 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createQueueToday" not in first.runtime_bytes
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createPlanToday" in first.feature_bundles["planning"].runtime_bytes
assert b"function createTomorrowPlan" in first.feature_bundles["planning"].runtime_bytes
assert b"function createPlanToday" not in first.runtime_bytes
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
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 createSignOutReview" not in first.runtime_bytes
assert b"function createSignOutReview" in first.feature_bundles["sign-out"].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
@@ -116,6 +125,19 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch():
build = build_frontend(FRONTEND)
workspace = build.feature_bundles["today-timer"]
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name]
assert build.dashboard_html.count(f'<script src="{workspace.runtime_name}"></script>') == 0
assert len(build.runtime_gzip_bytes) <= 100 * 1024
assert len(workspace.runtime_gzip_bytes) <= 110 * 1024
shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0]
assert f"BASE + '{workspace.runtime_name}'" not in shell_block
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
build = build_frontend(FRONTEND)
bundles = {"core.js": build.runtime_bytes}
+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",
}
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff. Show more