Compare commits

..
647 Commits
Author SHA1 Message Date
timmy f9d602f85f Merge pull request 'Pin the current item from mobile detail' (#1490) from timmy/1489-pin-current-mobile-detail into main
CI / browser-journey (push) Failing after 1h14m26s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Has been cancelled
CI / lint (push) Successful in 3m39s
2026-08-28 02:11:46 +00:00
timmy 2a06cf1daa feat: pin current mobile detail item (Closes #1489)
CI / browser-journey (pull_request) Failing after 1h10m38s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m50s
CI / release-candidate (pull_request) Has been cancelled
2026-08-27 23:30:06 +00:00
timmy b0dbcb5a25 Merge pull request 'Compact and rank pinned mobile work by recent use' (#1488) from timmy/1487-compact-rank-pinned-mobile-work into main
CI / browser-journey (push) Successful in 7m38s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m47s
2026-08-27 22:46:49 +00:00
timmy 2766a75f24 Compact and rank pinned mobile work
CI / browser-journey (pull_request) Successful in 7m34s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 22:34:37 +00:00
timmy 5f9b5b3a7f Merge pull request 'Make mobile Recent Work sync generation-safe and self-healing' (#1486) from timmy/1485-mobile-recent-work-sync into main
CI / browser-journey (push) Successful in 7m49s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m46s
2026-08-27 21:55:39 +00:00
timmy cec5b93665 fix: make mobile recent work sync generation-safe (Closes #1485)
CI / browser-journey (pull_request) Successful in 8m1s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 21:42:47 +00:00
timmy 5af34508b4 Merge pull request 'feat: Stabilize Today-to-Week dialog closure in packaged browser CI' (#1484) from timmy/1483-stabilize-today-to-week-dialog-closure-in-packag into main
CI / browser-journey (push) Successful in 7m38s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m50s
2026-08-27 21:15:15 +00:00
timmy 9b1abf37d0 fix: keep reschedule dialog closed after successful move
CI / browser-journey (pull_request) Successful in 7m42s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 20:48:02 +00:00
timmy b0b28a936c Merge pull request 'Require fresh authorization and audit CI job reruns' (#1482)
CI / browser-journey (push) Failing after 7m52s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m41s
2026-08-27 20:20:50 +00:00
timmy f23be99ab4 security: fresh-authorize CI job reruns (Closes #1481)
CI / browser-journey (pull_request) Successful in 7m40s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 4m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 20:08:15 +00:00
timmy 1484c579f9 Merge pull request 'feat: Disk usage 86%' (#1480) from timmy/1479-disk-usage-86 into main
CI / browser-journey (push) Successful in 7m47s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m55s
2026-08-27 19:42:11 +00:00
timmy afec9b11d1 ops: add disk capacity incident assessment
CI / browser-journey (pull_request) Successful in 7m50s
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-27 19:14:25 +00:00
timmy ca37d8a1bb Merge pull request 'Pin frequent work across signed-in mobile devices' (#1478) from timmy/1477-pin-mobile-frequent-work into main
CI / browser-journey (push) Successful in 7m23s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m48s
2026-08-27 18:46:56 +00:00
timmy 94bd2cafba feat: pin frequent mobile work (Closes #1477)
CI / browser-journey (pull_request) Successful in 7m46s
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-27 18:34:01 +00:00
timmy 2b630d3fa8 Merge pull request 'Sync recent work across signed-in mobile devices' (#1476) from timmy/1475-sync-mobile-recent-work into main
CI / browser-journey (push) Successful in 7m27s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m48s
2026-08-27 17:45:02 +00:00
timmy 19732ea5a6 feat: sync mobile recent work across devices (Closes #1475)
CI / browser-journey (pull_request) Successful in 7m29s
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-27 17:32:46 +00:00
timmy 12a7a3c4b0 Merge pull request 'feat: Restore bare-environment full-suite cache-policy test' (#1474) from timmy/1473-restore-bare-environment-full-suite-cache-policy into main
CI / browser-journey (push) Successful in 7m27s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m49s
2026-08-27 16:49:42 +00:00
timmy 454e2dcffc test: restore cache-policy suite setup (Closes #1473)
CI / browser-journey (pull_request) Successful in 7m33s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 16:16:52 +00:00
rockachopa 0e70fb9b62 Merge pull request 'Reopen recent work from mobile Queues' (#1472) from timmy/1471-mobile-recent-work into main
CI / browser-journey (push) Successful in 7m32s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m44s
2026-08-27 15:53:08 +00:00
timmy eb101bf5ce feat: reopen recent work from mobile queues (Closes #1471)
CI / browser-journey (pull_request) Successful in 7m53s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 15:40:41 +00:00
rockachopa 46e06c1a37 Merge pull request 'Demand-load Today and Planning workspace on mobile' (#1469) from timmy/1468-demand-load-mobile-workspace into main
CI / browser-journey (push) Successful in 7m30s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m48s
Merge pull request 'Demand-load Today and Planning workspace on mobile' (#1469)
2026-08-27 14:46:10 +00:00
timmy 02b9fdd2e4 fix: finish identity recovery before workspace readiness
CI / browser-journey (pull_request) Successful in 7m28s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 14:34:13 +00:00
timmy 5ccf499b1a test: isolate workspace retry from service worker
CI / browser-journey (pull_request) Failing after 7m38s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 4m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 13:10:30 +00:00
timmy e8b2f31e5e fix: restore hydrated session state before action replay
CI / browser-journey (pull_request) Failing after 7m24s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:27:44 +00:00
timmy c1d719842f test: await hydrated state in reload journeys
CI / browser-journey (pull_request) Failing after 6m56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:25:22 +00:00
timmy 4a99f54f12 test: hydrate demand workspace after release reloads
CI / browser-journey (pull_request) Failing after 7m16s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:22:42 +00:00
timmy 8909bedd78 test: align packaged journeys with demand hydration
CI / browser-journey (pull_request) Failing after 7m47s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 4m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:17:20 +00:00
timmy 5d9cec1dd7 perf: demand-load mobile workspace features (Closes #1468)
CI / browser-journey (pull_request) Failing after 9m26s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 11:47:09 +00:00
timmy 2a44cf20fc Merge pull request 'Make mobile queue priority self-healing across devices' (#1467) from timmy/1466-self-healing-queue-priority into main
CI / browser-journey (push) Successful in 7m31s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m37s
2026-08-27 10:43:43 +00:00
timmy 1e8d27c18f feat: make mobile queue priority self-healing (Closes #1466)
CI / browser-journey (pull_request) Successful in 7m12s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 10:32:02 +00:00
timmy c5b801460d Merge pull request 'Sync personalized mobile queue priority across devices' (#1465)
CI / browser-journey (push) Successful in 7m27s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m27s
Closes #1464
2026-08-27 09:39:51 +00:00
timmy b18aa5ed63 feat: sync mobile queue priority across devices (Closes #1464)
CI / browser-journey (pull_request) Successful in 7m11s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 09:27:27 +00:00
timmy dbc305108a Merge pull request 'Scope Security activity to the signed-in Gitea identity' (#1463) from timmy/1462-principal-scoped-security-activity into main
CI / browser-journey (push) Successful in 7m20s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m33s
2026-08-27 08:22:10 +00:00
timmy 3b9f184a47 security: scope activity journal by principal (Closes #1462)
CI / browser-journey (pull_request) Successful in 7m26s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 08:10:27 +00:00
timmy bf716cab6e Merge pull request 'Personalize routine priority in adaptive mobile Queues' (#1461) from timmy/1460-personalized-mobile-queue-priority into main
CI / browser-journey (push) Successful in 7m34s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m41s
2026-08-27 07:21:38 +00:00
timmy dbabd8640b feat: personalize adaptive mobile queue priority (Closes #1460)
CI / browser-journey (pull_request) Successful in 7m39s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 07:09:06 +00:00
timmy 890fd57531 Merge pull request 'Keep adaptive mobile Work actionable while offline' (#1459) from timmy/1458-connectivity-aware-mobile-work into main
CI / browser-journey (push) Failing after 7m45s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m56s
2026-08-27 06:11:57 +00:00
timmy b0b051184b feat: keep adaptive mobile Work actionable offline (Closes #1458)
CI / browser-journey (pull_request) Successful in 7m49s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 05:59:13 +00:00
timmy 2432d4fc03 Merge pull request 'Make Following and My PRs actionable from adaptive mobile Work' (#1457) from timmy/1456-actionable-following-authored into main
CI / browser-journey (push) Successful in 7m33s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m39s
2026-08-27 04:46:58 +00:00
timmy c97383fccb feat: continue Following and My PRs from mobile Work (Closes #1456)
CI / browser-journey (pull_request) Successful in 7m35s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 04:34:34 +00:00
timmy 89b1ad1f08 Merge pull request 'Scope Security Center device revocation to the signed-in identity' (#1455) from timmy/1454-principal-scoped-device-revocation into main
CI / browser-journey (push) Successful in 7m20s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m44s
Scope Security Center device revocation to the signed-in identity (#1455)
2026-08-27 03:21:57 +00:00
timmy 0328e197ac security: scope device revocation by principal (Closes #1454)
CI / browser-journey (pull_request) Successful in 7m18s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 03:09:59 +00:00
timmy 7a09452bef Make mobile Queues census include Following and My PRs (#1453)
CI / browser-journey (push) Successful in 7m16s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m48s
Closes #1452
2026-08-27 02:14:24 +00:00
timmy 4b4637fadb feat: include Following and My PRs in queue census (Closes #1452)
CI / browser-journey (pull_request) Successful in 7m29s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m54s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 02:02:02 +00:00
timmy 977493907e Merge pull request 'Give mobile Queues one adaptive Start / Continue action' (#1451) from timmy/1450-adaptive-mobile-queue-action into main
CI / browser-journey (push) Successful in 7m16s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m49s
2026-08-27 01:24:49 +00:00
timmy 9381ff9d84 feat: unify mobile queue start and continue action (Closes #1450)
CI / browser-journey (pull_request) Successful in 7m18s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 01:12:38 +00:00
timmy c2b7ce67d8 Bind enrolled passkeys to the upstream Gitea identity (#1449)
CI / browser-journey (push) Successful in 7m4s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m42s
Closes #1448
2026-08-27 00:25:09 +00:00
timmy 43b0216bda security: bind passkeys to upstream identity (Closes #1448)
CI / browser-journey (pull_request) Successful in 7m15s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 00:12:57 +00:00
timmy ff469d96d1 Recover Human Gate decisions after interrupted mobile responses
CI / browser-journey (push) Successful in 7m10s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m40s
Closes #1446
2026-08-26 23:05:32 +00:00
timmy 94c18c7a35 test: classify deferred offline context rejection
CI / browser-journey (pull_request) Successful in 7m15s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 22:53:44 +00:00
timmy a77a73a269 test: stabilize Human Gate recovery journey
CI / browser-journey (pull_request) Failing after 7m13s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 22:25:00 +00:00
timmy b303683ba1 feat: recover interrupted Human Gate decisions (Closes #1446)
CI / browser-journey (pull_request) Failing after 7m20s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 22:10:01 +00:00
rockachopa 60f140d26a Merge pull request 'Paginate durable Human Gate decision history on mobile' (#1445) from timmy/1444-paginated-human-gate-history into main
CI / browser-journey (push) Successful in 7m30s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m53s
2026-08-26 21:23:05 +00:00
timmy 0f1cac0e25 feat: paginate mobile Human Gate history (Closes #1444)
CI / browser-journey (pull_request) Successful in 7m21s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 21:10:10 +00:00
rockachopa 57c7373079 Merge pull request 'Review durable Human Gate decision history and receipts' (#1442) from timmy/1441-human-gate-decision-history into main
CI / browser-journey (push) Successful in 7m6s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m43s
2026-08-26 20:01:02 +00:00
timmy a5d05d7590 feat: review durable Human Gate decision history (Closes #1441)
CI / browser-journey (pull_request) Successful in 7m26s
CI / build-release (pull_request) Successful in 13s
CI / lint (pull_request) Successful in 4m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 19:43:50 +00:00
rockachopa efa01cf47f Merge pull request 'Keep Human Gate release decisions visible and actionable on mobile' (#1440)
CI / browser-journey (push) Successful in 7m51s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 4m19s
Closes #1439
2026-08-26 18:38:56 +00:00
timmy 62f92105ec feat: keep Human Gate decisions in reach on mobile (Closes #1439)
CI / browser-journey (pull_request) Successful in 8m4s
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 4m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 18:10:22 +00:00
rockachopa 38206b90e4 Merge pull request 'Notify operators when Human Gate decisions are waiting' (#1438) from timmy/1437-human-gate-push-notifications into main
CI / browser-journey (push) Successful in 8m11s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 4m15s
2026-08-26 17:15:26 +00:00
timmy de3c68fc0e docs: explain private Human Gate alerts
CI / browser-journey (pull_request) Successful in 7m42s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 4m21s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 17:02:54 +00:00
timmy 7158e00b97 feat: notify pending Human Gate decisions (Closes #1437)
CI / browser-journey (pull_request) Failing after 7m57s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 4m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 16:47:57 +00:00
timmy ad15f15e3c Merge pull request 'Resume Human Gate evidence reviews after mobile artifact handoffs' (#1436) from timmy/1435-resume-human-gate-review into main
CI / browser-journey (push) Successful in 7m40s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 4m14s
Merge pull request #1436: resume Human Gate evidence reviews
2026-08-26 15:29:27 +00:00
timmy aba217f89b feat: resume Human Gate evidence reviews (Closes #1435)
CI / browser-journey (pull_request) Successful in 7m38s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 4m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 15:16:03 +00:00
rockachopa 1e9c3444fa Merge pull request 'Refresh Human Gates atomically on every reopen' (#1434) from timmy/1433-refresh-human-gates-reopen into main
CI / browser-journey (push) Successful in 8m15s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 4m10s
2026-08-26 14:03:30 +00:00
timmy 2e9d108c6f feat: refresh Human Gates review sessions (Closes #1433)
CI / browser-journey (pull_request) Successful in 7m28s
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-26 13:50:11 +00:00
rockachopa 7dc32138e4 Merge pull request 'Recover progressive mobile identity after partial live snapshots' (#1432) from timmy/1431-recover-progressive-mobile-identity into main
CI / browser-journey (push) Successful in 7m40s
CI / build-release (push) Successful in 10s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m58s
2026-08-26 12:55:55 +00:00
timmy 26288d364c feat: recover progressive identity after partial snapshots
CI / browser-journey (pull_request) Successful in 7m24s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 4m5s
CI / release-candidate (pull_request) Has been skipped
Closes #1431
2026-08-26 12:42:47 +00:00
timmy 97a36ea22f Merge pull request 'Preserve healthy live sections during partial upstream timeouts' (#1430) from timmy/1429-section-isolated-live-snapshot-deadlines into main
CI / browser-journey (push) Successful in 7m19s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m59s
2026-08-26 11:55:23 +00:00
timmy dc9e64e98e feat: isolate live snapshot section deadlines (Closes #1429)
CI / browser-journey (pull_request) Successful in 7m12s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 4m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 11:27:35 +00:00
timmy 065cc46553 Merge pull request 'Make mobile Work and Queues usable before optional hydration' (#1428) from timmy/1427-progressive-mobile-dock into main
CI / browser-journey (push) Successful in 7m12s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m51s
Merge pull request #1428: progressive mobile dock
2026-08-26 10:40:10 +00:00
timmy 664317212d test: gate progressive dock browser journey
CI / browser-journey (pull_request) Successful in 7m7s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 10:28:27 +00:00
timmy 62de2e6011 feat: make mobile dock progressive (Closes #1427)
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 4m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 10:18:44 +00:00
timmy edd653eaaf Merge pull request 'Share one cold-launch live snapshot across progressive Human Gates and My Work' (#1426) from timmy/1425-shared-progressive-live-snapshot into main
CI / browser-journey (push) Successful in 6m53s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m48s
2026-08-26 09:31:18 +00:00
timmy 15d7bc9234 feat: share progressive live snapshot (Closes #1425)
CI / browser-journey (pull_request) Successful in 6m54s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 08:55:08 +00:00
rockachopa bea0e0e429 Merge pull request 'Adaptive mobile Queues next-up flow' (#1424) from timmy/1423-adaptive-mobile-queues into main
CI / browser-journey (push) Successful in 6m57s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m47s
2026-08-26 07:43:07 +00:00
timmy b7d496600e test: exercise collapsed all queues
CI / browser-journey (pull_request) Successful in 6m37s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 07:32:16 +00:00
timmy 8b2fd305ca feat: prioritize adaptive mobile queues
CI / browser-journey (pull_request) Failing after 9m7s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m38s
CI / release-candidate (pull_request) Has been skipped
Closes #1423
2026-08-26 07:12:27 +00:00
timmy 1e8d9a27be Merge pull request 'Fresh-authorize and audit Human Gate decisions' (#1422)
CI / browser-journey (push) Successful in 8m50s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m53s
Closes #1421
2026-08-26 06:22:50 +00:00
timmy 1c66905b4a feat: fresh-authorize and audit Human Gate decisions
CI / browser-journey (pull_request) Successful in 6m39s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 06:11:28 +00:00
timmy 4319c161cb Merge pull request 'Open Human Gates before optional workspace hydration' (#1420) from timmy/1419-open-human-gates-before-optional-hydration into main
CI / browser-journey (push) Successful in 7m21s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 4m22s
2026-08-26 05:05:08 +00:00
timmy 5ec4dd7f6e feat: open Human Gates before optional hydration
CI / browser-journey (pull_request) Successful in 7m51s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 4m13s
CI / release-candidate (pull_request) Has been skipped
Closes #1419
2026-08-26 04:52:14 +00:00
timmy 718f0d1209 feat: surface Human Gates in mobile preparation (#1418)
CI / browser-journey (push) Failing after 8m3s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 4m11s
Closes #1417
2026-08-26 03:42:45 +00:00
rockachopa e9d257e203 Merge pull request 'feat: Human Gates inbox: one hash-bound review queue to zero' (#1416) from timmy/1415-human-gates-inbox-one-hash-bound-review-queue-to into main
CI / browser-journey (push) Successful in 7m54s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 4m13s
2026-08-26 02:09:24 +00:00
timmy af16f987f1 fix: restore timed break after identity hydration
CI / browser-journey (pull_request) Successful in 7m16s
CI / build-release (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 4m19s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 01:56:33 +00:00
timmy f4d6f9176c docs: record Human Gates identity invariants
CI / browser-journey (pull_request) Failing after 7m22s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 01:30:39 +00:00
timmy ab329aa126 fix: isolate overlapping Human Gate loads
CI / browser-journey (pull_request) Failing after 7m28s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 4m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 01:04:25 +00:00
timmy 9d84d5c5c4 fix: close Human Gates review races
CI / browser-journey (pull_request) Successful in 7m31s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 4m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 00:56:52 +00:00
timmy dbb8ff4d4e fix: harden Human Gates review flow
CI / browser-journey (pull_request) Successful in 7m38s
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-26 00:45:40 +00:00
timmy 364fc60116 test: show Human Gate project in review detail
CI / browser-journey (pull_request) Successful in 7m2s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 4m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 00:10:27 +00:00
timmy e02115bd71 Add durable Human Gates inbox 2026-08-26 00:05:27 +00:00
rockachopa 7ec78ac182 Merge pull request 'Open progressive mobile My Work in-app before hydration' (#1414) from timmy/1413-progressive-my-work-detail into main
CI / browser-journey (push) Successful in 6m53s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m53s
2026-08-25 23:43:17 +00:00
timmy 94728d81ff feat: open progressive My Work in app (Closes #1413)
CI / browser-journey (pull_request) Successful in 6m47s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 23:31:34 +00:00
rockachopa 7960f14b5b Merge pull request 'Keep progressive mobile My Work live through delayed hydration' (#1412) from timmy/1411-progressive-my-work-live-recovery into main
CI / browser-journey (push) Successful in 6m23s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m50s
2026-08-25 22:44:51 +00:00
timmy f9b29d8d14 feat: keep progressive My Work live (Closes #1411)
CI / browser-journey (pull_request) Successful in 6m38s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 22:30:14 +00:00
rockachopa e7e8c24d05 Recover progressive mobile captures after reload (#1410)
CI / browser-journey (push) Successful in 8m13s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 11s
CI / lint (push) Successful in 4m18s
Closes #1409
2026-08-25 21:41:01 +00:00
timmy 8b56618e62 feat: recover progressive mobile captures (Closes #1409)
CI / browser-journey (pull_request) Successful in 6m18s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 21:29:50 +00:00
timmy dcad1da1da Merge pull request 'Enable mobile capture before optional workspace hydration' (#1408) from timmy/1407-progressive-mobile-capture into main
CI / browser-journey (push) Successful in 6m39s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m35s
2026-08-25 20:37:00 +00:00
timmy 8bb8fb0cd9 feat: enable progressive mobile capture
CI / browser-journey (pull_request) Successful in 6m26s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m36s
CI / release-candidate (pull_request) Has been skipped
Closes #1407
2026-08-25 20:11:13 +00:00
rockachopa a23276f0b8 Merge pull request 'Reuse the progressive live snapshot during mobile hydration' (#1406) from timmy/1405-progressive-live-snapshot-handoff into main
CI / browser-journey (push) Successful in 6m39s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m42s
Merge pull request 'Reuse the progressive live snapshot during mobile hydration' (#1406)
2026-08-25 19:25:21 +00:00
timmy 54cb078d1a perf: reuse progressive live snapshot during hydration (Closes #1405)
CI / browser-journey (pull_request) Successful in 6m25s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 19:13:23 +00:00
rockachopa 1822004f3d Merge pull request 'Preserve mobile My Work queue truth through hydration' (#1404) from timmy/1403-progressive-queue-handoff into main
CI / browser-journey (push) Failing after 7m21s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m32s
2026-08-25 18:14:32 +00:00
timmy c15f7768e4 feat: preserve progressive My Work queue truth (Closes #1403)
CI / browser-journey (pull_request) Successful in 6m52s
CI / build-release (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 3m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 18:01:12 +00:00
rockachopa 024087d051 Merge pull request 'Hydrate mobile My Work before optional tools' (#1402) from timmy/1401-progressive-my-work-hydration into main
CI / browser-journey (push) Successful in 6m29s
CI / build-release (push) Successful in 10s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m32s
2026-08-25 16:56:49 +00:00
timmy 297ae7550a feat: hydrate mobile My Work before optional tools
CI / browser-journey (pull_request) Successful in 6m40s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m33s
CI / release-candidate (pull_request) Has been skipped
Closes #1401
2026-08-25 16:41:27 +00:00
rockachopa e44f49708a Merge pull request 'Fix 320px active-Today week reschedule completion' (#1400) from timmy/1398-320-active-today-week-reschedule-dialog into main
CI / browser-journey (push) Successful in 6m45s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m35s
2026-08-25 15:34:32 +00:00
timmy 3e7d758782 test: cover reschedule retry recovery
CI / browser-journey (pull_request) Successful in 6m43s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 15:12:47 +00:00
timmy 185f3f9706 fix: close mobile week reschedule while saving
CI / browser-journey (pull_request) Failing after 7m3s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m28s
CI / release-candidate (pull_request) Has been skipped
Closes #1398
2026-08-25 14:55:54 +00:00
rockachopa aed278629b Merge pull request 'feat: Find photo-only reply drafts from mobile My Work' (#1399) from timmy/1396-find-photo-only-reply-drafts-from-mobile-my-work into main
CI / browser-journey (push) Successful in 6m26s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m30s
2026-08-25 14:12:39 +00:00
timmy d273de3506 fix: preserve completed mobile onboarding during draft sync
CI / browser-journey (pull_request) Successful in 6m25s
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-25 13:35:51 +00:00
timmy fa876e1e38 feat: surface photo-only reply drafts in My Work (Closes #1396)
CI / browser-journey (pull_request) Failing after 6m31s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 13:17:23 +00:00
rockachopa 306d6e3056 Merge pull request 'feat: CI regression: active timed break hidden after corrupt Today recovery on main 553a012' (#1395) from timmy/1394-ci-regression-active-timed-break-hidden-after-co into main
CI / browser-journey (push) Successful in 6m25s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m37s
2026-08-25 12:22:32 +00:00
timmy 978351dff2 fix: preserve active break over reload checkpoint (Closes #1394)
CI / browser-journey (pull_request) Successful in 6m49s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 11:56:50 +00:00
rockachopa 553a01217a Merge pull request 'Schedule mobile notification quiet hours with one catch-up digest' (#1393) from timmy/1392-mobile-notification-quiet-hours into main
CI / browser-journey (push) Failing after 6m30s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m47s
2026-08-25 11:28:45 +00:00
timmy cc74b7b8db feat: schedule mobile notification quiet hours (Closes #1392)
CI / browser-journey (pull_request) Successful in 6m29s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 11:17:40 +00:00
timmy 2acd9431e0 Merge pull request 'Keep mobile Search and Preview focus-contained through Back navigation' (#1391) from timmy/1390-mobile-search-modal-focus into main
CI / browser-journey (push) Successful in 6m11s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m26s
2026-08-25 10:20:10 +00:00
timmy 4855bc7c8d feat: contain mobile Search focus through Preview (Closes #1390)
CI / browser-journey (pull_request) Successful in 6m32s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 10:08:55 +00:00
rockachopa 6e19639b19 Merge pull request 'Make Search and Following conversations actionable on mobile' (#1389) from timmy/1388-search-following-comment-actions into main
CI / browser-journey (push) Successful in 6m29s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m32s
2026-08-25 09:22:04 +00:00
timmy 7662cca9e6 feat: act on Search and Following comments (Closes #1388)
CI / browser-journey (pull_request) Successful in 6m19s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 09:11:14 +00:00
rockachopa 0682bbcc2c Merge pull request 'Apply reviewer suggestions across files atomically' (#1387) from timmy/1386-atomic-multi-file-review-suggestions into main
CI / browser-journey (push) Successful in 6m21s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m34s
2026-08-25 08:12:56 +00:00
timmy 1c02dcb217 feat: apply review suggestions across files
CI / browser-journey (pull_request) Successful in 6m13s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m32s
CI / release-candidate (pull_request) Has been skipped
Closes #1386
2026-08-25 08:02:16 +00:00
rockachopa 7bb22e3525 Merge pull request 'Apply same-file review suggestions as one atomic mobile fix' (#1385) from timmy/1384-atomic-review-suggestion-batch into main
CI / browser-journey (push) Successful in 6m9s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m40s
2026-08-25 07:26:00 +00:00
timmy 869543e992 feat: batch mobile review suggestions (Closes #1384)
CI / browser-journey (pull_request) Successful in 6m20s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 07:14:54 +00:00
timmy c8ffde7d96 Merge pull request 'Apply reviewer code suggestions from mobile' (#1383) from timmy/1382-mobile-review-suggestions into main
CI / browser-journey (push) Successful in 6m11s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m22s
2026-08-25 06:19:21 +00:00
timmy ed662c3a3a feat: apply mobile review suggestions (Closes #1382)
CI / browser-journey (pull_request) Successful in 5m59s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 06:08:22 +00:00
timmy 8a881419db Merge pull request 'React to conversation comments without leaving mobile work' (#1381) from timmy/1380-mobile-comment-reactions into main
CI / browser-journey (push) Successful in 6m3s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m26s
2026-08-25 05:21:13 +00:00
timmy b4f3c6d419 feat: react to mobile conversation comments (Closes #1380)
CI / browser-journey (pull_request) Successful in 6m29s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 05:09:32 +00:00
rockachopa fc7a83fbd7 Merge pull request 'Pin release pipeline actions and enforce least-privilege permissions' (#1379) from timmy/1378-pin-release-actions into main
CI / browser-journey (push) Successful in 5m27s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m5s
2026-08-25 04:21:41 +00:00
timmy 22fe5dd1d8 ci: pin release pipeline actions (Closes #1378)
CI / browser-journey (pull_request) Successful in 5m31s
CI / build-release (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 3m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 03:57:29 +00:00
rockachopa 9d97b2e84a Merge pull request 'Commit bounded review feedback fixes from mobile' (#1377) from timmy/1376-mobile-review-feedback-fix into main
CI / browser-journey (push) Successful in 5m29s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m10s
2026-08-25 03:17:59 +00:00
timmy 502fcb30f5 feat: commit review feedback fixes from mobile (Closes #1376)
CI / browser-journey (pull_request) Successful in 5m18s
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-25 03:08:53 +00:00
rockachopa b541c707fc Merge pull request 'Bind background push delivery to upstream operator identity' (#1375) from timmy/1374-bind-push-session-identity into main
CI / browser-journey (push) Successful in 5m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m17s
2026-08-25 02:16:17 +00:00
timmy b40a44fc45 fix: bind background push to operator identity (Closes #1374)
CI / browser-journey (pull_request) Successful in 5m47s
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-25 02:06:25 +00:00
rockachopa d1adbe72d6 Merge pull request 'Bind operator sessions to upstream Gitea identity' (#1373) from timmy/1372-bind-sessions-upstream-identity into main
CI / browser-journey (push) Successful in 5m48s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m21s
2026-08-25 01:33:53 +00:00
timmy d6e6cf2e67 feat: bind operator sessions to upstream identity (Closes #1372)
CI / browser-journey (pull_request) Successful in 5m49s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 01:12:42 +00:00
rockachopa 53706f18ab Merge pull request 'Prepare conflict-safe rollback pull requests from failed releases' (#1371) from timmy/1370-conflict-safe-release-rollback into main
CI / browser-journey (push) Successful in 5m31s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m20s
2026-08-25 00:31:40 +00:00
timmy f0be3a44a6 ci: retry transient mobile navigation journey
CI / browser-journey (pull_request) Successful in 5m40s
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-25 00:21:58 +00:00
timmy b753cdd922 feat: prepare conflict-safe release rollback pulls (Closes #1370)
CI / browser-journey (pull_request) Failing after 6m7s
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-24 23:55:33 +00:00
rockachopa 400534f446 Merge pull request 'Complete secure source-branch deletion in operator mode' (#1369) from timmy/1368-secure-source-branch-deletion into main
CI / browser-journey (push) Successful in 5m13s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m15s
Closes #1368
2026-08-24 22:40:22 +00:00
timmy a43ecac94b fix: authorize secure source branch deletion (Closes #1368)
CI / browser-journey (pull_request) Successful in 5m20s
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-24 22:30:56 +00:00
rockachopa d6993c40fb Merge pull request 'Recover failed post-merge checks from mobile release receipts' (#1367) from timmy/1366-post-merge-release-check-recovery into main
CI / browser-journey (push) Successful in 5m18s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m14s
2026-08-24 21:45:51 +00:00
timmy 966116e5ee feat: recover failed post-merge checks (Closes #1366)
CI / browser-journey (pull_request) Successful in 5m30s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 21:36:16 +00:00
timmy d4ddac07ab Merge pull request 'Delete merged source branches safely from mobile receipts' (#1365) from timmy/1364-delete-merged-source-branches into main
CI / browser-journey (push) Successful in 5m37s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m14s
2026-08-24 20:54:32 +00:00
timmy b6684bb7ba feat: label source branch deletion security events
CI / browser-journey (pull_request) Successful in 5m49s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 20:44:42 +00:00
timmy 07f5770aa8 feat: delete merged source branches safely (Closes #1364)
CI / browser-journey (pull_request) Failing after 5m42s
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-24 20:29:32 +00:00
timmy f3876d359d Merge pull request 'feat: Create pull requests from pushed branches on mobile' (#1363) from timmy/1362-create-pull-requests-from-pushed-branches-on-mob into main
CI / browser-journey (push) Successful in 5m22s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m22s
2026-08-24 19:52:48 +00:00
timmy e5b9833096 feat: create mobile pull requests from pushed branches (Closes #1362)
CI / browser-journey (pull_request) Successful in 5m16s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m19s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 19:31:16 +00:00
rockachopa ae98564d9b Merge pull request 'Update outdated authored pull branches from mobile' (#1361) from timmy/1360-update-authored-pull-branch into main
CI / browser-journey (push) Successful in 5m23s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m11s
2026-08-24 18:12:25 +00:00
timmy b85fe4a0a3 feat: update authored pull branches from mobile (Closes #1360)
CI / browser-journey (pull_request) Successful in 5m31s
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-24 18:02:23 +00:00
rockachopa a9becd079f Merge pull request 'Recover closed authored pull requests from mobile Search' (#1359) from timmy/1358-reopen-closed-authored-pull-search into main
CI / browser-journey (push) Successful in 5m32s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m18s
2026-08-24 17:22:36 +00:00
timmy 99750d44f5 feat: recover closed authored pulls from search (Closes #1358)
CI / browser-journey (pull_request) Successful in 5m31s
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-24 17:12:51 +00:00
rockachopa e4d0cc9115 Merge pull request 'Close obsolete authored pull requests from mobile with recovery' (#1357) from timmy/1356-close-authored-pull-recovery into main
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 3m26s
2026-08-24 16:28:53 +00:00
timmy 77f22f3cd5 feat: close authored pulls with recovery (Closes #1356)
CI / browser-journey (pull_request) Successful in 5m58s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 16:18:24 +00:00
timmy fde3ba21ca Merge pull request 'Recover failed CI checks from the mobile pull workspace' (#1355) from timmy/1354-mobile-ci-recovery into main
CI / browser-journey (push) Successful in 5m55s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m30s
2026-08-24 15:16:09 +00:00
timmy 8cb92c6858 feat: recover failed CI checks from mobile (Closes #1354)
CI / browser-journey (pull_request) Successful in 5m44s
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 3m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 15:06:01 +00:00
rockachopa 3e7a6c81fc Merge pull request 'feat: CI browser journey: Insights overlay does not inert page header at 320px' (#1353) from timmy/1352-ci-browser-journey-insights-overlay-does-not-ine into main
CI / browser-journey (push) Successful in 5m32s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m31s
2026-08-24 14:12:39 +00:00
timmy 084c0b5732 Fix mobile overlay inert ownership (#1352)
CI / browser-journey (pull_request) Successful in 5m45s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m19s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 13:49:48 +00:00
rockachopa c73fbf8379 Merge pull request 'Load pull workspaces through one authorized snapshot' (#1351) from timmy/1350-authorized-pull-snapshot into main
CI / browser-journey (push) Failing after 5m27s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m20s
2026-08-24 13:24:47 +00:00
timmy b47ed5d86e perf: reuse authorized pull snapshots (Closes #1350)
CI / browser-journey (pull_request) Successful in 5m32s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 13:14:48 +00:00
timmy f0f415c65d Merge pull request 'Complete authored pull request workspace access' (#1349) from timmy/1348-authored-pull-workspace into main
CI / browser-journey (push) Successful in 5m26s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m9s
2026-08-24 12:20:21 +00:00
timmy 2102b0e5da feat: complete authored pull workspace access (Closes #1348)
CI / browser-journey (pull_request) Successful in 5m22s
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-24 12:10:49 +00:00
rockachopa 050390d6f6 Merge pull request 'Surface authored pull requests in My Work' (#1347) from timmy/1346-authored-pull-queue into main
CI / browser-journey (push) Successful in 5m16s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m18s
2026-08-24 11:24:14 +00:00
timmy be5603d41b feat: surface authored pulls in My Work (Closes #1346)
CI / browser-journey (pull_request) Successful in 5m20s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 11:13:54 +00:00
timmy 4e074ae18e Merge pull request 'Publish draft pull requests from mobile' (#1345) from timmy/1344-publish-draft-pull into main
CI / browser-journey (push) Successful in 5m14s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m10s
2026-08-24 10:16:34 +00:00
timmy a4ae3e1c7f feat: publish draft pull requests from mobile (Closes #1344)
CI / browser-journey (pull_request) Successful in 5m33s
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-24 10:06:51 +00:00
timmy 1a2297e943 Merge pull request 'Load pull review feedback on demand without blocking mobile review' (#1343) from timmy/1342-lazy-review-feedback into main
CI / browser-journey (push) Successful in 5m23s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m28s
2026-08-24 09:26:53 +00:00
timmy 611e79fc1e feat: load pull review feedback on demand (Closes #1342)
CI / browser-journey (pull_request) Successful in 5m34s
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-24 09:16:30 +00:00
rockachopa 50817bed9a Merge pull request 'Cancel and replace pending pull review requests from mobile' (#1341) from timmy/1340-cancel-pending-review into main
CI / browser-journey (push) Successful in 5m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m25s
2026-08-24 08:25:56 +00:00
timmy 1f9f0f5c22 test: gate mobile review cancellation journey
CI / browser-journey (pull_request) Successful in 5m24s
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-24 08:16:21 +00:00
timmy d776a2b003 feat: cancel pending pull review requests (Closes #1340)
CI / browser-journey (pull_request) Failing after 5m18s
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-24 08:01:25 +00:00
timmy 7517d81046 Merge pull request 'Address requested review feedback from mobile pull detail' (#1339) from timmy/1338-address-review-feedback into main
CI / browser-journey (push) Successful in 5m45s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m33s
2026-08-24 06:53:19 +00:00
timmy 091a01ba67 feat: address requested review feedback (Closes #1338)
CI / browser-journey (pull_request) Successful in 5m23s
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 3m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 06:42:45 +00:00
rockachopa bad14d1b94 Merge pull request 'Show requested-change feedback in mobile pull detail' (#1337) from timmy/1336-review-feedback into main
CI / browser-journey (push) Successful in 5m32s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m34s
2026-08-24 05:48:23 +00:00
timmy c4bb2f5eba feat: show requested-change feedback (Closes #1336)
CI / browser-journey (pull_request) Successful in 5m24s
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-24 05:38:30 +00:00
rockachopa d2a815c4bc Merge pull request 'Show reviewer decisions before merging a pull request' (#1335) from timmy/1334-reviewer-decisions into main
CI / browser-journey (push) Successful in 5m34s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m45s
2026-08-24 04:41:15 +00:00
timmy 3bc1d2f602 test: exercise mobile reviewer status journey
CI / browser-journey (pull_request) Successful in 5m29s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 04:28:24 +00:00
timmy bd96c3caa3 feat: show pull reviewer decisions (Closes #1334)
CI / browser-journey (pull_request) Failing after 5m47s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 04:09:05 +00:00
rockachopa f8ab6977ed Merge pull request 'Revise pull request context before requesting review' (#1333) from timmy/1332-revise-pull-context into main
CI / browser-journey (push) Successful in 5m25s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m36s
2026-08-24 03:21:15 +00:00
timmy 8285b39e06 feat: revise pull review context from mobile (Closes #1332)
CI / browser-journey (pull_request) Successful in 5m33s
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-24 03:11:11 +00:00
rockachopa 8c4969cc44 Merge pull request 'Request teammate review from mobile pull detail' (#1331) from timmy/1330-request-teammate-review into main
CI / browser-journey (push) Successful in 5m31s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m40s
2026-08-24 02:21:19 +00:00
timmy 1f950f93dd feat: request teammate pull reviews (Closes #1330)
CI / browser-journey (pull_request) Successful in 5m35s
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-24 02:11:24 +00:00
rockachopa 505a40d077 Merge pull request 'Review watched pull request changes before acknowledgement' (#1329) from timmy/1328-review-watched-pr-changes into main
CI / browser-journey (push) Failing after 5m32s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m20s
2026-08-24 01:02:29 +00:00
timmy 0cabce66ed test: account for conditional Changes navigation
CI / browser-journey (pull_request) Successful in 5m30s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 00:52:55 +00:00
timmy df90fb1e0e feat: review watched pull request changes (Closes #1328)
CI / browser-journey (pull_request) Failing after 5m6s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 00:40:28 +00:00
rockachopa e39e92b025 Merge pull request 'Plan Following changes and continue review' (#1327) from timmy/1326-plan-following-change-continue-review into main
CI / browser-journey (push) Successful in 5m21s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m27s
2026-08-23 23:43:39 +00:00
timmy 07836f7176 feat: plan Following changes and continue review (Closes #1326)
CI / browser-journey (pull_request) Successful in 5m46s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 23:33:36 +00:00
rockachopa 0f36d3077b Merge pull request 'Combine Following changes with Updates in the app badge' (#1325)
CI / browser-journey (push) Successful in 5m29s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m26s
Closes #1324
2026-08-23 22:48:16 +00:00
timmy 33c8761e80 feat: combine review attention app badge (Closes #1324)
CI / browser-journey (pull_request) Successful in 5m24s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 22:38:21 +00:00
rockachopa 157703527a Merge pull request 'Explain Following changes before acknowledging review' (#1323) from timmy/1322-explain-following-changes into main
CI / browser-journey (push) Successful in 5m27s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m27s
2026-08-23 21:39:31 +00:00
timmy 5921027e80 feat: explain Following changes before review (Closes #1322)
CI / browser-journey (pull_request) Successful in 5m48s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 4m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 21:28:50 +00:00
rockachopa 31700d5c71 Merge pull request 'Keep a Following change for later and continue review' (#1321) from timmy/1320-keep-following-for-later into main
CI / browser-journey (push) Successful in 5m53s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m47s
2026-08-23 20:35:33 +00:00
timmy c3cb498ce1 feat: keep Following changes for later (Closes #1320)
CI / browser-journey (pull_request) Successful in 6m34s
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 3m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 20:24:14 +00:00
timmy f168a0187d Merge pull request 'feat: CI browser journey loses active break state after corrupt-record recovery' (#1319) from timmy/1315-ci-browser-journey-loses-active-break-state-afte into main
CI / browser-journey (push) Successful in 5m37s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m14s
2026-08-23 19:46:52 +00:00
timmy 877c995b47 fix: preserve active break during session adoption (Closes #1315)
CI / browser-journey (pull_request) Successful in 5m18s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 19:26:02 +00:00
rockachopa e572b3410e Merge pull request 'Open Following alerts directly in changed-first review' (#1318) from timmy/1317-open-following-alert-review into main
CI / browser-journey (push) Successful in 5m33s
CI / build-release (push) Successful in 10s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m58s
2026-08-23 18:57:50 +00:00
timmy a2501e47c3 feat: open Following alerts in changed review (Closes #1317)
CI / browser-journey (pull_request) Successful in 5m14s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 18:37:57 +00:00
timmy 2b6730288a Merge pull request 'feat: Notify mobile operators when Following changes' (#1316) from timmy/1313-notify-mobile-operators-when-following-changes into main
CI / browser-journey (push) Successful in 6m6s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m48s
2026-08-23 18:02:52 +00:00
timmy 0efd44fd90 feat: notify devices when Following changes (Closes #1313)
CI / browser-journey (pull_request) Successful in 6m8s
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-23 17:16:28 +00:00
timmy 187b0decae Merge pull request 'feat: CI browser journey intermittently leaves week reschedule dialog open' (#1312) from timmy/1311-ci-browser-journey-intermittently-leaves-week-re into main
CI / browser-journey (push) Successful in 6m15s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m46s
2026-08-23 16:07:23 +00:00
timmy 75846cf045 fix: retry unchanged reschedule intent after stale revision (Closes #1311)
CI / browser-journey (pull_request) Successful in 6m58s
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-23 15:43:30 +00:00
rockachopa 544ffb4ae7 Merge pull request 'Watch assigned issues and pull requests from mobile detail' (#1310)
CI / browser-journey (push) Failing after 5m25s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m23s
Closes #1309
2026-08-23 15:14:34 +00:00
timmy eea9097740 test: cover detail watch in packaged mobile CI
CI / browser-journey (pull_request) Successful in 5m13s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 15:05:19 +00:00
timmy 35a88bb398 feat: watch assigned work from mobile detail (Closes #1309)
CI / browser-journey (pull_request) Failing after 5m41s
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-23 14:42:06 +00:00
rockachopa 4e3f67df3d Merge pull request 'Follow pull requests from mobile Search into Following' (#1308) from timmy/1307-follow-pulls-from-search into main
CI / browser-journey (push) Successful in 5m20s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m45s
2026-08-23 13:51:13 +00:00
timmy 7131792ded feat: follow pull requests from mobile Search (Closes #1307)
CI / browser-journey (pull_request) Successful in 5m17s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 13:41:33 +00:00
rockachopa 41c28cc70a Merge pull request 'Retire closed issues during Following review' (#1306) from timmy/1305-retire-closed-following into main
CI / browser-journey (push) Successful in 5m50s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m47s
2026-08-23 12:36:07 +00:00
timmy aa12c4ab9d test: validate closed Following retirement on phone
CI / browser-journey (pull_request) Successful in 5m23s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 12:26:39 +00:00
timmy c4bb7a9be1 feat: retire closed issues during Following review (Closes #1305)
CI / browser-journey (pull_request) Failing after 5m22s
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-23 12:12:55 +00:00
rockachopa fdf38a72ac Merge pull request 'Follow any open issue from mobile Search' (#1304) from timmy/1303-follow-any-open-issue into main
CI / browser-journey (push) Successful in 5m34s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m31s
Follow any open issue from mobile Search (#1304)
2026-08-23 11:13:07 +00:00
timmy 29e3f55e07 feat: follow any open issue from search (Closes #1303)
CI / browser-journey (pull_request) Successful in 5m37s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m54s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 11:02:33 +00:00
rockachopa 0a18d1ac0f Review unseen Following changes during Prepare Today (#1302)
CI / browser-journey (push) Successful in 5m21s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m33s
Closes #1301
2026-08-23 10:19:29 +00:00
timmy 12c0e18dfe feat: review Following during Prepare Today (Closes #1301)
CI / browser-journey (pull_request) Successful in 5m34s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 10:09:12 +00:00
rockachopa 875ec45928 Merge pull request 'Add a sequential review pass for Following' (#1300) from timmy/1299-following-review-pass into main
CI / browser-journey (push) Successful in 5m22s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m34s
2026-08-23 09:26:23 +00:00
timmy b79973fc2e feat: add sequential Following review pass (Closes #1299)
CI / browser-journey (pull_request) Successful in 5m45s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 09:15:57 +00:00
timmy 6b98eb5cbe Merge pull request 'Turn Following into a changed-first mobile review inbox' (#1298) from timmy/1297-changed-first-following into main
CI / browser-journey (push) Successful in 5m32s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 4m18s
2026-08-23 08:04:15 +00:00
timmy 32e2a228b9 feat: make Following a changed-first inbox (Closes #1297)
CI / browser-journey (pull_request) Successful in 7m3s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 4m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 07:38:53 +00:00
timmy bbf397454d Merge pull request 'Make Search watch admission truthful and recoverable' (#1296) from timmy/1295-truthful-search-watch-admission into main
CI / browser-journey (push) Successful in 6m41s
CI / build-release (push) Successful in 10s
CI / release-candidate (push) Successful in 11s
CI / lint (push) Successful in 3m48s
2026-08-23 06:36:03 +00:00
timmy d3ba5975a0 refactor: make Following sync outcome explicit
CI / browser-journey (pull_request) Successful in 7m9s
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 4m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 06:23:57 +00:00
timmy 6c12731215 fix: make Search watch admission recoverable (Closes #1295)
CI / browser-journey (pull_request) Failing after 7m36s
CI / build-release (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 4m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 06:05:48 +00:00
timmy 4fe07bcd52 Merge pull request 'Add a mobile Following queue for watched issues' (#1294) from timmy/1293-mobile-following-watched-issues into main
CI / browser-journey (push) Successful in 7m5s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 4m6s
2026-08-23 04:57:11 +00:00
timmy 846c28001e fix: defer Following sync until user intent
CI / browser-journey (pull_request) Successful in 6m50s
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 4m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 04:45:27 +00:00
timmy 5c43a5678a feat: add mobile Following queue for watched issues (Closes #1293)
CI / browser-journey (pull_request) Failing after 6m46s
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 4m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 04:27:23 +00:00
timmy 4b4e55dbe2 Merge pull request 'feat: CI timing gate flakes under runner load' (#1292) from timmy/1289-ci-timing-gate-flakes-under-runner-load into main
CI / browser-journey (push) Successful in 7m13s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 10s
CI / lint (push) Successful in 4m4s
2026-08-23 02:45:12 +00:00
timmy fe1049ee99 test: make health timing gate deterministic (Closes #1289)
CI / browser-journey (pull_request) Successful in 7m24s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 4m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 02:40:35 +00:00
timmy 73017c1a30 Merge pull request 'Watch an unassigned issue from mobile Search' (#1291) from timmy/1290-watch-mobile-search into main
CI / browser-journey (push) Successful in 6m52s
CI / build-release (push) Successful in 9s
CI / release-candidate (push) Successful in 11s
CI / lint (push) Successful in 4m15s
2026-08-23 02:08:24 +00:00
timmy 9c187b5ae5 feat: watch issues from mobile search (Closes #1290)
CI / browser-journey (pull_request) Successful in 7m6s
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 4m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 01:55:53 +00:00
timmy 7fbbc2f942 Merge pull request 'feat: Disk usage 93%' (#1288) from timmy/1287-disk-usage-93 into main
CI / browser-journey (push) Failing after 7m30s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 4m7s
2026-08-23 01:37:44 +00:00
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) 2026-08-22 11:15:10 +00:00
rockachopa 9eac3bb958 Merge pull request 'Persist privacy-safe calendar free windows with Week Ahead' (#1259)
Closes #1258
2026-08-22 10:04:09 +00:00
timmy fc93620118 feat: persist calendar free windows with Week Ahead (Closes #1258) 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
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) 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
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) 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 2026-08-22 06:31:00 +00:00
timmy 4b645d8460 feat: encrypt completed Filed review history (Closes #1252) 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 2026-08-22 04:52:47 +00:00
timmy 2e8efdb2bb test: cover fallback lease for Week replay 2026-08-22 04:38:26 +00:00
timmy e92e476f49 test: exercise real cross-tab replay coordinator 2026-08-22 04:36:02 +00:00
timmy 4a6a829401 fix: serialize Week-to-Today replay across tabs 2026-08-22 04:25:01 +00:00
timmy c03d0a091f Revert "ci: pin checkout and Python setup actions"
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 2026-08-22 03:40:21 +00:00
timmy 59c87f06a2 ci: pin checkout and Python setup actions 2026-08-22 03:38:27 +00:00
timmy f76b2f8289 refactor: simplify pull queue admission 2026-08-22 03:33:34 +00:00
timmy 7c4e798776 feat: queue offline Week-to-Today pulls (Closes #1249) 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 2026-08-22 02:50:55 +00:00
timmy c7d38593dd feat: keep Week-to-Today pulls through reloads (Closes #1246) 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 2026-08-22 01:43:15 +00:00
timmy 666671ee59 feat: confirm capacity before Week-to-Today pulls (Closes #1244) 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 2026-08-22 00:24:40 +00:00
timmy 3fd5983df6 feat: pull Week Ahead work into Today (Closes #1242) 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 2026-08-21 22:59:20 +00:00
timmy 449ab78991 feat: unplan Week Ahead work with undo (Closes #1240) 2026-08-21 22:45:15 +00:00
timmy 95887d7493 Merge pull request 'Rotate shared private-state encryption keys without downtime' (#1238)
Closes #1237
2026-08-21 21:37:25 +00:00
timmy 965a73ab49 feat: rotate shared private-state keys (Closes #1237) 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 2026-08-21 20:30:03 +00:00
timmy d47205a561 test: verify mobile Week Ahead retirement 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 2026-08-21 19:12:01 +00:00
timmy d63765a9b1 feat: queue offline Today-to-Week moves (Closes #1233) 2026-08-21 19:02:30 +00:00
rockachopa c87d56c939 Merge pull request 'Keep Today-to-Week rescheduling through offline reloads' (#1232) 2026-08-21 18:27:28 +00:00
timmy e234d6b6c3 feat: keep Today-to-Week moves through offline reloads (Closes #1231) 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 2026-08-21 17:13:00 +00:00
timmy 9d8a362583 feat: reschedule active Today work into Week Ahead (Closes #1228) 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 2026-08-21 15:49:07 +00:00
timmy ffba6f1212 feat: reflow overloaded Week Ahead plans (Closes #1226) 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
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) 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 2026-08-21 13:50:37 +00:00
timmy 9f5baaef6e feat: open Week Ahead planned work (Closes #1222) 2026-08-21 13:38:48 +00:00
rockachopa a3861da125 Preserve Search-planned work details in offline Week Ahead (#1221)
Closes #1220
2026-08-21 12:27:32 +00:00
timmy 2b874d8427 feat: preserve Search work details offline (Closes #1220) 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 2026-08-21 11:08:47 +00:00
timmy 37950bd0ed feat: keep Week Ahead available offline (Closes #1218) 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 2026-08-21 09:31:56 +00:00
timmy 42e97b4a79 feat: add read-first Week Ahead overview (Closes #1216) 2026-08-21 09:07:27 +00:00
timmy 45e2798d37 Merge pull request 'Update repeated Week Ahead calendar exports without duplicate blocks' (#1215)
Closes #1214
2026-08-21 07:39:04 +00:00
timmy 218dd43764 feat: update repeated Week Ahead calendar exports (Closes #1214) 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 2026-08-21 06:57:16 +00:00
timmy 69a3a2c568 feat: make calendar import timezone-correct and bounded (Closes #1212) 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 2026-08-21 06:00:10 +00:00
timmy 4e275b1518 feat: avoid imported busy time in Week Ahead export (Closes #1210) 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 2026-08-21 05:03:41 +00:00
timmy 15be0ec026 test: cover private recurrence additions on mobile 2026-08-21 04:53:57 +00:00
timmy ab74426cbc test: validate truthful mobile calendar import gating 2026-08-21 04:42:06 +00:00
timmy f1adffef6b feat: make calendar capacity recurrence-aware (Closes #1208) 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 2026-08-21 03:28:00 +00:00
timmy e77ad7c809 feat: import private calendar capacity for Week Ahead (Closes #1206) 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 2026-08-21 02:17:28 +00:00
timmy e5ccfb0c5e fix: restore Today break after sync recovery (Closes #1204) 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 2026-08-21 01:03:10 +00:00
timmy f11fc17a2c Fix mobile Today handoff clearance (#1202) 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 2026-08-21 00:18:01 +00:00
timmy a10592c349 feat: reconcile Today with due Week Ahead work (Closes #1200) 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 2026-08-20 23:24:36 +00:00
timmy dd3016b96b feat: export Week Ahead as calendar blocks (Closes #1198) 2026-08-20 23:12:28 +00:00
rockachopa 875f4eea29 Merge pull request 'Batch-plan Search results into Week Ahead' (#1197) 2026-08-20 22:11:15 +00:00
timmy 78839a50fe feat: batch-plan Search results into Week Ahead (Closes #1196) 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 2026-08-20 21:07:52 +00:00
timmy c8998eb1ba test: require Search Week Ahead browser coverage 2026-08-20 20:58:36 +00:00
timmy b5b1a3b0d7 ci: exercise Search Week Ahead mobile sheet 2026-08-20 20:51:34 +00:00
timmy ea1b5a77f5 feat: plan Search results into Week Ahead (Closes #1194) 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 2026-08-20 19:38:32 +00:00
timmy daffa894d8 feat: remind from due Week Ahead plans (Closes #1192) 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 2026-08-20 18:42:52 +00:00
timmy 9f40a4c5ac feat: make Week Ahead review readable and editable (Closes #1190) 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
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) 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 2026-08-20 16:45:31 +00:00
timmy 55e1c3d6dd feat: plan Week Ahead in one mobile pass (Closes #1186) 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 2026-08-20 15:54:53 +00:00
timmy e0f7c711ed feat: reconcile Week Ahead edits per day (Closes #1184) 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 2026-08-20 14:44:23 +00:00
timmy f0706e2e6f feat: preserve Today before Week Ahead rollover (Closes #1182) 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 2026-08-20 13:57:04 +00:00
timmy 47cad77312 feat: keep Week Ahead plans through offline saves (Closes #1180) 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 2026-08-20 13:01:21 +00:00
timmy 53c4340d76 fix: tolerate invalid device timezones in Week Ahead (Closes #1177) 2026-08-20 12:26:35 +00:00
timmy 5a9cce4a08 test: surface Week Ahead browser launch errors (Refs #1177) 2026-08-20 12:07:09 +00:00
timmy 4fbc9c62d9 fix: allow Week Ahead while identity finishes restoring (Refs #1177) 2026-08-20 11:53:45 +00:00
timmy a1b4698988 fix: open Week Ahead before account refresh (Refs #1177) 2026-08-20 11:41:04 +00:00
timmy e01681af09 feat: plan the next seven days on mobile (Closes #1177) 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 2026-08-20 10:24:38 +00:00
timmy 7c1292faa3 test: isolate first-task release probe (Refs #1174) 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 2026-08-20 09:58:30 +00:00
timmy c99d03b9bc feat: carry wrap-up work into Tomorrow (Closes #1174) 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 2026-08-20 08:16:45 +00:00
timmy 988f6646c2 feat: hand off Today lock-screen actions privately (Closes #1172) 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 2026-08-20 07:36:50 +00:00
timmy 3cfe6b9f75 ci: gate packaged Tomorrow conflict recovery (Closes #1170) 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
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) 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 2026-08-20 06:01:50 +00:00
timmy 6fb547e8ce fix: preserve synchronous Today reconciliation 2026-08-20 05:48:08 +00:00
timmy d86e84253a feat: launch planned days with morning reminders (Closes #1166) 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 2026-08-20 04:12:33 +00:00
timmy 059722de6c fix: bind Tomorrow conflict recovery to operator account 2026-08-20 04:01:25 +00:00
timmy 16a6ee1136 feat: resolve mobile Tomorrow plan conflicts (Closes #1164) 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 2026-08-20 03:09:59 +00:00
timmy bdf1ddaada fix: restore break status after account confirmation 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 2026-08-20 02:19:59 +00:00
timmy f42f89ca21 feat: keep Tomorrow plans through offline saves (Closes #1160) 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 2026-08-20 01:29:18 +00:00
timmy 0fd6314391 feat: make Tomorrow a first-class mobile queue (Closes #1158) 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 2026-08-20 00:46:35 +00:00
timmy 16841f18e1 fix: drain first-task completion during Today refresh (Closes #1156) 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 2026-08-19 23:56:19 +00:00
timmy 7c2e9861d7 fix: tolerate unavailable browser timezone data 2026-08-19 23:39:34 +00:00
timmy 7deb1c1de9 fix: recover Tomorrow promotion after missed rollover (Closes #1154) 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 2026-08-19 22:56:33 +00:00
timmy 0cb7328aa1 feat: plan tomorrow independently (Closes #1152) 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 2026-08-19 22:00:26 +00:00
timmy 37e919f43f fix: preserve first-task completion delivery 2026-08-19 21:52:50 +00:00
timmy d0780a72a7 feat: make Today completion durably atomic (Closes #1150) 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 2026-08-19 20:51:35 +00:00
timmy de405efdbc test: verify first-task completion on a replacement phone 2026-08-19 20:44:03 +00:00
timmy fa47ded3ab feat: sync first-task completion across devices (Closes #1148) 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
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) 2026-08-19 19:34:31 +00:00
timmy 941a508d4b Finish first-task onboarding with an active Today session
Closes #1144
2026-08-19 18:59:13 +00:00
timmy ebb7a78cab feat: finish onboarding with active Today (Closes #1144) 2026-08-19 18:51:15 +00:00
rockachopa b4a5401b22 Merge pull request 'Preserve timed break across reload' (#1143)
Closes #1142
2026-08-19 18:32:30 +00:00
timmy 870edda123 Preserve timed break when active work reopens 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 2026-08-19 17:41:31 +00:00
timmy 304e4a8a24 feat: guide first mobile task activation (Closes #1140) 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 2026-08-19 16:44:43 +00:00
timmy 348f43f1bc feat: pause Today for live data status (Closes #1138) 2026-08-19 16:35:09 +00:00
rockachopa 161563d6f5 Merge pull request 'Contain focus inside mobile Security Center' (#1137)
Closes #1136
2026-08-19 15:45:00 +00:00
timmy 931c82361b feat: contain Security Center focus (Closes #1136) 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 2026-08-19 14:44:26 +00:00
timmy 4b3e53f4d0 feat: contain mobile Device Setup focus (Closes #1134) 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 2026-08-19 13:43:48 +00:00
timmy 1248647aa4 feat: recover blocked notification setup (Closes #1132) 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
rockachopa 51f4223f4b Merge pull request 'Make Security Center a Back-safe mobile investigation flow' (#1021) from timmy/1020-mobile-security-center-flow into main
CI / browser-journey (push) Successful in 2m34s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m11s
2026-08-17 12:04:30 +00:00
timmy 241f0f3a2a feat: make Security Center Back-safe on mobile (Closes #1020)
CI / browser-journey (pull_request) Successful in 2m35s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 11:57:27 +00:00
rockachopa 34d5f86d53 Merge pull request 'Foreground mobile delivery attention handoffs' (#1019) from timmy/1018-mobile-delivery-attention-handoff into main
CI / browser-journey (push) Successful in 2m21s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m10s
2026-08-17 10:48:48 +00:00
timmy 3fde7cf3ad fix: foreground mobile delivery attention handoffs (Closes #1018)
CI / browser-journey (pull_request) Successful in 2m22s
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-17 10:42:19 +00:00
rockachopa 6913fc8257 Merge pull request 'Keep mobile delivery recovery single-flight until retry settles' (#1017) from timmy/1016-single-flight-delivery-recovery into main
CI / browser-journey (push) Successful in 2m13s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m7s
2026-08-17 10:08:11 +00:00
timmy 437da72f3d fix: keep delivery recovery single-flight (Closes #1016)
CI / browser-journey (pull_request) Successful in 2m20s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 10:02:00 +00:00
timmy 9a2b8d97bd Merge pull request 'Guide mobile delivery recovery one item at a time' (#1015) from timmy/1014-guided-delivery-recovery into main
CI / browser-journey (push) Successful in 2m13s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m49s
2026-08-17 09:12:25 +00:00
timmy 1e4f101219 feat: guide mobile delivery recovery (Closes #1014)
CI / browser-journey (pull_request) Successful in 2m4s
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-17 09:04:41 +00:00
timmy c0a02a05d8 Merge pull request 'Make actionable delivery recovery a first-class mobile queue' (#1013) from timmy/1012-mobile-delivery-recovery-queue 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 2m55s
2026-08-17 08:36:15 +00:00
timmy 246f995b56 feat: prioritize mobile delivery recovery (Closes #1012)
CI / browser-journey (pull_request) Successful in 2m5s
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 08:30:09 +00:00
timmy db28cc5690 Merge pull request 'Guide blocked deliveries through Prepare Today' (#1011) from timmy/1010-prepare-today-delivery-recovery into main
CI / browser-journey (push) Successful in 1m51s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m40s
2026-08-17 08:07:58 +00:00
timmy 26aa12019e feat: guide blocked deliveries through Prepare Today (Closes #1010)
CI / browser-journey (pull_request) Successful in 1m53s
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-17 08:01:48 +00:00
timmy 1baa9c1f91 Merge pull request 'Continue mobile Work into Filed follow-up' (#1009) from timmy/1008-mobile-work-filed-follow-up into main
CI / browser-journey (push) Successful in 2m6s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m48s
2026-08-17 07:39:03 +00:00
timmy d64c646b68 feat: continue mobile Work into Filed follow-up (Closes #1008)
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-17 07:28:00 +00:00
rockachopa 021af60cf3 Merge pull request 'Preserve mobile My Work section history across Back and reload' (#1007) from timmy/1006-mobile-work-section-history into main
CI / browser-journey (push) Successful in 1m53s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m40s
2026-08-17 07:07:39 +00:00
timmy 09dbdb9961 feat: preserve mobile work section history (Closes #1006)
CI / browser-journey (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 06:59:20 +00:00
rockachopa 484dcc6b9c Merge pull request 'Resume private mobile share bundles after sign-in' (#1005)
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 2m49s
Closes #1004
2026-08-17 06:32:24 +00:00
timmy 7571a4aa2d fix: resume private share bundles after sign-in (Closes #1004)
CI / browser-journey (pull_request) Successful in 2m30s
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 06:25:58 +00:00
rockachopa dc68305dcb Merge pull request 'Add mobile pull-to-refresh for My Work' (#1003) from timmy/1002-mobile-pull-to-refresh into main
CI / browser-journey (push) Successful in 1m55s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m54s
2026-08-17 05:49:33 +00:00
timmy 386acda1dc feat: add mobile pull-to-refresh for My Work (Closes #1002)
CI / browser-journey (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 05:43:39 +00:00
timmy a2606f1dc2 Merge pull request 'Guard IndexedDB private work cleanup' (#1001)
CI / browser-journey (push) Successful in 2m9s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m58s
Closes #1000
2026-08-17 04:45:00 +00:00
timmy a2c91e0296 fix: guard IndexedDB private work cleanup (Closes #1000)
CI / browser-journey (pull_request) Successful in 2m9s
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-17 04:39:04 +00:00
rockachopa 8148545295 Merge pull request 'Show and safely clear private device storage' (#999) from timmy/998-private-device-storage into main
CI / browser-journey (push) Successful in 2m12s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m1s
2026-08-17 03:43:21 +00:00
timmy 70c8ea6584 feat: manage private device storage (Closes #998)
CI / browser-journey (pull_request) Successful in 2m5s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 03:37:21 +00:00
timmy f30143b5ea Merge pull request 'Defer the mobile workspace bundle behind an offline-safe bootstrap' (#997) from timmy/996-defer-mobile-workspace-bootstrap into main
CI / browser-journey (push) Successful in 1m59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m44s
2026-08-17 02:52:39 +00:00
timmy cf76bc3636 test: model worker transport outage deterministically
CI / browser-journey (pull_request) Successful in 2m3s
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 02:46:42 +00:00
timmy 0fc152f623 fix: recover deferred workspace reconnects
CI / browser-journey (pull_request) Failing after 2m45s
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 02:16:48 +00:00
timmy 377fd889c5 fix: preserve reconnect across deferred workspace startup
CI / browser-journey (pull_request) Failing after 2m53s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 01:41:22 +00:00
timmy 0a4c9a0ae8 fix: replay reconnect after workspace bootstrap
CI / browser-journey (pull_request) Failing after 2m44s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 01:23:44 +00:00
timmy 1a4f930b50 test: allow workspace activation before offline delivery
CI / browser-journey (pull_request) Failing after 3m4s
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-17 01:13:15 +00:00
timmy 49bbeadc48 perf: defer mobile workspace bootstrap (Closes #996)
CI / browser-journey (pull_request) Failing after 2m43s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 01:04:20 +00:00
rockachopa 5470e48e26 Make mobile Home task-first with a Back-safe Insights view
CI / browser-journey (push) Successful in 1m53s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m2s
Closes #994
2026-08-17 00:14:58 +00:00
timmy 0fcb7f5152 feat: make mobile Home task-first with Insights (Closes #994)
CI / browser-journey (pull_request) Successful in 2m8s
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-17 00:08:48 +00:00
rockachopa 0f30ca94ab Merge pull request 'Review wrap-up commitments before rebuilding Today' (#993) from timmy/992-review-wrap-up-commitments into main
CI / browser-journey (push) Successful in 1m47s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m45s
2026-08-16 23:20:55 +00:00
timmy 468676d2f4 feat: review wrap-up commitments in Today (Closes #992)
CI / browser-journey (pull_request) Successful in 1m50s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 23:15:00 +00:00
rockachopa 40623f939c Merge pull request 'Schedule unfinished Today work during session wrap-up' (#991) from timmy/990-schedule-unfinished-today-work into main
CI / browser-journey (push) Successful in 1m39s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m53s
2026-08-16 22:12:24 +00:00
timmy 110fd860b3 feat: schedule unfinished work during Today wrap-up (Closes #990)
CI / browser-journey (pull_request) Successful in 1m36s
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-16 22:07:04 +00:00
rockachopa edf5d3fda4 Merge pull request 'Block releases with vulnerable runtime dependencies' (#989) from timmy/988-block-vulnerable-runtime-dependencies into main
CI / browser-journey (push) Successful in 1m33s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m51s
2026-08-16 21:09:47 +00:00
timmy e395878f92 security: block vulnerable runtime dependencies (Closes #988)
CI / browser-journey (pull_request) Successful in 1m20s
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-16 21:04:44 +00:00
rockachopa f48b9b5a6c Merge pull request 'feat: Gate releases on the packaged mobile Plan Today handoff journey' (#987) from timmy/985-gate-releases-on-the-packaged-mobile-plan-today- into main
CI / browser-journey (push) Successful in 1m34s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m23s
Merge pull request 'Gate releases on the packaged mobile Plan Today handoff journey' (#987)
2026-08-16 20:47:59 +00:00
timmy 971656b76c test: gate packaged mobile Plan Today handoff (Closes #985)
CI / browser-journey (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 20:27:25 +00:00
timmy 48ec66cc30 Merge pull request 'Sanitize image evidence at the server upload boundary' (#984) from timmy/983-server-image-sanitization into main
CI / browser-journey (push) Successful in 1m16s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m11s
2026-08-16 19:38:34 +00:00
timmy 966028e0a6 security: sanitize image evidence server-side (Closes #983)
CI / browser-journey (pull_request) Successful in 1m11s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 19:33:46 +00:00
rockachopa 21eb223882 Merge pull request 'Gate releases on the packaged mobile Find Work journey' (#982)
CI / browser-journey (push) Successful in 1m12s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m10s
Closes #981
2026-08-16 19:02:46 +00:00
timmy e10bd2d261 test: gate packaged mobile Find Work journey (Closes #981)
CI / browser-journey (pull_request) Successful in 1m20s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 2m21s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 18:58:13 +00:00
timmy 0728bbdd6a Merge pull request 'Guide mobile Find Work from discovery through assignment' (#980) from timmy/979-mobile-find-work-navigation into main
CI / browser-journey (push) Successful in 1m6s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m11s
2026-08-16 18:11:41 +00:00
timmy 53e556ddab feat: guide mobile Find Work workflow (Closes #979)
CI / browser-journey (pull_request) Successful in 1m18s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 18:04:55 +00:00
timmy 611b10b477 Merge pull request 'Preserve offline workflows across PWA activation' (#978) from timmy/977-preserve-offline-feature-cache-activation into main
CI / browser-journey (push) Successful in 1m6s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m6s
2026-08-16 17:39:09 +00:00
timmy 85a000422d test: exercise migrated workflow offline
CI / browser-journey (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 17:35:24 +00:00
timmy d80091d244 fix: preserve offline workflows across activation (Closes #977)
CI / browser-journey (pull_request) Failing after 1m43s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 17:25:48 +00:00
rockachopa 5217f6a86f Merge pull request 'Add persistent mobile Plan Today workflow navigation' (#976) from timmy/975-mobile-plan-today-navigation into main
CI / browser-journey (push) Successful in 1m43s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m58s
2026-08-16 16:40:44 +00:00
timmy fc33746d4c feat: add mobile Plan Today navigation (Closes #975)
CI / browser-journey (pull_request) Successful in 1m2s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 16:36:49 +00:00
rockachopa cc449b3d2f Merge pull request 'Add persistent mobile Search Preview workflow navigation' (#974) from timmy/973-mobile-search-preview-navigation into main
CI / browser-journey (push) Successful in 1m2s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m4s
2026-08-16 16:08:44 +00:00
timmy 8de2318f43 test: isolate Search Preview browser checks
CI / browser-journey (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 16:05:06 +00:00
timmy 24f781d398 feat: add mobile Search Preview navigation (Closes #973)
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 2m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 15:58:41 +00:00
timmy 25f752339f Merge pull request 'Add mobile navigation to the New issue workflow' (#972) from timmy/971-mobile-new-issue-navigation into main
CI / browser-journey (push) Successful in 59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m1s
2026-08-16 15:16:04 +00:00
timmy fe95b4e32a fix: keep mobile filing navigation selected
CI / browser-journey (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 15:12:02 +00:00
timmy 2b671d91fd feat: add mobile new issue navigation (Closes #971)
CI / browser-journey (pull_request) Failing after 1m0s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 15:02:13 +00:00
timmy dcc3348c12 Merge pull request 'Add persistent mobile requested-review workspace navigation' (#970) from timmy/969-mobile-requested-review-navigation into main
CI / browser-journey (push) Successful in 55s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m59s
2026-08-16 14:32:36 +00:00
timmy 31fb3fc8ae feat: add mobile requested-review navigation (Closes #969)
CI / browser-journey (pull_request) Successful in 55s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 14:28:25 +00:00
timmy a0b3db6895 Merge pull request 'Add persistent mobile update workspace navigation' (#968) from timmy/967-mobile-update-workspace-navigation into main
CI / browser-journey (push) Successful in 54s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m5s
2026-08-16 13:46:40 +00:00
timmy 6c7b6ebd4d feat: add mobile update workspace navigation (Closes #967)
CI / browser-journey (pull_request) Successful in 1m3s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 13:42:19 +00:00
timmy 3c86e02efe Merge pull request 'Add persistent mobile pull request workspace navigation' (#966) from timmy/965-mobile-pull-workspace-navigation into main
CI / browser-journey (push) Successful in 51s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m59s
2026-08-16 13:04:52 +00:00
timmy c48baac1ea test: keep rendered checks out of unit CI
CI / browser-journey (pull_request) Successful in 54s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 13:01:03 +00:00
timmy 311396cfac feat: add mobile pull workspace navigation (Closes #965)
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 12:55:03 +00:00
timmy 762614b2d3 Merge pull request 'Preserve My Work conversation photo drafts' (#964) from timmy/963-preserve-my-work-photo-drafts into main
CI / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m3s
2026-08-16 12:38:07 +00:00
timmy 31f2c71575 feat: preserve My Work photo drafts (Closes #963)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 12:34:01 +00:00
timmy 50f1af83f6 Merge pull request 'Add persistent mobile issue detail navigation' (#962) from timmy/961-mobile-issue-detail-navigation into main
CI / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m59s
2026-08-16 12:05:55 +00:00
timmy 6c200d7c97 feat: add mobile issue detail navigation (Closes #961)
CI / browser-journey (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 12:02:02 +00:00
rockachopa 6954ffc050 Merge pull request 'Queue mobile Search replies for offline delivery' (#960) from timmy/959-offline-search-replies into main
CI / browser-journey (push) Successful in 54s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m2s
2026-08-16 11:08:30 +00:00
timmy c8a237aba4 test: tolerate container reconnect latency
CI / browser-journey (pull_request) Successful in 55s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 11:05:04 +00:00
timmy 7fa43a9e50 feat: queue offline Search replies (Closes #959)
CI / browser-journey (pull_request) Failing after 1m10s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 11:00:11 +00:00
timmy 2e6c20d68d Preserve mobile Search photo reply drafts across interruptions (#958)
CI / browser-journey (push) Successful in 1m0s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m1s
Closes #957
2026-08-16 10:12:08 +00:00
timmy 5af45cda57 feat: preserve Search photo reply drafts (Closes #957)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 10:07:46 +00:00
timmy cab5802547 Merge pull request 'Attach photo evidence to mobile Search preview replies' (#956) from timmy/955-search-preview-photo-evidence into main
CI / browser-journey (push) Successful in 51s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m58s
2026-08-16 09:34:05 +00:00
timmy d3a57f222e feat: attach photos from Search preview replies (Closes #955)
CI / browser-journey (pull_request) Successful in 51s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 09:30:18 +00:00
timmy 053f32c428 Merge pull request 'Dictate replies from mobile Search preview' (#954) from timmy/953-dictate-search-preview-replies into main
CI / browser-journey (push) Successful in 58s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m12s
2026-08-16 08:59:30 +00:00
timmy d63166b581 feat: dictate replies from mobile Search preview (Closes #953)
CI / browser-journey (pull_request) Successful in 51s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 08:55:49 +00:00
timmy 51710972eb Merge pull request 'Dictate, review, and send mobile conversation replies' (#952) from timmy/951-dictate-mobile-conversation-replies into main
CI / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m2s
2026-08-16 08:39:34 +00:00
timmy 0e13872311 feat: dictate mobile conversation replies (Closes #951)
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 08:35:34 +00:00
timmy eac5342fa5 Merge pull request 'Strip private metadata from mobile photo evidence' (#950) from timmy/949-strip-photo-metadata into main
CI / browser-journey (push) Successful in 1m2s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m2s
2026-08-16 07:58:41 +00:00
timmy a8e931f306 feat: strip metadata from photo evidence (Closes #949)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 07:55:04 +00:00
timmy 00fd96f4a4 Merge pull request 'Edit and redact mobile conversation photos before sending' (#948) from timmy/947-edit-redact-conversation-photos into main
CI / browser-journey (push) Successful in 55s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m0s
2026-08-16 07:32:05 +00:00
timmy 0d5fa13d65 feat: edit photos in conversation replies (Closes #947)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 07:28:10 +00:00
rockachopa 6270253c24 Merge pull request 'Review and caption mobile conversation photo bundles before sending' (#946) from timmy/945-review-caption-conversation-photo-bundles into main
CI / browser-journey (push) Successful in 1m0s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m8s
2026-08-16 07:02:09 +00:00
timmy 5da5d7cdc4 feat: review conversation photo bundles before sending (Closes #945)
CI / browser-journey (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 06:56:05 +00:00
timmy 55bd353daf Merge pull request 'Send multi-photo evidence bundles in mobile conversation replies' (#944) from timmy/943-mobile-conversation-photo-bundles into main
CI / browser-journey (push) Successful in 1m0s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m7s
2026-08-16 06:13:37 +00:00
timmy c5d31c1972 feat: send mobile conversation photo bundles (Closes #943)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 06:06:07 +00:00
timmy 843906208c Capture photo evidence in mobile conversation replies (#942)
CI / browser-journey (push) Successful in 1m0s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m3s
Closes #941
2026-08-16 05:37:38 +00:00
timmy 2338a07bab feat: capture photo evidence in conversation replies (Closes #941)
CI / browser-journey (pull_request) Successful in 1m2s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m6s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 05:33:34 +00:00
rockachopa 67d8828aea Merge pull request 'Save photo-only mobile captures as untitled Drafts' (#940) from timmy/939-photo-only-untitled-drafts into main
CI / browser-journey (push) Successful in 59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m2s
2026-08-16 05:06:46 +00:00
timmy caa0fbc2fd feat: save photo-only captures as drafts (Closes #939)
CI / browser-journey (pull_request) Successful in 1m2s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 04:59:08 +00:00
timmy 8a85b344f8 Merge pull request 'Capture camera-first photo evidence from mobile New issue' (#938) from timmy/937-camera-first-photo-evidence into main
CI / browser-journey (push) Successful in 59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m7s
Merge pull request #938 from timmy/937-camera-first-photo-evidence
2026-08-16 04:33:45 +00:00
timmy b4662a175d feat: capture camera-first photo evidence (Closes #937)
CI / browser-journey (pull_request) Successful in 1m2s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 04:29:44 +00:00
timmy fb08785ad5 Merge pull request 'Keep multi-segment mobile voice dictation free of repeated phrases' (#936) from timmy/935-deduplicate-mobile-voice-results into main
CI / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m7s
2026-08-16 04:00:46 +00:00
timmy 2bb155a9e4 fix: deduplicate cumulative voice results (Closes #935)
CI / browser-journey (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 03:56:50 +00:00
timmy c4715fc95c Merge pull request 'Enable same-origin microphone access for voice capture' (#934) from timmy/933-same-origin-microphone into main
CI / browser-journey (push) Successful in 58s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m10s
Merge PR #934: enable same-origin microphone access
2026-08-16 03:28:43 +00:00
timmy bd637d0141 fix: enable same-origin voice capture (Closes #933)
CI / browser-journey (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 03:24:10 +00:00
timmy 785fb990b4 Merge pull request #932 from timmy/931-recover-mobile-voice-transcripts
CI / browser-journey (push) Successful in 1m4s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m1s
Recover interrupted mobile voice transcripts privately

Closes #931
2026-08-16 02:42:02 +00:00
timmy f8c2c080f9 feat: recover interrupted mobile voice transcripts (Closes #931)
CI / browser-journey (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 02:37:54 +00:00
timmy 47220929bf Merge pull request 'Add voice-to-draft mobile issue capture' (#930) from timmy/929-voice-to-draft-mobile-capture into main
CI / browser-journey (push) Successful in 1m3s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m8s
2026-08-16 02:06:46 +00:00
timmy 3b4bb618dd feat: add voice-to-draft mobile capture (Closes #929)
CI / browser-journey (pull_request) Successful in 1m10s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 02:02:24 +00:00
rockachopa 9adfcd01b6 Merge pull request 'feat: CI regression: offline artifact browser journey does not flush after reconnect' (#928) from timmy/927-ci-regression-offline-artifact-browser-journey-d into main
CI / browser-journey (push) Successful in 58s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m6s
2026-08-16 01:39:48 +00:00
timmy 64e764b996 fix: flush durable outboxes after reconnect (Closes #927)
CI / browser-journey (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 01:32:11 +00:00
timmy 76eed47487 Merge pull request 'Close completed delegated parent from mobile Filed review' (#926) from timmy/925-close-completed-delegated-parent into main
CI / browser-journey (push) Failing after 1m17s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 2m5s
2026-08-16 01:02:34 +00:00
timmy 05b2f769e4 feat: close completed delegated parent from Filed review (Closes #925)
CI / browser-journey (pull_request) Successful in 1m4s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 00:58:39 +00:00
rockachopa 40b124bc2c Merge pull request #924 from timmy/923-complete-delegated-parent-checklist
CI / browser-journey (push) Successful in 1m3s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m2s
Complete delegated parent task from mobile Filed review
2026-08-16 00:35:39 +00:00
timmy 4ab0ffde58 feat: complete delegated parent task from Filed review (Closes #923)
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 00:31:37 +00:00
timmy ab77beb3fe Merge pull request 'Promote checklist steps into related issues from mobile detail' (#922) from timmy/921-promote-checklist-related-issue into main
CI / browser-journey (push) Successful in 2m5s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m58s
2026-08-15 23:47:02 +00:00
timmy 170828b308 feat: promote checklist steps to related issues (Closes #921)
CI / browser-journey (pull_request) Successful in 58s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 23:40:17 +00:00
rockachopa 08139bc3ee Merge pull request 'Manage checklist steps inline from mobile issue detail' (#920) from timmy/919-manage-checklist-steps into main
CI / browser-journey (push) Successful in 57s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m55s
2026-08-15 22:42:45 +00:00
timmy 4589c46f16 feat: manage mobile checklist steps inline (Closes #919)
CI / browser-journey (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 22:38:52 +00:00
rockachopa 6b924a4757 Merge pull request #918 from timmy/917-ci-regression-offline-artifact-browser-journey-d
CI / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m58s
Fix queued issue delivery after offline reload and reconnect.
2026-08-15 21:41:45 +00:00
timmy 1602528e47 fix: resume queued delivery on reconnect (Closes #917)
CI / browser-journey (pull_request) Successful in 1m0s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 21:31:42 +00:00
rockachopa d28140bbd1 Merge pull request 'Add checklist steps inline from mobile issue detail' (#916) from timmy/915-mobile-add-checklist-step into main
CI / browser-journey (push) Failing after 1m17s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 1m58s
2026-08-15 21:13:17 +00:00
timmy 3f559a50ea feat: add mobile checklist steps inline (Closes #915)
CI / browser-journey (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 21:09:25 +00:00
timmy e7a4eb3331 Merge pull request 'Resolve offline checklist conflicts from mobile Drafts' (#914)
CI / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m0s
Closes #913
2026-08-15 20:04:12 +00:00
timmy 7b27f85b39 feat: resolve mobile checklist conflicts (Closes #913)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 20:00:14 +00:00
timmy 7204a426af Merge pull request 'Finish a mobile checklist and continue Today' (#912) from timmy/911-checklist-close-next into main
CI / browser-journey (push) Successful in 1m1s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m54s
2026-08-15 19:32:07 +00:00
timmy 2aad1a9bef feat: finish mobile checklists and continue Today (Closes #911)
CI / browser-journey (pull_request) Successful in 58s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 19:27:54 +00:00
timmy dbadc73a57 Merge pull request 'Continue mobile checklist progress across offline changes' (#910) from timmy/909-offline-checklist-coalesce into main
CI / browser-journey (push) Successful in 59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m57s
2026-08-15 19:05:22 +00:00
timmy 59bc13c5b0 test: stabilize transport admission deadline
CI / browser-journey (pull_request) Successful in 1m0s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 19:01:24 +00:00
timmy 5c370cbe33 feat: continue offline checklist progress (Closes #909)
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 18:26:34 +00:00
timmy 475739328c Merge pull request 'Queue offline issue checklist progress for conflict-safe delivery' (#908)
CI / browser-journey (push) Successful in 57s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m58s
2026-08-15 17:39:42 +00:00
timmy 411e40d117 feat: queue offline checklist progress (Closes #907)
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 17:35:21 +00:00
319 changed files with 66660 additions and 1634 deletions

No files matched your search

+17 -12
View File
@@ -7,21 +7,26 @@ on:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: { python-version: "3.11" }
- run: pip install -r requirements.txt
- run: pip install -r requirements-audit.txt
- run: python3 -m pip_audit -r requirements.txt --strict
- run: python3 -m pytest tests/ -q
build-release:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build deterministic release bundle
run: |
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
@@ -31,7 +36,7 @@ jobs:
--commit "$GITHUB_SHA" \
--source-date-epoch "$SOURCE_DATE_EPOCH"
- name: Upload tested release bundle
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
name: release-bundle
path: dist/
@@ -42,11 +47,11 @@ jobs:
env:
STACKCHAIN_RUN_RELEASE_E2E: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: { python-version: "3.11" }
- name: Download assembled release bundle
uses: actions/download-artifact@v3
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: release-bundle
path: dist
@@ -54,8 +59,8 @@ jobs:
run: |
pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium
- name: Exercise mobile offline filing from the release artifact
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q
- name: Exercise packaged mobile work journeys
run: python3 -m pytest tests/e2e -q
release-candidate:
runs-on: ubuntu-latest
@@ -64,9 +69,9 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Download tested release bundle
uses: actions/download-artifact@v3
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: release-bundle
path: dist
@@ -80,7 +85,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
+277 -24
View File
@@ -18,13 +18,22 @@ token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, discover, claim, and release issue assignments,
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
either stage. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
requests, merge assigned pull requests, and submit pull-request reviews. For authored
pulls, up to eight non-overlapping one-line reviewer suggestions across bounded UTF-8
files can be staged during the mobile feedback pass, reviewed in a path-grouped manifest,
and committed as one atomic branch update. The batch preserves every original blob
identity, applies same-file replacements from the bottom up, rejects overlapping or stale
lines, and verifies the advanced pull head and every final file before reporting success.
The server uses Gitea's multi-file contents mutation so a cross-file implementation-and-test
fix cannot be partially committed.
After an exact merged commit fails release checks, its mobile release receipt can load the
failed job evidence and prepare a draft rollback pull request. The server revalidates the
operator's participation and push access, reverses only bounded UTF-8 files whose current
default-branch content still matches the failed merge, and creates one atomic commit on a
stable operator-owned branch. Conflicts, renames, binary files, and oversized changes fail
closed; retrying converges on the same draft PR, which opens in the existing review workspace.
Assigned-issue, assigned-pull-request, and unread-update conversation composers accept an ordered bundle of up to five PNG, JPEG, or WebP photos. Repeated camera captures append to the bundle, the gallery picker accepts multiple images, and each composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary. Before sending, the selected conversation photo can use the same touch editor as New issue evidence to crop, privacy-redact, highlight, or add an arrow; Apply replaces only that flattened derivative while preserving its caption and bundle position, and Cancel leaves the original unchanged.
For online delivery, every photo uploads before the comment is posted to the exact conversation target, producing one ordered Markdown comment or reply; validation or upload failures keep the typed text and removable preview available for retry. Offline photo conversations admit every image Blob to IndexedDB before confirmation, keep only bounded metadata in localStorage, and checkpoint each upload separately so reconnect resumes at the first unconfirmed photo without duplicating an upload, comment, reply, or reply-and-read transition. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
@@ -44,10 +53,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
@@ -79,17 +88,93 @@ 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.
Durable admission account-binds the exact issue/pull kind and stores image bytes in IndexedDB before the UI
clears or advances. Reconnect revalidates the visible Search target through the narrower preview attachment
and comment routes instead of assigned-work routes. Existing per-photo upload operation IDs and confirmed
Markdown checkpoints survive handoff, so retry resumes from the first unfinished photo and the final comment
posts once. **Send & next** advances only after durable admission; a storage failure keeps the current result,
text, evidence, and Search position unchanged.
Named mobile Search views preserve the query, type, status, and optional repository scope. They are
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path.
The mobile **Customize routine order** control is also portable across authenticated devices. Reordering
remains immediate when offline and is marked **Sync pending** until connectivity returns. Rapid taps are
coalesced into one latest-order write; transient network or server failures retry automatically with bounded
backoff, and a clean phone refreshes the account order when it returns to the foreground. The complete
routine order is stored as an encrypted, revisioned collection scoped to the confirmed Gitea login; a
concurrent edit shows explicit **Keep this device** and **Use other device** actions instead of silently
losing either order. Account changes discard stale responses and retry work. Resetting publishes the
canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path.
Mobile **Recent work** is also portable across signed-in devices. Opening an issue, pull request,
review, Filed item, or update records its canonical detail route locally before navigation and marks
the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks
merge the server list without duplicate routes, while each confirmed account remains bounded to its
five most recent items. A separate **Pin** action keeps up to 20 frequently revisited items above
Recent work even after that five-item window advances; **Unpin** removes only the pin, and both actions
apply offline-first before account-scoped synchronization. Open and Pin/Unpin remain separate touch and
keyboard targets. Titles, repositories, routes, and pins are encrypted at rest with the shared
private-state key; stale responses from a prior account are discarded. Set
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
after the account-scoped collection confirms the same mutation. This completes the read → watch → revisit flow without
changing ownership, review assignment, or scheduling work.
Following is a read-first, account-scoped collection: it is encrypted at rest, revisioned, bounded to
50 typed items, and synchronized across signed-in devices. Opening a row reuses the correct issue or
pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work
closes or merges, the sequential review exposes **Stop watching & next** so the completed item can be
retired without leaving the preview; the next captured change opens immediately, and retiring the
final item completes the Following phase. Changed cards explain whether work closed, reopened, changed title, or
received other activity. During review, conversation messages newer than the prior reviewed revision are highlighted,
and the revision is acknowledged only after both detail and conversation context load; a failed conversation load keeps
that exact change unseen for Retry or **Keep for later & next**. For open work that still needs thought, **Keep for later & next**
restores only the loaded revision to the unseen queue and continues the captured pass without changing
Gitea state, ownership, planning, or watch status. Failed or unconfirmed Gitea mutations leave the collection
and current review position unchanged. Following counts never influence the recommended Work queue. Set
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
Each device can explicitly opt in to **Notify me when Following changes**. Stackchain polls that
channel independently, validates the device session immediately before delivery, and checkpoints
the exact unseen revision set so unchanged work stays silent across workers. Lock-screen payloads
contain only a bounded count and the `#/my-work/following` route—never repository names, titles,
bodies, or comments. Disabling Following alerts leaves Updates, deadlines, and start-day reminders
unchanged.
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and
Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day
checkpoint: finishing Agenda, Updates, or the final Filed review returns to a focused handoff using fresh queue
counts, while reopening the queue sheet resumes the next live phase. **Finish for now** removes only that local
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, Filed,
and unseen Following activity, then opens the highest-priority non-empty review queue. Following refreshes only when
Prepare Today is opened; selecting that phase starts its changed-first sequential review directly. Starting the pass
saves a confirmed-account, local-day checkpoint: finishing Agenda, Updates, the final Filed review, or the final
Following revision returns to a focused handoff using fresh queue counts, while reopening the queue sheet resumes the
next live phase. **Finish for now** removes only that local
checkpoint. Once urgent review is clear the pass continues the existing Today plan, or opens Find Work when Today
is empty; the briefing and checkpoint never change Gitea state.
Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the
@@ -112,10 +197,24 @@ 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);
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
**Add step** directly beside the plan. A new step is normalized, checked for duplicates, and appended without
exposing or replacing the full issue description. Offline additions enter the same account-bound durable
issue-content queue as checklist toggles; reconnect conflict review applies unique local additions to the
latest remote body while preserving remote prose and task-state changes.
**Comment & next** on that current issue or pull request posts the handoff online or admits it
to durable account-bound delivery, then removes the item only from Today and opens the next
one without closing or merging it. **Reply & next** provides the same one-action continuation
@@ -133,7 +232,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
@@ -175,8 +313,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, mobile Recent work, 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
@@ -242,6 +398,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.
@@ -250,13 +444,19 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
# three individual alerts followed by one private digest that opens Updates. A later
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
# that thread's updated_at revision; unchanged and older snapshots remain silent.
# that thread's updated_at revision; unchanged and older snapshots remain silent. Opt-in
# Following alerts use generic copy and deep-link to the changed-first Following review.
# Browser push services must resolve exclusively to public IP addresses. Stackchain
# validates endpoints at enrollment and again before delivery, rejects redirects,
# and removes legacy subscriptions that resolve to private or reserved networks.
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
# 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
@@ -275,6 +475,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
@@ -307,8 +530,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
@@ -376,6 +604,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
@@ -639,3 +872,23 @@ Checkpoint replacement uses writer-unique, flushed temporary files and an
atomic rename, preventing concurrent writers from colliding or exposing partial
JSON. Full behavior and safety gates are documented in
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
## Human Gates inbox
Authenticated release producers use `POST /api/v1/human-gates/intake` with an
`Idempotency-Key` and an immutable `"candidate_hash"`; durable account-bound
SQLite storage is configured by `STACKCHAIN_HUMAN_GATE_DB`. Account isolation
binds each queue to the upstream principal ID and login, including its offline
browser cache. Review decisions carry `expected_revision`, a stable idempotency
key across network retries and mobile app restarts, and return durable receipts.
An interrupted decision response is restored as **Decision outcome unknown**;
the operator verifies the exact persisted operation instead of creating a second
decision. The client clears that operation only after recovering a receipt. If producer evidence
changes after a release or hold, the update reopens the exact hash for a new
revision-checked decision instead of silently retaining the old outcome.
New hashes mark older pending candidates `superseded` without removing their audit
history. See [`docs/human-gates.md`](docs/human-gates.md) for the complete
producer body, decision API, and **Telegram coalescing contract**. Lock-screen
and Telegram notifications expose count and route only, using
`#/my-work/human-gates`; they never expose project, title, candidate hash,
artifacts, checks, provenance, reasons, or receipts.
+45
View File
@@ -0,0 +1,45 @@
# Human Gates producer and notification contract
Human Gates is an account-bound release-candidate inbox. The canonical mobile route is `#/my-work/human-gates`. Reads may use the last account-scoped browser cache, but Release/Hold decisions require a live authenticated identity and an online server round trip.
A cold open of the canonical route confirms the account and opens Human Gates from the core browser runtime while optional Today and Planning bundles continue hydrating or recovering. The full workspace adopts that controller and fixed review snapshot without a second queue request or duplicate decision handlers.
## Producer intake
Authenticated producers submit `POST /api/v1/human-gates/intake` with a unique `Idempotency-Key` header and JSON such as:
```json
{
"source": "release-bot",
"project": "stackchain/dashboard",
"candidate_hash": "abc123",
"title": "Dashboard candidate",
"priority": 7,
"artifacts": [{"name": "manifest", "url": "https://forge.example/artifacts/manifest.json"}],
"links": [{"label": "change", "url": "https://forge.example/pulls/1415"}],
"checks": [{"name": "browser", "state": "success", "required": true}],
"score": {"value": 92, "provenance": "release-evaluator/v2"},
"provenance": {"producer": "release-bot", "run_id": "run-9"}
}
```
The immutable identity is authenticated account + `source` + `project` + `candidate_hash`. Retrying the same key and body returns the same gate. Reusing a key for different facts, or redefining an existing candidate hash, returns 409. A newer hash from the same source/project atomically marks older pending candidates `superseded`; detail history remains available for audit. Configure durable storage with `STACKCHAIN_HUMAN_GATE_DB` (default: `$STACKCHAIN_STATE_DIR/human-gates.sqlite3`).
Consumers list `GET /api/v1/human-gates`, inspect `GET /api/v1/human-gates/{id}`, and submit `POST /api/v1/human-gates/{id}/decision` with a new `Idempotency-Key`, `expected_revision`, and either `release` or `hold`. Hold requires a reason. Release requires all three checklist confirmations; if any required check is not successful it also requires an explicit override reason. Durable receipts are available at `GET /api/v1/human-gate-receipts/{receipt_id}`. All endpoints are authenticated, account-bound, and `Cache-Control: no-store`.
Before a mobile client submits Release or Hold, it stores one account-, gate-, and revision-bound pending operation with the exact payload and idempotency key. A transport interruption changes the decision tray to **Decision outcome unknown** and blocks replacement decisions. **Verify decision** replays that exact operation; server-side idempotency returns the original receipt if the decision committed, or executes it once if the first request never arrived. The pending operation and saved review progress are removed only after a receipt is confirmed. Reloading or restarting the installed app restores this verification flow even when the committed gate is no longer present in the live pending queue. Pending decision data never crosses the immutable account cache boundary.
## Privacy-safe notification contract
Web Push is opt-in per authenticated device under **My Work → Settings → Notify me when release decisions are waiting**. The preference is stored with that device's push subscription; revoked sessions are removed before delivery. The poller honors the device's routine-alert quiet hours, coalesces unchanged pending counts, and routes a notification tap to `#/my-work/human-gates`.
Web Push, Telegram, and other lock-screen adapters MUST expose **count and route only**:
```json
{
"pending_count": 3,
"route": "#/my-work/human-gates"
}
```
The notification text may say “3 Human Gates pending” and provide the route. It MUST NOT include project names, candidate hash values, titles, artifact/link URLs, checks, scores, provenance, decision history, superseded candidate facts, reasons, or receipts. Multiple intake/supersession events before delivery replace the pending notification with the latest count rather than emitting one message per candidate. A transition to zero clears the outstanding notification; it does not send candidate detail. Producers and adapters must fetch current state after authenticated route open rather than treating notification delivery as an action authorization.
+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);
+163 -21
View File
@@ -1,10 +1,50 @@
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, mergeChecklistConflict, now = () => Date.now(), maxItems = 50 }) {
const storageKey = 'stackchain.authored-outbox.v1';
const makeId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
function messageAttachments(message) {
const values = Array.isArray(message?.attachments) ? message.attachments :
(Array.isArray(message?.attachment) ? message.attachment : (message?.attachment ? [message.attachment] : []));
return values.filter(Boolean).slice(0, 5);
}
function attachmentMetadata(value) {
const note = String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
return {
filename: String(value.filename || ''),
contentType: String(value.contentType || ''),
stored: true,
...(note ? { note } : {}),
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
};
}
function durableAttachment(value) {
const note = String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
return {
filename: String(value.filename || ''),
contentType: String(value.contentType || ''),
...(note ? { note } : {}),
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
...(value.blob ? { blob:value.blob } : { data:String(value.data || '') }),
};
}
function checklistOperation(value) {
const action = String(value?.action || '');
if (!['rename', 'remove', 'move-earlier', 'move-later'].includes(action)) return null;
return {
action,
index:Number(value.index),
...(action === 'rename' ? { label:String(value.label || '') } : {}),
};
}
function reviewFingerprint(message) {
return JSON.stringify({
@@ -15,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 {
@@ -45,12 +99,20 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
function enqueue(message, mirror = true) {
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
if (message.kind === 'search-reply' && !['issue', 'pull'].includes(message.targetKind)) {
throw new Error('Choose an exact Search result before queueing a reply.');
}
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
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 || '') &&
@@ -61,8 +123,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
}
}
if (message.kind === 'issue-content') {
const queuedContent = items.find(item => item.kind === 'issue-content' && item.status === 'queued' &&
item.ownerLogin === ownerLogin && item.repository === String(message.repository || '') &&
item.number === Number(message.number || 0));
if (queuedContent) {
const operation = checklistOperation(message.checklistOperation);
const replacement = {
...queuedContent,
operationId: String(requestedOperationId || makeId()).slice(0, 128),
title: String(message.title || ''),
body: String(message.body || ''),
...(operation ? { checklistOperations:[...(queuedContent.checklistOperations || []), operation] } : {}),
};
write(items.map(item => item.id === queuedContent.id ? replacement : item), mirror);
return replacement;
}
}
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
const id = String(requestedOperationId || makeId()).slice(0, 128);
const attachments = messageAttachments(message);
const item = {
id,
operationId: requestedOperationId || id,
@@ -74,14 +154,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
...(message.kind === 'search-reply' ? { targetKind:String(message.targetKind) } : {}),
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
stored: true,
},
} : {}),
...(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ?
(attachments.length === 1 ? { attachment:attachmentMetadata(attachments[0]) } :
{ attachments:attachments.map(attachmentMetadata) }) : {}),
...(message.kind === 'pull-review' ? {
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
@@ -96,6 +173,14 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
blockerNumber: Number(message.blockerNumber || 0),
present: message.present === true,
} : {}),
...(message.kind === 'issue-content' ? {
title: String(message.title || ''),
baseBody: String(message.baseBody ?? message.body ?? ''),
expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
...(checklistOperation(message.checklistOperation) ? {
checklistOperations:[checklistOperation(message.checklistOperation)],
} : {}),
} : {}),
};
items.push(item);
write(items, mirror);
@@ -103,8 +188,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
}
async function enqueueDurably(message) {
const previousItems = read();
const item = enqueue(message, false);
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
const attachments = messageAttachments(message);
if (attachments.length && ['search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) &&
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
write(read().filter(candidate => candidate.id !== item.id), false);
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
@@ -112,19 +199,15 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? {
const durableItems = read().map(candidate => candidate.id === item.id && attachments.length ? {
...candidate,
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
...(message.attachment.blob ? { blob: message.attachment.blob } :
{ data: String(message.attachment.data || '') }),
},
...(attachments.length === 1 ? { attachment:durableAttachment(attachments[0]) } :
{ attachments:attachments.map(durableAttachment) }),
} : candidate);
try {
await backgroundSync.reconcile(durableItems, 'authored');
} catch (error) {
write(read().filter(candidate => candidate.id !== item.id), false);
write(previousItems, false);
throw error;
}
try {
@@ -171,9 +254,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (item.kind === 'issue-blocker') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
}
if (item.kind === 'issue-content') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content';
}
if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
}
if (item.kind === 'search-reply') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/comments?kind=' + encodeURIComponent(item.targetKind);
}
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
}
@@ -246,9 +336,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments,
} : item.kind === 'issue-content' ? {
title:item.title, body:item.body, expected_updated_at:item.expectedUpdatedAt,
} : { body: item.body };
result = await fetchJson(endpoint(item), {
method: 'POST',
method: item.kind === 'issue-content' ? 'PATCH' : 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
@@ -256,6 +348,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
},
body: JSON.stringify(body),
});
if (item.kind === 'issue-content' &&
(result?.number !== item.number || result?.title !== item.title || result?.body !== item.body)) {
throw new Error('Checklist update was not confirmed.');
}
}
}
if (!result) return { blocked: true };
@@ -334,6 +430,51 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return retryItem(id, currentLogin);
}
async function resolveIssueContentConflict(id, latest, currentLogin) {
const previousItems = read();
const item = previousItems.find(candidate => candidate.id === id);
currentLogin = String(currentLogin || '').trim();
if (!item || item.kind !== 'issue-content' || item.status !== 'attention') {
throw new Error('This checklist conflict is no longer available.');
}
if (!currentLogin || item.ownerLogin !== currentLogin) {
throw new Error('Confirm the account that queued this checklist update.');
}
if (typeof mergeChecklistConflict !== 'function') {
throw new Error('Checklist conflict review is unavailable.');
}
const merged = mergeChecklistConflict({
baseBody: String(item.baseBody ?? item.body ?? ''),
localBody: String(item.body || ''),
remoteBody: String(latest?.body || ''),
});
if (merged.conflicts?.length) return merged;
if (!String(latest?.updated_at || '')) throw new Error('Latest issue revision is unavailable.');
const rebased = {
...item,
operationId: String(makeId()).slice(0, 128),
title: String(latest?.title || ''),
baseBody: String(latest?.body || ''),
body: merged.body,
expectedUpdatedAt: String(latest.updated_at),
status: 'queued',
};
delete rebased.error;
delete rebased.deliveryState;
const nextItems = previousItems.map(candidate => candidate.id === id ? rebased : candidate);
write(nextItems, false);
if (backgroundSync?.reconcile) {
try { await backgroundSync.reconcile(nextItems, 'authored'); }
catch (error) {
write(previousItems, false);
throw error;
}
try { await backgroundSync.requestSync?.(); }
catch (_error) { /* Foreground retry remains available. */ }
}
return { ...merged, item: rebased };
}
function reconcileBackground(records) {
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
@@ -359,7 +500,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return items;
}
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
return { enqueue, enqueueDurably, update, discard, flush, retry, resolveIssueContentConflict,
reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
+128 -59
View File
@@ -105,6 +105,11 @@ function createIssueSyncStore({
};
incoming.set(current.id, replacement);
}
if (current.kind === 'issue-content' && replacement.kind === 'issue-content' &&
current.operationId !== replacement.operationId && current.status === 'sending' &&
Number(current.claimUntil) > Number(now())) {
throw new Error('Checklist sync is already delivering. Retry this change.');
}
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(['attention', 'authorization'].includes(current.status) &&
replacement.status === current.status) ||
@@ -181,10 +186,15 @@ function createIssueSyncStore({
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
? { attachment: current.attachment } : {};
const preservedAttachments = current?.operationId === item?.operationId &&
current?.attachments?.every(value => value?.data || value?.blob) &&
item?.attachments?.every(value => value?.stored && !value.data && !value.blob)
? { attachments:current.attachments } : {};
const next = current && current.operationId === item.operationId ? {
...item, ...preservedAttachment,
...item, ...preservedAttachment, ...preservedAttachments,
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
...(current.attachmentMarkdowns ? { attachmentMarkdowns:current.attachmentMarkdowns } : {}),
} : { ...item };
await records.put(next);
return next;
@@ -410,6 +420,24 @@ function createBackgroundIssueSync({
},
};
}
if (item.kind === 'issue-content') {
return {
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content',
options: {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title,
body: item.body,
expected_updated_at: item.expectedUpdatedAt,
}),
},
};
}
if (item.kind === 'pull-review') {
return {
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
@@ -436,6 +464,13 @@ function createBackgroundIssueSync({
item,
);
}
if (item.kind === 'search-reply') {
return authoredRequest(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/comments?kind=' + encodeURIComponent(item.targetKind),
item,
);
}
return {
url: base + 'api/v1/repos/' + repository + '/issues',
options: {
@@ -485,31 +520,68 @@ function createBackgroundIssueSync({
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
}
function conversationAttachments(item) {
return (Array.isArray(item?.attachments) ? item.attachments : [item?.attachment]).filter(Boolean);
}
async function uploadConversationAttachments(item, url) {
let current = item;
const attachments = conversationAttachments(current);
const confirmedMarkdowns = attachments.map(value => String(value?.confirmed?.markdown || ''));
const firstMissing = confirmedMarkdowns.findIndex(value => !value);
const leadingConfirmed = confirmedMarkdowns.slice(0, firstMissing < 0 ? confirmedMarkdowns.length : firstMissing);
const markdowns = Array.isArray(current.attachmentMarkdowns) ?
current.attachmentMarkdowns.slice(0, attachments.length) :
(current.attachmentMarkdown ? [current.attachmentMarkdown] : leadingConfirmed);
for (let index = markdowns.length; index < attachments.length; index += 1) {
const uploaded = await requestStage(current, url, {
method:'POST',
headers:{
Accept:'application/json',
'Idempotency-Key':String(attachments[index]?.operationId || stageOperationId(
current.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
)).slice(0, 128),
},
body:attachmentMultipart(attachments[index]),
});
const markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
markdowns.push(markdown);
await checkpointClaim(current, stored => ({
...stored, attachmentMarkdowns:markdowns.slice(),
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
}));
current = {
...current, attachmentMarkdowns:markdowns.slice(),
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
};
}
return {
current,
markdown:markdowns.map((value, index) =>
evidenceMarkdown(attachments[index], value, index)).join('\n\n'),
};
}
async function deliverReplyRead(item) {
let current = item;
if (!current.replyConfirmed) {
let attachmentMarkdown = current.attachmentMarkdown;
if (current.attachment && !attachmentMarkdown) {
const uploaded = await requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
method:'POST',
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
body:attachmentMultipart(current.attachment),
});
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
current = { ...current, attachmentMarkdown };
let attachmentMarkdown = '';
if (conversationAttachments(current).length) {
const uploaded = await uploadConversationAttachments(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments');
current = uploaded.current;
attachmentMarkdown = uploaded.markdown;
}
const text = String(current.body || '').trim();
const replyBody = attachmentMarkdown ?
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
const options = authoredRequest('', { ...current, body:replyBody }).options;
if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
if (conversationAttachments(current).length) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
await requestStage(
current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
@@ -615,28 +687,9 @@ function createBackgroundIssueSync({
async function deliverScreenshotComment(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestStage(
item,
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: attachmentMultipart(item.attachment),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(item, current => ({ ...current, attachmentMarkdown }));
}
const uploaded = await uploadConversationAttachments(item,
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments');
const attachmentMarkdown = uploaded.markdown;
const text = String(item.body || '').trim();
return requestStage(
item,
@@ -652,26 +705,35 @@ function createBackgroundIssueSync({
);
}
async function deliverUpdateScreenshotReply(item) {
async function deliverSearchReply(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
let current = item;
let attachmentMarkdown = current.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
method:'POST',
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
body:attachmentMultipart(current.attachment),
});
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
current = { ...current, attachmentMarkdown };
let attachmentMarkdown = '';
if (conversationAttachments(current).length) {
const uploaded = await uploadConversationAttachments(current,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
'/preview/attachments?kind=' + encodeURIComponent(current.targetKind));
current = uploaded.current;
attachmentMarkdown = uploaded.markdown;
}
const text = String(current.body || '').trim();
return requestStage(current,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
'/preview/comments?kind=' + encodeURIComponent(current.targetKind), {
method:'POST',
headers:{Accept:'application/json','Content-Type':'application/json',
'Idempotency-Key':stageOperationId(current.operationId, 'comment')},
body:JSON.stringify({body:attachmentMarkdown ?
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text}),
});
}
async function deliverUpdateScreenshotReply(item) {
const uploaded = await uploadConversationAttachments(item,
base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/attachments');
const current = uploaded.current;
const attachmentMarkdown = uploaded.markdown;
const text = String(current.body || '').trim();
return requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
method:'POST',
@@ -685,8 +747,9 @@ function createBackgroundIssueSync({
const request = deliveryRequest(item);
try {
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
item.kind === 'search-reply' ? await deliverSearchReply(item) :
item.kind === 'update-reply' && conversationAttachments(item).length ? await deliverUpdateScreenshotReply(item) :
conversationAttachments(item).length && ['issue-comment', 'pull-comment'].includes(item.kind) ?
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
@@ -706,6 +769,12 @@ function createBackgroundIssueSync({
throw error;
}
}
if (item.kind === 'issue-content' &&
(delivered?.number !== item.number || delivered?.title !== item.title || delivered?.body !== item.body)) {
const error = new Error('Checklist update was not confirmed.');
error.status = 422;
throw error;
}
await completeClaim(item, delivered);
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
+141
View File
@@ -0,0 +1,141 @@
function mergeChecklistConflict({ baseBody, localBody, remoteBody, operations = [] }) {
const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/;
function tasks(body) {
const entries = [];
String(body || '').split('\n').forEach((line, lineIndex) => {
const match = line.match(taskPattern);
if (!match) return;
const label = match[4].trim();
const key = label.replace(/\s+/g, ' ').toLocaleLowerCase();
if (!key) return;
entries.push({ key, label, checked: match[2].toLowerCase() === 'x', lineIndex, match });
});
return entries;
}
function grouped(entries) {
const result = new Map();
entries.forEach(entry => result.set(entry.key, [...(result.get(entry.key) || []), entry]));
return result;
}
function replayOperations(body, requested, reportChanges) {
let lines = String(body || '').split('\n');
const visibleTasks = value => tasks(value).filter(entry => /^[-*+]/.test(entry.match[1]));
const logical = visibleTasks(baseBody).map(entry => ({ key:entry.key, label:entry.label }));
const replayed = [];
for (const operation of requested) {
const index = Number(operation?.index);
const target = logical[index];
if (!target) return { body:null, changes:replayed, conflict:{ label:'Checklist step', reason:'missing' } };
let entries = visibleTasks(lines.join('\n'));
const matches = entries.filter(entry => entry.key === target.key);
if (matches.length !== 1) {
return { body:null, changes:replayed, conflict:{
label:target.label, reason:matches.length ? 'ambiguous' : 'missing',
} };
}
const match = matches[0];
if (operation.action === 'rename') {
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
const key = label.toLocaleLowerCase();
if (!label || entries.some(entry => entry.key === key && entry.lineIndex !== match.lineIndex)) {
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'ambiguous' } };
}
lines[match.lineIndex] = match.match[1] + match.match[2] + match.match[3] + label;
if (reportChanges) replayed.push({ label:target.label, renamed:label });
target.label = label;
target.key = key;
} else if (operation.action === 'remove') {
lines.splice(match.lineIndex, 1);
logical.splice(index, 1);
if (reportChanges) replayed.push({ label:target.label, removed:true });
} else if (operation.action === 'move-earlier' || operation.action === 'move-later') {
const neighborIndex = operation.action === 'move-earlier' ? index - 1 : index + 1;
const neighbor = logical[neighborIndex];
const neighborMatches = neighbor ? entries.filter(entry => entry.key === neighbor.key) : [];
if (neighborMatches.length !== 1 || Math.abs(neighborMatches[0].lineIndex - match.lineIndex) !== 1) {
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'order-changed' } };
}
const neighborLine = neighborMatches[0].lineIndex;
[lines[match.lineIndex], lines[neighborLine]] = [lines[neighborLine], lines[match.lineIndex]];
[logical[index], logical[neighborIndex]] = [logical[neighborIndex], logical[index]];
if (reportChanges) replayed.push({
label:target.label, moved:operation.action === 'move-earlier' ? 'earlier' : 'later',
});
}
}
return { body:lines.join('\n'), changes:replayed, conflict:null };
}
if (Array.isArray(operations) && operations.length) {
const baseReplay = replayOperations(baseBody, operations, false);
const remoteReplay = replayOperations(remoteBody, operations, true);
const conflict = baseReplay.conflict || remoteReplay.conflict;
if (conflict) return { body:null, changes:remoteReplay.changes || [], conflicts:[conflict] };
const residual = mergeChecklistConflict({
baseBody:baseReplay.body, localBody, remoteBody:remoteReplay.body,
});
return {
body:residual.body,
changes:[...remoteReplay.changes, ...residual.changes],
conflicts:residual.conflicts,
};
}
const base = grouped(tasks(baseBody));
const local = grouped(tasks(localBody));
const remoteEntries = tasks(remoteBody);
const remote = grouped(remoteEntries);
const changes = [];
const conflicts = [];
for (const [key, baseMatches] of base) {
const localMatches = local.get(key) || [];
if (baseMatches.length !== 1 || localMatches.length !== 1) continue;
if (baseMatches[0].checked === localMatches[0].checked) continue;
const remoteMatches = remote.get(key) || [];
if (remoteMatches.length !== 1) {
conflicts.push({
label: baseMatches[0].label,
reason: remoteMatches.length ? 'ambiguous' : 'missing',
});
continue;
}
changes.push({ label: remoteMatches[0].label, checked: localMatches[0].checked });
}
for (const [key, localMatches] of local) {
if (base.has(key)) continue;
if (localMatches.length !== 1) {
conflicts.push({ label:localMatches[0].label, reason:'ambiguous' });
continue;
}
const remoteMatches = remote.get(key) || [];
if (remoteMatches.length > 1) {
conflicts.push({ label: localMatches[0].label, reason: 'ambiguous' });
continue;
}
if (remoteMatches.length === 0) {
changes.push({ label: localMatches[0].label, checked:localMatches[0].checked, added:true });
}
}
if (conflicts.length) return { body: null, changes, conflicts };
const desired = new Map(changes.map(change => [
change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked,
]));
const lines = String(remoteBody || '').split('\n');
remoteEntries.forEach(entry => {
if (!desired.has(entry.key)) return;
const marker = desired.get(entry.key) ? 'x' : ' ';
lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4];
});
changes.filter(change => change.added).forEach(change => {
lines.push('- [' + (change.checked ? 'x' : ' ') + '] ' + change.label);
});
return { body: lines.join('\n'), changes, conflicts: [] };
}
if (typeof module !== 'undefined') module.exports = mergeChecklistConflict;
+95 -2
View File
@@ -1,4 +1,8 @@
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
const reactionLabels = {
'+1': 'Thumbs up', '-1': 'Thumbs down', laugh: 'Laugh', hooray: 'Hooray',
confused: 'Confused', heart: 'Heart', rocket: 'Rocket', eyes: 'Eyes',
};
const encodedRepository = repository => String(repository || '').split('/')
.map(encodeURIComponent).join('/');
@@ -14,6 +18,12 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
'/comments/' + encodeURIComponent(commentId);
}
function reactionPath(context, commentId, content = '') {
return pathFor(context, commentId) + '/reactions' +
(content ? '/' + encodeURIComponent(content) : '');
}
const pendingReactions = new Set();
const controller = {
isOwned(comment) {
const login = String(getLogin() || '').trim();
@@ -41,15 +51,98 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
pager.remove(commentId);
return pager.snapshot();
},
async loadReactions(context, commentId) {
return fetchJson(reactionPath(context, commentId), {
headers: { Accept: 'application/json' },
});
},
async setReaction(context, commentId, content, active) {
return fetchJson(reactionPath(context, commentId, content), {
method: 'PUT',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ active: Boolean(active) }),
});
},
reactionHtml(state) {
const current = new Map((state?.reactions || []).map(item => [item.content, item]));
return Object.entries(reactionLabels).map(([content, label]) => {
const item = current.get(content) || {};
const count = Number.isInteger(item.count) && item.count > 0 ? item.count : 0;
const selected = item.selected === true;
return '<button type="button" data-comment-reaction="' + content + '" ' +
'aria-pressed="' + selected + '" aria-label="' + label + ' ' + count + '">' +
label + ' ' + count + '</button>';
}).join('') + '<button type="button" data-comment-reactions-close>Cancel</button>';
},
actionHtml(comment) {
return controller.isOwned(comment) ?
const owned = controller.isOwned(comment) ?
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
'<button type="button" data-comment-action="edit">Edit</button>' +
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
return owned + '<div class="comment-reactions" aria-label="Comment reactions">' +
'<button type="button" data-comment-reactions-open aria-expanded="false" ' +
'aria-label="React to this comment">React</button>' +
'<div class="comment-reaction-menu" data-comment-reaction-menu hidden></div></div>';
},
wire({ root, getSurface, isOffline, escapeHtml }) {
root.addEventListener('click', async event => {
const button = event.target.closest('[data-comment-action]');
const actionButton = event.target.closest('[data-comment-action]');
const reactionClose = actionButton ? null : event.target.closest('[data-comment-reactions-close]');
const reactionButton = actionButton ? null : event.target.closest('[data-comment-reaction]');
const reactionTrigger = actionButton ? null : event.target.closest('[data-comment-reactions-open]');
if (reactionClose) {
const card = reactionClose.closest('.issue-comment');
const menu = card?.querySelector('[data-comment-reaction-menu]');
const trigger = card?.querySelector('[data-comment-reactions-open]');
if (menu && trigger) {
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
}
return;
}
if (reactionButton || reactionTrigger) {
const control = reactionButton || reactionTrigger;
const card = control.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
const surface = getSurface();
const menu = card?.querySelector('[data-comment-reaction-menu]');
const trigger = card?.querySelector('[data-comment-reactions-open]');
if (!commentId || !menu || !trigger) return;
if (isOffline()) {
surface.status.textContent = 'Reconnect to react.';
return;
}
if (pendingReactions.has(commentId)) return;
pendingReactions.add(commentId);
control.disabled = true;
try {
if (reactionButton) {
const content = reactionButton.dataset.commentReaction;
const active = reactionButton.getAttribute('aria-pressed') !== 'true';
const state = await controller.setReaction(surface.context, commentId, content, active);
menu.innerHTML = controller.reactionHtml(state);
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
surface.status.textContent = 'Reaction updated.';
} else {
surface.status.textContent = 'Loading reactions…';
const state = await controller.loadReactions(surface.context, commentId);
menu.innerHTML = controller.reactionHtml(state);
menu.hidden = false;
trigger.setAttribute('aria-expanded', 'true');
surface.status.textContent = '';
}
} catch (error) {
surface.status.textContent = error.message || 'Reactions are unavailable. Please retry.';
} finally {
pendingReactions.delete(commentId);
control.disabled = false;
}
return;
}
const button = actionButton;
if (!button) return;
const card = button.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
+50 -7
View File
@@ -44,9 +44,14 @@ function createContextPoller({
const freshness = snapshot && snapshot.freshness;
const sections = freshness && freshness.sections;
const sectionValues = sections && Object.values(sections);
const degradedRetrySeconds = (sectionValues || [])
.filter(section => section.degraded)
.map(section => Number(section.retry_in_seconds ?? freshness.retry_in_seconds))
.filter(seconds => Number.isFinite(seconds) && seconds > 0);
const failedSectionDelay = degradedRetrySeconds.length ?
Math.min(...degradedRetrySeconds) * 1000 : null;
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
const retrySeconds = Number(freshness.retry_in_seconds);
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
return failedSectionDelay || intervalMs;
}
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
const healthyDeadlines = (sectionValues || [])
@@ -55,10 +60,10 @@ function createContextPoller({
.filter(seconds => Number.isFinite(seconds));
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
const earliestDeadline = Math.min(...healthyDeadlines);
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
return intervalMs;
}
return Math.max(0, earliestDeadline) * 1000;
const healthyDelay = earliestDeadline <= 0 &&
sectionValues.some(section => !section.degraded && section.revalidating) ?
intervalMs : Math.max(0, earliestDeadline) * 1000;
return failedSectionDelay === null ? healthyDelay : Math.min(healthyDelay, failedSectionDelay);
}
return intervalMs;
}
@@ -121,7 +126,7 @@ function createContextPoller({
let request;
try {
request = fetchContext({ ...revisions }, { signal: controller.signal });
request = fetchContext(options.full ? {} : { ...revisions }, { signal: controller.signal });
} catch (error) {
request = Promise.reject(error);
}
@@ -166,9 +171,47 @@ function createContextPoller({
return refresh({ force: true });
}
function adopt(snapshot) {
if (
stopped || activeRequest || retainedSnapshot || !snapshot ||
typeof snapshot !== 'object' ||
!Object.prototype.hasOwnProperty.call(snapshot, 'context')
) return false;
retainedSnapshot = { ...snapshot };
revisions = { ...(snapshot.revisions || {}) };
failureStreak = 0;
lastSuccessAt = Date.now();
nextDelayMs = snapshotDelay(snapshot);
const changedSections = ['context', 'events', 'notifications'].filter(
section => Object.prototype.hasOwnProperty.call(snapshot, section)
);
onSnapshot(retainedSnapshot, changedSections);
schedule(nextDelayMs);
return true;
}
async function adoptPending(snapshotPromise) {
if (!snapshotPromise || typeof snapshotPromise.then !== 'function') return false;
let deadline = null;
const timeout = new Promise(resolve => {
deadline = setDeadlineTimer(() => resolve(null), timeoutMs);
});
try {
const snapshot = await Promise.race([
Promise.resolve(snapshotPromise).catch(() => null),
timeout,
]);
return adopt(snapshot);
} finally {
clearDeadlineTimer(deadline);
}
}
return {
start: refresh,
refresh,
adopt,
adoptPending,
setVisible,
getState() {
return {
+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;
+93
View File
@@ -0,0 +1,93 @@
(function (root, factory) {
const createConversationPhotoDrafts = factory();
if (typeof module === 'object' && module.exports) module.exports = createConversationPhotoDrafts;
else root.createConversationPhotoDrafts = createConversationPhotoDrafts;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function createConversationPhotoDrafts({ store, lanes, onChange = () => {} }) {
const states = {};
Object.entries(lanes || {}).forEach(([kind, lane]) => {
states[kind] = { ...lane, target:null, generation:0, restoring:false, pending:Promise.resolve() };
});
function state(kind) {
const value = states[kind];
if (!value) throw new Error('Unknown conversation photo draft lane.');
return value;
}
async function checkpoint(kind) {
const current = state(kind);
if (!current.target || current.restoring) return null;
const target = { ...current.target };
const attachments = await current.controller.serialize();
const save = current.pending.then(() => store.save(target, attachments));
current.pending = save.catch(() => null);
try {
const saved = await save;
onChange();
return saved;
}
catch (error) { current.onError?.(error); throw error; }
}
async function open(kind, target) {
const current = state(kind);
const generation = ++current.generation;
current.target = { ...target };
await current.pending;
const attachments = await store.load(target);
if (generation !== current.generation) return false;
current.restoring = true;
try {
current.controller.clear();
if (attachments?.length) current.controller.restore(attachments);
} finally { current.restoring = false; }
return Boolean(attachments?.length);
}
async function leave(kind) {
const current = state(kind);
const generation = current.generation;
if (!current.target) return true;
try { await checkpoint(kind); }
catch (_error) { return false; }
if (generation === current.generation) {
current.restoring = true;
try { current.controller.clear(); }
finally {
current.restoring = false;
current.target = null;
current.generation += 1;
}
}
return true;
}
async function complete(kind) {
const current = state(kind);
if (!current.target) return false;
const target = { ...current.target };
await current.pending;
await store.remove(target);
onChange();
current.restoring = true;
try { current.controller.clear(); }
finally {
current.restoring = false;
current.target = null;
current.generation += 1;
}
return true;
}
async function switchTo(kind, target, isCurrent = () => true) {
if (!await leave(kind) || !isCurrent()) return false;
return open(kind, target);
}
function hasTarget(kind) { return Boolean(state(kind).target); }
return { checkpoint, open, switchTo, leave, complete, hasTarget };
};
});
+163
View File
@@ -0,0 +1,163 @@
(function (root, factory) {
const createConversationReplyDraftStore = factory();
if (typeof module === 'object' && module.exports) module.exports = createConversationReplyDraftStore;
else root.createConversationReplyDraftStore = createConversationReplyDraftStore;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const dbName = 'stackchain-conversation-reply-drafts-v1';
const storeName = 'drafts';
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error || new Error('Conversation photo draft storage failed.'));
});
}
function createTransaction(indexedDB) {
if (!indexedDB) return null;
let databasePromise;
function database() {
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(storeName)) {
request.result.createObjectStore(storeName, { keyPath:'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Conversation photo draft storage is unavailable.'));
});
return databasePromise;
}
return async (operation, key, value) => {
const db = await database();
const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
const records = transaction.objectStore(storeName);
if (operation === 'put') return requestResult(records.put(value));
if (operation === 'delete') return requestResult(records.delete(key));
if (operation === 'list') return requestResult(records.getAll());
return requestResult(records.get(key));
};
}
function normalizedTarget(target) {
const kind = String(target?.kind || '');
if (kind === 'update') {
const notificationId = Number(target?.notificationId || target?.notification_id || 0);
if (!Number.isInteger(notificationId) || notificationId < 1) {
throw new Error('Open an unread update before saving photo evidence.');
}
return { kind, notificationId };
}
const repository = String(target?.repository || '');
const number = Number(target?.number || 0);
if (!['issue', 'pull'].includes(kind) || !repository || !Number.isInteger(number) || number < 1) {
throw new Error('Open a conversation before saving photo evidence.');
}
return { kind, repository, number };
}
function attachment(value) {
const blob = value?.blob;
const data = String(value?.data || '');
if (!blob && !data) throw new Error('A saved conversation photo is unavailable.');
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
const operationId = String(value?.operationId || '').slice(0, 128);
const markdown = String(value?.confirmed?.markdown || '');
return {
filename:String(value?.filename || ''), contentType:String(value?.contentType || ''),
...(blob ? { blob } : { data }), ...(note ? { note } : {}),
...(operationId ? { operationId } : {}), ...(markdown ? { confirmed:{ markdown } } : {}),
};
}
return function createConversationReplyDraftStore({
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();
if (!ownerLogin) throw new Error('Confirm your Gitea account before saving conversation photos.');
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, 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, 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, scope:recordScope, ...normalized,
updatedAt:Date.now(), attachments:list,
});
return list;
}
async function load(target) {
if (!transact) return null;
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
const record = await transact('get', id);
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);
if (!same || !Array.isArray(record.attachments) || !record.attachments.length) return null;
return record.attachments.slice(0, 5).map(attachment);
}
async function remove(target) {
if (!transact) return false;
const { id } = identity(target);
await transact('delete', id);
return true;
}
async function list() {
if (!transact || draftScope !== 'conversation') return [];
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) return [];
const records = await transact('list');
return (Array.isArray(records) ? records : []).flatMap(record => {
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
(record.scope && record.scope !== 'conversation') ||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
const updatedAt = Number(record.updatedAt || 0);
if (record.kind === 'update') {
const notificationId = Number(record.notificationId || 0);
if (!Number.isInteger(notificationId) || notificationId < 1) return [];
return [{
id:record.id, kind:'photo-reply', label:'Update photos', title:'Update #' + notificationId,
photo_count:record.attachments.length, updated_at:updatedAt,
route:{ kind:'update', notification_id:notificationId }, photo_store:'conversation',
}];
}
const repository = String(record.repository || '');
const number = Number(record.number || 0);
if (!['issue', 'pull'].includes(record.kind) || !repository ||
!Number.isInteger(number) || number < 1) return [];
const label = record.kind === 'issue' ? 'Issue photos' : 'PR photos';
return [{
id:record.id, kind:'photo-reply', label, repository, number,
title:repository + '#' + number, photo_count:record.attachments.length, updated_at:updatedAt,
route:{ kind:record.kind, repository, number }, photo_store:'conversation',
}];
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
}
return { save, load, remove, list };
};
});
+33
View File
@@ -81,4 +81,37 @@ function createConversationPager({ loadPage }) {
};
}
function renderConversationComment(comment, controller, escapeHtml, formatTime, renderMarkdown) {
const actions = controller?.actionHtml?.(comment) || '';
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') +
'</div>' + actions + '<div class="issue-sheet-content markdown-content">' +
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
}
function createConversationRenderers({qs,renderComment,updateReadPosition,getSelectedUpdate}) {
function status(kind, comments, total) {
qs('#' + kind + '-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
function paint(kind, state, controller) {
const comments = state?.comments || [];
qs('#' + kind + '-comments').innerHTML = comments.length ? comments.map(comment =>
kind === 'pull' ? '<div class="pull-comment-card">' + renderComment(comment,controller) + '</div>' :
renderComment(comment,controller)).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-' + kind + '-comments').hidden = !Number.isInteger(state?.older_page);
status(kind,comments,state?.total);
if (kind === 'update') {
const newest = qs('#update-comments .issue-comment:last-child');
updateReadPosition.ready(String(getSelectedUpdate()?.notification_id || ''),newest);
}
}
return {
paintIssueConversation:(state,controller)=>paint('issue',state,controller),
paintPullConversation:(state,controller)=>paint('pull',state,controller),
paintUpdateConversation:(state,controller)=>paint('update',state,controller),
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationPager;
+136
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);
@@ -43,6 +96,85 @@ function buildRelatedDraft(value = {}, template = null) {
return draft;
}
function relatedChecklistDraft(item, label) {
const title = String(label || '').trim().replace(/\s+/g, ' ').slice(0, 240);
if (!title) throw new Error('Choose a checklist step to file.');
const repository = String(item?.repository || '');
const number = Number(item?.number);
const url = String(item?.url || '');
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
!Number.isInteger(number) || number < 1 || !/^https:\/\//.test(url)) {
throw new Error('The parent issue link is unavailable.');
}
return {repository, title,
body:'Related to [' + repository + '#' + number + '](' + url + ').', labelIds:[]};
}
function linkChecklistTask(raw, targetIndex, child) {
const url = String(child?.url || '');
if (!/^https:\/\//.test(url)) throw new Error('The related issue link is unavailable.');
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
let fenced = false;
let taskIndex = 0;
for (let index = 0; index < parts.length; index += 2) {
const line = parts[index];
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
if (fenced) continue;
const task = line.match(/^(\s*[-*+]\s+\[[ xX]\]\s+)(.*)$/);
if (!task) continue;
if (taskIndex === Number(targetIndex)) {
if (/^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(task[2])) throw new Error('That checklist step is already linked.');
const label = task[2].trim().replace(/\s+/g, ' ');
parts[index] = task[1] + '[' + label + '](' + url + ')';
return parts.join('');
}
taskIndex += 1;
}
throw new Error('The checklist step is no longer available.');
}
function createChecklistPromotion({ issueController, issueCapture, clearAttachments, onLinked, onStatus }) {
let pending = null;
const persistedPromotion = value => ({
item:{repository:value.repository, number:value.number, url:value.url},
detail:{title:value.title, body:value.body, updated_at:value.updatedAt}, taskIndex:value.taskIndex,
});
return {
pending:() => Boolean(pending),
start(context) {
pending = {item:{...context.item}, detail:{...context.detail}, taskIndex:Number(context.taskIndex)};
const draft = issueController.relatedTaskDraft(context.item, context.detail, context.taskIndex, context.label);
issueCapture.saveDraft(draft);
clearAttachments();
return draft;
},
cancel() { if (!pending) return false; pending = null; issueCapture.clearDraft(); return true; },
deliveryContext() {
if (!pending) return null;
return {repository:pending.item.repository, number:pending.item.number, url:pending.item.url,
title:pending.detail.title, body:pending.detail.body, updatedAt:pending.detail.updated_at,
taskIndex:pending.taskIndex};
},
async finish(child, persisted = null) {
const promotion = pending || (persisted ? persistedPromotion(persisted) : null);
if (!promotion || !child) return false;
pending = null;
try {
const confirmed = await issueController.linkRelatedTask(
promotion.item, promotion.detail, promotion.taskIndex, child
);
onLinked(promotion, confirmed);
onStatus('Related issue created and linked from its parent checklist.');
return true;
} catch (error) {
onStatus('Related issue created, but the parent link needs attention.' +
(child.url ? ' ' + child.url : '') + ' ' + error.message);
return false;
}
},
};
}
function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
const NO_OWNER = '__unassigned__';
const select = documentRef.querySelector('#create-issue-assignee');
@@ -758,5 +890,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
createIssueCapture.buildRelatedDraft = buildRelatedDraft;
createIssueCapture.relatedChecklistDraft = relatedChecklistDraft;
createIssueCapture.linkChecklistTask = linkChecklistTask;
createIssueCapture.createChecklistPromotion = createChecklistPromotion;
createIssueCapture.createModalLifecycle = createIssueModalLifecycle;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
+203
View File
@@ -0,0 +1,203 @@
function createController({request, storage, login, createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now())}) {
const storageKey = 'stackchain.create-pull.v1.' + String(login || '').toLowerCase();
let operationId = createId();
let current = {
repository:'', branches:[], head:'', base:'', expected_head_sha:'',
title:'', body:'', draft:true,
};
try {
const saved = JSON.parse(storage?.getItem?.(storageKey) || 'null');
if (saved && typeof saved === 'object') {
for (const field of ['repository', 'head', 'base', 'title', 'body']) {
if (typeof saved[field] === 'string') current[field] = saved[field];
}
if (typeof saved.draft === 'boolean') current.draft = saved.draft;
}
} catch (_) {}
function snapshot() {
return JSON.parse(JSON.stringify(current));
}
function persist() {
storage?.setItem?.(storageKey, JSON.stringify({
repository:current.repository, head:current.head, base:current.base,
title:current.title, body:current.body, draft:current.draft,
}));
}
async function selectRepository(repository) {
const options = await request('api/v1/repos/' + repository + '/pull-creation-options');
const branches = Array.isArray(options?.branches) ? options.branches : [];
const base = branches.some(branch => branch.name === options.default_branch) ? options.default_branch : (branches[0]?.name || '');
const headBranch = branches.find(branch => branch.name !== base) || branches[0] || {};
current = {
...current, repository, branches, base,
head:headBranch.name || '', expected_head_sha:headBranch.sha || '',
};
persist();
return snapshot();
}
function update(values) {
current = {...current, ...values};
if (Object.prototype.hasOwnProperty.call(values, 'head')) {
current.expected_head_sha = current.branches.find(branch => branch.name === values.head)?.sha || '';
}
persist();
return snapshot();
}
async function submit() {
const result = await request('api/v1/repos/' + current.repository + '/pulls', {
method:'POST',
headers:{'Idempotency-Key':operationId},
body:{
head:current.head, base:current.base, title:current.title, body:current.body,
draft:current.draft, expected_head_sha:current.expected_head_sha,
},
});
operationId = createId();
return result;
}
return {
state:snapshot,
selectRepository,
update,
submit,
};
}
function createBinding({controller, render, status, onCreated}) {
return {
async repositoryChanged(repository) {
status('Loading branches…');
try {
const state = await controller.selectRepository(repository);
render(state);
status(state.branches.length ? 'Choose the source and base branches.' : 'No branches are available.');
return state;
} catch (error) {
status(error.message || 'Branches could not be loaded.');
throw error;
}
},
changed(values) {
const state = controller.update(values);
render(state);
return state;
},
async submit() {
status('Creating pull request…');
try {
const result = await controller.submit();
status(result.existing ? `Pull request #${result.number} is already open.` : `Pull request #${result.number} created.`);
onCreated(result);
return result;
} catch (error) {
status(error.message || 'The pull request could not be created. Your draft is safe.');
throw error;
}
},
};
}
async function bindDashboard() {
const qs = selector => document.querySelector(selector);
const switchButton = qs('#switch-to-create-pull');
switchButton.disabled = true;
const identityResponse = await fetch('api/v1/background-identity').catch(()=>null);
const identity = await identityResponse?.json().catch(()=>({}));
if (!identityResponse?.ok || !identity?.login) {
switchButton.title = 'Account identity is unavailable.';
return null;
}
const login = identity.login;
const root = qs('#create-pull-sheet');
const repository = qs('#create-pull-repository');
const head = qs('#create-pull-head');
const base = qs('#create-pull-base');
const title = qs('#create-pull-title');
const body = qs('#create-pull-body');
const submit = qs('#submit-create-pull');
const request = async (url, options) => {
const response = await fetch(url, options ? {
method:options.method,
headers:{'Content-Type':'application/json', ...options.headers},
body:JSON.stringify(options.body),
} : undefined);
const payload = await response.json().catch(()=>({}));
if (!response.ok) throw new Error(payload.error || payload.detail || 'Request failed.');
return payload;
};
const controller = createController({request, storage:localStorage, login});
const setOptions = (select, branches) => {
select.replaceChildren(...branches.map(branch => {
const option = document.createElement('option');
option.value = option.textContent = branch.name;
return option;
}));
};
const render = state => {
setOptions(head, state.branches);
setOptions(base, state.branches);
head.value = state.head;
base.value = state.base;
head.disabled = base.disabled = !state.branches.length;
qs('#create-pull-head-receipt').textContent = state.expected_head_sha ? 'Source at ' + state.expected_head_sha.slice(0, 12) : '';
submit.disabled = !(state.repository && state.head && state.base && state.head !== state.base && title.value.trim());
};
const binding = createBinding({
controller, render,
status:message=>{ qs('#create-pull-status').textContent = message; },
onCreated:result=>{
root.hidden = true;
qs('main').inert = false;
location.hash = '#/my-work/pull/' + result.repository + '/' + result.number;
location.reload();
},
});
const update = () => binding.changed({
head:head.value, base:base.value, title:title.value, body:body.value,
draft:document.querySelector('input[name="create-pull-mode"]:checked')?.value !== 'ready',
});
repository.addEventListener('change', () => binding.repositoryChanged(repository.value).catch(()=>{}));
[head,base,title,body].forEach(field => field.addEventListener('input', update));
document.querySelectorAll('input[name="create-pull-mode"]').forEach(field => field.addEventListener('change', () => {
update();
submit.textContent = field.value === 'ready' && field.checked ? 'Create ready pull request' : 'Create draft pull request';
}));
qs('#create-pull-form').addEventListener('submit', event => {
event.preventDefault();
update();
binding.submit().catch(()=>{});
});
const close = () => {
root.hidden = true;
qs('main').inert = false;
qs('#new-issue').focus();
};
qs('#cancel-create-pull').addEventListener('click', close);
switchButton.addEventListener('click', () => {
qs('#cancel-new-issue').click();
repository.replaceChildren(...Array.from(qs('#create-issue-repository').options).map(source => {
const option = document.createElement('option');
option.value = source.value;
option.textContent = source.textContent;
return option;
}));
root.hidden = false;
qs('main').inert = true;
qs('#cancel-create-pull').focus();
});
root.addEventListener('keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); close(); }
});
switchButton.disabled = false;
return binding;
}
const cp = {createController, createBinding, b:bindDashboard};
if (typeof module !== 'undefined') module.exports = cp;
else cp.b();
+788 -21
View File
File diff suppressed because it is too large. Load diff
+1776 -303
View File
File diff suppressed because it is too large. Load diff
+142
View File
@@ -0,0 +1,142 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else {
root.createDeviceStorage = factory;
root.createDeviceStorage.mount = document => factory({
summary:document.querySelector('#device-storage-summary'),
detail:document.querySelector('#device-storage-detail'),
clearCachesButton:document.querySelector('#clear-device-caches'),
clearAllButton:document.querySelector('#clear-private-device-data'),
localStorage:root.localStorage,
sessionStorage:root.sessionStorage,
caches:root.caches,
storageManager:root.navigator?.storage,
privateDatabases:root.stackchainPrivateDatabases,
inspectPrivateDatabases:root.inspectStackchainPrivateDatabases,
clearPrivateDeviceData:root.stackchainPrivateDeviceData,
});
}
})(typeof self !== 'undefined' ? self : this, function createDeviceStorage(options) {
let privateItemCount = 0;
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-'));
function ownedStorageCount(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;
}
function megabytes(value) {
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.';
const itemCount = ownedStorageCount(options.localStorage)
+ (options.sessionStorage === options.localStorage ? 0 : ownedStorageCount(options.sessionStorage));
privateItemCount = itemCount;
const inventory = await options.inspectPrivateDatabases?.(options.privateDatabases)
|| { recordCount: 0, unavailable: true };
privateRecordCount = inventory.recordCount;
inventoryUnavailable = inventory.unavailable;
const cacheCount = (await ownedCaches()).length;
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 };
}
async function clearCaches() {
options.clearCachesButton.disabled = true;
try {
await Promise.all((await ownedCaches()).map(name => options.caches.delete(name)));
await refresh();
options.summary.textContent = 'Cached app copies cleared. Private work was kept.';
} catch (error) {
options.summary.textContent = `Cached copies could not be cleared: ${error.message}`;
} finally {
options.clearCachesButton.disabled = false;
}
}
async function clearAll() {
if ((privateItemCount > 0 || privateRecordCount > 0 || inventoryUnavailable) && !fullClearArmed) {
fullClearArmed = true;
options.clearAllButton.textContent = 'Confirm: clear private work';
options.summary.textContent = 'Private drafts or queued work may not be synced. Press confirm to clear them from this device.';
return;
}
options.clearAllButton.disabled = true;
try {
await options.clearPrivateDeviceData();
privateItemCount = 0;
fullClearArmed = false;
options.clearAllButton.textContent = 'Clear all private data';
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}`;
} finally {
options.clearAllButton.disabled = false;
}
}
async function start() {
options.clearCachesButton.addEventListener('click', clearCaches);
options.clearAllButton.addEventListener('click', clearAll);
return refresh();
}
return { refresh, start, persistenceReadiness, requestPersistence };
});
+13 -3
View File
@@ -155,6 +155,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
.filter(Boolean).join('\n\n');
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
const checklistConflict = !quarantined && item.kind === 'issue-content' &&
item.status === 'attention' && typeof item.baseBody === 'string';
return {
id: 'stackchain.authored-outbox.v1:' + item.id,
outbox_id: item.id,
@@ -162,7 +164,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
label: checklistConflict ? 'Checklist conflict' : item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
@@ -172,6 +174,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: copyText,
checklist_conflict: checklistConflict,
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
@@ -237,7 +240,14 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
}
function partition(items = list()) {
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
const isDelivery = item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
const needsAction = item => isDelivery(item) && (
item.status === 'attention' || item.status === 'authorization' || item.status === 'completion' ||
item.delivery_state === 'uncertain' || item.quarantined
);
const deliveries = items.filter(isDelivery).sort((left, right) =>
Number(needsAction(right)) - Number(needsAction(left))
);
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
const counts = deliveries.reduce((summary, item) => {
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
@@ -248,7 +258,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
const retryable = deliveries.filter(item =>
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
);
return { drafts, deliveries, counts, retryable };
return { drafts, deliveries, counts, retryable, actionable:deliveries.filter(needsAction).length };
}
function deliveryLabel(item) {
+292
View File
@@ -0,0 +1,292 @@
(function (root, factory) {
const exports = factory();
if (typeof module === 'object' && module.exports) {
module.exports = exports.createFollowing;
module.exports.attachFollowing = exports.attachFollowing;
module.exports.planningDisposition = exports.planningDisposition;
} else {
root.createFollowing = exports.createFollowing;
root.attachFollowing = exports.attachFollowing;
root.followingPlanningDisposition = exports.planningDisposition;
}
})(typeof self !== 'undefined' ? self : this, function () {
function planningDisposition(detail, controls) {
const active = detail?.following === true && detail.kind === 'issue' && detail.state === 'open' &&
Boolean(detail.claimable || detail.assigned_to_me);
const result = active ? {active:true,today:'Add to Today & next',later:'Later & next',week:'Week Ahead & next'} : {active:false};
if (!controls && globalThis.document) {
const query = selector => globalThis.document.querySelector(selector);
controls = {root:query('.search-preview-primary-actions'),claim:query('#claim-search-result'),
today:query('#queue-search-result'),later:query('#defer-search-result'),week:query('#plan-search-result'),
start:query('#start-search-result'),watch:query('#watch-search-result')};
}
controls?.root?.classList.toggle('following-disposition-mode', active);
if (active && controls) {
controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true;
controls.today.textContent = result.today;
controls.later.textContent = result.later;
controls.week.textContent = result.week;
}
return result;
}
function createFollowing(options) {
let generation = 0;
let snapshot = {revision:0, items:[]};
let review = null;
function sameItem(left, right) {
return (left?.kind || 'issue') === (right?.kind || 'issue') &&
left?.repository === right?.repository && Number(left?.number) === Number(right?.number);
}
function publish(status, error) {
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
state.degraded = snapshot.degraded === true;
state.refreshFailures = Number(snapshot.refreshFailures) || 0;
if (review?.acknowledged.size) state.reviewSummary = {
reviewed:review.acknowledged.size,
remaining:snapshot.items.filter(item => item.has_unseen_change === true).length,
};
if (error) state.error = error;
options.render?.(state);
if (status === 'ready') options.onCount?.(
snapshot.items.filter(item => item.has_unseen_change === true).length);
return state;
}
async function load() {
const requestGeneration = ++generation;
publish('loading');
try {
const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}});
if (requestGeneration !== generation) return snapshot;
snapshot = {
revision:Number(result?.revision) || 0,
items:Array.isArray(result?.items) ? result.items.slice(0, 50)
.map(item => ({...item, kind:item.kind === 'pull' ? 'pull' : 'issue'})) : [],
degraded:result?.degraded === true,
refreshFailures:Number(result?.refresh_failures) || 0,
};
publish('ready');
return snapshot;
} catch (error) {
if (requestGeneration === generation) publish('error', error);
throw error;
}
}
async function acknowledge(item) {
const current = snapshot.items.find(candidate => sameItem(candidate, item) &&
candidate.updated_at === item?.updated_at);
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
await options.onAcknowledge(current);
current.has_unseen_change = false;
review?.acknowledged.add(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at);
publish('ready');
return true;
}
async function keepForLater(item) {
const exact = candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at;
const current = snapshot.items.find(exact);
const index = review?.active ? review.items.findIndex(exact) : -1;
if (!current || index < 0 || typeof options.onKeep !== 'function') return null;
await options.onKeep(current);
current.has_unseen_change = true;
review.acknowledged.delete(current.kind + ':' + current.repository + '#' +
current.number + '@' + current.updated_at);
review.items.splice(index, 1);
const next = review.items[index] || null;
if (!next) review.active = false;
publish('ready');
if (next) await open(snapshot.items.findIndex(candidate => sameItem(candidate, next)));
return next;
}
async function open(index) {
const item = snapshot.items[Number(index)];
if (!item) return false;
await options.onOpen?.({...item, following:true});
await acknowledge(item);
return true;
}
async function startReview() {
const items = snapshot.items
.filter(item => item.has_unseen_change === true)
.map(item => ({...item, following:true}));
if (!items.length) return false;
review = {items, more:false, active:true, acknowledged:new Set()};
await open(snapshot.items.indexOf(snapshot.items.find(item => sameItem(item, items[0]))));
return true;
}
function finishReview() {
const completed = Boolean(review?.completed || (review?.active &&
!snapshot.items.some(item => item.has_unseen_change === true)));
if (review) review.active = false;
if (completed && !review.completed) {
review.completed = true;
options.onReviewComplete?.();
}
publish('ready');
return completed;
}
function retire(item) {
const same = candidate => sameItem(candidate, item);
const index = review?.active ? review.items.findIndex(same) : -1;
snapshot.items = snapshot.items.filter(candidate => !same(candidate));
if (index < 0) {
publish('ready');
return null;
}
review.items.splice(index, 1);
const next = review.items[index] || null;
if (!next) {
review.active = false;
if (!review.completed) {
review.completed = true;
options.onReviewComplete?.();
}
}
publish('ready');
return next ? {...next} : null;
}
return {
load, open, startReview, previewLoaded:acknowledge, keepForLater, finishReview, retire,
session:() => review?.active ? {items:[...review.items], more:false} : null,
items:() => snapshot.items.map(item => ({...item})),
count:() => snapshot.items.length,
};
}
function attachFollowing(onOpen, hooks = {}) {
const document = globalThis.document;
const query = selector => document.querySelector(selector);
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[character]);
const formatTime = value => new Date(value).toLocaleString();
const fetchJson = async (url, options) => {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
return payload;
};
const changeRevision = (item, action) => {
const [owner, repo] = item.repository.split('/');
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
encodeURIComponent(repo) + '/issues/' + item.number + '/' + action +
'?kind=' + encodeURIComponent(item.kind), {
method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
body:JSON.stringify({updated_at:item.updated_at}),
});
};
let feature;
let activeReviewItem = null;
const show = () => query('#following-sheet').open || query('#following-sheet').showModal();
function render(state) {
hooks.onStatus?.(state.status);
const list = query('#following-list');
const status = query('#following-status');
const reviewButton = query('#review-following');
query('#retry-following').hidden = state.status !== 'error';
if (state.status === 'loading') return void (status.textContent = 'Loading watched items…');
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
const unseen = state.items.filter(item => item.has_unseen_change === true).length;
reviewButton.hidden = unseen === 0;
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
status.textContent = (state.reviewSummary
? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
: '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length
? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.')
: 'No watched items yet. Watch an issue or pull request from Search to keep it here.');
list.innerHTML = state.items.map((item, index) =>
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
'" type="button" data-following-index="' + index + '"><span>' +
(item.has_unseen_change ? '<em>' + escapeHtml(item.change_summary || 'New activity') + '</em>' : '') + '<strong>' +
escapeHtml(item.title) + '</strong><small>' + escapeHtml(
(item.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + item.repository + ' #' + item.number +
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
'</small></span><span aria-hidden="true">›</span></button>').join('');
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
query('#following-sheet').close();
feature.open(Number(button.dataset.followingIndex)).catch(() => {});
}));
}
feature = createFollowing({
fetchJson, render,
onCount:count => {
const value = query('[data-mobile-queue-count="following"]');
value.textContent = count;
value.closest('button').setAttribute('aria-label', 'Following, ' + count +
(count === 1 ? ' unseen change' : ' unseen changes'));
hooks.onCount?.(count, feature.items());
},
onOpen:item => {
activeReviewItem = item;
return onOpen(item);
},
onReviewComplete:hooks.onReviewComplete,
onAcknowledge:item => changeRevision(item, 'seen'),
onKeep:item => changeRevision(item, 'keep'),
});
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
query('#review-following').addEventListener('click', () => {
query('#following-sheet').close();
feature.startReview().catch(() => {
show();
});
});
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
const keepButton = query('#keep-following-for-later');
keepButton.addEventListener('click', async () => {
const status = query('#keep-following-status');
keepButton.disabled = true;
status.textContent = 'Keeping this change for later…';
try {
if (!await feature.keepForLater(activeReviewItem)) query('#close-search-preview').click();
status.textContent = 'Kept for later.';
} catch (_) {
status.textContent = 'Could not keep this change for later. Retry when ready.';
keepButton.disabled = false;
}
});
return {
load:feature.load,
review:feature.startReview,
async route() {
await feature.load().then(feature.startReview).catch(() => false) || show();
},
open() {
show();
feature.load().catch(() => {});
return 'opened-following';
},
session:feature.session,
previewLoaded:feature.previewLoaded,
keepForLater:feature.keepForLater,
retire:feature.retire,
preview(state) {
const disposition = planningDisposition(state?.detail || state?.item);
keepButton.hidden = state?.item?.following !== true || disposition.active;
keepButton.disabled = false;
if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = '';
},
returnToFollowing() {
const completed = feature.finishReview();
if (completed) return 'completed-following';
show();
globalThis.requestAnimationFrame?.(() => {
const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following');
target?.focus();
});
return 'returned-following';
},
};
}
return {createFollowing, attachFollowing, planningDisposition};
});
+510
View File
@@ -0,0 +1,510 @@
function createHumanGates(options = {}) {
const storage = options.storage || window.localStorage;
const getLogin = options.getLogin || (() => '');
const getAccountKey = options.getAccountKey || getLogin;
const isOnline = options.isOnline || (() => navigator.onLine);
const location = options.location || window.location;
const fetchJson = options.fetchJson;
const nodes = options.nodes || {};
let queue = { pending_count: 0, items: [] };
let reviewSnapshot = [];
let reviewIndex = -1;
let loadedAccountKey = '';
let loadEpoch = 0;
const decisionKeys = new Map();
let decisionFlight = null;
let openFlight = null;
let onChange = options.onChange;
let historyItems = [];
let historyNextCursor = null;
let historyLoadedMore = false;
let historyLoadError = '';
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[character]);
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
const progressKey = item => 'stackchain.human-gate-review.v1:' +
String(getAccountKey() || '').trim().toLowerCase() + ':' + item.id + ':' + item.revision;
const decisionStorageKey = () => 'stackchain.human-gate-decision.v1:' +
String(getAccountKey() || '').trim().toLowerCase();
const setText = (node, value) => { if (node) node.textContent = value; };
const setHtml = (node, value) => { if (node) node.innerHTML = value; };
const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state);
function validSnapshot(value) {
return value && Number.isInteger(value.pending_count) && Array.isArray(value.items) ? value : null;
}
function restore() {
if (!getLogin()) return null;
try { return validSnapshot(JSON.parse(storage.getItem(cacheKey()) || 'null')); } catch (_) { return null; }
}
function save(value) {
if (!getLogin()) return;
try { storage.setItem(cacheKey(), JSON.stringify(value)); } catch (_) {}
}
function restoreProgress(item) {
if (!getLogin() || !item) return null;
try {
const value = JSON.parse(storage.getItem(progressKey(item)) || 'null');
if (!value || value.gate_id !== item.id || value.revision !== item.revision) return null;
return value;
} catch (_) { return null; }
}
function saveProgress(values = {}) {
const item = current();
if (!getLogin() || !item) return false;
const checklist = values.checklist || {};
const progress = {
gate_id:item.id, revision:item.revision,
checklist:Object.fromEntries(['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].map(key => [key, checklist[key] === true])),
reason:String(values.reason || ''), override_reason:String(values.override_reason || ''),
};
try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; }
}
function restorePendingDecision(item = null) {
if (!getLogin()) return null;
try {
const value = JSON.parse(storage.getItem(decisionStorageKey()) || 'null');
if (!value || typeof value !== 'object' || !value.idempotency_key || !value.operation ||
!value.payload || !value.gate_id || !Number.isInteger(value.revision)) return null;
if (item && (value.gate_id !== item.id || value.revision !== item.revision)) return null;
return value;
} catch (_) { return null; }
}
function savePendingDecision(value) {
storage.setItem(decisionStorageKey(), JSON.stringify(value));
}
function clearPendingDecision(item) {
if (!restorePendingDecision(item)) return;
try { storage.removeItem?.(decisionStorageKey()); } catch (_) {}
}
function valuesFromDetail() {
const checklist = Object.fromEntries(Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []).map(input => [input.dataset.gateChecklist, input.checked]));
return {
checklist,
reason:nodes.detail?.querySelector?.('[data-gate-reason]')?.value || '',
override_reason:nodes.detail?.querySelector?.('[data-gate-override]')?.value || '',
};
}
function updateReadiness(values = valuesFromDetail()) {
const completed = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
.filter(key => values.checklist?.[key] === true).length;
setText(nodes.detail?.querySelector?.('[data-gate-readiness]'), completed + ' of 3 confirmations complete');
}
function captureProgress() {
if (!nodes.detail?.querySelectorAll) return false;
const values = valuesFromDetail();
updateReadiness(values);
return saveProgress(values);
}
nodes.detail?.addEventListener?.('input', captureProgress);
nodes.detail?.addEventListener?.('change', captureProgress);
function render() {
setText(nodes.count, String(queue.pending_count));
if (!queue.pending_count) {
setHtml(nodes.list, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>No release candidates need your decision.</span></div>');
setText(nodes.status, 'Human Gates inbox zero.');
return;
}
setText(nodes.status, queue.pending_count + (queue.pending_count === 1 ? ' gate pending.' : ' gates pending.'));
setHtml(nodes.list, queue.items.map(item =>
'<button class="human-gate-card" type="button" data-human-gate-id="' + escape(item.id) + '">' +
'<strong>' + escape(item.title) + '</strong><code>' + escape(item.candidate_hash) + '</code>' +
'<span>Priority ' + escape(item.priority ?? 0) + '</span></button>'
).join(''));
}
function setView(view) {
nodes.pendingTab?.setAttribute?.('aria-pressed', view === 'pending' ? 'true' : 'false');
nodes.historyTab?.setAttribute?.('aria-pressed', view === 'history' ? 'true' : 'false');
}
function renderHistory(preserveDetail = false) {
setView('history');
if (historyNextCursor) {
setText(nodes.status, historyItems.length + ' Human Gate decisions loaded. Older decisions are available.');
} else if (historyLoadedMore) {
setText(nodes.status, 'All ' + historyItems.length + ' Human Gate decisions loaded.');
} else {
setText(nodes.status, historyItems.length + (historyItems.length === 1 ? ' past Human Gate decision.' : ' past Human Gate decisions.'));
}
if (!historyItems.length) {
setHtml(nodes.list, '<div class="human-gates-zero"><strong>No decision history</strong><span>Released and held candidates will appear here.</span></div>');
setHtml(nodes.detail, '');
return;
}
setHtml(nodes.list, historyItems.map(item =>
'<button class="human-gate-card human-gate-history-card" type="button" data-human-gate-history-id="' + escape(item.id) + '">' +
'<strong>' + escape(item.title) + '</strong><span class="human-gate-state human-gate-state-' + escape(item.state) + '">' +
escape(item.state.charAt(0).toUpperCase() + item.state.slice(1)) + '</span>' +
'<code>' + escape(item.candidate_hash) + '</code><time>' + escape(new Date(Number(item.updated_at) * 1000).toLocaleString()) + '</time></button>'
).join('') + (historyNextCursor ? '<button class="human-gate-history-more" type="button" data-human-gate-history-more>' +
(historyLoadError ? 'Retry older decisions' : 'Load older decisions') + '</button>' : ''));
Array.from(nodes.list?.querySelectorAll?.('[data-human-gate-history-id]') || []).forEach(card => {
card.addEventListener('click', () => selectHistory(card.dataset.humanGateHistoryId).catch(error => {
setText(nodes.status, error.message || 'Human Gate history is unavailable.');
}));
});
nodes.list?.querySelector?.('[data-human-gate-history-more]')?.addEventListener?.('click', () => {
loadMoreHistory().catch(error => setText(nodes.status, error.message || 'Older Human Gate decisions are unavailable.'));
});
if (!preserveDetail) setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Decision history</strong><span>Open a candidate to review its durable receipt.</span></div>');
}
async function showHistory() {
if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const result = validSnapshot(await fetchJson('api/v1/human-gates?state=history&limit=20'));
if (!result) throw new Error('Human Gate history response is invalid.');
historyItems = result.items;
historyNextCursor = result.next_cursor || null;
historyLoadedMore = false;
historyLoadError = '';
renderHistory();
return JSON.parse(JSON.stringify(historyItems));
}
async function loadMoreHistory() {
if (!historyNextCursor) return JSON.parse(JSON.stringify(historyItems));
if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
const cursor = historyNextCursor;
let result;
try {
result = validSnapshot(await fetchJson(
'api/v1/human-gates?state=history&limit=20&cursor=' + encodeURIComponent(cursor)
));
if (!result) throw new Error('Human Gate history response is invalid.');
} catch (error) {
historyLoadError = error?.message || 'Older Human Gate decisions are unavailable.';
renderHistory(true);
setText(nodes.status, historyLoadError + ' Loaded decisions are still available.');
throw error;
}
const known = new Set(historyItems.map(item => item.id));
historyItems.push(...result.items.filter(item => !known.has(item.id)));
historyNextCursor = result.next_cursor || null;
historyLoadedMore = true;
historyLoadError = '';
renderHistory(true);
return JSON.parse(JSON.stringify(historyItems));
}
async function selectHistory(gateId) {
const summary = historyItems.find(item => item.id === gateId);
if (!summary) throw new Error('Gate is not in the current history.');
const detail = await fetchJson('api/v1/human-gates/' + encodeURIComponent(gateId));
if (!detail || detail.id !== gateId) throw new Error('Gate history detail is invalid.');
let receipt = null;
if (detail.receipt_id) receipt = await fetchJson('api/v1/human-gate-receipts/' + encodeURIComponent(detail.receipt_id));
const checklist = receipt?.checklist || {};
const confirmations = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
.filter(key => checklist[key] === true).map(key => '<li>' + escape(key.replaceAll('_', ' ')) + '</li>').join('');
setHtml(nodes.detail,
'<article class="human-gate-detail human-gate-receipt"><p class="small">' + escape(detail.state.toUpperCase()) + '</p>' +
'<h3>' + escape(detail.title) + '</h3><p>Project <strong>' + escape(detail.project) + '</strong></p>' +
'<p>Exact candidate <code>' + escape(detail.candidate_hash) + '</code></p>' +
(receipt ? '<p>Decision receipt <code>' + escape(receipt.receipt_id) + '</code></p>' +
'<p>Decided ' + escape(new Date(Number(receipt.decided_at) * 1000).toLocaleString()) + '</p>' +
(receipt.reason ? '<h4>Reason</h4><p>' + escape(receipt.reason) + '</p>' : '') +
(receipt.override_reason ? '<h4>Override</h4><p>' + escape(receipt.override_reason) + '</p>' : '') +
(confirmations ? '<h4>Confirmed</h4><ul>' + confirmations + '</ul>' : '') :
'<p>No decision receipt exists because this candidate was superseded.</p>') + '</article>');
return {detail, receipt};
}
function showPending() {
setView('pending');
render();
renderDetail(current());
return JSON.parse(JSON.stringify(queue));
}
function renderDetail(item) {
if (!item) {
setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>Fixed review snapshot complete.</span></div>');
return;
}
const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''};
const pendingDecision = restorePendingDecision(item);
const checked = key => progress.checklist?.[key] === true ? ' checked' : '';
const checks = (item.checks || []).map(check =>
'<li class="gate-check gate-check-' + escape(check.state) + '"><strong>' + escape(check.name) + '</strong> · ' + escape(check.state) + (check.required ? ' · required' : '') + '</li>'
).join('');
const artifacts = (item.artifacts || []).map(artifact => '<li><a href="' + escape(artifact.url) + '" target="_blank" rel="noreferrer noopener">' + escape(artifact.name) + '</a></li>').join('');
const links = (item.links || []).map(link => '<li><a href="' + escape(link.url) + '" target="_blank" rel="noreferrer noopener">' + escape(link.label) + '</a></li>').join('');
const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '<li><strong>' + escape(key) + '</strong> · ' + escape(value) + '</li>').join('');
const history = (item.history || []).map(event => '<li><strong>' + escape(event.action) + '</strong> · ' + escape(event.at) + '</li>').join('');
setHtml(nodes.detail,
'<article class="human-gate-detail"><h3>' + escape(item.title) + '</h3>' +
'<p>Project <strong>' + escape(item.project) + '</strong></p>' +
'<p>Exact candidate <code>' + escape(item.candidate_hash) + '</code></p>' +
'<p>Score ' + escape(item.score?.value ?? 'not supplied') + ' · ' + escape(item.score?.provenance || '') + '</p>' +
'<h4>Artifacts</h4><ul>' + artifacts + '</ul><h4>Links</h4><ul>' + links + '</ul>' +
'<h4>Checks</h4><ul>' + checks + '</ul><h4>Provenance</h4><ul>' + provenance + '</ul>' +
'<h4>History</h4><ul>' + history + '</ul>' +
'<label><input type="checkbox" data-gate-checklist="exact_hash"' + checked('exact_hash') + '> Exact hash reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="artifacts_reviewed"' + checked('artifacts_reviewed') + '> Artifacts reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"' + checked('provenance_reviewed') + '> Provenance reviewed</label>' +
'<label>Hold reason<textarea data-gate-reason>' + escape(progress.reason) + '</textarea></label>' +
'<label>Override reason<textarea data-gate-override>' + escape(progress.override_reason) + '</textarea></label>' +
'<div class="human-gate-decision-tray' + (pendingDecision ? ' human-gate-decision-recovery' : '') + '"><div class="human-gate-decision-state">' +
(pendingDecision ? '<strong>Decision outcome unknown</strong><span role="status">The previous ' + escape(pendingDecision.decision) +
' response was interrupted. Verify it before making another decision.</span>' :
'<strong data-gate-readiness>0 of 3 confirmations complete</strong><span data-gate-error role="alert" hidden></span>') + '</div>' +
'<div class="human-gate-decision-actions">' + (pendingDecision ?
'<button type="button" data-gate-recover>Verify decision</button>' :
'<button type="button" data-gate-decision="hold">Hold &amp; next</button><button type="button" data-gate-decision="release">Release &amp; next</button>') +
'</div></div></article>'
);
updateReadiness();
}
async function load() {
const epoch = ++loadEpoch;
const accountKey = String(getAccountKey() || '').trim().toLowerCase();
if (accountKey !== loadedAccountKey) {
loadedAccountKey = accountKey;
queue = { pending_count: 0, items: [] };
reviewSnapshot = [];
reviewIndex = -1;
render();
}
const cached = restore();
if (cached) {
queue = cached;
render();
publish({available:true, authoritative:false, cached:true});
}
try {
const live = validSnapshot(await fetchJson('api/v1/human-gates'));
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
if (!live) throw new Error('Human Gates response is invalid.');
queue = { pending_count: live.pending_count, items: live.items.slice() };
const interrupted = restorePendingDecision();
if (interrupted && interrupted.item?.id === interrupted.gate_id && interrupted.item.revision === interrupted.revision) {
const recoveryItem = {...interrupted.item, decision_recovery:true};
const existingIndex = queue.items.findIndex(candidate => candidate.id === interrupted.gate_id);
if (existingIndex >= 0) queue.items[existingIndex] = recoveryItem;
else {
queue.items.unshift(recoveryItem);
queue.pending_count += 1;
}
}
save(queue);
render();
publish({available:true, authoritative:true});
return queue;
} catch (error) {
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
if (!cached) {
publish({available:false, authoritative:false});
throw error;
}
setText(nodes.status, 'Offline cached gate list · reconnect before deciding.');
return queue;
}
}
function reviewNext() {
if (reviewIndex < 0) {
reviewSnapshot = queue.items.slice();
reviewIndex = 0;
}
const item = reviewSnapshot[reviewIndex] || null;
renderDetail(item);
if (item) {
const index = reviewIndex;
fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id)).then(detail => {
if (!detail || detail.id !== item.id) throw new Error('Gate detail is invalid.');
if (reviewIndex !== index || reviewSnapshot[index]?.id !== item.id) return;
reviewSnapshot[index] = detail;
renderDetail(detail);
}).catch(() => { setText(nodes.status, 'Gate detail is unavailable. Retry while online.'); });
}
return item;
}
function select(gateId) {
if (reviewIndex < 0) reviewSnapshot = queue.items.slice();
const index = reviewSnapshot.findIndex(item => item.id === gateId);
if (index < 0) throw new Error('Gate is not in the current review snapshot.');
reviewIndex = index;
return reviewNext();
}
function current() { return reviewIndex < 0 ? null : (reviewSnapshot[reviewIndex] || null); }
function idempotencyKey(item, decision, payload) {
const operation = item.id + ':' + item.revision + ':' + decision + ':' + JSON.stringify(payload);
const pending = restorePendingDecision(item);
if (pending?.operation === operation) return { operation, key: pending.idempotency_key };
if (decisionKeys.has(operation)) return { operation, key: decisionKeys.get(operation) };
const nonce = globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2));
const key = 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce;
decisionKeys.set(operation, key);
return { operation, key };
}
function completeDecision(item, decision, operation, receipt) {
decisionKeys.delete(operation);
clearPendingDecision(item);
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
queue.pending_count = Math.max(0, queue.pending_count - 1);
save(queue); render();
publish({available:true, authoritative:true, decision:true});
reviewIndex += 1;
const next = current();
if (next) reviewNext(); else renderDetail(null);
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
return { receipt, next };
}
async function postDecision(item, pending) {
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pending.idempotency_key },
body: JSON.stringify(pending.payload),
});
return completeDecision(item, pending.decision, pending.operation, receipt);
}
function decisionError(message, selector) {
const error = new Error(message);
error.targetSelector = selector;
return error;
}
async function decideAndNext(decision, values = {}) {
const item = current();
if (!item) throw new Error('No gate is selected.');
if (!isOnline()) throw new Error('Human Gate decisions require an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const checklist = values.checklist || {};
const complete = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].every(key => checklist[key] === true);
if (decision === 'release' && !complete) {
const firstMissing = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].find(key => checklist[key] !== true);
throw decisionError('Complete the release checklist before deciding.', '[data-gate-checklist="' + firstMissing + '"]');
}
const unmet = (item.checks || []).filter(check => check.required && check.state !== 'success');
if (decision === 'release' && unmet.length && !String(values.override_reason || '').trim()) {
const names = unmet.map(check => String(check.name || 'Unnamed check')).join(', ');
throw decisionError('An explicit override reason is required for unmet required checks: ' + names + '.', '[data-gate-override]');
}
if (decision === 'hold' && !String(values.reason || '').trim()) {
throw decisionError('A hold reason is required.', '[data-gate-reason]');
}
const payload = {
expected_revision: item.revision, decision,
reason: String(values.reason || '').trim(),
override_reason: String(values.override_reason || '').trim(), checklist,
};
if (decisionFlight) return decisionFlight;
const operation = (async () => {
const decisionKey = idempotencyKey(item, decision, payload);
const pending = {
gate_id:item.id, revision:item.revision, decision, payload,
operation:decisionKey.operation, idempotency_key:decisionKey.key,
item:JSON.parse(JSON.stringify(item)),
};
savePendingDecision(pending);
try {
return await postDecision(item, pending);
} catch (error) {
renderDetail(item);
setText(nodes.status, 'Decision outcome unknown. Verify the interrupted decision while online.');
throw error;
}
})();
decisionFlight = operation;
try {
return await operation;
} finally {
if (decisionFlight === operation) decisionFlight = null;
}
}
async function recoverDecision() {
const item = current();
if (!item) throw new Error('No gate is selected.');
if (!isOnline()) throw new Error('Decision verification requires an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const pending = restorePendingDecision(item);
if (!pending) throw new Error('No interrupted decision exists for this gate revision.');
if (decisionFlight) return decisionFlight;
const operation = postDecision(item, pending);
decisionFlight = operation;
try { return await operation; }
finally { if (decisionFlight === operation) decisionFlight = null; }
}
async function submitDecision(decision) {
const values = valuesFromDetail();
updateReadiness(values);
const errorNode = nodes.detail?.querySelector?.('[data-gate-error]');
if (errorNode) { errorNode.textContent = ''; errorNode.hidden = true; }
const buttons = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-decision]') || []);
buttons.forEach(button => { button.disabled = true; });
try {
return await decideAndNext(decision, values);
} catch (error) {
if (errorNode) {
errorNode.textContent = error?.message || 'The decision could not be saved.';
errorNode.hidden = false;
}
let target = error?.targetSelector && nodes.detail?.querySelector?.(error.targetSelector);
if (!target && error?.targetSelector?.startsWith('[data-gate-checklist=')) {
const key = error.targetSelector.match(/"([^"]+)"/)?.[1];
target = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || [])
.find(input => input.dataset.gateChecklist === key);
}
target?.scrollIntoView?.({block:'center', behavior:'smooth'});
target?.focus?.({preventScroll:true});
throw error;
} finally {
buttons.forEach(button => { button.disabled = false; });
}
}
function open() {
location.hash = '#/my-work/human-gates';
if (nodes.panel) nodes.panel.hidden = false;
if (openFlight) return openFlight;
const operation = (async () => {
setView('pending');
await load();
reviewSnapshot = queue.items.slice();
reviewIndex = 0;
return reviewNext();
})();
openFlight = operation;
const clearFlight = () => {
if (openFlight === operation) openFlight = null;
};
operation.then(clearFlight, clearFlight);
return operation;
}
return {
load, open, reviewNext, select, showHistory, loadMoreHistory, selectHistory, showPending,
decideAndNext, recoverDecision, submitDecision, current, saveProgress,
setOnChange(callback) { onChange = callback; },
restoreCached: restore,
snapshot: () => JSON.parse(JSON.stringify(queue)),
route: () => location.hash,
};
}
if (typeof module !== 'undefined') module.exports = createHumanGates;
if (typeof window !== 'undefined') window.createHumanGates = createHumanGates;
+1235 -118
View File
File diff suppressed because it is too large. Load diff
+55 -20
View File
@@ -41,6 +41,7 @@
const maxFiles = options.maxFiles === MAX_FILES ? MAX_FILES : 1;
const inspectPixels = options.inspectPixels === true;
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
const onCheckpoint = options.onCheckpoint;
const createOperationId = options.createOperationId || (() => {
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
@@ -49,6 +50,13 @@
});
let selected = [];
let selectionGeneration = 0;
let restoring = false;
function changed() {
if (!restoring && typeof onCheckpoint === 'function') {
Promise.resolve().then(onCheckpoint).catch(() => { /* Caller surfaces durable-storage failures. */ });
}
}
function commitSelection(file) {
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
@@ -60,6 +68,7 @@
else throw new Error(MAX_FILES_MESSAGE);
}
selected.push({file, note:'', confirmed:null, serialized:null, operationId:createOperationId()});
changed();
return state();
}
@@ -93,6 +102,7 @@
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
selectionGeneration += 1;
selected.splice(position, 1);
changed();
return state();
}
@@ -103,6 +113,7 @@
from >= selected.length || to < 0 || to >= selected.length || from === to) return state();
const [item] = selected.splice(from, 1);
selected.splice(to, 0, item);
changed();
return state();
}
@@ -117,6 +128,7 @@
selected[position] = {
...selected[position], file, confirmed:null, serialized:null, operationId:createOperationId(),
};
changed();
return state();
}
@@ -125,6 +137,7 @@
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
selected[position].note = normalizeNote(value);
selected[position].serialized = null;
changed();
return state();
}
@@ -134,15 +147,18 @@
}
function restore(value) {
if (Array.isArray(value)) {
restoring = true;
try {
if (Array.isArray(value)) {
clear();
if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE);
value.forEach(attachment => restoreOne(attachment));
return state();
}
clear();
if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE);
value.forEach(attachment => restoreOne(attachment));
restoreOne(value);
return state();
}
clear();
restoreOne(value);
return state();
} finally { restoring = false; }
}
function restoreOne(value) {
@@ -162,6 +178,10 @@
...(blob ? { filename, contentType, blob } : { filename, contentType, data }),
...(item.note ? {note:item.note} : {}),
};
const operationId = String(value?.operationId || '').slice(0, 128);
if (operationId) item.operationId = operationId;
const markdown = String(value?.confirmed?.markdown || '');
if (markdown) item.confirmed = { markdown };
}
function state() {
@@ -178,7 +198,11 @@
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
...(item.note ? {note:item.note} : {}),
};
return { ...item.serialized };
return {
...item.serialized,
operationId:item.operationId,
...(item.confirmed?.markdown ? { confirmed:{ markdown:item.confirmed.markdown } } : {}),
};
}
async function serialize() {
@@ -206,6 +230,7 @@
evidence.confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
}
await onCheckpoint?.();
}
if (evidence.note) {
markdown.push('**Screenshot ' + (markdown.length + 1) + ' — ' + escapeMarkdown(evidence.note) + '**\n\n' +
@@ -220,6 +245,8 @@
}
function mount(options) {
const inputs = Array.from(new Set((options.inputs || [options.input]).filter(Boolean)));
if (!inputs.includes(options.input)) inputs.push(options.input);
const controller = create({
...options, inspectPixels:true,
maxFiles:options.maxFiles || (options.input?.multiple ? MAX_FILES : 1),
@@ -234,7 +261,7 @@
}) : null;
function setBusy(value) {
options.input.disabled = Boolean(value);
inputs.forEach(input => { input.disabled = Boolean(value); });
options.remove.disabled = Boolean(value);
if (options.editor?.edit) options.editor.edit.disabled = Boolean(value) || !controller.state();
review?.setBusy(value);
@@ -248,9 +275,12 @@
if (options.editor?.edit) options.editor.edit.disabled = true;
options.image.src = '';
options.preview.hidden = true;
options.input.value = '';
options.input.disabled = false;
inputs.forEach(input => {
input.value = '';
input.disabled = false;
});
clearSelection();
options.onChange?.(controller.state());
}
function showPreview(file, optimized) {
@@ -264,15 +294,16 @@
(options.readyMessage || 'Screenshot ready to upload with this comment.');
}
options.input.addEventListener('change', event => {
const files = Array.from(event.target.files || []);
function selectFromInput(event) {
const sourceInput = event.target;
const files = Array.from(sourceInput.files || []);
const sequence = ++selectionSequence;
if (!files.length) return;
let first;
try { first = controller.select(files[0]); }
catch (error) {
options.status.textContent = error.message;
options.input.value = '';
sourceInput.value = '';
return;
}
if (!reviewEnabled && files.length === 1 && (!first || typeof first.then !== 'function')) {
@@ -282,14 +313,14 @@
let optimized = Boolean(first && typeof first.then === 'function');
if (optimized) {
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
inputs.forEach(input => { input.disabled = true; });
}
const selectAll = files.slice(1).reduce((pending, file) => pending.then(async () => {
const result = controller.select(file);
if (result && typeof result.then === 'function') {
optimized = true;
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
inputs.forEach(input => { input.disabled = true; });
await result;
}
}), Promise.resolve(first));
@@ -306,15 +337,17 @@
options.status.textContent = values.length + ' screenshots ready to file in this order.';
}
}
options.onChange?.(controller.state());
}).catch(error => {
if (sequence === selectionSequence) {
options.status.textContent = error.message;
options.input.value = '';
sourceInput.value = '';
}
}).finally(() => {
if (sequence === selectionSequence) options.input.disabled = false;
if (sequence === selectionSequence) inputs.forEach(input => { input.disabled = false; });
});
});
}
inputs.forEach(input => input.addEventListener('change', selectFromInput));
options.remove.addEventListener('click', () => {
const current = controller.state();
const count = Array.isArray(current) ? current.length : (current ? 1 : 0);
@@ -337,6 +370,7 @@
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
}
options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.';
options.onChange?.(controller.state());
});
});
@@ -371,7 +405,8 @@
onApplied: async index => {
const value = await controller.serialize();
review.render(value, index);
options.status.textContent = 'Edited screenshot flattened and ready to file.';
options.status.textContent = options.editor.appliedMessage ||
'Edited screenshot flattened and ready to file.';
},
});
options.editor.edit.disabled = !controller.state();
+1
View File
@@ -200,6 +200,7 @@
if (source && model) paint({source, model, canvas:options.canvas, maxDimension:1600});
}
function close() {
if (!source && !model) return;
options.dialog.hidden = true;
if (source && typeof source.close === 'function') source.close();
source = null;
-1
View File
@@ -31,7 +31,6 @@
const dimensionScale = Math.min(1, MAX_DIMENSION / Math.max(width, height));
const byteScale = file.size > MAX_BYTES ? Math.sqrt(MAX_BYTES / file.size) * 0.92 : 1;
let scale = Math.min(pixelScale, dimensionScale, byteScale);
if (scale === 1 && file.size <= MAX_BYTES) return file;
const canvas = makeCanvas();
const context = canvas && canvas.getContext && canvas.getContext('2d');
if (!context) throw new Error('decode');
+12
View File
@@ -76,6 +76,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80);
plan.capturedBody = '';
}
const promotion = value?.checklistPromotion;
if (promotion?.repository && promotion?.number && promotion?.url && promotion?.updatedAt) {
plan.checklistPromotion = {
repository:String(promotion.repository), number:Number(promotion.number), url:String(promotion.url),
title:String(promotion.title || ''), body:String(promotion.body || '').slice(0, 10000),
updatedAt:String(promotion.updatedAt), taskIndex:Number(promotion.taskIndex),
};
}
return plan;
}
@@ -134,6 +142,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (blockers) item.blockers = blockers;
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
if (relatedDraft) item.relatedDraft = relatedDraft;
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
@@ -496,6 +505,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const confirmed = [];
const completions = [];
const filings = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
@@ -505,6 +515,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (result.issue) {
confirmed.push(result.issue);
if (result.item?.relatedDraft) filings.push({issue:result.issue, relatedDraft:result.item.relatedDraft});
if (result.item?.completionIntent) completions.push({
id: result.item.id,
intent: result.item.completionIntent,
@@ -546,6 +557,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return {
confirmed: result.issue ? [result.issue] : [],
filings: result.issue && result.item?.relatedDraft ? [{issue:result.issue, relatedDraft:result.item.relatedDraft}] : [],
completions: result.issue && result.item?.completionIntent ? [{
id: result.item.id,
intent: result.item.completionIntent,
+296 -7
View File
@@ -24,7 +24,135 @@ function escapeOptionHtml(value) {
})[character]);
}
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function appendChecklistTask(body, label) {
const normalized = String(label || '').trim().replace(/\s+/g, ' ');
if (!normalized) throw new Error('Enter a checklist step.');
const key = normalized.toLocaleLowerCase();
const duplicate = String(body || '').split('\n').some(line => {
const match = line.match(/^\s*[-*+]\s+\[[ xX]\]\s+(.*)$/);
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
});
if (duplicate) throw new Error('That checklist step already exists.');
const prefix = body ? String(body).replace(/\s+$/, '') + '\n' : '';
return prefix + '- [ ] ' + normalized;
}
function manageChecklistTask(raw, targetIndex, operation = {}) {
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
let fenced = false;
let taskIndex = 0;
for (let index = 0; index < parts.length; index += 2) {
const line = parts[index];
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
if (fenced) continue;
const task = line.match(/^([-*+]\s+\[[ xX]\]\s+)(.*)$/);
if (!task) continue;
if (taskIndex === Number(targetIndex)) {
if (operation.action === 'rename') {
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
if (!label) throw new Error('Enter a checklist step.');
const key = label.toLocaleLowerCase();
let inFence = false;
const duplicate = parts.some((candidate, candidateIndex) => {
if (candidateIndex % 2) return false;
if (/^\s*```/.test(candidate)) { inFence = !inFence; return false; }
if (inFence || candidateIndex === index) return false;
const match = candidate.match(/^[-*+]\s+\[[ xX]\]\s+(.*)$/);
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
});
if (duplicate) throw new Error('That checklist step already exists.');
parts[index] = task[1] + label;
} else if (operation.action === 'remove') {
if (index + 1 < parts.length) parts.splice(index, 2);
else if (index > 0) parts.splice(index - 1, 2);
} else if (operation.action === 'move-earlier' && index >= 2 && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index - 2])) {
[parts[index - 2], parts[index]] = [parts[index], parts[index - 2]];
} else if (operation.action === 'move-later' && index + 2 < parts.length && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index + 2])) {
[parts[index], parts[index + 2]] = [parts[index + 2], parts[index]];
}
return parts.join('');
}
taskIndex += 1;
}
return parts.join('');
}
function checklistIsComplete(raw) {
const lines = String(raw || '').split(/(?:\r\n|\n|\r)/);
let fenced = false;
let found = false;
for (const line of lines) {
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
if (fenced) continue;
const task = line.match(/^\s*[-*+]\s+\[([ xX])\]\s+/);
if (!task) continue;
found = true;
if (task[1].toLocaleLowerCase() !== 'x') return false;
}
return found;
}
function completeDelegatedParentTask(raw, childUrl) {
const url = String(childUrl || '');
if (!/^https:\/\/[^\s)]+$/.test(url)) throw new Error('The delegated issue link is unavailable.');
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
let fenced = false;
let taskIndex = 0;
for (let index = 0; index < parts.length; index += 2) {
const line = parts[index];
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
if (fenced) continue;
const task = line.match(/^(\s*[-*+]\s+\[)([ xX])(\]\s+.*)$/);
if (!task) continue;
const linkedTask = task[3].match(/^\]\s+\[[^\]]+\]\((https:\/\/[^\s)]+)\)\s*$/);
if (linkedTask?.[1] === url) {
const alreadyCompleted = task[2].toLocaleLowerCase() === 'x';
if (!alreadyCompleted) parts[index] = task[1] + 'x' + task[3];
const body = parts.join('');
return { body, taskIndex, alreadyCompleted, checklistComplete:checklistIsComplete(body) };
}
taskIndex += 1;
}
throw new Error('The parent checklist no longer links to this delegated issue.');
}
async function finishDelegatedParentReview({ parentItem, parentDetail, childUrl, updateContent, closeParent }) {
const completion = completeDelegatedParentTask(parentDetail?.body, childUrl);
if (!completion.alreadyCompleted) {
await updateContent(parentItem, {
title:parentDetail.title,
body:completion.body,
expectedUpdatedAt:parentDetail.updated_at,
});
}
if (completion.checklistComplete) await closeParent(parentItem);
return { updated:!completion.alreadyCompleted, closed:completion.checklistComplete };
}
function delegatedParentReference(childDetail) {
const body = String(childDetail?.body || '');
const match = body.match(/(?:^|\n)Related to \[([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9]\d*)\]\((https:\/\/[^\s)]+)\)\.(?:\r?$|\s)/m);
if (!match) return null;
return { repository:match[1], number:Number(match[2]), url:match[3] };
}
function resolveDelegatedParent(child, childDetail, parentDetail) {
const parent = delegatedParentReference(childDetail);
if (!parent) return null;
try {
const completion = completeDelegatedParentTask(parentDetail?.body, child?.url);
return {
parent,
taskIndex:completion.taskIndex,
alreadyCompleted:completion.alreadyCompleted,
checklistComplete:completion.checklistComplete,
};
} catch (_error) {
return null;
}
}
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, relatedTaskDraft: relatedTaskDraftTransform = (...args) => globalThis.createIssueCapture?.relatedChecklistDraft(...args), linkRelatedTask: linkRelatedTaskTransform = (...args) => globalThis.createIssueCapture?.linkChecklistTask(...args), enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
@@ -45,6 +173,9 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
return {
delegatedParentReference,
resolveDelegatedParent,
completeDelegatedParentTask,
readOnly(item) {
return Boolean(item?.is_completed || (item?.is_filed && !item?.is_assigned));
},
@@ -152,6 +283,72 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
expectedUpdatedAt: detail.updated_at,
});
},
manageTask(item, detail, taskIndex, operation) {
if (typeof manageTaskTransform !== 'function') return Promise.reject(new Error('Checklist management is unavailable.'));
return this.updateContent(item, {
title: detail.title,
body: manageTaskTransform(detail.body, taskIndex, operation),
expectedUpdatedAt: detail.updated_at,
});
},
relatedTaskDraft(item, _detail, _taskIndex, label) {
if (typeof relatedTaskDraftTransform !== 'function') throw new Error('Related issue capture is unavailable.');
return relatedTaskDraftTransform(item, label);
},
linkRelatedTask(item, detail, taskIndex, child) {
if (typeof linkRelatedTaskTransform !== 'function') return Promise.reject(new Error('Related issue linking is unavailable.'));
return this.updateContent(item, {
title:detail.title,
body:linkRelatedTaskTransform(detail.body, taskIndex, child),
expectedUpdatedAt:detail.updated_at,
});
},
async addTask(item, detail, label) {
const body = appendChecklistTask(detail.body, label);
return this.updateContent(item, {
title: detail.title,
body,
expectedUpdatedAt: detail.updated_at,
});
},
async queueAddedTask(item, detail, label) {
if (typeof enqueueDurably !== 'function') {
throw new Error('Offline checklist updates are unavailable.');
}
const body = appendChecklistTask(detail.body, label);
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
},
async queueManagedTask(item, detail, taskIndex, operation) {
if (typeof manageTaskTransform !== 'function' || typeof enqueueDurably !== 'function') {
throw new Error('Offline checklist management is unavailable.');
}
const body = manageTaskTransform(detail.body, taskIndex, operation);
const checklistOperation = {
action:String(operation?.action || ''), index:Number(taskIndex),
...(operation?.label ? { label:String(operation.label) } : {}),
};
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at, checklistOperation });
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
},
async queueTask(item, detail, taskIndex, checked) {
if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') {
throw new Error('Offline checklist updates are unavailable.');
}
const body = toggleTask(detail.body, taskIndex, checked);
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
},
pendingTask(item, detail, items, ownerLogin) {
const pending = [...(items || [])].reverse().find(candidate => candidate?.kind === 'issue-content' &&
candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) &&
candidate.ownerLogin === ownerLogin && ['queued', 'sending', 'attention'].includes(candidate.status));
if (!pending) return { ...detail };
return { ...detail, title:pending.title, body:pending.body, checklist_pending:true };
},
bindTaskToggles({ container, status, retry, current, confirmed, restore }) {
container.addEventListener('change', async event => {
const control = event.target.closest('input.task-list-toggle');
@@ -161,13 +358,14 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
container.querySelectorAll('input.task-list-toggle').forEach(input => { input.disabled = true; });
status.textContent = 'Updating checklist…';
try {
const result = await this.toggleTask(
state.item, state.detail, Number(control.dataset.taskIndex), control.checked
const result = await (state.offline ? this.queueTask : this.toggleTask).call(
this, state.item, state.detail, Number(control.dataset.taskIndex), control.checked
);
const latest = current();
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
confirmed(state.item, state.detail, result);
status.textContent = 'Checklist updated.';
confirmed(state.item, state.detail, state.offline ? result.detail : result);
if (state.offline) status.textContent = 'Checklist queued. Pending sync.';
else status.textContent = 'Checklist updated.';
}
} catch (error) {
const latest = current();
@@ -179,12 +377,97 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
}
});
},
bindTaskManagement({ container, editor, label, earlier, later, fileRelated, onFileRelated, remove, cancel, status, sheetStatus, retry, current, confirmed }) {
let taskIndex = null;
let trigger = null;
const reset = (returnFocus = false) => {
editor.hidden = true;
status.textContent = '';
if (returnFocus && trigger?.isConnected) trigger.focus();
taskIndex = null;
trigger = null;
};
container.addEventListener('click', event => {
const control = event.target.closest('button.task-list-manage');
if (!control || !current()?.detail?.updated_at) return;
taskIndex = Number(control.dataset.taskIndex);
trigger = control;
label.value = control.dataset.taskLabel || '';
earlier.disabled = control.dataset.taskFirst === 'true';
later.disabled = control.dataset.taskLast === 'true';
fileRelated.disabled = /^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(label.value);
editor.hidden = false;
status.textContent = fileRelated.disabled ? 'This step already links to related work.' :
'Rename, reorder, remove, or file this step as related work.';
label.focus();
});
const run = async operation => {
const state = current();
const selectedIndex = taskIndex;
if (!state?.item || !state.detail?.updated_at || selectedIndex === null) return;
if (operation.action === 'remove' && !globalThis.confirm('Remove this checklist step?')) return;
const controls = editor.querySelectorAll('button,input');
controls.forEach(control => { control.disabled = true; });
status.textContent = state.offline ? 'Queueing checklist change…' : 'Updating checklist…';
try {
const result = await (state.offline ? this.queueManagedTask : this.manageTask).call(
this, state.item, state.detail, selectedIndex, operation
);
if (current()?.item !== state.item) return;
confirmed(state.item, state.detail, state.offline ? result.detail : result);
reset();
sheetStatus.textContent = state.offline ? 'Checklist change queued. Pending sync.' : 'Checklist step updated.';
const focusIndex = operation.action === 'remove' ? Math.max(0, selectedIndex - 1) : selectedIndex;
container.querySelector('button.task-list-manage[data-task-index="' + focusIndex + '"]')?.focus();
} catch (error) {
if (current()?.item === state.item) {
status.textContent = error.message + ' Your change is still here; retry.';
retry.hidden = false;
label.focus();
}
} finally {
if (!editor.hidden) controls.forEach(control => { control.disabled = false; });
}
};
editor.addEventListener('submit', event => { event.preventDefault(); run({ action:'rename', label:label.value }); });
earlier.addEventListener('click', () => run({ action:'move-earlier' }));
later.addEventListener('click', () => run({ action:'move-later' }));
fileRelated.addEventListener('click', async () => {
const state = current();
const selectedIndex = taskIndex;
if (!state?.item || !state.detail?.updated_at || selectedIndex === null || fileRelated.disabled) return;
try {
await onFileRelated({ item:state.item, detail:state.detail, taskIndex:selectedIndex,
label:label.value, trigger });
reset();
} catch (error) {
status.textContent = error.message + ' The checklist step was not changed.';
label.focus();
}
});
remove.addEventListener('click', () => run({ action:'remove' }));
cancel.addEventListener('click', () => reset(true));
return { reset };
},
renderTasks(container, detail, interactive) {
container.classList.remove('checklist-pending');
container.classList.toggle('checklist-pending', detail.checklist_pending === true);
container.innerHTML = renderMarkdown(
detail.body || 'No description provided.', { interactiveTasks: Boolean(interactive) }
detail.body || 'No description provided.', {
interactiveTasks: Boolean(interactive),
manageTasks: Boolean(interactive),
}
);
},
checklistCompletion(container, { interactive, today, offline, dismissed = false }) {
const tasks = Array.from(container.querySelectorAll('input.task-list-toggle'));
const visible = Boolean(interactive && !dismissed && tasks.length && tasks.every(task => task.checked));
return {
visible,
status: offline ? 'Checklist complete · pending sync' : 'Checklist complete',
label: today ? (offline ? 'Queue close & next' : 'Close & next') :
(offline ? 'Queue issue closure' : 'Close issue'),
};
},
mergeContent(snapshot, item, detail, confirmed, replace) {
return {
snapshot: snapshot ? replace(snapshot, item.repository, item.number, confirmed) : snapshot,
@@ -289,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;
@@ -379,4 +663,9 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
createIssueSheet.createPlanningLoader = createPlanningLoader;
createIssueSheet.manageChecklistTask = manageChecklistTask;
createIssueSheet.completeDelegatedParentTask = completeDelegatedParentTask;
createIssueSheet.finishDelegatedParentReview = finishDelegatedParentReview;
createIssueSheet.delegatedParentReference = delegatedParentReference;
createIssueSheet.resolveDelegatedParent = resolveDelegatedParent;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
+9 -5
View File
@@ -160,15 +160,17 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function enqueue(action, itemId, wakeAt = null) {
function enqueue(action, itemId, wakeAt = null, handoff = null) {
if (!['defer', 'restore'].includes(action) || !itemId ||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
(action === 'defer' && typeof wakeAt !== 'string') ||
![null, 'today'].includes(handoff)) return false;
pending().filter(operation => operation.item_id === itemId)
.forEach(operation => removeOperation(operation.operation_id));
const operation = {
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
base_revision: Math.max(0, savedRevision()),
};
if (handoff) operation.handoff = handoff;
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
@@ -189,9 +191,11 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
const marker = migrationPrefix + storageKey.slice(prefix.length);
try {
if (storage.getItem(marker)) return false;
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
enqueue('defer', itemId, wakeAt)
);
Object.entries(records || {}).forEach(([itemId, record]) => {
const wakeAt = record && typeof record === 'object' ? record.wake_at : record;
const handoff = record?.handoff === 'today' ? 'today' : null;
enqueue('defer', itemId, wakeAt, handoff);
});
storage.setItem(marker, '1');
return true;
} catch (_error) {
+22 -10
View File
@@ -38,7 +38,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
}
}
function defer(item, until) {
function defer(item, until, options = {}) {
const key = storageKey();
const id = identity(item);
const wake = new Date(until);
@@ -46,9 +46,10 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
const records = read();
const wakeAt = wake.toISOString();
records[id] = wakeAt;
const handoff = options.handoff === 'today' ? 'today' : null;
records[id] = handoff ? { wake_at:wakeAt, handoff } : wakeAt;
if (!write(records)) return 'unavailable';
onChange('defer', id, wakeAt);
onChange('defer', id, wakeAt, handoff);
return 'deferred';
}
@@ -93,9 +94,13 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
function adopt(records) {
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
const normalized = {};
Object.entries(records).forEach(([id, wake]) => {
Object.entries(records).forEach(([id, record]) => {
const wake = record && typeof record === 'object' ? record.wake_at : record;
const wakeTime = new Date(wake).getTime();
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
if (id && Number.isFinite(wakeTime)) {
const wakeAt = new Date(wakeTime).toISOString();
normalized[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
}
});
return write(normalized);
}
@@ -119,25 +124,32 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const wakeTimes = [];
const expired = [];
Object.entries(records).forEach(([id, wake]) => {
const handoffs = [];
Object.entries(records).forEach(([id, record]) => {
const wake = record && typeof record === 'object' ? record.wake_at : record;
const wakeTime = new Date(wake).getTime();
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
if (Number.isFinite(wakeTime)) expired.push(id);
if (Number.isFinite(wakeTime)) {
expired.push(id);
if (record?.handoff === 'today') handoffs.push(id);
}
return;
}
if (pruneMissing && !available.has(id)) return;
retained[id] = new Date(wakeTime).toISOString();
const wakeAt = new Date(wakeTime).toISOString();
retained[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
wakeTimes.push(wakeTime);
});
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
onExpire(expired);
onExpire(expired, handoffs);
}
schedule(wakeTimes, current);
const deferredIds = new Set(Object.keys(retained));
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
const later = (items || []).flatMap(item => {
const wake = retained[identity(item)];
const record = retained[identity(item)];
const wake = record && typeof record === 'object' ? record.wake_at : record;
return wake ? [{ ...item, deferred_until: wake }] : [];
});
return { active, later };
+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 };
});
+5 -3
View File
@@ -4,7 +4,7 @@
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
function validShareContinuation(value) {
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200,
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 6, search: 200,
preview: 200, search_kind: 5, search_state: 6, search_repository: 161 };
const params = new URLSearchParams(value.slice(3));
const entries = Array.from(params.entries());
@@ -35,7 +35,9 @@
const launch = params.get('launch');
const shared = params.get('shared');
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
if (shared === 'bundle') {
if (launch !== 'new' || entries.some(([name]) => !['launch', 'shared'].includes(name))) return './';
} else if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
return value;
}
@@ -117,7 +119,7 @@
return;
}
if (reason === 'session-idle') {
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
status.textContent = 'Stackchain locked. Your drafts and queued work are still on this device. Sign in to resume.';
return;
}
if (reason !== 'session-revoked') return;
+6 -1
View File
@@ -43,8 +43,13 @@
' class="task-list-toggle" data-task-index="' + (firstTaskIndex + offset) +
'" aria-label="Mark ' + escapeHtml(body) + (checked ? ' incomplete"' : ' complete"') :
' disabled';
const manage = options.interactiveTasks && options.manageTasks ?
'<button type="button" class="task-list-manage" data-task-index="' + (firstTaskIndex + offset) +
'" data-task-label="' + escapeHtml(body) + '" aria-label="Manage step: ' + escapeHtml(body) + '"' +
(offset === 0 ? ' data-task-first="true"' : '') +
(offset === lines.length - 1 ? ' data-task-last="true"' : '') + '>Manage</button>' : '';
return '<li class="task-list-item"><input type="checkbox"' + control +
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
(checked ? ' checked' : '') + '> ' + renderInline(body) + manage + '</li>';
}).join('');
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
}
+125
View File
@@ -0,0 +1,125 @@
(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;
const confirmedCounts = {updates:0, following:0, 'human-gates':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 syncCount(channel, count) {
if (!enabled) return;
try {
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({type:'stackchain-app-badge-count', channel, count});
} catch (_error) { /* A later refresh can retry. */ }
}
async function render() {
const confirmedCount = Math.min(
9999, confirmedCounts.updates + confirmedCounts.following + confirmedCounts['human-gates']
);
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) {
if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates);
if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following);
if (confirmedCounts['human-gates'] > 0) await syncCount('human-gates', confirmedCounts['human-gates']);
}
if (!enabled && available()) {
confirmedCounts.updates = 0;
confirmedCounts.following = 0;
confirmedCounts['human-gates'] = 0;
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 review attention.'}
: {state:'incomplete', detail:'Show review attention without opening Stackchain.'};
}
async function enable() {
if (!available()) return false;
if (control) control.checked = true;
await change();
return true;
}
async function reconcile(channel, count, authoritative = false) {
if (!Object.hasOwn(confirmedCounts, channel)
|| authoritative !== true || !Number.isSafeInteger(count) || count < 0 || count > 9999) return false;
confirmedCounts[channel] = count;
await syncCount(channel, count);
return render();
}
return {start, change, reconcile, readiness, enable};
});
+122
View File
@@ -0,0 +1,122 @@
function createMobileCreateIssueNavigation(options) {
const buttons = options.buttons || {};
const targets = options.targets || {};
const listeners = new Map();
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
let filingAvailable = !options.filing?.hidden;
let observer = null;
let navigationLockUntil = 0;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (!button) return;
if (key === name) button.setAttribute('aria-current', 'location');
else button.removeAttribute('aria-current');
});
}
function setFilingAvailable(available) {
filingAvailable = Boolean(available);
const button = buttons.file;
if (!button) return;
if (filingAvailable) button.removeAttribute('aria-disabled');
else button.setAttribute('aria-disabled', 'true');
}
function navigate(name) {
if (name === 'file') setFilingAvailable(!options.filing?.hidden);
if (name === 'file' && !filingAvailable) return false;
const target = targets[name];
if (!target) return false;
target.scrollIntoView({
block: 'start',
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
navigationLockUntil = Date.now() + 500;
select(name);
return true;
}
function reset(available = !options.filing?.hidden) {
setFilingAvailable(available);
select('describe');
}
return {
start() {
Object.entries(buttons).forEach(([name, button]) => {
if (!button || listeners.has(button)) return;
const listener = event => {
event.preventDefault();
navigate(name);
};
listeners.set(button, listener);
button.addEventListener('click', listener);
});
reset();
const observe = options.observe || ((handler, observedTargets, root) => {
if (typeof IntersectionObserver === 'undefined') return null;
const instance = new IntersectionObserver(handler, {
root,
rootMargin: '-20% 0px -60% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1],
});
observedTargets.forEach(target => instance.observe(target));
return instance;
});
observer = observe(entries => {
if (Date.now() < navigationLockUntil) return;
const visible = entries
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
.filter(entry => targetNames.get(entry.target) !== 'file' || filingAvailable)
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visible) select(targetNames.get(visible.target));
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
if (observer) observer.disconnect();
observer = null;
},
navigate,
reset,
select,
setFilingAvailable,
};
}
function attachMobileCreateIssueNavigation({ document, window }) {
const bySection = name => document.querySelector('[data-create-issue-section="' + name + '"]');
const filing = document.getElementById('create-issue-filing');
const sheet = document.getElementById('create-issue-sheet');
const navigation = createMobileCreateIssueNavigation({
root:document.querySelector('.create-issue-panel'),
buttons:{describe:bySection('describe'), evidence:bySection('evidence'), file:bySection('file')},
targets:{
describe:document.getElementById('create-issue-describe'),
evidence:document.getElementById('create-issue-evidence'),
file:filing,
},
filing,
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
navigation.start();
document.getElementById('file-new-issue').addEventListener('click', () => {
setTimeout(() => navigation.navigate('file'), 0);
});
new MutationObserver(() => {
navigation.setFilingAvailable(!filing.hidden);
if (!filing.hidden && sheet.classList.contains('open')) navigation.navigate('file');
}).observe(filing, {attributes:true, attributeFilter:['hidden']});
new MutationObserver(() => {
if (sheet.classList.contains('open')) navigation.reset(!filing.hidden);
}).observe(sheet, {
attributes:true, attributeFilter:['class'],
});
return navigation;
}
if (typeof module !== 'undefined') module.exports = createMobileCreateIssueNavigation;
else attachMobileCreateIssueNavigation({document, window});
+156
View File
@@ -0,0 +1,156 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileDeliveryRecovery = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileDeliveryRecovery(options) {
const byId = id => typeof document === 'undefined' ? null : document.getElementById(id);
const elements = options.elements || (typeof document === 'undefined' ? null : {
dialog: byId('mobile-delivery-recovery'), close: byId('close-mobile-delivery-recovery'),
title: byId('mobile-delivery-recovery-title'), destination: byId('mobile-delivery-recovery-destination'),
reason: byId('mobile-delivery-recovery-reason'), progress: byId('mobile-delivery-recovery-progress'),
action: byId('mobile-delivery-recovery-action'), status: byId('mobile-delivery-recovery-status'),
});
const priority = {authorization: 0, attention: 1, uncertain: 2, waiting: 3};
function identity(item) {
return String(item?.outbox_id || item?.id || '');
}
function state(item) {
if (item?.status === 'authorization') return 'authorization';
if (item?.status === 'attention' || item?.checklist_conflict || item?.quarantined) return 'attention';
if (item?.delivery_state === 'uncertain') return 'uncertain';
return 'waiting';
}
function primary(item) {
const kind = state(item);
if (kind === 'authorization') {
return item?.outbox_kind === 'issue-close' ? 'Authorize & close' : 'Authorize & send';
}
if (item?.checklist_conflict) return 'Review changes';
if (kind === 'attention') return item?.quarantined ? 'Copy content' : 'Open current item';
if (kind === 'uncertain') return 'Verified not posted — retry';
return 'Retry now';
}
function ordered() {
return [...(options.getItems ? options.getItems() : [])].sort((left, right) => {
const byState = priority[state(left)] - priority[state(right)];
if (byState) return byState;
return identity(left).localeCompare(identity(right));
});
}
const stateLabels = {
authorization: 'Authorization required',
attention: 'Needs attention',
uncertain: 'Delivery uncertain',
waiting: 'Ready to retry',
};
let currentId = '';
let active = false;
let inFlight = null;
function currentItem(items) {
return items.find(item => identity(item) === currentId) || items[0];
}
function snapshot() {
const items = ordered();
const item = currentItem(items);
if (item) currentId = identity(item);
else currentId = '';
const index = item ? items.indexOf(item) : -1;
return {
count: items.length,
current: item ? {
id: identity(item),
state: state(item),
primary: primary(item),
position: index + 1,
total: items.length,
} : null,
};
}
function render() {
const result = snapshot();
if (!elements) return result;
const items = ordered();
const item = currentItem(items);
if (!item) {
if (active) {
active = false;
if (elements.dialog?.open) elements.dialog.close();
if (options.onComplete) options.onComplete();
}
return result;
}
elements.title.textContent = item.title || 'Queued delivery';
elements.destination.textContent = [item.repository, item.details].filter(Boolean).join(' · ') || 'Saved delivery';
elements.reason.textContent = item.last_attempt_error || item.ownership || stateLabels[state(item)];
elements.progress.textContent = 'Delivery ' + result.current.position + ' of ' + result.current.total + ' · ' + stateLabels[state(item)];
elements.action.textContent = primary(item);
if (elements.status) elements.status.textContent = '';
return result;
}
function open() {
const queue = byId('mobile-queue-sheet');
if (queue?.open) queue.close();
if (options.beforeOpen) options.beforeOpen();
currentId = '';
const result = snapshot();
if (!result.current) return 'empty';
active = true;
render();
if (elements?.dialog && !elements.dialog.open) elements.dialog.showModal();
return 'opened';
}
async function attend(item) {
const index = options.getIndex ? options.getIndex(item) : -1;
const selector = item.quarantined ? '.draft-copy' :
item.checklist_conflict ? '.draft-review-checklist' : '.draft-resume';
const action = document.querySelector('#my-work-list ' + selector + '[data-draft-index="' + index + '"]');
if (!action) throw new Error('Delivery changed.');
elements.dialog.close();
action.scrollIntoView();
action.focus();
return await action.onclick();
}
function activate() {
if (inFlight) return inFlight;
const items = ordered();
const item = currentItem(items);
const perform = state(item) === 'attention' ? attend : options.activate;
if (!item) return Promise.resolve(render());
if (elements?.action) elements.action.disabled = true;
if (elements?.status) elements.status.textContent = 'Working…';
inFlight = (async () => {
let failure = null;
try {
await perform(item, state(item));
} catch (error) {
failure = String(error?.message || 'Delivery could not be completed.').trim().slice(0, 160);
} finally {
if (elements?.action) elements.action.disabled = false;
}
const result = render();
if (failure && elements?.status) {
if (active && !elements.dialog.open) elements.dialog.showModal();
elements.status.textContent = failure + ' Try again.';
}
return result;
})().finally(() => { inFlight = null; });
return inFlight;
}
function start() {
if (elements?.action?.addEventListener) elements.action.addEventListener('click', activate);
if (elements?.close?.addEventListener) elements.close.addEventListener('click', () => elements.dialog.close());
}
return {activate, open, render, snapshot, start};
});
+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,
});
}
+194
View File
@@ -0,0 +1,194 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createMobileFindWorkNavigation = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const stages = ['discover', 'review', 'fit'];
return function createMobileFindWorkNavigation(options) {
const document = options.document;
const buttons = options.buttons || Object.fromEntries(stages.map(name =>
[name, document?.querySelector('[data-find-work-stage="' + name + '"]')]
));
const sections = options.sections || Object.fromEntries(stages.map(name =>
[name, document?.getElementById('find-work-' + name + '-stage')]
));
const history = options.history;
const eventTarget = options.eventTarget;
const onStageChange = options.onStageChange || (() => {});
const listeners = new Map();
let current = 'discover';
let selectedCount = 0;
let recovery = false;
let started = false;
function escape(value) {
return String(value || '').replace(/[&<>"']/g, character =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[character]
);
}
function renderReview() {
const reviewList = options.reviewList || document?.getElementById('find-work-review-list');
const reviewSummary = options.reviewSummary || document?.getElementById('find-work-review-summary');
if (!reviewList) return;
const items = options.controller?.selectedItems?.() || options.getSelectedItems?.() || [];
const planning = options.todayWork?.planning?.();
const today = options.todayWork ? {
limit:options.todayWork.limit, count:options.todayWork.read().length,
capacityMinutes:planning.capacity_minutes, estimates:planning.estimates,
} : options.getTodayState?.() || {};
const remainingSlots = Math.max(0, (Number(today.limit) || 0) - (Number(today.count) || 0));
const planned = Object.values(today.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
const remainingMinutes = today.capacityMinutes === null || today.capacityMinutes === undefined ? null :
Math.max(0, today.capacityMinutes - planned);
const formatMinutes = minutes => [Math.floor(minutes / 60) ? Math.floor(minutes / 60) + 'h' : '',
minutes % 60 ? minutes % 60 + 'm' : ''].filter(Boolean).join(' ') || '0m';
if (reviewSummary) reviewSummary.textContent = items.length + ' selected · ' + remainingSlots +
' Today slot' + (remainingSlots === 1 ? '' : 's') + ' open' +
(remainingMinutes === null ? '' : ' · ' + formatMinutes(remainingMinutes) + ' available') + '.';
reviewList.innerHTML = items.map(item => {
const id = String(item.repository || '') + '#' + String(item.number || '');
return '<article class="find-work-review-item"><div><span class="small">' + escape(id) +
'</span><strong>' + escape(item.title || 'Untitled work') + '</strong></div>' +
'<button type="button" data-find-work-review-remove="' + escape(id) + '">Remove</button></article>';
}).join('');
reviewList.querySelectorAll('[data-find-work-review-remove]').forEach(button =>
button.addEventListener('click', () => {
const item = items.find(candidate => String(candidate.repository || '') + '#' +
String(candidate.number || '') === button.dataset.findWorkReviewRemove);
if (item) (options.controller?.toggleSelection || options.toggleSelection)?.(item);
})
);
}
function allowed(name) {
return stages.includes(name) && (name === 'discover' || selectedCount > 0 || recovery);
}
function paint(name, focus = false, notify = true) {
if (!allowed(name)) name = 'discover';
current = name;
stages.forEach(stage => {
const button = buttons[stage];
const section = sections[stage];
if (button) {
button.disabled = stage !== 'discover' && selectedCount === 0 && !recovery;
if (stage === name) button.setAttribute('aria-current', 'step');
else button.removeAttribute('aria-current');
}
if (section) section.hidden = stage !== name;
});
if (name === 'review') renderReview();
if (name === 'fit') options.openFit?.(options.controller?.selectedItems?.() || []);
if (notify) onStageChange(name);
if (focus) sections[name]?.focus?.();
return name;
}
function stateFor(name) {
const state = { ...(history?.state || {}) };
if (name === 'discover') delete state.findWorkStage;
else state.findWorkStage = name;
return state;
}
function go(name) {
if (!allowed(name) || name === current) return false;
const currentIndex = stages.indexOf(current);
const nextIndex = stages.indexOf(name);
if (nextIndex < currentIndex && history?.go) {
history.go(nextIndex - currentIndex);
return true;
}
if (nextIndex === currentIndex - 1 && history?.back) {
history.back();
return true;
}
history?.pushState?.(stateFor(name), '');
paint(name, true);
return true;
}
function onPopState(event) {
if (event.state?.taskOverlay !== 'find') return;
paint(event.state?.findWorkStage || 'discover', true);
}
function sync(state = {}) {
selectedCount = Math.max(0, Number(state.selectedCount) || 0);
recovery = state.recovery === true;
if (!allowed(current)) paint('discover');
else paint(current, false, false);
}
function open(hasRecovery) {
if (options.controller) {
selectedCount = options.controller.selection().count;
recovery = hasRecovery === true;
}
const restored = history?.state?.taskOverlay === 'find' ? history.state.findWorkStage : null;
return paint(allowed(restored) ? restored : 'discover');
}
function complete() {
selectedCount = 0;
recovery = false;
history?.replaceState?.(stateFor('discover'), '');
return paint('discover');
}
function back() {
if (current === 'discover') return false;
history?.back?.();
return true;
}
return {
start() {
if (started) return;
started = true;
stages.forEach(name => {
const button = buttons[name];
if (!button) return;
const listener = event => { event.preventDefault(); go(name); };
listeners.set(button, listener);
button.addEventListener('click', listener);
});
if (document) {
const bind = (id, action) => {
const button = document.getElementById(id);
if (!button) return;
const listener = event => { event.preventDefault(); action(); };
listeners.set(button, listener);
button.addEventListener('click', listener);
};
bind('select-find-work', () => { complete(); options.controller?.startSelection?.(); });
bind('cancel-find-work-selection', () => { complete(); options.controller?.cancelSelection?.(); });
bind('claim-selected-work', () => go('review'));
bind('continue-find-work-fit', () => go('fit'));
bind('back-find-work-discover', back);
bind('cancel-find-work-estimates', back);
bind('close-find-work', () => history?.go?.(-(stages.indexOf(current) + 1)));
}
eventTarget?.addEventListener?.('popstate', onPopState);
paint('discover');
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
eventTarget?.removeEventListener?.('popstate', onPopState);
started = false;
},
sync,
open,
go,
back,
complete,
renderReview,
stage: () => current,
};
};
});
+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};
});
+110
View File
@@ -0,0 +1,110 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
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;
let backgroundInert = null;
function attr(element, name, value) {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
}
function containBackground() {
if (backgroundInert) return;
backgroundInert = new Map(o.backgrounds.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 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);
attr(o.root, 'aria-hidden', active || !o.mediaQuery.matches ? null : 'true');
o.root.inert = !active && o.mediaQuery.matches;
if (active) containBackground();
else releaseBackground();
o.dock.hidden = active;
attr(o.hud, 'data-overlay-hidden', active ? 'true' : null);
if (active) o.closeButton.focus();
else if (focus) o.launcher.focus();
}
function url(hash) {
return (location.pathname || '') + (location.search || '') + hash;
}
function open() {
if (!o.mediaQuery.matches || active) return 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(location.hash === route, active && location.hash !== route);
}
function close() {
if (!active) return false;
if (o.history.state?.mobileInsights) o.history.back();
else {
const state = { ...(o.history.state || {}) };
delete state.mobileInsights;
delete state.previousHash;
o.history.replaceState(state, '', url(previous));
render(false, true);
}
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.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();
}
});
o.mediaQuery.addEventListener?.('change', sync);
sync();
}
return { start, open, close, sync, current:() => active };
});
+77
View File
@@ -0,0 +1,77 @@
function createMobileIssueDetailNavigation(options) {
const buttons = options.buttons || {};
const targets = options.targets || {};
const listeners = new Map();
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
let observer = null;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (!button) return;
if (key === name) button.setAttribute('aria-current', 'location');
else button.removeAttribute('aria-current');
});
}
function navigate(name, navigationOptions = {}) {
const target = targets[name];
if (!target) return false;
const prepare = options.beforeNavigate && options.beforeNavigate[name];
if (prepare) prepare(target);
if (name === 'actions' && options.planning) options.planning.open = true;
target.scrollIntoView({
block: 'start',
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
if (name === 'reply' && navigationOptions.focus !== false) target.focus();
select(name);
return true;
}
return {
start() {
Object.entries(buttons).forEach(([name, button]) => {
if (!button || listeners.has(button)) return;
const listener = event => {
event.preventDefault();
navigate(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
};
listeners.set(button, listener);
button.addEventListener('click', listener);
});
select('overview');
const observe = options.observe || ((handler, observedTargets) => {
if (typeof IntersectionObserver === 'undefined') return null;
const instance = new IntersectionObserver(handler, {
root: options.root || null,
rootMargin: '-20% 0px -60% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1],
});
observedTargets.forEach(target => instance.observe(target));
return instance;
});
observer = observe(entries => {
const visible = entries
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visible) {
const name = targetNames.get(visible.target);
select(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
}
}, Array.from(targetNames.keys()).filter(Boolean));
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
if (observer) observer.disconnect();
observer = null;
},
navigate,
select,
};
}
if (typeof module !== 'undefined') module.exports = createMobileIssueDetailNavigation;
+104
View File
@@ -0,0 +1,104 @@
function createMobilePlanTodayNavigation(options) {
const buttons = options.buttons || {};
const targets = options.targets || {};
const root = options.root;
const listeners = new Map();
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
const now = options.now || (() => Date.now());
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
let observer = null;
let navigationLockUntil = 0;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (!button) return;
if (key === name) button.setAttribute('aria-current', 'location');
else button.removeAttribute('aria-current');
});
}
function navigate(name) {
const target = targets[name];
if (!root || !target) return false;
root.scrollTo({
top: Math.max(0, target.offsetTop - root.offsetTop),
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
navigationLockUntil = now() + 500;
select(name);
return true;
}
function reset() {
select('fit');
}
return {
start() {
Object.entries(buttons).forEach(([name, button]) => {
if (!button || listeners.has(button)) return;
const listener = event => {
event.preventDefault();
navigate(name);
};
listeners.set(button, listener);
button.addEventListener('click', listener);
});
reset();
const observe = options.observe || ((handler, observedTargets, observedRoot) => {
if (typeof IntersectionObserver === 'undefined') return null;
const instance = new IntersectionObserver(handler, {
root: observedRoot,
rootMargin: '-20% 0px -60% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1],
});
observedTargets.forEach(target => instance.observe(target));
return instance;
});
observer = observe(entries => {
if (now() < navigationLockUntil) return;
const visible = entries
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visible) select(targetNames.get(visible.target));
}, Array.from(targetNames.keys()).filter(Boolean), root);
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
if (observer) observer.disconnect();
observer = null;
},
navigate,
reset,
select,
};
}
function attachMobilePlanTodayNavigation({document, window}) {
const bySection = name => document.querySelector('[data-plan-today-section="' + name + '"]');
const sheet = document.getElementById('plan-today-sheet');
const panel = document.querySelector('.plan-today-panel');
const navigation = createMobilePlanTodayNavigation({
root: panel,
buttons: {
fit: bySection('fit'),
today: bySection('today'),
available: bySection('available'),
},
targets: {
fit: document.getElementById('plan-today-fit'),
today: document.getElementById('plan-today-selected'),
available: document.getElementById('plan-today-available-work'),
},
prefersReducedMotion: () => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
navigation.start();
new MutationObserver(() => {
if (!sheet.hidden) navigation.reset();
}).observe(sheet, {attributes: true, attributeFilter: ['hidden']});
return navigation;
}
if (typeof module !== 'undefined') module.exports = createMobilePlanTodayNavigation;
else attachMobilePlanTodayNavigation({document, window});
+88
View File
@@ -0,0 +1,88 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.createMobilePullRefresh = factory;
})(typeof self !== 'undefined' ? self : this, function createMobilePullRefresh(options) {
const surface = options.surface || document.querySelector('#my-work');
const indicator = options.indicator || document.querySelector('#mobile-pull-refresh');
const threshold = options.threshold || 64;
const isMobile = options.isMobile || (() => window.matchMedia('(max-width: 600px)').matches);
const getScrollY = options.getScrollY || (() => window.scrollY);
const isBlocked = options.isBlocked || (() => Array.from(options.overlays || document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal')).some(overlay =>
(overlay.checkVisibility ? overlay.checkVisibility() : !overlay.closest('[hidden]')) &&
(overlay.classList.contains('open') || overlay.getAttribute('aria-modal') === 'true')));
let gesture = null;
let inFlight = null;
function show(state, text) {
indicator.hidden = false;
indicator.dataset.state = state;
indicator.textContent = text;
}
function resetGesture() {
gesture = null;
}
function startsOnExcludedTarget(target) {
if (!target) return false;
if (target.closest?.('button, a, input, textarea, select, summary, [contenteditable="true"], [role="button"]')) return true;
for (let node = target; node && node !== surface; node = node.parentElement) {
if ((node.scrollWidth || 0) > (node.clientWidth || 0)) return true;
}
return false;
}
function onPointerDown(event) {
if (inFlight || event.pointerType !== 'touch' || !isMobile() || getScrollY() > 0 || isBlocked() || startsOnExcludedTarget(event.target)) return;
gesture = {id:event.pointerId, x:event.clientX, y:event.clientY, ready:false};
surface.setPointerCapture?.(event.pointerId);
}
function onPointerMove(event) {
if (!gesture || gesture.id !== event.pointerId) return;
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
if (dy <= 0 || Math.abs(dx) > dy) return resetGesture();
event.preventDefault();
gesture.ready = dy >= threshold;
show(gesture.ready ? 'ready' : 'pulling', gesture.ready ? 'Release to refresh' : 'Pull to refresh');
}
async function onPointerUp(event) {
if (!gesture || gesture.id !== event.pointerId) return;
const shouldRefresh = gesture.ready;
resetGesture();
surface.releasePointerCapture?.(event.pointerId);
if (!shouldRefresh || inFlight) return;
show('refreshing', 'Refreshing My Work…');
inFlight = Promise.resolve().then(options.refresh);
try {
await inFlight;
show('success', 'My Work is up to date');
} catch (_) {
show('error', 'Could not refresh · pull to retry');
} finally {
inFlight = null;
}
}
function onPointerCancel() {
resetGesture();
}
return {
start() {
surface.addEventListener('pointerdown', onPointerDown);
surface.addEventListener('pointermove', onPointerMove);
surface.addEventListener('pointerup', onPointerUp);
surface.addEventListener('pointercancel', onPointerCancel);
},
stop() {
surface.removeEventListener('pointerdown', onPointerDown);
surface.removeEventListener('pointermove', onPointerMove);
surface.removeEventListener('pointerup', onPointerUp);
surface.removeEventListener('pointercancel', onPointerCancel);
resetGesture();
},
};
});
+100 -10
View File
@@ -8,28 +8,114 @@
later: 'No deferred work is ready to open.',
draft: 'No drafts are ready to open.',
};
const continuation = [
['attention', 'Start Attention'],
['today', 'Continue Today'],
['update', 'Resume Updates'],
['agenda', 'Open Agenda'],
['later', 'Start Later'],
['draft', 'Open Drafts'],
const labels = {
delivery: 'Recover Delivery', gate: 'Review Human Gates', attention: 'Start Attention',
today: 'Continue Today', update: 'Resume Updates', agenda: 'Open Agenda',
following: 'Review Following', authored: 'Open My PRs', filed: 'Review Filed',
later: 'Start Later', draft: 'Open Drafts',
};
const criticalQueueNames = ['delivery', 'gate'];
const defaultRoutineOrder = [
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
];
const onlineOnlyQueues = new Set(['delivery', 'gate']);
const allQueues = [
'today', 'tomorrow', 'week', 'agenda', 'delivery', 'gate', 'attention',
'update', 'following', 'filed', 'authored', 'later', 'draft', 'find', 'recaps',
];
function routineOrder() {
const proposed = options.getRoutineOrder ? options.getRoutineOrder() : defaultRoutineOrder;
if (!Array.isArray(proposed) || proposed.length !== defaultRoutineOrder.length ||
new Set(proposed).size !== defaultRoutineOrder.length ||
proposed.some(name => !defaultRoutineOrder.includes(name))) return defaultRoutineOrder.slice();
return proposed.slice();
}
function activeQueueNames() {
return criticalQueueNames.concat(routineOrder());
}
function recommend() {
const counts = options.getCounts ? options.getCounts() : {};
const match = continuation.find(([name]) => Number(counts[name]) > 0);
const online = options.isOnline ? options.isOnline() : true;
const match = activeQueueNames().map(name => [name, labels[name]]).find(([name]) =>
Number(counts[name]) > 0 && (online || !onlineOnlyQueues.has(name))
);
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
const [name, label] = match;
const count = Math.max(0, Number(counts[name]) || 0);
return {name, count, label: label + ' (' + count + ')'};
}
function adaptiveRecommendation() {
const preparation = options.getPreparation ? options.getPreparation() : null;
if (preparation && (preparation.active || Number(preparation.total) > 0)) {
const prefix = preparation.active ? 'Resume preparation · ' : 'Start day · ';
return {name: 'prepare', count: Math.max(0, Number(preparation.total) || 0), label: prefix + preparation.label};
}
return recommend();
}
function presentation() {
const counts = options.getCounts ? options.getCounts() : {};
const names = activeQueueNames();
const active = names
.map(name => ({name, count: Math.max(0, Number(counts[name]) || 0)}))
.filter(item => item.count > 0);
names.forEach(name => {
if (counts[name + 'Unavailable'] && !active.some(item => item.name === name)) {
active.push({name, unavailable: true});
}
});
return {
nextUp: adaptiveRecommendation(),
active,
planning: ['today', 'tomorrow', 'week'],
all: allQueues.slice(),
};
}
function renderPresentation() {
const view = presentation();
const planning = new Set(view.planning);
const active = view.active.filter(item => !planning.has(item.name));
const activeNames = new Set(active.map(item => item.name));
if (options.nextAction) {
options.nextAction.textContent = view.nextUp.label;
options.nextAction.dataset.queue = view.nextUp.name;
const prefix = view.nextUp.name === 'prepare' ? 'Start or continue: ' : 'Next up: ';
options.nextAction.setAttribute('aria-label', prefix + view.nextUp.label);
}
active.forEach(item => {
const row = options.rows?.[item.name];
if (!row) return;
if (item.unavailable) row.setAttribute('data-unavailable', 'true');
else row.removeAttribute('data-unavailable');
options.activeList?.append(row);
});
view.planning.forEach(name => {
const row = options.rows?.[name];
if (row) options.planningList?.append(row);
});
view.all.forEach(name => {
if (planning.has(name) || activeNames.has(name)) return;
const row = options.rows?.[name];
if (!row) return;
row.removeAttribute('data-unavailable');
options.allList?.append(row);
});
if (options.activeSection) options.activeSection.hidden = active.length === 0;
return view;
}
function open(name) {
if (name === 'delivery' && options.openDelivery) return options.openDelivery();
if (name === 'gate' && options.openHumanGates) return options.openHumanGates();
if (name === 'today') return options.openToday();
if (name === 'agenda') return options.openAgenda();
if (name === 'update' && options.openUpdates) return options.openUpdates();
if (name === 'following' && options.openFollowing) return options.openFollowing();
if (name === 'filed' && options.openFiled) return options.openFiled();
options.selectFilter(name);
const action = options.firstAction(name);
@@ -42,7 +128,11 @@
}
function continueWork() {
const next = recommend();
const next = adaptiveRecommendation();
if (next.name === 'prepare') {
options.openPreparation();
return 'prepare';
}
if (next.name === 'find') {
options.openFindWork();
return 'find';
@@ -50,5 +140,5 @@
return open(next.name);
}
return { open, recommend, continueWork };
return { open, recommend, adaptiveRecommendation, presentation, renderPresentation, continueWork };
});
+340
View File
@@ -0,0 +1,340 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileQueuePriority = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileQueuePriority(options = {}) {
const DEFAULT_ORDER = [
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
];
const storage = options.storage;
const getLogin = options.getLogin || (() => '');
const fetchJson = options.fetchJson;
const prefix = 'stackchain-mobile-queue-priority-v1:';
const labels = options.labels || {};
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
const setTimer = options.setTimeout || setTimeout;
const clearTimer = options.clearTimeout || clearTimeout;
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
const retryBaseMs = Number.isFinite(options.retryBaseMs) ? Math.max(1, options.retryBaseMs) : 1000;
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryBaseMs, options.retryMaxMs) : 30000;
let memory = null;
const syncFlights = new Map();
let debounceTimer = null;
let retryTimer = null;
let retryAccount = '';
let retryAttempts = 0;
function key() {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function valid(order) {
return Array.isArray(order) && order.length === DEFAULT_ORDER.length &&
new Set(order).size === DEFAULT_ORDER.length &&
order.every(name => DEFAULT_ORDER.includes(name) && typeof name === 'string');
}
function fresh() {
return {revision:0, order:DEFAULT_ORDER.slice(), pending:false, status:'ready', remote:null};
}
function read() {
const accountKey = key();
if (!accountKey || !storage) return fresh();
if (memory?.key === accountKey) return memory.value;
let value = fresh();
try {
const saved = JSON.parse(storage.getItem(accountKey) || 'null');
if (valid(saved)) value = {revision:0, order:saved.slice(), pending:true, status:'pending', remote:null};
else if (saved && valid(saved.order) && Number.isInteger(saved.revision) && saved.revision >= 0) {
value = {
revision:saved.revision, order:saved.order.slice(), pending:Boolean(saved.pending),
status:saved.status === 'conflict' ? 'conflict' : (saved.pending ? 'pending' : 'ready'),
remote:saved.remote && valid(saved.remote.order) ? {
revision:Number(saved.remote.revision) || 0, order:saved.remote.order.slice(),
} : null,
};
}
} catch (_error) {}
memory = {key:accountKey, value};
return value;
}
function persist(value) {
const accountKey = key();
if (!accountKey || !storage) return false;
memory = {key:accountKey, value};
try {
storage.setItem(accountKey, JSON.stringify(value));
return true;
} catch (_error) {
return false;
}
}
function snapshot() {
const value = read();
return {
revision:value.revision, order:value.order.slice(), pending:value.pending,
status:value.status, remote:value.remote ? {revision:value.remote.revision, order:value.remote.order.slice()} : null,
};
}
function announce(value) {
if (options.status) {
options.status.textContent = ({pending:'Sync pending.', conflict:'Routine order changed on another device.',
syncing:'Syncing routine order…', error:'Routine order could not sync.', ready:''})[value.status] || '';
}
options.onState?.(snapshot());
}
function getOrder() {
return read().order.slice();
}
function save(order) {
if (!key() || !valid(order)) return false;
const current = read();
const value = {revision:current.revision, order:order.slice(), pending:true, status:'pending', remote:null};
if (!persist(value)) return false;
options.onChange?.(order.slice());
announce(value);
scheduleSync();
return true;
}
function scheduleSync() {
if (!fetchJson || !key()) return false;
if (debounceTimer) clearTimer(debounceTimer);
debounceTimer = setTimer(() => {
debounceTimer = null;
void sync();
}, debounceMs);
return true;
}
function clearRetry() {
if (retryTimer) clearTimer(retryTimer);
retryTimer = null;
retryAccount = '';
retryAttempts = 0;
}
function transient(error) {
const status = Number(error?.status) || 0;
return status === 0 || status === 408 || status === 425 || status === 429 || status >= 500;
}
function scheduleRetry(accountKey) {
if (retryTimer && retryAccount !== accountKey) clearRetry();
if (retryTimer || key() !== accountKey) return false;
const delay = Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempts));
retryAttempts += 1;
retryAccount = accountKey;
retryTimer = setTimer(() => {
retryTimer = null;
retryAccount = '';
const current = read();
if (key() !== accountKey || !current.pending || current.status === 'conflict') return;
void sync();
}, delay);
return true;
}
function move(name, delta) {
const order = getOrder();
const index = order.indexOf(name);
const next = index + (Number(delta) < 0 ? -1 : 1);
if (index < 0 || next < 0 || next >= order.length) return order;
[order[index], order[next]] = [order[next], order[index]];
save(order);
return order.slice();
}
function reset() {
const order = DEFAULT_ORDER.slice();
if (fetchJson && key()) save(order);
else {
const accountKey = key();
if (accountKey && storage) {
try { storage.removeItem(accountKey); } catch (_error) {}
}
memory = null;
options.onChange?.(order.slice());
}
return order;
}
function adopt(snapshotValue, accountKey = key()) {
if (!accountKey || key() !== accountKey) return snapshot();
if (!snapshotValue || !Number.isInteger(snapshotValue.revision) || snapshotValue.revision < 0 || !valid(snapshotValue.order)) {
throw new Error('Queue priority response is invalid.');
}
const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null};
clearRetry();
persist(value);
options.onChange?.(value.order.slice());
announce(value);
render();
return snapshot();
}
async function load() {
const accountKey = key();
if (!fetchJson || !accountKey) return snapshot();
try {
const remote = await fetchJson('api/v1/queue-priority');
if (key() !== accountKey) return snapshot();
const local = read();
if (local.pending) {
local.remote = valid(remote?.order) ? {revision:remote.revision, order:remote.order.slice()} : null;
persist(local);
return sync();
}
return adopt(remote, accountKey);
} catch (_error) {
if (key() !== accountKey) return snapshot();
const current = read();
current.status = current.pending ? 'pending' : 'error';
persist(current); announce(current);
return snapshot();
}
}
async function drain(accountKey) {
while (key() === accountKey) {
const current = read();
if (!current.pending) return snapshot();
const sent = {revision:current.revision, order:current.order.slice()};
current.status = 'syncing'; persist(current); announce(current);
try {
const saved = await fetchJson('api/v1/queue-priority', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify(sent),
});
if (key() !== accountKey) return snapshot();
if (!saved || !Number.isInteger(saved.revision) || !valid(saved.order)) {
throw new Error('Queue priority response is invalid.');
}
const latest = read();
if (latest.order.some((name, index) => name !== sent.order[index])) {
latest.revision = saved.revision; latest.pending = true;
latest.status = 'pending'; latest.remote = null;
persist(latest); announce(latest);
continue;
}
return adopt(saved, accountKey);
} catch (error) {
if (key() !== accountKey) return snapshot();
const latest = read();
const remote = error?.status === 409 && error?.payload?.detail?.snapshot;
if (remote && valid(remote.order) && Number.isInteger(remote.revision)) {
clearRetry();
latest.status = 'conflict'; latest.pending = true;
latest.remote = {revision:remote.revision, order:remote.order.slice()};
} else {
latest.status = 'pending'; latest.pending = true;
if (transient(error)) scheduleRetry(accountKey);
}
persist(latest); announce(latest); render();
return snapshot();
}
}
return snapshot();
}
function sync() {
if (debounceTimer) {
clearTimer(debounceTimer);
debounceTimer = null;
}
const accountKey = key();
const current = read();
if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot());
if (syncFlights.has(accountKey)) return syncFlights.get(accountKey);
const flight = drain(accountKey).finally(() => {
if (syncFlights.get(accountKey) === flight) syncFlights.delete(accountKey);
});
syncFlights.set(accountKey, flight);
return flight;
}
async function useLocal() {
const current = read();
if (!current.remote) return snapshot();
current.revision = current.remote.revision;
current.remote = null; current.pending = true; current.status = 'pending';
persist(current);
return sync();
}
function useRemote() {
const current = read();
return current.remote ? adopt(current.remote) : snapshot();
}
function displayName(name) {
return labels[name] || name.charAt(0).toUpperCase() + name.slice(1);
}
function render() {
if (!options.list || !documentRef) return getOrder();
const order = getOrder();
const signedIn = Boolean(key());
const rows = order.map((name, index) => {
const row = documentRef.createElement('div');
row.setAttribute('data-queue-priority', name);
row.setAttribute('class', 'mobile-queue-priority-row');
const label = documentRef.createElement('span');
label.textContent = displayName(name);
const controls = documentRef.createElement('span');
controls.setAttribute('class', 'mobile-queue-priority-controls');
const earlier = documentRef.createElement('button');
earlier.textContent = 'Earlier'; earlier.setAttribute('type', 'button');
earlier.setAttribute('aria-label', 'Move ' + displayName(name) + ' earlier');
earlier.disabled = !signedIn || index === 0;
earlier.addEventListener('click', () => {
move(name, -1); render();
});
const later = documentRef.createElement('button');
later.textContent = 'Later'; later.setAttribute('type', 'button');
later.setAttribute('aria-label', 'Move ' + displayName(name) + ' later');
later.disabled = !signedIn || index === order.length - 1;
later.addEventListener('click', () => {
move(name, 1); render();
});
controls.append(earlier, later); row.append(label, controls);
return row;
});
options.list.replaceChildren(...rows);
if (options.resetButton) options.resetButton.disabled = !signedIn;
if (options.conflict) options.conflict.hidden = read().status !== 'conflict';
return order;
}
function start() {
options.resetButton?.addEventListener('click', () => {
reset(); render();
});
options.keepLocalButton?.addEventListener('click', () => { void useLocal(); });
options.useRemoteButton?.addEventListener('click', () => { useRemote(); });
return render();
}
function startLifecycle(lifecycle = {}) {
const windowObject = lifecycle.window;
const lifecycleDocument = lifecycle.document;
const reconcile = () => {
if (!key()) return Promise.resolve(snapshot());
return read().pending ? sync() : load();
};
windowObject?.addEventListener?.('online', () => { void reconcile(); });
lifecycleDocument?.addEventListener?.('visibilitychange', () => {
if (!lifecycleDocument.hidden) void reconcile();
});
return reconcile;
}
return {getOrder, move, reset, render, start, startLifecycle, load, sync, scheduleSync, useLocal, useRemote,
state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
});
+455
View File
@@ -0,0 +1,455 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileRecentWork = factory;
})(typeof globalThis !== 'undefined' ? globalThis : this, function createMobileRecentWork(options) {
'use strict';
const storage = options.storage;
const getLogin = options.getLogin;
const fetchJson = options.fetchJson;
const limit = Math.max(1, Number(options.limit) || 5);
const pinnedLimit = Math.max(1, Number(options.pinnedLimit) || 20);
const prefix = 'stackchain.mobile-recent-work.v1.';
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
const setTimer = options.setTimeout || setTimeout;
const clearTimer = options.clearTimeout || clearTimeout;
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
const retryMs = Number.isFinite(options.retryMs) ? Math.max(1, options.retryMs) : 1000;
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryMs, options.retryMaxMs) : 30000;
let syncFlight = null;
let syncAccount = '';
let debounceTimer = null;
let retryTimer = null;
let retryAccount = '';
let retryAttempt = 0;
let operationSequence = 0;
let pinsExpanded = false;
let currentItem = null;
const detailPins = Array.from(options.detailPins || []);
detailPins.forEach(button => button.addEventListener?.('click', () => {
if (!currentItem) return;
const isPinned = pinned().some(item => item.route === currentItem.route);
if (isPinned) unpin(currentItem.route);
else pin(currentItem);
}));
options.pinnedToggle?.addEventListener?.('click', () => {
pinsExpanded = !pinsExpanded;
render();
});
function operationId() {
operationSequence += 1;
return Date.now().toString(36) + '-' + operationSequence.toString(36);
}
function clearRetry(resetAttempt = false) {
if (retryTimer) clearTimer(retryTimer);
retryTimer = null;
retryAccount = '';
if (resetAttempt) retryAttempt = 0;
}
function scheduleRetry(accountKey) {
if (retryTimer || key() !== accountKey || !hasPending(read())) return false;
retryAccount = accountKey;
const delay = Math.min(retryMaxMs, retryMs * (2 ** retryAttempt));
retryAttempt += 1;
retryTimer = setTimer(() => {
const timer = retryTimer;
retryTimer = null;
retryAccount = '';
if (timer) clearTimer(timer);
if (key() === accountKey && hasPending(read())) void sync();
}, delay);
return true;
}
function login() {
return String(getLogin?.() || '').trim().toLowerCase();
}
function key() {
const owner = login();
return owner ? prefix + owner : '';
}
function normalize(item) {
const kind = String(item?.kind || '');
const number = Number(item?.number ?? item?.notification_id);
if (!kinds.has(kind) || !Number.isSafeInteger(number) || number < 1) return null;
let route = '';
let repository = '';
if (kind === 'update') {
route = '#/my-work/update/' + number;
} else {
repository = String(item?.repository || '');
if (!repositoryPattern.test(repository)) return null;
route = '#/my-work/' + kind + '/' + repository + '/' + number;
}
const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180);
if (!title) return null;
return {kind, ...(repository ? {repository} : {}), number, title, route};
}
function normalizeList(value, maximum = limit) {
if (!Array.isArray(value)) return [];
const unique = [];
for (const candidate of value) {
const item = normalize(candidate);
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
if (unique.length === maximum) break;
}
return unique;
}
function normalizePinOps(value) {
if (!Array.isArray(value)) return [];
const unique = [];
for (const candidate of value) {
const action = candidate?.action;
const item = action === 'pin' ? normalize(candidate.item) : null;
const route = action === 'pin' ? item?.route : String(candidate?.route || '');
if ((action !== 'pin' && action !== 'unpin') || !route || (action === 'pin' && !item)) continue;
if (!unique.some(existing => (existing.item?.route || existing.route) === route)) {
const normalized = action === 'pin' ? {action, item} : {action, route};
if (typeof candidate.operationId === 'string' && candidate.operationId) normalized.operationId = candidate.operationId;
unique.push(normalized);
}
if (unique.length === pinnedLimit) break;
}
return unique;
}
function normalizePending(value) {
if (!Array.isArray(value)) return [];
const unique = [];
for (const candidate of value) {
const item = normalize(candidate);
if (!item || unique.some(existing => existing.route === item.route)) continue;
if (typeof candidate.operationId === 'string' && candidate.operationId) item.operationId = candidate.operationId;
unique.push(item);
if (unique.length === limit) break;
}
return unique;
}
function empty() {
return {items:[], pinned:[], pending:[], pinOps:[]};
}
function read() {
const storageKey = key();
if (!storageKey) return empty();
try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
if (Array.isArray(parsed)) return {...empty(), items:normalizeList(parsed)};
return {
items:normalizeList(parsed?.items),
pinned:normalizeList(parsed?.pinned, pinnedLimit),
pending:normalizePending(parsed?.pending),
pinOps:normalizePinOps(parsed?.pinOps),
};
} catch (_) {
return empty();
}
}
function persist(value, accountKey = key()) {
if (!accountKey || accountKey !== key()) return false;
try {
storage.setItem(accountKey, JSON.stringify({
items:normalizeList(value.items),
pinned:normalizeList(value.pinned, pinnedLimit),
pending:normalizePending(value.pending),
pinOps:normalizePinOps(value.pinOps),
}));
return true;
} catch (_) {
return false;
}
}
function hasPending(value) {
return value.pending.length > 0 || value.pinOps.length > 0;
}
function announce(value = read(), status = null) {
if (!options.status) return;
options.status.textContent = status || (hasPending(value) ? 'Sync pending.' : '');
}
function items() {
return read().items;
}
function pinned() {
return read().pinned;
}
function state() {
const value = read();
const pendingCount = value.pending.length + value.pinOps.length;
return {pending:pendingCount > 0, pendingCount};
}
function scheduleSync() {
if (!fetchJson || !key()) return false;
if (debounceTimer) clearTimer(debounceTimer);
debounceTimer = setTimer(() => {
debounceTimer = null;
void sync();
}, debounceMs);
return true;
}
function record(item) {
const accountKey = key();
const normalized = normalize(item);
if (!accountKey || !normalized) return false;
const current = read();
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
current.pinned = current.pinned.some(existing => existing.route === normalized.route)
? [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)]
: current.pinned;
current.pending = [{...normalized, operationId:operationId()}, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
if (!persist(current, accountKey)) return false;
announce(current);
render();
scheduleSync();
return true;
}
function queuePinOp(current, operation) {
const route = operation.item?.route || operation.route;
current.pinOps = [{...operation, operationId:operationId()}, ...current.pinOps.filter(existing => (existing.item?.route || existing.route) !== route)];
}
function pin(item) {
const accountKey = key();
const normalized = normalize(item);
if (!accountKey || !normalized) return false;
const current = read();
current.pinned = [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)].slice(0, pinnedLimit);
queuePinOp(current, {action:'pin', item:normalized});
if (!persist(current, accountKey)) return false;
announce(current);
render();
scheduleSync();
return true;
}
function unpin(route) {
const accountKey = key();
route = String(route || '');
if (!accountKey || !route) return false;
const current = read();
if (!current.pinned.some(item => item.route === route)) return false;
current.pinned = current.pinned.filter(item => item.route !== route);
queuePinOp(current, {action:'unpin', route});
if (!persist(current, accountKey)) return false;
announce(current);
render();
scheduleSync();
return true;
}
function applyPinOps(remote, operations) {
let result = normalizeList(remote, pinnedLimit);
for (const operation of [...normalizePinOps(operations)].reverse()) {
const route = operation.item?.route || operation.route;
result = operation.action === 'pin'
? [operation.item, ...result.filter(item => item.route !== route)].slice(0, pinnedLimit)
: result.filter(item => item.route !== route);
}
return result;
}
function adopt(snapshot, accountKey, pending = [], pinOps = []) {
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
const remote = normalizeList(snapshot.items);
const remotePinned = normalizeList(snapshot.pinned, pinnedLimit);
const unsent = normalizePending(pending);
const unsentPinOps = normalizePinOps(pinOps);
const value = {
items:normalizeList([...unsent, ...remote]),
pinned:applyPinOps(remotePinned, unsentPinOps),
pending:unsent,
pinOps:unsentPinOps,
};
persist(value, accountKey);
announce(value);
render();
return true;
}
async function drain(accountKey) {
while (key() === accountKey) {
const current = read();
if (!hasPending(current)) return current;
const sending = current.pending[current.pending.length - 1];
const pinOperation = sending ? null : current.pinOps[current.pinOps.length - 1];
announce(current, 'Syncing recent work…');
try {
let snapshot;
if (sending) {
snapshot = await fetchJson('api/v1/recent-work', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(normalize(sending)),
});
} else {
const isPin = pinOperation.action === 'pin';
snapshot = await fetchJson('api/v1/recent-work/pin', {
method:isPin ? 'PUT' : 'DELETE',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(isPin ? pinOperation.item : {route:pinOperation.route}),
});
}
if (key() !== accountKey) return read();
const latest = read();
const pending = sending
? latest.pending.filter(item => item.operationId !== sending.operationId)
: latest.pending;
const pinOps = pinOperation
? latest.pinOps.filter(operation => {
const sameRoute = (operation.item?.route || operation.route) === (pinOperation.item?.route || pinOperation.route);
return !sameRoute || operation.action !== pinOperation.action || operation.operationId !== pinOperation.operationId;
})
: latest.pinOps;
if (!adopt(snapshot, accountKey, pending, pinOps)) throw new Error('Recent work response is invalid.');
retryAttempt = 0;
} catch (_error) {
if (key() === accountKey) {
announce(read());
scheduleRetry(accountKey);
}
return read();
}
}
return read();
}
function sync() {
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
const accountKey = key();
if (retryTimer && retryAccount !== accountKey) clearRetry(true);
else if (retryTimer) clearRetry(false);
if (!fetchJson || !accountKey || !hasPending(read())) return Promise.resolve(read());
if (syncFlight && syncAccount === accountKey) return syncFlight;
syncAccount = accountKey;
syncFlight = drain(accountKey).finally(() => {
if (syncAccount === accountKey) { syncFlight = null; syncAccount = ''; }
});
return syncFlight;
}
async function load() {
const accountKey = key();
if (!fetchJson || !accountKey) return read();
try {
const snapshot = await fetchJson('api/v1/recent-work');
if (key() !== accountKey) return read();
const current = read();
adopt(snapshot, accountKey, current.pending, current.pinOps);
return hasPending(current) ? sync() : read();
} catch (_error) {
if (key() === accountKey) announce(read(), hasPending(read()) ? null : 'Recent work could not sync.');
return read();
}
}
function startLifecycle(lifecycle = {}) {
const reconcile = () => hasPending(read()) ? sync() : load();
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
lifecycle.document?.addEventListener?.('visibilitychange', () => {
if (!lifecycle.document.hidden) void reconcile();
});
return reconcile;
}
function detail(item) {
return item.kind === 'update'
? 'Update · #' + item.number
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
}
function row(item, isPinned) {
const itemDetail = detail(item);
const wrapper = options.document.createElement('div');
const button = options.document.createElement('button');
const action = options.document.createElement('button');
const copy = options.document.createElement('span');
const primary = options.document.createElement('strong');
const secondary = options.document.createElement('small');
wrapper.setAttribute('class', 'mobile-recent-work-row');
primary.textContent = item.title;
secondary.textContent = itemDetail;
copy.appendChild(primary);
copy.appendChild(secondary);
button.appendChild(copy);
button.setAttribute('type', 'button');
button.setAttribute('data-recent-work-route', item.route);
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + itemDetail.toLowerCase().replace(' · ', ' '));
button.addEventListener('click', () => {
if (isPinned) record(item);
options.openRoute?.(item.route);
});
action.textContent = isPinned ? 'Unpin' : 'Pin';
action.setAttribute('type', 'button');
action.setAttribute('data-recent-work-pin', isPinned ? 'unpin' : 'pin');
action.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + item.title);
action.addEventListener('click', () => isPinned ? unpin(item.route) : pin(item));
wrapper.appendChild(button);
wrapper.appendChild(action);
return wrapper;
}
function renderCurrent() {
const isPinned = currentItem && pinned().some(item => item.route === currentItem.route);
detailPins.forEach(button => {
button.hidden = !currentItem;
if (!currentItem) return;
button.textContent = isPinned ? 'Pinned' : 'Pin';
button.setAttribute('aria-pressed', isPinned ? 'true' : 'false');
button.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + currentItem.title);
button.setAttribute('data-current-work-pin', isPinned ? 'unpin' : 'pin');
});
}
function setCurrent(item) {
currentItem = normalize(item);
renderCurrent();
return Boolean(currentItem);
}
function render() {
const recent = items();
const fixed = pinned();
const fixedRoutes = new Set(fixed.map(item => item.route));
const visibleRecent = recent.filter(item => !fixedRoutes.has(item.route));
const list = options.list;
const section = options.section;
if (list && section && options.document) {
const rows = visibleRecent.map(item => row(item, false));
list.replaceChildren(...rows);
section.hidden = rows.length === 0;
}
if (options.pinnedList && options.pinnedSection && options.document) {
const visiblePins = pinsExpanded ? fixed : fixed.slice(0, 3);
const rows = visiblePins.map(item => row(item, true));
options.pinnedList.replaceChildren(...rows);
options.pinnedSection.hidden = rows.length === 0;
}
if (options.pinnedToggle) {
options.pinnedToggle.hidden = fixed.length <= 3;
options.pinnedToggle.textContent = pinsExpanded ? 'Show fewer' : 'Show all ' + fixed.length;
options.pinnedToggle.setAttribute('aria-expanded', pinsExpanded ? 'true' : 'false');
options.pinnedToggle.setAttribute('aria-controls', 'mobile-pinned-work-list');
}
renderCurrent();
return visibleRecent.length + fixed.length;
}
return {items, pinned, record, pin, unpin, setCurrent, render, load, sync, startLifecycle, state};
});
+81
View File
@@ -0,0 +1,81 @@
function createMobileReviewDetailNavigation(options) {
const buttons = options.buttons || {};
const targets = options.targets || {};
const listeners = new Map();
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
let observer = null;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (!button) return;
if (key === name) button.setAttribute('aria-current', 'location');
else button.removeAttribute('aria-current');
});
}
function navigate(name, navigationOptions = {}) {
const target = targets[name];
if (!target) return false;
target.scrollIntoView({
block:'start',
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
});
if (name === 'feedback' && options.summaryComposer && navigationOptions.focus !== false) {
options.summaryComposer.focus({ preventScroll:true });
}
select(name);
return true;
}
function reset() {
select('overview');
}
return {
start() {
Object.entries(buttons).forEach(([name, button]) => {
if (!button || listeners.has(button)) return;
const listener = event => {
event.preventDefault();
navigate(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
};
listeners.set(button, listener);
button.addEventListener('click', listener);
});
reset();
const observe = options.observe || ((handler, observedTargets, root) => {
if (typeof IntersectionObserver === 'undefined') return null;
const instance = new IntersectionObserver(handler, {
root:root || null,
rootMargin:'-20% 0px -60% 0px',
threshold:[0, 0.25, 0.5, 0.75, 1],
});
observedTargets.forEach(target => instance.observe(target));
return instance;
});
observer = observe(entries => {
const visible = entries
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visible) {
const name = targetNames.get(visible.target);
select(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
}
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
if (observer) observer.disconnect();
observer = null;
},
navigate,
reset,
select,
};
}
if (typeof module !== 'undefined') module.exports = createMobileReviewDetailNavigation;
+83
View File
@@ -0,0 +1,83 @@
(function (root, factory) {
const createMobileSearchModal = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = createMobileSearchModal;
else root.createMobileSearchModal = createMobileSearchModal;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const FOCUSABLE = [
'a[href]', 'button', 'input', 'select', 'textarea',
'[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]',
].join(',');
return function createMobileSearchModal(options) {
const documentRef = options.document;
const backgrounds = Array.from(options.backgrounds || [
documentRef.querySelector?.('body > header'),
documentRef.querySelector?.('main'),
documentRef.querySelector?.('#mobile-task-dock'),
]).filter(Boolean);
let surface = null;
let opener = null;
let priorInert = null;
function focusable() {
if (!surface || typeof surface.querySelectorAll !== 'function') return [];
return Array.from(surface.querySelectorAll(FOCUSABLE)).filter(element =>
!element.disabled && !element.hidden && element.getAttribute?.('aria-hidden') !== 'true' &&
(typeof element.matches !== 'function' || element.matches(':not([hidden])')) &&
(typeof element.getClientRects !== 'function' || element.getClientRects().length > 0)
);
}
function focus(element) {
if (element?.isConnected !== false && typeof element?.focus === 'function') element.focus();
}
function onKeydown(event) {
if (!surface || event.key !== 'Tab') return;
const controls = focusable();
if (!controls.length) {
event.preventDefault();
return;
}
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && documentRef.activeElement === first) {
event.preventDefault();
focus(last);
} else if (!event.shiftKey && documentRef.activeElement === last) {
event.preventDefault();
focus(first);
} else if (!controls.includes(documentRef.activeElement)) {
event.preventDefault();
focus(event.shiftKey ? last : first);
}
}
function transition(nextSurface, transitionOptions = {}) {
surface = nextSurface;
focus(transitionOptions.initialFocus || focusable()[0]);
}
function activate(nextSurface, activateOptions = {}) {
if (!surface) {
opener = activateOptions.opener || documentRef.activeElement || null;
priorInert = backgrounds.map(element => element.inert === true);
backgrounds.forEach(element => { element.inert = true; });
}
transition(nextSurface, activateOptions);
}
function deactivate() {
if (!surface) return;
backgrounds.forEach((element, index) => { element.inert = priorInert[index]; });
surface = null;
priorInert = null;
const restore = opener;
opener = null;
focus(restore);
}
documentRef.addEventListener('keydown', onKeydown);
return { activate, transition, deactivate, current: () => surface };
};
});
+132
View File
@@ -0,0 +1,132 @@
function createMobileSearchPreviewNavigation(options) {
const buttons = options.buttons || {};
const targets = options.targets || {};
const focusTargets = options.focusTargets || {};
const listeners = new Map();
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
let replyAvailable = options.replyAvailable !== false;
let observer = null;
let navigationLockUntil = 0;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (!button) return;
if (key === name) button.setAttribute('aria-current', 'location');
else button.removeAttribute('aria-current');
});
}
function setReplyAvailable(available) {
replyAvailable = Boolean(available);
const button = buttons.reply;
if (!button) return;
if (replyAvailable) button.removeAttribute('aria-disabled');
else button.setAttribute('aria-disabled', 'true');
}
function navigate(name) {
if (name === 'reply' && !replyAvailable) return false;
const target = targets[name];
if (!target) return false;
target.scrollIntoView({
block: 'start',
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
navigationLockUntil = Date.now() + 500;
select(name);
const focusTarget = focusTargets[name] || (name === 'reply' ? target : null);
if (focusTarget) focusTarget.focus({preventScroll:true});
return true;
}
function reset(available = replyAvailable) {
setReplyAvailable(available);
select('overview');
}
return {
start() {
Object.entries(buttons).forEach(([name, button]) => {
if (!button || listeners.has(button)) return;
const listener = event => {
event.preventDefault();
navigate(name);
};
listeners.set(button, listener);
button.addEventListener('click', listener);
});
reset(replyAvailable);
const observe = options.observe || ((handler, observedTargets, root) => {
if (typeof IntersectionObserver === 'undefined') return null;
const instance = new IntersectionObserver(handler, {
root,
rootMargin: '-20% 0px -60% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1],
});
observedTargets.forEach(target => instance.observe(target));
return instance;
});
observer = observe(entries => {
if (Date.now() < navigationLockUntil) return;
const visible = entries
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
.filter(entry => targetNames.get(entry.target) !== 'reply' || replyAvailable)
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visible) select(targetNames.get(visible.target));
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
if (observer) observer.disconnect();
observer = null;
},
navigate,
reset,
select,
setReplyAvailable,
};
}
function attachMobileSearchPreviewNavigation({document, window}) {
const bySection = name => document.querySelector('[data-search-preview-section="' + name + '"]');
const preview = document.getElementById('search-preview');
const reply = document.getElementById('search-preview-reply-workspace');
const navigation = createMobileSearchPreviewNavigation({
root:document.querySelector('.search-preview-panel'),
buttons:{
overview:bySection('overview'),
conversation:bySection('conversation'),
changes:bySection('changes'),
reply:bySection('reply'),
actions:bySection('actions'),
},
targets:{
overview:document.getElementById('search-preview-overview'),
conversation:document.getElementById('search-preview-conversation'),
changes:document.getElementById('search-preview-review'),
reply,
actions:document.getElementById('search-preview-actions'),
},
focusTargets:{reply:document.getElementById('search-preview-reply')},
replyAvailable:!reply.hidden,
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
navigation.start();
new MutationObserver(() => navigation.setReplyAvailable(!reply.hidden)).observe(reply, {
attributes:true, attributeFilter:['hidden'],
});
new MutationObserver(() => {
if (preview.classList.contains('open')) navigation.reset(!reply.hidden);
}).observe(preview, {attributes:true, attributeFilter:['class']});
['previous-search-result', 'next-search-result'].forEach(id => {
document.getElementById(id).addEventListener('click', () => {
setTimeout(() => navigation.reset(!reply.hidden), 0);
});
});
return navigation;
}
if (typeof module !== 'undefined') module.exports = createMobileSearchPreviewNavigation;
else attachMobileSearchPreviewNavigation({document, window});
+68 -9
View File
@@ -5,16 +5,52 @@
const checkpointKey = 'stackchain.mobile-start-day.v1';
const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
const reviewOrder = [
['delivery', 'Delivery recovery'],
['gate', 'Human Gates'],
['agenda', 'Agenda'],
['attention', 'Attention'],
['update', 'Updates'],
['filed', 'Filed'],
['following', 'Following'],
];
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');
@@ -68,18 +104,39 @@
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);
let phases = reviewPhases(counts);
const gateUnavailable = counts.gateUnavailable === true;
if (gateUnavailable) {
phases = phases.filter(phase => phase.name !== 'gate');
const afterDelivery = phases[0]?.name === 'delivery' ? 1 : 0;
phases.splice(afterDelivery, 0, {name:'gate', label:'Human Gates unavailable · retry', count:count(counts.gate)});
}
const followingUnavailable = counts.followingUnavailable === true;
if (followingUnavailable) {
phases = phases.filter(phase => phase.name !== 'following');
phases.push({name:'following', label:'Following unavailable', count:count(counts.following)});
}
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
const today = count(counts.today);
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 = phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work');
const nextLabel = next === 'delivery' ? 'Review Delivery' :
(next === 'gate' && gateUnavailable ? 'Retry Human Gates' :
(next === 'following' && followingUnavailable ? 'Retry Following' :
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'))));
const gateRetry = next === 'gate' && gateUnavailable;
const followingRetry = next === 'following' && followingUnavailable;
return {
total,
next,
label: nextLabel,
summary: total ? total + ' items before Today · ' + today + ' planned' :
summary: gateRetry ? 'Human Gates need retry before Today · ' + today + ' planned' :
followingRetry ? 'Following needs retry before Today · ' + today + ' planned' :
delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
' · ' + today + ' planned' :
total ? total + ' items before Today · ' + today + ' planned' :
(today ? 'Review clear · ' + today + ' planned' : 'Review clear · Today is empty'),
phases,
};
@@ -107,9 +164,9 @@
return true;
}
function reconcile({authoritative = false} = {}) {
function reconcile({authoritative = false, authoritativePhases = []} = {}) {
const saved = checkpoint();
if (!authoritative || !saved?.phase) return false;
if (!saved?.phase || (!authoritative && !authoritativePhases.includes(saved.phase))) return false;
const counts = options.getCounts ? options.getCounts() : {};
if (count(counts[saved.phase]) > 0) return false;
return completePhase(saved.phase);
@@ -128,14 +185,16 @@
options.elements.phases.textContent = current.phases.length ?
current.phases.map(phase => phase.label + ' ' + phase.count).join(' · ') :
'All urgent queues reviewed';
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
if (options.elements.action) {
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
}
if (options.elements.finish) options.elements.finish.hidden = !checkpoint();
return current;
}
function start() {
render();
if (options.elements) options.elements.action.addEventListener('click', startNext);
if (options.elements?.action) options.elements.action.addEventListener('click', startNext);
}
return {briefing, completePhase, finish, reconcile, render, start, startNext, state};
+25 -11
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,17 @@
function updateWork(mode) {
const text = {
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
prepare:'Prepare', 'prepare-resume':'Resume prep',
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda',
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
}[mode] || 'Work';
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Following', 'My PRs', '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) {
@@ -91,9 +98,9 @@
}
function updateQueues(counts) {
const names = 'today agenda attention update filed later draft'.split(' ');
const names = 'today agenda delivery gate attention update following filed authored later draft'.split(' ');
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
const actionableNames = ['today', 'attention', 'update', 'filed', 'later', 'draft'];
const actionableNames = ['today', 'delivery', 'gate', 'attention', 'update', 'following', 'filed', 'authored', 'later', 'draft'];
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
element.textContent = String(normalized[name] || 0);
@@ -101,6 +108,9 @@
options.queueRows?.update?.setAttribute(
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
);
options.queueRows?.following?.setAttribute(
'aria-label', 'Following, ' + normalized.following + ' unseen changes'
);
if (options.queueBadge) {
options.queueBadge.textContent = active + ' active';
options.queueBadge.hidden = active === 0;
@@ -117,9 +127,13 @@
? 'Queues: no active queues; no upcoming deadlines'
: 'Queues: Today ' + normalized.today
+ ', Agenda ' + normalized.agenda + ' due'
+ ', Delivery ' + normalized.delivery
+ ', Human Gates ' + normalized.gate
+ ', Attention ' + normalized.attention
+ ', Updates ' + normalized.update
+ ', Following ' + normalized.following
+ ', Filed ' + normalized.filed
+ ', My PRs ' + normalized.authored
+ ', Later ' + normalized.later
+ ', Drafts ' + normalized.draft
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
+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() };
});
+88
View File
@@ -0,0 +1,88 @@
function createMobileUpdateDetailNavigation(options) {
const buttons = options.buttons || {};
const targets = options.targets || {};
const listeners = new Map();
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
let observer = null;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (!button) return;
if (key === name) button.setAttribute('aria-current', 'location');
else button.removeAttribute('aria-current');
});
}
function navigate(name, navigationOptions = {}) {
if (name === 'activity') {
if (options.jumpToNewActivity) options.jumpToNewActivity();
select(name);
return true;
}
const target = targets[name];
if (!target) return false;
if (name === 'context') target.open = true;
target.scrollIntoView({
block:'start',
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
});
if (name === 'reply' && options.replyComposer && navigationOptions.focus !== false) {
options.replyComposer.focus({ preventScroll:true });
}
select(name);
return true;
}
function reset() {
if (targets.context) targets.context.open = false;
select('activity');
}
return {
start() {
Object.entries(buttons).forEach(([name, button]) => {
if (!button || listeners.has(button)) return;
const listener = event => {
event.preventDefault();
navigate(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
};
listeners.set(button, listener);
button.addEventListener('click', listener);
});
reset();
const observe = options.observe || ((handler, observedTargets) => {
if (typeof IntersectionObserver === 'undefined') return null;
const instance = new IntersectionObserver(handler, {
root:options.root || null,
rootMargin:'-20% 0px -60% 0px',
threshold:[0, 0.25, 0.5, 0.75, 1],
});
observedTargets.forEach(target => instance.observe(target));
return instance;
});
observer = observe(entries => {
const visible = entries
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visible) {
const name = targetNames.get(visible.target);
select(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
}
}, Array.from(targetNames.keys()).filter(Boolean));
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
if (observer) observer.disconnect();
observer = null;
},
navigate,
reset,
select,
};
}
if (typeof module !== 'undefined') module.exports = createMobileUpdateDetailNavigation;
+7 -1
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 && recommended.name !== 'today') return recommended.name;
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;
+6 -1
View File
@@ -71,6 +71,7 @@ function buildMyWork(data, now = new Date()) {
const assigned = (item.assignees || []).includes(login);
const isReview = (item.work_reasons || []).includes('review_requested');
const isFiled = (item.work_reasons || []).includes('created_by_me');
const isAuthored = (item.work_reasons || []).includes('authored_by_me');
const isCompleted = isFiled && item.state === 'closed';
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
const normalized = {
@@ -78,6 +79,7 @@ function buildMyWork(data, now = new Date()) {
key: (item.repository || 'unknown') + '#' + item.number,
is_review: isReview,
is_filed: isFiled,
is_authored: isAuthored,
is_completed: isCompleted,
is_assigned: assigned,
has_update: false,
@@ -85,7 +87,8 @@ function buildMyWork(data, now = new Date()) {
reason: priorityLabel ? priorityLabel + ' priority' :
(due && due.priority < 4 ? due.label :
(isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' :
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work'))))),
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' :
(isAuthored ? 'Authored by you' : 'Open work')))))),
_priority: priorityLabel ? 0 :
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))),
};
@@ -657,6 +660,7 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
let filtered = items;
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
else if (selectedFilter === 'filed') filtered = items.filter((item) => item.is_filed);
else if (selectedFilter === 'authored') filtered = items.filter((item) => item.is_authored);
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
@@ -1080,6 +1084,7 @@ function countMyWork(items) {
all: items.length,
attention: items.filter(needsAttention).length,
filed: items.filter((item) => item.is_filed).length,
authored: items.filter((item) => item.is_authored).length,
issue: items.filter((item) => item.kind === 'issue').length,
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
review: items.filter((item) => item.is_review).length,
+80
View File
@@ -0,0 +1,80 @@
(function (root, factory) {
const createPhotoDraftInbox = factory();
if (typeof module === 'object' && module.exports) module.exports = createPhotoDraftInbox;
else root.createPhotoDraftInbox = createPhotoDraftInbox;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
function targetKey(route) {
if (route?.kind === 'update') {
const notificationId = Number(route.notification_id || 0);
return notificationId > 0 ? 'update:' + notificationId : '';
}
const kind = route?.kind === 'search' ? route.target_kind : route?.kind;
const repository = String(route?.repository || '');
const number = Number(route?.number || 0);
return ['issue', 'pull'].includes(kind) && repository && number > 0 ?
kind + ':' + repository.toLowerCase() + '#' + number : '';
}
function removeTarget(item) {
const route = item?.route || {};
if (route.kind === 'update') return { kind:'update', notificationId:Number(route.notification_id) };
return {
kind:route.kind === 'search' ? route.target_kind : route.kind,
repository:route.repository,
number:Number(route.number),
};
}
return function createPhotoDraftInbox({ conversation, search }) {
let items = [];
async function refresh(existing = []) {
const occupied = new Set(existing.map(item => targetKey(item?.route)).filter(Boolean));
const results = await Promise.allSettled([
conversation?.list?.() || [],
search?.list?.() || [],
]);
items = results.flatMap(result => result.status === 'fulfilled' && Array.isArray(result.value) ? result.value : [])
.filter(item => {
const key = targetKey(item?.route);
if (!key || occupied.has(key)) return false;
occupied.add(key);
return true;
})
.sort((left, right) => Number(right.updated_at || 0) - Number(left.updated_at || 0) ||
String(left.id || '').localeCompare(String(right.id || '')));
return items.slice();
}
function list() { return items.slice(); }
async function discard(item) {
if (!item || !items.some(candidate => candidate.id === item.id && candidate.photo_store === item.photo_store)) {
return false;
}
const store = item.photo_store === 'search' ? search : conversation;
if (!store?.remove) return false;
await store.remove(removeTarget(item));
items = items.filter(candidate => !(candidate.id === item.id && candidate.photo_store === item.photo_store));
return true;
}
function description(item) {
return item?.kind === 'photo-reply' ?
Number(item.photo_count || 0) + (Number(item.photo_count) === 1 ? ' saved photo' : ' saved photos') :
(item?.preview || 'Unfinished draft');
}
function searchTarget(item) {
const route = item?.route || {};
return {
kind:route.target_kind, repository:route.repository,
number:route.number, title:item?.title,
};
}
return { refresh, list, discard, description, searchTarget };
};
});
+43
View File
@@ -0,0 +1,43 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.inspectStackchainPrivateDatabases = factory(root.indexedDB);
})(typeof globalThis !== 'undefined' ? globalThis : this, function createPrivateDataInspector(indexedDB) {
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Private storage inventory failed.'));
request.onblocked = () => reject(new Error('Private storage inventory was blocked.'));
});
}
async function countDatabase(name) {
const database = await requestResult(indexedDB.open(name));
try {
const storeNames = Array.from(database.objectStoreNames);
if (!storeNames.length) return 0;
const transaction = database.transaction(storeNames, 'readonly');
const counts = await Promise.all(storeNames.map(storeName =>
requestResult(transaction.objectStore(storeName).count())
));
return counts.reduce((total, count) => total + Number(count || 0), 0);
} finally {
database.close();
}
}
return async function inspectPrivateDatabases(registeredNames) {
if (!indexedDB?.databases) return { recordCount: 0, unavailable: true };
try {
const existing = new Set((await indexedDB.databases()).map(database => database.name));
const counts = await Promise.all(
registeredNames.filter(name => existing.has(name)).map(countDatabase)
);
return {
recordCount: counts.reduce((total, count) => total + count, 0),
unavailable: false,
};
} catch (_error) {
return { recordCount: 0, unavailable: true };
}
};
});
+5
View File
@@ -3,6 +3,11 @@
'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',
]);
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
else root.stackchainPrivateDatabases = databases;
+1 -1
View File
@@ -55,9 +55,9 @@
}
return async function clearPrivateDeviceData() {
await stopWorkerOutbox();
removeOwnedStorage(localStorage);
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
await stopWorkerOutbox();
for (const name of privateDatabases) await deletePrivateDatabase(name);
const keys = await caches?.keys?.() || [];
await Promise.all(
+182
View File
@@ -0,0 +1,182 @@
function createProgressiveCapture({
document,
storage,
getLogin = () => '',
createCaptures = globalThis.createUnfiledCaptures,
requestFullWorkspace = async () => {
const lifecycle = await globalThis.stackchainWorkspaceLifecycle;
return lifecycle?.optionalReady;
},
createId,
now,
}) {
const button = document.querySelector('[data-mobile-task="new"]');
const root = document.querySelector('#create-issue-sheet');
const title = document.querySelector('#create-issue-title');
const body = document.querySelector('#create-issue-body');
const heading = document.querySelector('#create-issue-heading');
const save = document.querySelector('#save-unfiled-issue');
const file = document.querySelector('#file-new-issue');
const cancel = document.querySelector('#cancel-new-issue');
const status = document.querySelector('#my-work-action-status');
const captures = createCaptures({
storage,
getCaptureLogin:getLogin,
getCurrentLogin:getLogin,
...(createId ? {createId} : {}),
...(now ? {now} : {}),
});
const listeners = [];
let started = false;
let saving = null;
let saved = false;
let handedOff = false;
const checkpointKey = 'stackchain.progressive-capture.v1';
function listen(element, name, callback) {
element?.addEventListener?.(name, callback);
listeners.push([element, name, callback]);
}
function close() {
root?.classList.remove('open');
}
function readCheckpoints() {
try {
const record = JSON.parse(storage?.getItem(checkpointKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && typeof item.ownerLogin === 'string' &&
typeof item.title === 'string' && typeof item.body === 'string');
} catch (_error) { return []; }
}
function readCheckpoint() {
const login = String(getLogin() || '').trim();
if (!login) return null;
return readCheckpoints().find(item => item.ownerLogin === login) || null;
}
function checkpoint() {
const ownerLogin = String(getLogin() || '').trim();
if (!ownerLogin) return false;
try {
const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin);
items.push({
ownerLogin,
title:String(title?.value || '').slice(0, 255),
body:String(body?.value || '').slice(0, 10000),
});
storage?.setItem(checkpointKey, JSON.stringify({
version:1, items,
}));
return true;
} catch (_error) { return false; }
}
function clearCheckpoint(expected) {
const current = readCheckpoint();
if (!current || current.title !== expected.title || current.body !== expected.body) return false;
try {
const ownerLogin = String(getLogin() || '').trim();
const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin);
if (items.length) storage?.setItem(checkpointKey, JSON.stringify({version:1, items}));
else storage?.removeItem(checkpointKey);
return true;
} catch (_error) { return false; }
}
function open(event) {
saved = false;
const recovered = readCheckpoint();
if (recovered) {
if (title) title.value = recovered.title;
if (body) body.value = recovered.body;
if (status) status.textContent = 'Unfinished capture restored from this phone.';
}
if (heading) heading.textContent = 'Capture work';
root?.classList.add('open');
title?.focus?.();
}
async function saveDraft() {
if (saved || saving) return saving;
save.disabled = true;
const draft = {title:title?.value || '', body:body?.value || ''};
saving = Promise.resolve().then(() => captures.save(draft)).then(() => {
saved = true;
clearCheckpoint(draft);
if (title) title.value = '';
if (body) body.value = '';
close();
if (status) status.textContent = 'Saved to Drafts. Choose a repository when you’re ready to file it.';
return true;
}).catch(error => {
if (status) status.textContent = error.message;
title?.focus?.();
return false;
}).finally(() => {
save.disabled = false;
saving = null;
});
return saving;
}
async function fileNow() {
if (file.disabled) return false;
file.disabled = true;
if (status) status.textContent = 'Loading filing tools…';
try {
await requestFullWorkspace();
return true;
} catch (_error) {
file.disabled = false;
if (status) status.textContent = 'Filing tools unavailable. Your capture is still editable; retry when connected.';
title?.focus?.();
return false;
}
}
function start() {
if (started) return false;
started = true;
root?.classList.add('progressive-capture');
listen(button, 'click', open);
listen(save, 'click', saveDraft);
listen(file, 'click', fileNow);
listen(cancel, 'click', close);
listen(title, 'input', checkpoint);
listen(body, 'input', checkpoint);
return true;
}
function handoff() {
if (handedOff) return null;
handedOff = true;
const state = {
open:Boolean(root?.classList.contains('open')),
title:title?.value || '', body:body?.value || '',
};
state.complete = () => clearCheckpoint(state);
root?.classList.remove('progressive-capture');
file.disabled = false;
listeners.forEach(([element, name, callback]) => element?.removeEventListener?.(name, callback));
listeners.length = 0;
return state;
}
return {start, handoff};
}
if (typeof window !== 'undefined' && typeof document !== 'undefined' &&
typeof createUnfiledCaptures === 'function' &&
window.matchMedia?.('(max-width: 600px)').matches === true) {
window.stackchainProgressiveCapture = createProgressiveCapture({
document,
storage:window.localStorage,
getLogin:() => window.stackchainProgressiveMyWork?.login?.() || '',
});
window.stackchainProgressiveCapture.start();
}
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveCapture;
+110
View File
@@ -0,0 +1,110 @@
function createProgressiveHumanGates(options = {}) {
const document = options.document || globalThis.document;
const location = options.location || globalThis.location;
const history = options.history || globalThis.history;
const storage = options.storage || globalThis.localStorage;
const isOnline = options.isOnline || (() => globalThis.navigator?.onLine !== false);
const fetchJson = options.fetchJson || (async (path, requestOptions = {}) => {
const response = await fetch(path, requestOptions);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
return payload;
});
const liveSnapshot = options.liveSnapshot || null;
const getIdentity = options.getIdentity || (async () => {
const response = liveSnapshot ? await liveSnapshot.acquire({requireIdentity:true}) :
await fetchJson('api/v1/live', {headers:{Accept:'application/json'}});
const user = response?.context?.user || {};
const login = String(user.login || '').trim();
return {login, accountKey:login && user.id ? String(user.id) + ':' + login : login};
});
const query = selector => document.querySelector(selector);
const nodes = {
count:query('#human-gates-count'), list:query('#human-gates-list'),
status:query('#human-gates-status'), panel:query('#human-gates'),
detail:query('#human-gate-detail'),
pendingTab:query('#human-gates-pending'), historyTab:query('#human-gates-history'),
};
let login = '';
let accountKey = '';
let started = false;
let startFlight = null;
const controller = createHumanGates({
storage, isOnline, location, fetchJson,
getLogin:() => login, getAccountKey:() => accountKey, nodes,
});
const showError = error => {
if (nodes.status) nodes.status.textContent = error?.message || 'Human Gates are unavailable.';
};
const open = () => start(true).catch(showError);
query('#open-human-gates')?.addEventListener?.('click', open);
query('#close-human-gates')?.addEventListener?.('click', () => {
if (nodes.panel) nodes.panel.hidden = true;
if (location.hash === '#/my-work/human-gates') history.replaceState({}, '', '#/my-work');
});
nodes.list?.addEventListener?.('click', event => {
const card = event.target?.closest?.('[data-human-gate-id]');
if (card) controller.select(card.dataset.humanGateId);
});
nodes.detail?.addEventListener?.('click', event => {
const recovery = event.target?.closest?.('[data-gate-recover]');
if (recovery) {
recovery.disabled = true;
controller.recoverDecision().catch(error => {
showError(error);
recovery.disabled = false;
});
return;
}
const decision = event.target?.closest?.('[data-gate-decision]')?.dataset.gateDecision;
if (!decision) return;
controller.submitDecision(decision).catch(error => {
if (!error?.targetSelector) showError(error);
});
});
nodes.pendingTab?.addEventListener?.('click', () => controller.showPending());
nodes.historyTab?.addEventListener?.('click', () => controller.showHistory().catch(showError));
async function start(force = false) {
if (!force && location.hash !== '#/my-work/human-gates') return false;
if (started) return controller.open().then(() => true);
if (startFlight) return startFlight;
startFlight = (async () => {
const identity = await getIdentity();
login = String(identity?.login || '').trim();
accountKey = String(identity?.accountKey || login).trim();
if (!login) throw new Error('Authenticated account identity is required.');
await controller.open();
started = true;
return true;
})();
try { return await startFlight; }
finally { startFlight = null; }
}
return {
start,
handoff() {
return {
controller, started,
adoptIdentity(nextLogin, nextAccountKey) {
login = String(nextLogin || '').trim();
accountKey = String(nextAccountKey || login).trim();
},
};
},
};
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.stackchainProgressiveHumanGates = createProgressiveHumanGates({
document, liveSnapshot:window.stackchainProgressiveLiveSnapshot,
});
void window.stackchainProgressiveHumanGates.start().catch(() => {});
}
if (typeof module !== 'undefined' && module.exports) {
globalThis.createHumanGates = globalThis.createHumanGates || require('./human-gates.js');
module.exports = createProgressiveHumanGates;
}
+49
View File
@@ -0,0 +1,49 @@
function createProgressiveLiveSnapshot({fetchSnapshot}) {
let value = null;
let flight = null;
const hasIdentity = snapshot => {
const user = snapshot?.context?.user || {};
return Boolean(String(user.login || '').trim());
};
const requireUsable = (snapshot, options) => {
if (!options?.requireIdentity || hasIdentity(snapshot)) return snapshot;
if (value === snapshot) value = null;
throw new Error('Authenticated account identity is unavailable.');
};
const acquire = (options = {}) => {
if (value) return Promise.resolve().then(() => requireUsable(value, options));
if (flight) return flight.then(snapshot => requireUsable(snapshot, options));
flight = Promise.resolve().then(() => fetchSnapshot()).then(snapshot => {
value = snapshot;
flight = null;
return snapshot;
}, error => {
flight = null;
throw error;
});
return flight.then(snapshot => requireUsable(snapshot, options));
};
return {
acquire,
snapshot:() => value,
pending:() => flight,
identity() {
const user = value?.context?.user || {};
const login = String(user.login || '').trim();
return {login, accountKey:login && user.id ? String(user.id) + ':' + login : login};
},
};
}
if (typeof window !== 'undefined') {
window.stackchainProgressiveLiveSnapshot = createProgressiveLiveSnapshot({
fetchSnapshot:async () => {
const response = await fetch('api/v1/live', {headers:{Accept:'application/json'}});
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
},
});
}
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveLiveSnapshot;
+119
View File
@@ -0,0 +1,119 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createProgressiveMobileDock = factory;
})(typeof self !== 'undefined' ? self : this, function createProgressiveMobileDock(options) {
const document = options.document;
const myWork = options.myWork;
const work = document.querySelector('[data-mobile-task="work"]');
const queues = document.querySelector('[data-mobile-task="queues"]');
const sheet = document.querySelector('#mobile-queue-sheet');
const close = document.querySelector('#close-mobile-queues');
const next = document.querySelector('#mobile-queue-next-action');
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
.map(row => [row.dataset.mobileQueue, row]));
const countElements = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue-count]'))
.map(element => [element.dataset.mobileQueueCount, element]));
const originalDescriptions = Object.fromEntries(Object.entries(rows)
.map(([name, row]) => [name, row.querySelector?.('small')?.textContent || '']));
const originalCounts = Object.fromEntries(Object.entries(countElements)
.map(([name, element]) => [name, element.textContent]));
const supported = new Set(['all', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update']);
const listeners = [];
let started = false;
let lastTask = null;
let unsubscribe = null;
function listen(element, name, listener) {
if (!element) return;
element.addEventListener(name, listener);
listeners.push([element, name, listener]);
}
function render() {
const counts = myWork?.counts?.() || {};
Object.entries(rows).forEach(([name, row]) => {
const count = Math.max(0, Number(counts[name]) || 0);
const countElement = countElements[name];
if (supported.has(name)) {
row.removeAttribute('data-unavailable');
row.removeAttribute('data-progressive-loading');
if (countElement) countElement.textContent = String(count);
return;
}
row.setAttribute('data-unavailable', 'true');
row.setAttribute('data-progressive-loading', 'true');
const description = row.querySelector?.('small');
if (description) description.textContent = 'Still loading';
if (countElement) countElement.textContent = '…';
});
const actionable = ['attention', 'filed', 'authored', 'review', 'update']
.find(name => Number(counts[name]) > 0);
if (next) {
if (actionable) {
next.textContent = 'Open ' + (actionable === 'authored' ? 'My PRs' : actionable[0].toUpperCase() + actionable.slice(1))
+ ' (' + counts[actionable] + ')';
next.dataset.queue = actionable;
next.removeAttribute('data-unavailable');
} else {
next.textContent = Number(counts.all) > 0 ? 'Open assigned work (' + counts.all + ')' : 'Assigned work is still loading';
next.dataset.queue = 'all';
if (!Number(counts.all)) next.setAttribute('data-unavailable', 'true');
}
}
}
function openSheet() {
lastTask = 'queues';
render();
if (sheet && !sheet.open) sheet.showModal();
}
function openQueue(name) {
if (!supported.has(name)) return false;
lastTask = 'work';
if (sheet?.open) sheet.close();
myWork?.selectQueue?.(name, {openFirst:true});
return true;
}
function start() {
if (started) return false;
started = true;
listen(work, 'click', () => { lastTask = 'work'; myWork?.openFirst?.(); });
listen(queues, 'click', openSheet);
listen(close, 'click', () => sheet?.open && sheet.close());
Object.entries(rows).forEach(([name, row]) => listen(row, 'click', () => openQueue(name)));
listen(next, 'click', () => openQueue(next.dataset.queue || 'all'));
unsubscribe = myWork?.subscribe?.(render) || null;
render();
return true;
}
function handoff() {
return {queueSheetOpen:Boolean(sheet?.open), lastTask};
}
function stop() {
listeners.splice(0).forEach(([element, name, listener]) => element.removeEventListener(name, listener));
unsubscribe?.();
unsubscribe = null;
Object.entries(rows).forEach(([name, row]) => {
row.removeAttribute('data-unavailable');
row.removeAttribute('data-progressive-loading');
const description = row.querySelector?.('small');
if (description) description.textContent = originalDescriptions[name];
if (countElements[name]) countElements[name].textContent = originalCounts[name];
});
started = false;
}
return {start, stop, handoff, render, openQueue};
});
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.stackchainProgressiveMobileDock = createProgressiveMobileDock({
document,
myWork:window.stackchainProgressiveMyWork,
});
window.stackchainProgressiveMobileDock.start();
}
+278
View File
@@ -0,0 +1,278 @@
function createProgressiveMyWork({
document, fetchSnapshot, liveSnapshot: snapshotBroker = null,
pollerOptions = {}, lifecycleTarget = globalThis,
}) {
const list = document.querySelector('#my-work-list');
const status = document.querySelector('#my-work-status');
const detail = document.querySelector('#progressive-work-detail');
const detailTitle = document.querySelector('#progressive-work-detail-title');
const detailMeta = document.querySelector('#progressive-work-detail-meta');
const detailReason = document.querySelector('#progressive-work-detail-reason');
const closeDetail = document.querySelector('#close-progressive-work-detail');
const openGitea = document.querySelector('#open-progressive-work-gitea');
const filters = Array.from(document.querySelectorAll('[data-work-filter]'));
const listeners = [];
const lifecycleListeners = [];
const subscribers = new Set();
let items = [];
let active = 'all';
let stopped = false;
let selectedByUser = false;
let liveSnapshot = null;
let liveSnapshotPromise = null;
let confirmedLogin = '';
let poller = null;
let detailTrigger = null;
let openWork = null;
let contextUnavailable = false;
const deferredQueues = {
today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts',
};
const fetchProgressiveSnapshot = (revisions = {}, options = {}) =>
snapshotBroker && Object.keys(revisions || {}).length === 0 ?
snapshotBroker.acquire() : fetchSnapshot(revisions, options);
const escapeHtml = value => String(value || '').replace(/[&<>"']/g, character => ({
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
})[character]);
const safeUrl = value => {
try {
const url = new URL(String(value || ''), globalThis.location?.href || 'https://invalid.example/');
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_error) { return ''; }
};
const matches = (item, filter) =>
filter === 'review' ? item.is_review :
filter === 'update' ? item.has_update :
filter === 'attention' ? item.needs_attention :
filter === 'filed' ? item.is_filed :
filter === 'authored' ? item.is_authored :
filter === 'pull' ? item.kind === 'pull' && !item.is_review : item.kind === filter;
const visibleItems = () => active === 'all' ? items : items.filter(item => matches(item, active));
const notify = () => subscribers.forEach(subscriber => subscriber());
const queueCounts = () => ({
all:items.length,
filed:items.filter(item => matches(item, 'filed')).length,
authored:items.filter(item => matches(item, 'authored')).length,
attention:items.filter(item => matches(item, 'attention')).length,
update:items.filter(item => matches(item, 'update')).length,
review:items.filter(item => matches(item, 'review')).length,
});
const closeProgressiveDetail = ({ restoreFocus = true } = {}) => {
if (!detail || detail.hidden) return;
detail.hidden = true;
if (restoreFocus) detailTrigger?.focus?.();
detailTrigger = null;
openWork = null;
};
const openProgressiveDetail = (item, trigger) => {
if (!detail || !item) return;
detailTrigger = trigger;
openWork = item;
detailTitle.textContent = item.title || item.key || 'Untitled work';
detailMeta.textContent = (item.key || 'Unknown work item') + ' · ' +
(item.kind === 'pull' ? 'Pull request' : 'Issue');
detailReason.textContent = item.reason || 'Assigned to you';
const href = safeUrl(item.url);
openGitea.hidden = !href;
if (href) openGitea.href = href;
else openGitea.removeAttribute?.('href');
detail.hidden = false;
closeDetail?.focus?.();
};
const updateCounts = () => {
['all','attention','filed','authored','issue','pull','review','update'].forEach(filter => {
const count = filter === 'all' ? items.length : items.filter(item => matches(item, filter)).length;
const element = document.querySelector('[data-work-count="' + filter + '"]');
if (element) element.textContent = String(count);
});
};
const render = () => {
if (stopped || !list) return;
const visible = visibleItems();
list.innerHTML = contextUnavailable && !items.length ?
'<div class="muted">Assigned work is reconnecting…</div>' : deferredQueues[active] ?
'<div class="muted">' + deferredQueues[active] + ' is still loading…</div>' : visible.length ? visible.map((item, index) => {
const title = escapeHtml(item.title || item.key || 'Untitled work');
const context = escapeHtml(item.key || '');
const reason = escapeHtml(item.reason || 'Assigned to you');
return '<article class="my-work-card progressive-my-work-card">' +
'<button class="my-work-card-main" type="button" data-progressive-work-index="' + index + '">' +
'<strong>' + title + '</strong><span class="small">' + context + ' · ' + reason + '</span>' +
'</button></article>';
}).join('') : '<div class="muted">No work in this queue.</div>';
filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active)));
};
filters.forEach(button => {
const listener = () => { active = button.dataset.workFilter || 'all'; selectedByUser = true; render(); };
button.addEventListener('click', listener);
listeners.push([button, listener]);
});
const openListener = event => {
const trigger = event.target?.closest?.('[data-progressive-work-index]');
if (!trigger) return;
const item = visibleItems()[Number(trigger.dataset.progressiveWorkIndex)];
if (!item) return;
event.preventDefault?.();
openProgressiveDetail(item, trigger);
};
const closeListener = () => closeProgressiveDetail();
const keyListener = event => {
if (event.key !== 'Escape' || detail?.hidden) return;
event.preventDefault?.();
closeProgressiveDetail();
};
list?.addEventListener?.('click', openListener);
closeDetail?.addEventListener?.('click', closeListener);
lifecycleTarget.addEventListener?.('keydown', keyListener);
const applySnapshot = snapshot => {
if (stopped) return false;
const transferable = snapshot && typeof snapshot === 'object' &&
Object.prototype.hasOwnProperty.call(snapshot, 'context');
const contextDegraded = transferable && (
!snapshot.context || snapshot.freshness?.sections?.context?.degraded
);
if (contextDegraded) {
contextUnavailable = true;
render();
notify();
if (status) status.textContent = 'Assigned work is reconnecting…';
return false;
}
contextUnavailable = false;
if (transferable) liveSnapshot = snapshot;
const context = snapshot?.context || snapshot || {};
confirmedLogin = String(context.user?.login || '').trim();
items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] });
updateCounts();
render();
notify();
const assigned = items.filter(item => item.is_assigned).length;
if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.';
return true;
};
if (typeof createContextPoller === 'function') {
poller = createContextPoller({
...pollerOptions,
fetchContext: fetchProgressiveSnapshot,
onSnapshot: applySnapshot,
onError: () => {
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
},
isHidden: pollerOptions.isHidden || (() => Boolean(document.hidden)),
});
const recover = () => {
if (!document.hidden) void poller.refresh();
};
['online', 'visibilitychange'].forEach(eventName => {
lifecycleTarget.addEventListener?.(eventName, recover);
lifecycleListeners.push([eventName, recover]);
});
}
return {
login() { return confirmedLogin; },
counts() { return queueCounts(); },
subscribe(subscriber) {
subscribers.add(subscriber);
return () => subscribers.delete(subscriber);
},
openFirst() {
const item = visibleItems()[0];
if (!item) return deferredQueues[active] ? 'loading' : 'empty';
const trigger = list?.querySelector?.('[data-progressive-work-index="0"]') || null;
openProgressiveDetail(item, trigger);
return 'opened';
},
selectQueue(name, {openFirst = false} = {}) {
const allowed = new Set(['all','attention','filed','authored','issue','pull','review','update','today','agenda','later','draft']);
if (!allowed.has(name)) return 'unsupported';
active = name;
selectedByUser = true;
render();
notify();
return openFirst ? this.openFirst() : 'selected';
},
handoff() {
const state = { selectedFilter:selectedByUser ? active : null };
if (openWork) {
state.openWork = {
kind:openWork.kind, key:openWork.key, number:openWork.number,
repository:openWork.repository,
};
openWork = null;
}
if (liveSnapshot) {
state.liveSnapshot = liveSnapshot;
liveSnapshot = null;
liveSnapshotPromise = null;
} else if (liveSnapshotPromise) {
state.liveSnapshotPromise = liveSnapshotPromise;
liveSnapshotPromise = null;
}
return state;
},
async start() {
if (status) status.textContent = 'Loading assigned work…';
if (poller) {
const request = poller.start();
liveSnapshotPromise = request.then(snapshot => (
snapshot && typeof snapshot === 'object' &&
Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null
));
return Boolean(await request);
}
const request = Promise.resolve().then(() => fetchProgressiveSnapshot());
liveSnapshotPromise = request.then(snapshot => (
snapshot && typeof snapshot === 'object' &&
Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null
), () => null);
try {
const snapshot = await request;
if (stopped) return false;
const applied = applySnapshot(snapshot);
if (!liveSnapshot) liveSnapshotPromise = null;
return applied;
} catch (_error) {
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
return false;
}
},
stop() {
stopped = true;
poller?.stop();
listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener));
list?.removeEventListener?.('click', openListener);
closeDetail?.removeEventListener?.('click', closeListener);
lifecycleTarget.removeEventListener?.('keydown', keyListener);
lifecycleListeners.forEach(([eventName, listener]) =>
lifecycleTarget.removeEventListener?.(eventName, listener));
subscribers.clear();
},
};
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.stackchainProgressiveMyWork = createProgressiveMyWork({
document,
lifecycleTarget: window,
liveSnapshot:window.stackchainProgressiveLiveSnapshot,
fetchSnapshot: async (revisions = {}, { signal } = {}) => {
const query = createContextPoller.buildRevisionQuery(revisions);
const response = await fetch('api/v1/live' + (query ? '?' + query : ''), {
headers:{Accept:'application/json'}, signal,
});
if (!response.ok) {
const error = new Error('HTTP ' + response.status);
error.retryAfterMs = createContextPoller.retryAfterMs(response.headers.get('Retry-After'));
throw error;
}
return response.json();
},
});
void window.stackchainProgressiveMyWork.start();
}
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveMyWork;
+1223 -10
View File
File diff suppressed because it is too large. Load diff
+313 -8
View File
@@ -2,9 +2,75 @@
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,
followingControl, followingStatus,
humanGateControl, humanGateStatus,
quietControl = globalThis.document?.querySelector('#push-quiet-hours'),
quietStart = globalThis.document?.querySelector('#push-quiet-start'),
quietEnd = globalThis.document?.querySelector('#push-quiet-end'),
quietStatus = globalThis.document?.querySelector('#push-quiet-status'),
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 +82,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 +132,30 @@
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;
if (followingControl) followingControl.checked = false;
if (humanGateControl) humanGateControl.checked = false;
configuration.subscribed = false;
configuration.deadline_enabled = false;
configuration.start_day_enabled = false;
configuration.following_enabled = false;
configuration.human_gates_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.';
if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.';
if (humanGateStatus) humanGateStatus.textContent = 'Human Gate decision alerts 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 +174,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 +205,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 +224,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 +240,225 @@
}
}
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 changeFollowing() {
followingControl.disabled = true;
try {
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (followingControl.checked) pendingIntent = 'following';
if (followingControl.checked && !subscription) subscription = await ensureSubscription();
if (followingControl.checked && !subscription) {
followingControl.checked = false;
followingStatus.textContent = status.textContent;
return false;
}
await fetchJson('api/v1/push-subscription/following', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:followingControl.checked}),
});
configuration.following_enabled = followingControl.checked;
pendingIntent = null;
followingStatus.textContent = followingControl.checked
? 'Following change alerts enabled for this device.'
: 'Following change alerts are off for this device.';
return true;
} catch (_error) {
followingControl.checked = !followingControl.checked;
followingStatus.textContent = 'Could not change Following alerts. Check your connection and try again.';
return false;
} finally {
followingControl.disabled = false;
}
}
async function changeHumanGates() {
humanGateControl.disabled = true;
try {
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (humanGateControl.checked) pendingIntent = 'human-gates';
if (humanGateControl.checked && !subscription) subscription = await ensureSubscription();
if (humanGateControl.checked && !subscription) {
humanGateControl.checked = false;
humanGateStatus.textContent = status.textContent;
return false;
}
await fetchJson('api/v1/push-subscription/human-gates', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:humanGateControl.checked}),
});
configuration.human_gates_enabled = humanGateControl.checked;
pendingIntent = null;
humanGateStatus.textContent = humanGateControl.checked
? 'Human Gate decision alerts enabled for this device.'
: 'Human Gate decision alerts are off for this device.';
return true;
} catch (_error) {
humanGateControl.checked = !humanGateControl.checked;
humanGateStatus.textContent = 'Could not change Human Gate alerts. Check your connection and try again.';
return false;
} finally {
humanGateControl.disabled = false;
}
}
async function changeQuietHours() {
if (!quietControl) return false;
for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = true;
try {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
await fetchJson('api/v1/push-subscription/quiet-hours', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
enabled:quietControl.checked,
start:quietStart?.value || '22:00',
end:quietEnd?.value || '07:00',
timezone,
}),
});
configuration.quiet_hours_enabled = quietControl.checked;
configuration.quiet_hours_start = quietStart?.value || '22:00';
configuration.quiet_hours_end = quietEnd?.value || '07:00';
quietStatus.textContent = quietControl.checked
? `Routine alerts paused from ${configuration.quiet_hours_start} to ${configuration.quiet_hours_end} local time.`
: 'Routine alert quiet hours are off for this device.';
return true;
} catch (_error) {
quietStatus.textContent = 'Could not save quiet hours. Check your connection and try again.';
return false;
} finally {
for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = false;
}
}
async function enableDeadline() {
deadlineControl.checked = true;
return changeDeadline();
}
async function recoverPermission(intent = null) {
if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following', 'human-gates'].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();
}
if (pendingIntent === 'following') {
followingControl.checked = true;
return changeFollowing();
}
if (pendingIntent === 'human-gates') {
humanGateControl.checked = true;
return changeHumanGates();
}
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);
followingControl?.addEventListener('change', changeFollowing);
humanGateControl?.addEventListener('change', changeHumanGates);
quietControl?.addEventListener('change', changeQuietHours);
quietStart?.addEventListener('change', changeQuietHours);
quietEnd?.addEventListener('change', changeQuietHours);
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;
if (followingControl) followingControl.disabled = true;
if (humanGateControl) humanGateControl.disabled = true;
if (quietControl) quietControl.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 (followingControl) followingControl.checked = Boolean(configuration.following_enabled);
if (humanGateControl) humanGateControl.checked = Boolean(configuration.human_gates_enabled);
if (quietControl) quietControl.checked = Boolean(configuration.quiet_hours_enabled);
if (quietStart) quietStart.value = configuration.quiet_hours_start || '22:00';
if (quietEnd) quietEnd.value = configuration.quiet_hours_end || '07:00';
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.';
if (followingStatus) followingStatus.textContent = configuration.following_enabled
? 'Following change alerts enabled for this device.'
: 'Following change alerts are off for this device.';
if (humanGateStatus) humanGateStatus.textContent = configuration.human_gates_enabled
? 'Human Gate decision alerts enabled for this device.'
: 'Human Gate decision alerts are off for this device.';
if (quietStatus) quietStatus.textContent = configuration.quiet_hours_enabled
? `Routine alerts paused from ${quietStart.value} to ${quietEnd.value} local time.`
: 'Routine alert quiet hours are off for this device.';
renderDeadlineSnooze();
}
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
return {init, change, changeDeadline, changeStartDay, changeFollowing, changeHumanGates, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
});
+44
View File
@@ -0,0 +1,44 @@
function createReconnectOutboxes({
refresh,
restoreIdentity,
flushIssue,
flushAuthored,
flushNotificationReads,
}) {
let activeReconnect = null;
let followUpRequested = false;
async function reconnectOnce() {
const snapshot = await refresh();
const freshness = snapshot?.freshness?.sections?.context;
const identityFresh = snapshot?.context && !snapshot.context.error &&
!freshness?.stale && !freshness?.degraded && !freshness?.revalidating;
const login = identityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (!login) return false;
restoreIdentity(login);
await Promise.all([
flushIssue(),
flushAuthored(),
flushNotificationReads(),
]);
return true;
}
return function reconnectOutboxes() {
if (activeReconnect) {
followUpRequested = true;
return activeReconnect;
}
activeReconnect = (async () => {
let result = false;
do {
followUpRequested = false;
result = await reconnectOnce();
} while (followUpRequested);
return result;
})().finally(() => { activeReconnect = null; });
return activeReconnect;
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createReconnectOutboxes;
+464
View File
@@ -0,0 +1,464 @@
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, confirmAction = null, openPull = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
const prefix = 'stackchain.release-receipt.v1:';
const limit = 12;
let entries = [];
let refreshing = null;
const recoveries = new Map();
const retrying = new Map();
const rollingBack = new Map();
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,
...(
mergeResult?.source_repository === item.repository
&& mergeResult?.source_branch
&& mergeResult?.source_head_sha
? {
source_branch: String(mergeResult.source_branch),
source_head_sha: String(mergeResult.source_head_sha),
cleanup: { state: 'available', message: 'Merged branch retained.' },
}
: {}
),
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 failedChecks = checks.filter(check => ['failure', 'error'].includes(check.state));
const failing = failedChecks.map(check => check.name).filter(Boolean);
const failures = failedChecks.filter(check => check?.recovery).map(check => ({
name: check.name || 'Failed check',
description: check.description || '',
url: check.url || '',
recovery: check.recovery,
}));
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, failures };
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.source_branch && entry.source_head_sha) {
const branch = document.createElement('span');
branch.className = 'small release-branch-cleanup-status';
branch.textContent = entry.cleanup?.state === 'deleted'
? 'Branch ' + entry.source_branch + ' · deleted'
: 'Branch ' + entry.source_branch + ' · ' + entry.source_head_sha.slice(0, 8);
copy.append(branch);
}
if (entry.rollback?.state === 'prepared') {
const rollbackStatus = document.createElement('span');
rollbackStatus.className = 'small release-rollback-status';
rollbackStatus.textContent = entry.rollback.message;
copy.append(rollbackStatus);
}
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);
}
for (const failure of (entry.status?.failures || [])) {
const runId = failure.recovery?.run_id;
const jobIndex = failure.recovery?.job_index;
if (!Number.isInteger(runId) || !Number.isInteger(jobIndex)) continue;
const key = identity(entry) + '@' + runId + ':' + jobIndex;
const summary = document.createElement('section');
summary.className = 'release-failure-summary';
const failureName = document.createElement('span');
failureName.className = 'small';
failureName.textContent = failure.name + (failure.description ? ' · ' + failure.description : '');
const review = document.createElement('button');
review.type = 'button';
review.textContent = 'Review failure';
review.setAttribute('aria-label', 'Review failed check ' + failure.name + ' for ' + entry.commit_sha.slice(0, 8));
review.addEventListener('click', () => reviewFailure(
entry.repository, entry.commit_sha, runId, jobIndex
).catch(error => {
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
}));
summary.append(failureName, review);
const detail = recoveries.get(key);
if (detail) {
const recovery = document.createElement('div');
recovery.className = 'release-failure-recovery';
const sha = document.createElement('strong');
sha.textContent = 'Merge ' + entry.commit_sha.slice(0, 8);
const excerpt = document.createElement('pre');
excerpt.textContent = detail.excerpt || 'No log excerpt was returned.';
const actions = document.createElement('div');
actions.className = 'release-failure-actions';
if (failure.url) {
const job = document.createElement('a');
job.href = failure.url;
job.textContent = 'Open job';
actions.append(job);
}
const retry = document.createElement('button');
retry.type = 'button';
retry.textContent = retrying.has(key) ? 'Retrying…' : 'Retry failed check';
retry.disabled = retrying.has(key);
retry.addEventListener('click', () => retryFailure(
entry.repository, entry.commit_sha, runId, jobIndex
).catch(error => {
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
render();
}));
actions.append(retry);
const rollback = document.createElement('button');
rollback.type = 'button';
rollback.textContent = entry.rollback?.state === 'prepared'
? 'Open rollback #' + entry.rollback.number
: (rollingBack.has(identity(entry)) ? 'Preparing rollback…' : 'Prepare rollback PR');
rollback.disabled = rollingBack.has(identity(entry));
rollback.setAttribute(
'aria-label',
'Prepare rollback pull request for ' + entry.key + ' at ' + entry.commit_sha.slice(0, 8),
);
rollback.addEventListener('click', () => prepareRollback(entry.repository, entry.commit_sha).catch(error => {
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
render();
}));
actions.append(rollback);
recovery.append(sha, excerpt, actions);
summary.append(recovery);
}
copy.append(summary);
}
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));
if (entry.source_branch && entry.cleanup?.state !== 'deleted') {
const actions = document.createElement('div');
actions.className = 'release-watchlist-actions';
const cleanup = document.createElement('button');
cleanup.type = 'button';
cleanup.textContent = entry.cleanup?.state === 'deleting' ? 'Deleting…' : 'Delete source branch';
cleanup.disabled = entry.cleanup?.state === 'deleting' || entry.cleanup?.state === 'advanced';
cleanup.setAttribute(
'aria-label',
'Delete merged source branch ' + entry.source_branch + ' at ' + entry.source_head_sha.slice(0, 8),
);
cleanup.addEventListener('click', () => deleteBranch(entry.repository, entry.commit_sha).catch(error => {
if (statusNode) statusNode.textContent = String(error?.message || error);
}));
actions.append(cleanup, button);
row.append(copy, actions);
} else {
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 recoveryPath(entry, runId, jobIndex) {
return 'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
+ '/pulls/' + encodeURIComponent(entry.number)
+ '/release-receipt/' + encodeURIComponent(entry.commit_sha)
+ '/checks/' + encodeURIComponent(runId) + '/jobs/' + encodeURIComponent(jobIndex);
}
async function reviewFailure(repository, commitSha, runId, jobIndex) {
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
if (!entry) throw new Error('Tracked release is unavailable.');
const key = identity(entry) + '@' + runId + ':' + jobIndex;
const detail = await fetchJson(recoveryPath(entry, runId, jobIndex) + '/failure', {
headers: { Accept: 'application/json' },
});
if (detail?.commit_sha !== entry.commit_sha) {
throw new Error('Release failure evidence did not match the merged commit.');
}
recoveries.set(key, detail);
render();
return detail;
}
function retryFailure(repository, commitSha, runId, jobIndex) {
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
if (!entry) return Promise.reject(new Error('Tracked release is unavailable.'));
const key = identity(entry) + '@' + runId + ':' + jobIndex;
if (retrying.has(key)) return retrying.get(key);
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
const failure = entry.status?.failures?.find(value =>
value.recovery?.run_id === runId && value.recovery?.job_index === jobIndex
);
if (!approve('Retry ' + (failure?.name || 'failed release check') + ' for ' + commitSha.slice(0, 8) + '?')) {
return Promise.resolve(false);
}
const operation = fetchJson(recoveryPath(entry, runId, jobIndex) + '/retry', {
method: 'POST',
headers: { Accept: 'application/json' },
}).then(result => {
if (result?.commit_sha !== entry.commit_sha) {
throw new Error('Release retry did not match the merged commit.');
}
entry.status = { ...entry.status, ci_state: 'pending', label: 'Checks running', checks: [], failures: [] };
recoveries.delete(key);
persist();
render();
schedule(0);
return true;
}).finally(() => retrying.delete(key));
retrying.set(key, operation);
return operation;
}
function prepareRollback(repository, commitSha) {
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
if (!entry) return Promise.reject(new Error('Tracked release is unavailable.'));
if (entry.rollback?.state === 'prepared') {
openPull?.(entry.rollback.pull);
return Promise.resolve(entry.rollback.pull);
}
const key = identity(entry);
if (rollingBack.has(key)) return rollingBack.get(key);
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
if (!approve('Prepare a draft rollback PR for ' + entry.key + ' at ' + commitSha.slice(0, 8) + '?')) {
return Promise.resolve(false);
}
const operation = fetchJson(
'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
+ '/pulls/' + encodeURIComponent(entry.number)
+ '/release-receipt/' + encodeURIComponent(entry.commit_sha) + '/rollback',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': 'release-rollback-' + entry.number + '-' + entry.commit_sha,
},
},
).then(result => {
if (result?.rollback_of !== entry.commit_sha || !Number.isInteger(result?.number)) {
throw new Error('Rollback preparation did not match the failed release.');
}
const pull = {
...result,
kind: 'pull',
key: result.repository + '#' + result.number,
state: 'open',
};
entry.rollback = {
state: 'prepared',
number: result.number,
message: 'Draft rollback #' + result.number + ' is ready for review.',
pull,
};
persist();
render();
if (dialog?.open) dialog.close();
openPull?.(pull);
return result;
}).finally(() => rollingBack.delete(key));
rollingBack.set(key, operation);
render();
return operation;
}
async function deleteBranch(repository, commitSha) {
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
if (!entry?.source_branch || !entry?.source_head_sha || entry.cleanup?.state === 'deleted') {
throw new Error('Source branch cleanup is unavailable.');
}
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
if (!approve('Delete ' + entry.source_branch + ' at ' + entry.source_head_sha.slice(0, 8) + '?')) return false;
entry.cleanup = { state: 'deleting', message: 'Deleting source branch…' };
persist();
render();
try {
await fetchJson(
'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
+ '/pulls/' + encodeURIComponent(entry.number) + '/source-branch',
{
method: 'DELETE',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
source_branch: entry.source_branch,
expected_head_sha: entry.source_head_sha,
}),
},
);
entry.cleanup = { state: 'deleted', message: 'Source branch deleted.' };
persist();
render();
return true;
} catch (error) {
entry.cleanup = {
state: error?.status === 409 ? 'advanced' : 'available',
message: error?.status === 409
? 'Source branch has newer commits and was retained.'
: 'Branch retained. Retry when connected.',
};
persist();
render();
throw error;
}
}
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, reviewFailure, retryFailure, prepareRollback, deleteBranch, dismiss, bind, fetchJson };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;
+11 -1
View File
@@ -384,10 +384,20 @@ function renderChecks(checks, escapeHtml, { offline = false } = {}) {
const url = typeof check.url === 'string' ? check.url : '';
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
const recovery = check.recovery;
const inspect = !offline && ['failure', 'error'].includes(check.state) &&
Number.isInteger(recovery?.run_id) && recovery.run_id > 0 &&
Number.isInteger(recovery?.job_index) && recovery.job_index >= 0
? '<button type="button" class="ci-check-inspect" data-run-id="' + recovery.run_id +
'" data-job-index="' + recovery.job_index + '">Inspect failure</button>'
: '';
const actions = inspect || link
? '<div class="ci-check-actions">' + inspect + link + '</div>'
: '';
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
'<span class="small">' + escapeHtml(check.state || 'unknown') +
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + actions + '</article>';
}).join('');
return { summary, html, expanded: failed > 0 };
}
+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;
+339 -20
View File
@@ -30,8 +30,116 @@
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/conversation?' + query.toString();
};
root.followingPullReviewPath = item => {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
return 'api/v1/following/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review-data';
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewMutation = fetchJson => (detail, action) => {
if (action === 'reopen-pull') {
const path = root.searchPreviewPath(detail).replace('/issues/', '/pulls/').replace(/\/preview.*$/, '/reopen');
return fetchJson(path, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify({expected_head_sha:detail.head_sha}),
});
}
return fetchJson(root.searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'});
};
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/subscription?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionOptions = fetchJson => {
const options = {
load:async detail => {
if (['issue', 'pull'].includes(detail.kind) && detail.state === 'closed' && detail.following === true) {
return {...detail, watching:true};
}
if (!(['issue', 'pull'].includes(detail.kind) && detail.state === 'open')) return detail;
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
return {...detail, watching:result.watching === true};
},
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
}),
review:item => fetchJson(root.followingPullReviewPath(item)),
};
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
headers:{Accept:'application/json'},
})});
return options;
};
root.createDetailWatch = ({fetchJson,refreshFollowing,onState}) => {
const api=root.searchPreviewSubscriptionOptions(fetchJson);
let item,watching=false,mutation;
return {
async open(next) {
item=next; onState('loading',watching);
const result=await api.load(next).catch(error=>{
if(item===next)onState('error',watching,error);throw error;
});
if (item !== next) return;
watching=result.watching === true; onState('ready',watching);
},
toggle() {
if (mutation) return mutation;
const next=!watching;
onState(next?'watching':'unwatching',watching);
mutation=api.watch(item,next).then(async result => {
if (result?.watching !== next || result?.following_synced !== true)
throw new Error(result?.error || 'Unconfirmed.');
watching=next; await refreshFollowing();
onState(watching?'watched':'unwatched',watching);
}).catch(error => {onState('error',watching,error);throw error;})
.finally(()=>{mutation=null;});
return mutation;
},
};
};
root.searchPreviewWatchStatus = state => ({
watching:'Starting watch…', unwatching:'Stopping watch…',
watched:'Watching · available in Following. Future activity will appear in Updates.',
unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch ' +
(state.detail?.kind === 'pull' ? 'pull request' : 'issue') + ' to repair.',
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
})[state.status] || '';
root.renderSearchPreviewWatch = (detail, state, button) => {
const watchableKind = ['issue', 'pull'].includes(detail.kind);
const retiring = watchableKind && detail.state === 'closed' &&
detail.following === true && detail.watching === true;
button.hidden = !(watchableKind && detail.state === 'open') && !retiring;
button.textContent = retiring ? 'Stop watching & next' :
(detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : 'issue'));
button.disabled = state.status === 'watching' || state.status === 'unwatching';
};
root.renderSearchPreviewStart = (detail, state, button) => {
const pull = detail.kind === 'pull' && detail.state === 'closed' &&
detail.authored_pull_reopenable === true;
const issue = detail.kind === 'issue' &&
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)));
button.hidden = !(detail.reviewable || pull || issue);
button.textContent = detail.reviewable ? 'Review now' : pull ? 'Reopen in My Work' :
detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
button.disabled = state.status === 'claiming' || state.status === 'reopening';
};
root.createSearchAuthoredPullRecovery = o =>
async d => {
if (!o.confirm('Reopen ' + d.repository + ' #' + d.number + '?')) return 'canceled';
await o.reopen(d);
await o.refresh();
const item = o.find(d);
if (!item) {
o.unavailable('Reopened. Refresh My Work.');
return 'unavailable';
}
o.open(item);
return 'opened';
};
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
const detail = getDetail();
if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
});
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
storage,
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
@@ -40,7 +148,7 @@
body:JSON.stringify({body}),
}),
});
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown, actions) => {
const comments = document.querySelector('#search-preview-comments');
const status = document.querySelector('#search-preview-conversation-status');
const retry = document.querySelector('#retry-search-preview-conversation');
@@ -50,10 +158,16 @@
retry.hidden = older.hidden = true;
return;
}
const isNew = comment => Boolean(conversation.reviewedAt && comment.created_at &&
new Date(comment.created_at).getTime() > new Date(conversation.reviewedAt).getTime());
const newCount = (conversation.comments || []).filter(isNew).length;
comments.innerHTML = (conversation.comments || []).map(comment =>
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
'<article class="search-preview-comment issue-comment' + (isNew(comment) ? ' new-since-review' : '') +
'" data-comment-id="' + Number(comment.id || 0) + '">' +
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</div>' +
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') +
(isNew(comment) ? ' · <strong>New since last review</strong>' : '') + '</div>' +
(actions?.actionHtml?.(comment) || '') +
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
).join('');
retry.hidden = conversation.status !== 'error';
@@ -65,7 +179,21 @@
'Conversation unavailable. Preview and planning actions still work.';
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
else status.textContent = conversation.comments.length +
(conversation.comments.length === 1 ? ' message.' : ' messages.');
(conversation.comments.length === 1 ? ' message' : ' messages') +
(newCount ? ' · ' + newCount + ' new since last review' : '') + '.';
};
root.createSearchPreviewConversationActions = ({hydrator,rootNode,retry,paint,wire}) => {
let latest = null;
const show = conversation => {
latest = conversation;
if (!conversation) {
paint(null, null);
return Promise.resolve(false);
}
return hydrator.show({root:rootNode,state:conversation,paint,retry,wire});
};
retry.addEventListener('click', () => { if (latest) void show(latest); });
return show;
};
root.renderSearchPreviewReply = (state, detail, preview, document) => {
const section = document.querySelector('.search-preview-reply');
@@ -81,15 +209,67 @@
}
input.value = preview.replyDraft();
const replying = state.status === 'replying';
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
if (replying) status.textContent = 'Sending reply…';
buttons.forEach(button => {
button.disabled = replying || (!input.value.trim() && !preview.hasReplyAttachments?.());
});
if (replying) status.textContent = 'Saving reply for delivery…';
else if (state.status === 'queued') status.textContent = 'Reply queued. It will send when connected.';
else if (state.status === 'replied') status.textContent = 'Reply posted.';
else if (state.status === 'reply-error') status.textContent =
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
};
root.renderSearchPreviewReview = (review, document, escapeHtml) => {
const section = document.querySelector('#search-preview-review');
const status = document.querySelector('#search-preview-review-status');
const files = document.querySelector('#search-preview-files');
const retry = document.querySelector('#retry-search-preview-review');
section.hidden = !review;
retry.hidden = review?.status !== 'error';
files.innerHTML = '';
if (!review) {
status.textContent = '';
return;
}
if (review.status === 'loading') {
status.textContent = 'Loading CI and changed files…';
return;
}
if (review.status === 'error') {
status.textContent = 'Changes unavailable. This revision has not been marked reviewed.';
return;
}
const data = review.data || {};
const changed = Array.isArray(data.files) ? data.files : [];
const ci = ({success:'CI passed', failure:'CI failed', error:'CI failed', pending:'CI pending'})[
data.ci_state
] || 'CI status unavailable';
status.textContent = ci + ' · ' + changed.length + ' changed ' +
(changed.length === 1 ? 'file.' : 'files.');
files.innerHTML = changed.map(file => {
const lines = (Array.isArray(file.diff_lines) ? file.diff_lines : []).map(raw => {
const line = String(raw);
const kind = line.startsWith('@@') ? 'hunk' : line.startsWith('+') ? 'added' :
line.startsWith('-') ? 'removed' : 'context';
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(line) + '</span>';
}).join('');
const diff = file.diff_available
? '<pre class="pull-diff">' + lines +
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
: '<div class="pull-diff-empty">' +
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') + '</div>';
return '<article class="search-preview-file"><strong>' + escapeHtml(file.filename || 'Unknown file') +
'</strong><span class="small">' + escapeHtml(file.status || 'changed') + ' · +' +
Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span>' + diff + '</article>';
}).join('');
};
root.renderSearchPreviewWorkspaces = (state, detail, preview, document, escapeHtml) => {
root.renderSearchPreviewReply(state, detail, preview, document);
root.renderSearchPreviewReview(state.review, document, escapeHtml);
document.querySelector('[data-search-preview-section="changes"]').hidden = !state.review;
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
return function createSearchPreview({ fetchJson, fetchConversation, fetchReview, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
@@ -102,13 +282,24 @@
let shareRequest = null;
let moveRequest = null;
let replyRequest = null;
let watchRequest = null;
let conversation = null;
let review = null;
let openedRevision = null;
function sameItem(left, right) {
return left && right && left.kind === right.kind && left.repository === right.repository &&
Number(left.number) === Number(right.number);
}
async function notifyOpened(item, requestGeneration) {
const revision = [item?.kind, item?.repository, item?.number, item?.updated_at].join(':');
if (requestGeneration !== generation || openedRevision === revision) return false;
await onOpened?.({...item});
if (requestGeneration === generation) openedRevision = revision;
return true;
}
function replyKey(item, suffix) {
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
@@ -140,6 +331,10 @@
}
function publish(state) {
if (conversation && !Object.prototype.hasOwnProperty.call(state, 'conversation')) {
state = {...state, conversation};
}
if (review && !Object.prototype.hasOwnProperty.call(state, 'review')) state = {...state, review};
const position = navigation(state.item || current);
if (navigationRoot) {
const bar = navigationRoot.querySelector('.search-preview-navigation');
@@ -173,7 +368,10 @@
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
const previousComments = page && Array.isArray(conversation?.comments)
? conversation.comments : [];
conversation = { status:'loading', comments:previousComments, olderPage:page ?? null };
conversation = {
status:'loading', comments:previousComments, olderPage:page ?? null,
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
};
publish({ status:'ready', item:current, detail, conversation });
return fetchConversation(detail, page).then(result => {
if (requestGeneration !== generation) return result;
@@ -185,6 +383,7 @@
status:'ready',
comments,
olderPage:result?.older_page ?? null,
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
};
publish({ status:'ready', item:current, detail:current, conversation });
return result;
@@ -193,6 +392,7 @@
conversation = {
status:'error', comments:previousComments,
olderPage:page ?? conversation?.olderPage ?? null, error,
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
};
publish({ status:'ready', item:current, detail:current, conversation });
}
@@ -200,20 +400,85 @@
});
}
function loadReview(detail, requestGeneration) {
if (!(detail?.following === true && detail?.kind === 'pull' && typeof fetchReview === 'function')) {
review = null;
return Promise.resolve(null);
}
review = {status:'loading', data:null};
publish({status:'ready', item:current, detail:current, conversation, review});
return fetchReview(detail).then(data => {
if (requestGeneration === generation) {
review = {status:'ready', data};
publish({status:'ready', item:current, detail:current, conversation, review});
}
return data;
}).catch(error => {
if (requestGeneration === generation) {
review = {status:'error', data:null, error};
publish({status:'ready', item:current, detail:current, conversation, review});
}
return null;
});
}
async function acknowledgeWhenContextReady(requestGeneration) {
const conversationReady = typeof fetchConversation !== 'function' || conversation?.status === 'ready';
const reviewRequired = current?.following === true && current?.kind === 'pull' &&
typeof fetchReview === 'function';
if (conversationReady && (!reviewRequired || review?.status === 'ready')) {
await notifyOpened(current, requestGeneration);
}
}
const conversationPager = {
snapshot() {
return {...(conversation || {}), comments:(conversation?.comments || []).map(comment => ({...comment}))};
},
replace(comment) {
if (!comment || !Number.isInteger(comment.id) || !conversation) return conversationPager.snapshot();
conversation = {...conversation, comments:(conversation.comments || []).map(existing =>
existing.id === comment.id ? {...comment} : existing)};
publish({status:'ready', item:current, detail:current, conversation});
return conversationPager.snapshot();
},
remove(commentId) {
if (!conversation) return conversationPager.snapshot();
conversation = {...conversation, comments:(conversation.comments || []).filter(comment => comment.id !== commentId)};
publish({status:'ready', item:current, detail:current, conversation});
return conversationPager.snapshot();
},
};
const api = {
commentPager() { return conversationPager; },
hasReplyAttachments() {
return Boolean(hasAttachments?.());
},
open(item) {
if (current && !sameItem(current, item) && hasAttachments?.()) clearAttachments?.();
generation += 1;
const requestGeneration = generation;
current = { ...item };
conversation = null;
review = null;
openedRevision = null;
publish({ status: 'loading', item: current });
return fetchJson(current).then(detail => {
return fetchJson(current).then(async detail => {
if (requestGeneration === generation) {
current = { ...current, ...detail };
if (typeof fetchConversation === 'function') {
loadConversation(current, requestGeneration);
const context = loadConversation(current, requestGeneration);
if (current.following === true) {
const reviewContext = loadReview(current, requestGeneration);
await Promise.all([context, reviewContext]);
await acknowledgeWhenContextReady(requestGeneration);
} else {
await notifyOpened(current, requestGeneration);
}
} else {
publish({ status: 'ready', item: current, detail });
await notifyOpened(current, requestGeneration);
}
}
return detail;
@@ -225,6 +490,7 @@
});
},
close() {
clearAttachments?.();
generation += 1;
current = null;
onState({ status: 'closed' });
@@ -261,9 +527,19 @@
})().finally(() => { moveRequest = null; });
return moveRequest;
},
retryConversation() {
if (!current) return Promise.resolve(null);
return loadConversation(current, generation);
async retryConversation() {
if (!current) return null;
const requestGeneration = generation;
const result = await loadConversation(current, requestGeneration);
if (current?.following === true) await acknowledgeWhenContextReady(requestGeneration);
return result;
},
async retryReview() {
if (!current) return null;
const requestGeneration = generation;
const result = await loadReview(current, requestGeneration);
await acknowledgeWhenContextReady(requestGeneration);
return result;
},
loadOlderConversation() {
if (!current || !conversation?.olderPage) return Promise.resolve(null);
@@ -282,9 +558,11 @@
},
reply({ advance = false } = {}) {
if (replyRequest) return replyRequest;
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
if (!current || (typeof postReply !== 'function' && typeof queueReply !== 'function')) {
return Promise.reject(new Error('Replying is unavailable.'));
}
const body = api.replyDraft().trim();
if (!body) return Promise.reject(new Error('Write a reply first.'));
if (!body && !hasAttachments?.()) return Promise.reject(new Error('Write a reply or add a photo first.'));
const item = { ...current };
const operationKey = replyKey(item, 'operation');
let operationId = stored(operationKey);
@@ -293,13 +571,24 @@
save(operationKey, operationId);
}
publish({ status:'replying', item:current, detail:current, conversation });
replyRequest = postReply(item, body, operationId).then(comment => {
const comments = [...(conversation?.comments || [])];
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
replyRequest = (typeof queueReply === 'function' ?
Promise.resolve(queueReply(item, body, operationId)) :
Promise.resolve(typeof prepareReply === 'function' ? prepareReply(item, body) : body)
.then(preparedBody => {
if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.');
return postReply(item, preparedBody, operationId);
})
).then(async comment => {
await afterReply?.(item);
if (!comment?.queued) {
const comments = [...(conversation?.comments || [])];
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
}
save(replyKey(item, 'draft'), '');
save(operationKey, '');
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
clearAttachments?.();
publish({ status:comment?.queued ? 'queued' : 'replied', item:current, detail:current, conversation, result:comment });
return advance ? api.next().then(() => comment) : comment;
}).catch(error => {
publish({ status:'reply-error', item:current, detail:current, conversation, error });
@@ -327,11 +616,41 @@
reopen(detail) {
return run('reopen', 'reopening', 'reopened', detail);
},
reopenPull(detail) {
return run('reopen-pull', 'reopening', 'reopened', detail);
},
setWatching(watching) {
if (watchRequest) return watchRequest;
if (!current || typeof watch !== 'function') {
return Promise.reject(new Error('Watching is unavailable.'));
}
const detail = current;
publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
watchRequest = watch(detail, watching).then(async result => {
current = { ...current, watching:result?.watching === true };
const status = result?.following_synced === false
? 'watch-partial' : (watching ? 'watched' : 'unwatched');
publish({ status, item:current, detail:current, result });
if (!watching && result?.watching === false && typeof afterUnwatch === 'function') {
const next = await afterUnwatch({...current});
if (next) {
onNavigate?.(next);
await api.open(next);
}
}
return result;
}).catch(error => {
publish({ status:'watch-error', item:current, detail, error });
throw error;
}).finally(() => { watchRequest = null; });
return watchRequest;
},
};
if (navigationRoot) {
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
navigationRoot.querySelector('#retry-search-preview-review').addEventListener('click', () => api.retryReview());
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
const reply = navigationRoot.querySelector('#search-preview-reply');
reply?.addEventListener('input', event => {
+146
View File
@@ -0,0 +1,146 @@
(function (root, factory) {
const createSearchReplyDraftStore = factory();
if (typeof module === 'object' && module.exports) module.exports = createSearchReplyDraftStore;
else root.createSearchReplyDraftStore = createSearchReplyDraftStore;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const dbName = 'stackchain-search-reply-drafts-v1';
const storeName = 'drafts';
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error || new Error('Search reply draft storage failed.'));
});
}
function createTransaction(indexedDB) {
if (!indexedDB) return null;
let databasePromise;
function database() {
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(storeName)) {
request.result.createObjectStore(storeName, { keyPath:'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Search reply draft storage is unavailable.'));
});
return databasePromise;
}
return async (operation, key, value) => {
const db = await database();
const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
const records = transaction.objectStore(storeName);
if (operation === 'put') return requestResult(records.put(value));
if (operation === 'delete') return requestResult(records.delete(key));
if (operation === 'list') return requestResult(records.getAll());
return requestResult(records.get(key));
};
}
function normalizedTarget(target) {
const kind = String(target?.kind || '');
const repository = String(target?.repository || '');
const number = Number(target?.number || 0);
if (!['issue', 'pull'].includes(kind) || !repository || !Number.isInteger(number) || number < 1) {
throw new Error('Choose a Search result before saving photo evidence.');
}
return { kind, repository, number };
}
function attachment(value) {
const blob = value?.blob;
const data = String(value?.data || '');
if (!blob && !data) throw new Error('A saved Search photo is unavailable.');
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
const operationId = String(value?.operationId || '').slice(0, 128);
const markdown = String(value?.confirmed?.markdown || '');
return {
filename:String(value?.filename || ''),
contentType:String(value?.contentType || ''),
...(blob ? { blob } : { data }),
...(note ? { note } : {}),
...(operationId ? { operationId } : {}),
...(markdown ? { confirmed:{ markdown } } : {}),
};
}
return function createSearchReplyDraftStore({
indexedDB = globalThis.indexedDB,
transaction,
getOwnerLogin = () => '',
} = {}) {
const transact = transaction || createTransaction(indexedDB);
function identity(target) {
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before saving Search photos.');
const normalized = normalizedTarget(target);
return {
ownerLogin,
target:normalized,
id:[ownerLogin, normalized.kind, normalized.repository, normalized.number]
.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 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, updatedAt:Date.now(), attachments:list,
});
return list;
}
async function load(target) {
if (!transact) return null;
const { id, ownerLogin, target:normalized } = identity(target);
const record = await transact('get', id);
if (record?.version !== 1 || record.ownerLogin !== ownerLogin || record.kind !== normalized.kind ||
record.repository !== normalized.repository || Number(record.number) !== normalized.number ||
!Array.isArray(record.attachments) || !record.attachments.length) return null;
return record.attachments.slice(0, 5).map(attachment);
}
async function remove(target) {
if (!transact) return false;
const { id } = identity(target);
await transact('delete', id);
return true;
}
async function list() {
if (!transact) return [];
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) return [];
const records = await transact('list');
return (Array.isArray(records) ? records : []).flatMap(record => {
const repository = String(record?.repository || '');
const number = Number(record?.number || 0);
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
!['issue', 'pull'].includes(record.kind) || !repository ||
!Number.isInteger(number) || number < 1 ||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
return [{
id:record.id, kind:'photo-reply',
label:'Search ' + (record.kind === 'pull' ? 'PR' : 'issue') + ' photos',
repository, number, title:repository + '#' + number,
photo_count:record.attachments.length, updated_at:Number(record.updatedAt || 0),
route:{ kind:'search', target_kind:record.kind, repository, number }, photo_store:'search',
}];
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
}
return { save, load, remove, list };
};
});
+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,item};
}
};
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.');await onPlanned(outcome.item||detail,true,outcome);close(true);}
else if(outcome.status==='planned'){announce('Planned for '+date+'.');await onPlanned(outcome.item||detail,false,outcome);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;}
+119 -6
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');
@@ -12,7 +12,99 @@
const activityList = root.document.getElementById('security-activity-list');
const activityStatus = root.document.getElementById('security-activity-status');
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
const sectionButtons = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [
section, root.document.getElementById(`security-section-${section}`),
]));
const sections = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [
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';
Object.entries(sectionButtons).forEach(([name, button]) => {
if (!button) return;
if (name === section) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
});
sections[section]?.scrollIntoView?.({ block: 'start' });
if (history && root.history?.pushState) {
root.history.pushState(
{ ...(root.history.state || {}), stackchainSecuritySection: section },
'',
`${root.location.pathname}${root.location.search || ''}#security/${section}`,
);
}
};
Object.entries(sectionButtons).forEach(([section, button]) => {
button?.addEventListener('click', () => navigate(section));
});
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
if (root.history?.state?.stackchainSecuritySection) root.history.back();
});
const handleRouteChange = section => {
if (section && !devicesSheet.hidden) {
containBackground();
navigate(section, { history: false });
return;
}
if (!devicesSheet.hidden) {
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();
}
});
const renderDevices = async () => {
devicesStatus.textContent = 'Loading active devices…';
@@ -39,8 +131,11 @@
revoke.type = 'button';
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
revoke.addEventListener('click', async () => {
if (device.current) await boundary.signOut();
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
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()]);
}
});
row.append(details, revoke);
devicesList.append(row);
@@ -82,6 +177,7 @@
: 'Passkey removed.';
await renderPasskeys();
if (outcome.session_revoked) await renderDevices();
await renderSecurityActivity();
} else {
remove.disabled = false;
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
@@ -122,7 +218,11 @@
const details = root.document.createElement('span');
details.className = 'small muted';
details.textContent = formatted.detail;
row.append(title, details);
const action = root.document.createElement('button');
action.type = 'button';
action.textContent = 'Review devices';
action.addEventListener('click', () => navigate('devices'));
row.append(title, details, action);
activityList.append(row);
});
}
@@ -131,6 +231,9 @@
passkey_counter_anomaly: 'Passkey counter anomaly',
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
source_branch_deleted: 'Source branch deleted',
release_rollback_prepared: 'Release rollback prepared',
ci_job_retried: 'CI job retried',
comment_deleted: 'Comment deleted',
pull_review_approved: 'Pull request approved',
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
@@ -150,6 +253,13 @@
.filter(value => typeof value === 'string' && value).join(' · ');
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
row.append(title, details);
if (event.kind === 'passkey_counter_anomaly') {
const action = root.document.createElement('button');
action.type = 'button';
action.textContent = 'Review passkeys';
action.addEventListener('click', () => navigate('passkeys'));
row.append(action);
}
activityList.append(row);
});
activityCursor = page.next_cursor;
@@ -167,8 +277,11 @@
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' });
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
};
devicesButton?.addEventListener('click', open);
@@ -186,5 +299,5 @@
enrollPasskey.disabled = false;
}
});
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
return { open, navigate, renderDevices, renderPasskeys, renderSecurityActivity };
});
+448 -18
View File
@@ -1,27 +1,176 @@
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-v107';
const CACHE = 'stackchain-dashboard-shell-v150';
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();
},
async getCounts() {
const database = await open();
const counts = {};
for (const channel of ['updates', 'following', 'human-gates']) {
counts[channel] = await new Promise((resolve, reject) => {
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('count:' + channel);
request.onsuccess = () => resolve(Number.isSafeInteger(request.result) ? request.result : 0);
request.onerror = () => reject(request.error || new Error('App badge count unavailable.'));
});
}
database.close();
return counts;
},
async setCount(channel, count) {
const database = await open();
await new Promise((resolve, reject) => {
const transaction = database.transaction(storeName, 'readwrite');
transaction.objectStore(storeName).put(count, 'count:' + channel);
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error || new Error('App badge count could not be saved.'));
transaction.onabort = transaction.onerror;
});
database.close();
},
async clearCounts() {
await this.setCount('updates', 0);
await this.setCount('following', 0);
await this.setCount('human-gates', 0);
},
};
}
const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference();
let renderedBackgroundBadgeCount = null;
async function reconcileBackgroundAppBadge(channel, count) {
if (!['updates', 'following', 'human-gates'].includes(channel)
|| !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) return false;
try {
await appBadgePreference.setCount(channel, count);
const counts = await appBadgePreference.getCounts();
const total = Math.min(9999, counts.updates + counts.following + counts['human-gates']);
if (renderedBackgroundBadgeCount === total) return false;
if (total > 0) await self.registration.setAppBadge(total);
else await self.registration.clearAppBadge();
renderedBackgroundBadgeCount = total;
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',
BASE + 'static/dashboard.css',
BASE + 'static/dashboard.js',
BASE + 'static/human-gates.js',
BASE + 'static/progressive-human-gates.js',
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',
BASE + 'static/security-center.js',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/saved-searches.js',
BASE + 'static/search-preview.js',
BASE + 'static/following.js',
BASE + 'static/search-reply-draft-store.js',
BASE + 'static/conversation-reply-draft-store.js',
BASE + 'static/conversation-photo-drafts.js',
BASE + 'static/photo-draft-inbox.js',
BASE + 'static/search-defer.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
@@ -32,6 +181,7 @@ const SHELL = [
BASE + 'static/draft-capacity-dialog.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/reconnect-outboxes.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-issue-close.js',
BASE + 'static/offline-issue-blocker.js',
@@ -39,14 +189,26 @@ const SHELL = [
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/progressive-live-snapshot.js',
BASE + 'static/progressive-my-work.js',
BASE + 'static/progressive-mobile-dock.js',
BASE + 'static/progressive-capture.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',
BASE + 'static/comment-next.js',
@@ -54,6 +216,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',
@@ -73,20 +241,39 @@ const SHELL = [
BASE + 'static/issue-attachment.js',
BASE + 'static/issue-filing-review.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/mobile-issue-detail-nav.js',
BASE + 'static/mobile-update-detail-nav.js',
BASE + 'static/mobile-review-detail-nav.js',
BASE + 'static/mobile-search-preview-nav.js',
BASE + 'static/mobile-plan-today-nav.js',
BASE + 'static/mobile-find-work-nav.js',
BASE + 'static/mobile-pull-refresh.js',
BASE + 'static/checklist-conflict.js',
BASE + 'static/voice-transcript-store.js',
BASE + 'static/voice-issue-capture.js',
BASE + 'static/voice-conversation-capture.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-pull-sheet.js',
BASE + 'static/mobile-create-issue-nav.js',
BASE + 'static/create-and-start.js',
BASE + 'static/assign-and-start.js',
BASE + 'static/filed-claim.js',
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-recent-work.js',
BASE + 'static/mobile-queue-priority.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/mobile-delivery-recovery.js',
BASE + 'static/mobile-start-day.js',
BASE + 'static/update-triage-session.js',
BASE + 'static/update-review-handoff.js',
@@ -97,10 +284,16 @@ const SHELL = [
BASE + 'static/update-decision-transaction.js',
BASE + 'static/agenda-session-launcher.js',
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',
BASE + 'static/device-storage.js',
BASE + 'static/mobile-device-setup.js',
BASE + 'static/mobile-search-viewport.js',
BASE + 'static/mobile-search-modal.js',
BASE + 'static/mobile-composer-viewport.js',
BASE + 'static/mention-composer.js',
BASE + 'static/push-notifications.js',
@@ -109,6 +302,8 @@ const SHELL = [
];
const OPTIONAL_FEATURES = [
];
const DEMAND_FEATURES = [
];
const SHARED_IMAGE_ID = 'shared-image';
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
@@ -352,15 +547,40 @@ self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
async function warmOptionalFeature(cache, staleCacheNames, asset) {
for (const cacheName of staleCacheNames) {
const staleCache = await caches.open(cacheName);
const cached = await staleCache.match(asset);
if (cached) {
await cache.put(asset, cached);
return;
}
}
await cache.add(asset);
}
async function cachedOptionalFeature(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE);
await cache.put(request, response.clone());
}
return response;
}
self.addEventListener('activate', event => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
const staleCacheNames = keys.filter(
key => key.startsWith('stackchain-dashboard-') && key !== CACHE
);
const cache = await caches.open(CACHE);
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
await Promise.allSettled(
OPTIONAL_FEATURES.map(asset => warmOptionalFeature(cache, staleCacheNames, asset))
);
await Promise.all(staleCacheNames.map(key => caches.delete(key)));
await self.clients.claim();
})());
});
@@ -369,7 +589,69 @@ 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-count') {
event.waitUntil((async () => {
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
const channel = event.data.channel;
const count = event.data.count;
if (!['updates', 'following', 'human-gates'].includes(channel)
|| !Number.isSafeInteger(count) || count < 0 || count > 9999) return;
try {
if (await appBadgePreference.get()) {
await appBadgePreference.setCount(channel, count);
renderedBackgroundBadgeCount = null;
}
} catch (_error) { /* A later authoritative refresh can restore the count. */ }
})());
}
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 appBadgePreference.clearCounts();
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();
@@ -380,12 +662,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 => {
@@ -397,7 +710,24 @@ 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 followingCount = Number(payload.following_count);
const humanGateCount = Number(payload.human_gate_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'
@@ -413,13 +743,50 @@ 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},
}
));
return;
}
if (
route === '#/my-work/human-gates'
&& tag === 'stackchain-human-gates-' + humanGateCount
&& Number.isSafeInteger(humanGateCount)
&& humanGateCount > 0
&& humanGateCount <= 50
) {
event.waitUntil(Promise.all([
reconcileBackgroundAppBadge('human-gates', humanGateCount),
self.registration.showNotification(
humanGateCount + ' release decision' + (humanGateCount === 1 ? ' is' : 's are') + ' waiting', {
body: 'Open Human Gates to review ' + (humanGateCount === 1 ? 'it.' : 'them.'),
tag,
data: {route},
}
),
]));
return;
}
if (
route === '#/my-work/following'
&& /^stackchain-following-[0-9a-f]{16}$/.test(tag)
&& Number.isSafeInteger(followingCount)
&& followingCount > 0
&& followingCount <= 50
) {
event.waitUntil(Promise.all([
reconcileBackgroundAppBadge('following', followingCount),
self.registration.showNotification(
followingCount + ' watched item' + (followingCount === 1 ? '' : 's') + ' changed', {
body: 'Open Following to review the latest activity.',
tag,
data: {route},
}),
]));
return;
}
if (
route === '#/my-work/updates'
&& tag === 'stackchain-update-digest'
@@ -427,11 +794,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('updates', 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;
@@ -451,7 +819,10 @@ self.addEventListener('push', event => {
];
options.data.notificationId = notificationId;
}
event.waitUntil(self.registration.showNotification('New work update', options));
event.waitUntil(Promise.all([
reconcileBackgroundAppBadge('updates', unreadCount),
self.registration.showNotification('New work update', options),
]));
});
async function openWorkRoute(route) {
@@ -478,9 +849,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));
@@ -496,10 +909,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') {
@@ -601,7 +1030,8 @@ self.addEventListener('fetch', event => {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
return;
}
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
if (url.origin === self.location.origin &&
(OPTIONAL_FEATURES.includes(url.pathname) || DEMAND_FEATURES.includes(url.pathname))) {
event.respondWith(cachedOptionalFeature(request));
}
});
+46 -15
View File
@@ -36,36 +36,55 @@
root.fetch = boundary.fetch;
const attach = () => {
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;
@@ -77,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();
@@ -387,6 +411,12 @@
});
}
function startReconnectResume() {
addActivityListener?.('online', async () => {
if (await refreshOfflineLease()) await resumeQueuedWork();
});
}
async function refreshOfflineLease() {
try {
const response = await sessionFetch(base + 'api/v1/session');
@@ -585,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');
@@ -617,6 +647,7 @@
handleServiceWorkerMessage,
refreshOfflineLease,
resumeQueuedWork,
startReconnectResume,
recordActivity,
startActivityHeartbeat,
};
+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;
+154
View File
@@ -0,0 +1,154 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createTodayHandoff = factory;
})(typeof self !== 'undefined' ? self : this, function createTodayHandoff(options) {
const prefix = 'stackchain.today-handoff.v1.';
const storage = options.storage || null;
const selected = new Set();
const excluded = new Set();
function key() {
const login = String(options.getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function pending() {
const storageKey = key();
if (!storageKey || !storage) return [];
try {
const value = JSON.parse(storage.getItem(storageKey) || '[]');
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
} catch (_) {
return [];
}
}
function write(ids) {
const storageKey = key();
if (!storageKey || !storage) return false;
const unique = [...new Set(ids.filter(id => typeof id === 'string' && id))];
try {
if (unique.length) storage.setItem(storageKey, JSON.stringify(unique));
else storage.removeItem(storageKey);
return true;
} catch (_) {
return false;
}
}
function capture(ids) {
const merged = [...pending()];
(ids || []).forEach(id => {
if (typeof id === 'string' && id && !merged.includes(id)) merged.push(id);
if (typeof id === 'string' && id) {
selected.add(id);
excluded.delete(id);
}
});
return write(merged);
}
function review(items) {
const byIdentity = new Map((items || options.items?.() || []).map(item => [options.identity(item), item]));
return pending().flatMap(identity => {
const item = byIdentity.get(identity);
if (!excluded.has(identity)) selected.add(identity);
return item ? [{ identity, item, selected:selected.has(identity) }] : [];
});
}
function choose(identity, include) {
if (!pending().includes(identity)) return false;
if (include) {
selected.add(identity);
excluded.delete(identity);
} else {
selected.delete(identity);
excluded.add(identity);
}
return true;
}
function render(items, list, escapeHtml) {
const rows = review(items);
list.innerHTML = rows.map(row => {
const title = String(row.item.title || row.identity).slice(0, 180);
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
return '<div class="today-handoff-item"><span><strong>' + escapeHtml(title) + '</strong>' +
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
'data-today-handoff="' + escapeHtml(row.identity) + '"' + (row.selected ? ' checked' : '') +
'> Today</label></div>';
}).join('');
list.querySelectorAll('[data-today-handoff]').forEach(input => input.addEventListener('change', () =>
choose(input.dataset.todayHandoff, input.checked)));
return rows;
}
async function commit(items) {
const rows = review(items);
const remaining = pending();
let scheduled = 0;
let blocked = 0;
for (const row of rows) {
if (!selected.has(row.identity)) continue;
if (options.todayWork.contains(row.item)) {
const index = remaining.indexOf(row.identity);
if (index >= 0) remaining.splice(index, 1);
selected.delete(row.identity);
continue;
}
const added = options.todayWork.add(row.item);
if (added !== 'added') {
blocked += 1;
continue;
}
if (!options.todaySync.enqueue('add', row.identity)) {
options.todayWork.remove?.(row.item);
blocked += 1;
continue;
}
const index = remaining.indexOf(row.identity);
if (index >= 0) remaining.splice(index, 1);
selected.delete(row.identity);
scheduled += 1;
}
write(remaining);
if (scheduled) await options.todaySync.flush();
return { scheduled, blocked, remaining:remaining.length };
}
function mount({ qs, escapeHtml, onComplete = () => {} }) {
const list = qs('#today-handoff-items');
let prompted = false;
function open() {
if (prompted) return false;
const rows = render(null, list, escapeHtml);
if (!rows.length) return false;
qs('#today-handoff-status').textContent = rows.length +
(rows.length === 1 ? ' commitment is ready to review.' : ' commitments are ready to review.');
const dialog = qs('#today-handoff-dialog');
if (!dialog.open) dialog.showModal();
prompted = true;
requestAnimationFrame(() => (list.querySelector('input') || qs('#confirm-today-handoff')).focus());
return true;
}
qs('#close-today-handoff').addEventListener('click', () => qs('#today-handoff-dialog').close());
qs('#confirm-today-handoff').addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
qs('#today-handoff-status').textContent = 'Saving Today’s plan…';
try {
const result = await commit();
if (result.blocked) qs('#today-handoff-status').textContent =
'Today is full. ' + result.remaining + ' commitment remains safe in My Work.';
else qs('#today-handoff-dialog').close();
onComplete(result);
} finally {
button.disabled = false;
}
});
return { open, reset:() => { prompted = false; } };
}
return { capture, choose, commit, mount, pending, render, review };
});
+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;
}
+7 -5
View File
@@ -217,7 +217,7 @@ async function saveTodayRecapTime(payload) {
}
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
describeWork = () => null, adjustPlan = () => {} }) {
describeWork = () => null, openWrapUp = () => {} }) {
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
const render = () => {
const draft = recap.snapshot();
@@ -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();
adjustPlan(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();
adjustPlan(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();
@@ -345,8 +347,8 @@ function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
}
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
describeWork, adjustPlan) {
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
describeWork, openWrapUp) {
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, openWrapUp };
const recap = createTodayRecap({
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
+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;
+408 -11
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,66 @@ 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();
const keepBreak = validBreak(state)?.identity === identity;
state.active_identity = identity;
state.entries = ledger;
state.entries[identity].started_at = running && !keepBreak ? now() : null;
state.entries[identity].running = Boolean(running) && !keepBreak;
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
if (!keepBreak) 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 +144,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 +163,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 +206,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 +241,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 +416,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,8 +487,12 @@ 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-hud]').forEach(element => {
const coaching = element.querySelector?.('[data-mobile-first-task-coach]:not([hidden])');
element.hidden = !active && !coaching;
});
queryAll('[data-mobile-today-open]').forEach(element => {
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
});
@@ -302,19 +545,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 +639,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;
}
+369
View File
@@ -0,0 +1,369 @@
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(retryConflict = false) {
if (flushing) return flushing;
if (!pending()) return Promise.resolve(false);
flushing = (async () => {
let result = null;
let conflictRetried = false;
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) {
if (error?.status === 409 && retryConflict && !conflictRetried) {
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
adoptToday(today); week.adopt(weekState);
const latest = readQueue();
if (today?.ids?.includes(record.body.identity) &&
latest[0]?.body?.operation_id === record.body.operation_id) {
latest[0] = {...latest[0], body:{
...latest[0].body,
today_revision:today.revision,
week_revision:weekState.revision,
}};
if (!saveQueue(latest)) throw new Error('Could not update this saved move after plans changed.');
conflictRetried = true;
continue;
}
}
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 {
if (admitted) {
result = await flush(true);
} else {
try {
result = await api('api/v1/week/reschedule', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
});
} catch (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);
if (!today?.ids?.includes(body.identity)) throw error;
body.today_revision = today.revision; body.week_revision = weekState.revision;
result = 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…';dialog.close();
let result;
try{
result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
}catch(error){
if(!dialog.open)dialog.showModal();
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;
}
try{
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){
announce(`${error.message||'Refresh unavailable.'} Move completed; refresh to continue.`);
}
});
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;
+159
View File
@@ -0,0 +1,159 @@
function createTodayWrapUp({ todayWork, tomorrowPlan, todaySync, limit = 5 }) {
let items = [];
const selected = new Set();
function open(candidates) {
const current = new Set(todayWork.read());
const byIdentity = new Map((candidates || []).map(item => [todayWork.identity(item), item]));
items = todayWork.read().flatMap(identity =>
current.has(identity) && byIdentity.has(identity) ? [byIdentity.get(identity)] : []
);
selected.clear();
return snapshot();
}
function choose(identity, schedule) {
if (!items.some(item => todayWork.identity(item) === identity)) return false;
if (schedule) selected.add(identity);
else selected.delete(identity);
return true;
}
function snapshot() {
return items.map(item => ({
item,
identity:todayWork.identity(item),
schedule:selected.has(todayWork.identity(item)),
}));
}
async function finish() {
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 (!todaySync.enqueue('remove', identity)) {
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.');
}
scheduled += 1;
}
await todaySync.flush();
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 };
}
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
let actualMinutes = {};
let workedItems = [];
function close() {
qs('#today-wrap-up-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
onClose();
}
function render() {
const rows = controller.snapshot();
qs('#today-wrap-up-items').innerHTML = rows.map(row => {
const title = String(row.item.title || row.identity).slice(0, 180);
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
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' : '') +
'> 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 = {}, recapWorkedItems = []) {
actualMinutes = { ...recapActualMinutes };
workedItems = recapWorkedItems.map(item => ({...item}));
controller.open(items);
qs('#today-wrap-up-status').textContent = '';
render();
qs('#today-wrap-up-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
requestAnimationFrame(() => (qs('#today-wrap-up-items input') || qs('#finish-today-wrap-up')).focus());
}
async function finish(button) {
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 + ' carried to Tomorrow · ' + result.left +
' left in Today' + (result.sync_pending ? ' · sync pending.' : '.');
close();
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 {
button.disabled = false;
}
}
function bind() {
qs('#close-today-wrap-up').addEventListener('click', close);
qs('#finish-today-wrap-up').addEventListener('click', event => finish(event.currentTarget));
}
return { open, close, finish, render, bind };
}
function setupTodayWrapUp(options) {
const controller = createTodayWrapUp(options);
const view = createTodayWrapUpView({ ...options, controller });
view.bind();
return view;
}
if (typeof module !== 'undefined' && module.exports) {
createTodayWrapUp.createView = createTodayWrapUpView;
createTodayWrapUp.setup = setupTodayWrapUp;
module.exports = createTodayWrapUp;
}
+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;
+17 -6
View File
@@ -18,6 +18,14 @@ function unfiledDraftSummary(item) {
};
}
function unfiledDraftDisplayTitle(item) {
return String(item?.title || '').trim() || (item?.hasAttachment ? 'Untitled photo draft' : 'Untitled draft');
}
function unfiledShouldFocusTitle({mobile = false, continuing = false} = {}) {
return !mobile || continuing;
}
function unfiledSavedMessage(item) {
return item.repository ? 'Saved planned Draft. Resume on any signed-in device.' :
'Saved to Drafts. Choose a repository when you’re ready to file it.';
@@ -49,7 +57,8 @@ function createUnfiledCaptures({
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item =>
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
typeof item.title === 'string' && (item.title.trim() || item.hasAttachment === true) &&
typeof item.body === 'string'
);
} catch (_error) { return []; }
}
@@ -122,9 +131,6 @@ function createUnfiledCaptures({
function prepare(note) {
const title = String(note?.title || '').trim().slice(0, 255);
const body = String(note?.body || '').trim().slice(0, 10000);
if (!title) throw new Error('Add a title before saving.');
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
const attachment = note?.attachment;
const attachments = note?.attachments;
if (Array.isArray(attachments) && attachments.length > 5) {
@@ -139,6 +145,9 @@ function createUnfiledCaptures({
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
if (!title && !hasAttachment && !validAttachments) throw new Error('Add a title or photo before saving.');
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
if ((hasAttachment || validAttachments) && !attachmentStore) {
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
}
@@ -344,7 +353,7 @@ function createUnfiledCaptures({
} : {}),
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
};
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
if (!item.id || (!item.title.trim() && !evidence.length) || !Number.isFinite(item.savedAt)) continue;
if (evidence.length) {
await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({
filename:String(entry.filename), contentType:String(entry.content_type),
@@ -382,7 +391,7 @@ function createUnfiledCaptures({
} : {}),
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
};
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
if (!item.id || (!item.title.trim() && !evidence.length) || !Number.isFinite(item.savedAt)) continue;
if (evidence.length) {
stagedEvidence.set(item.id, {attachments:evidence.map(entry => ({
filename:String(entry.filename), contentType:String(entry.content_type),
@@ -422,4 +431,6 @@ function createUnfiledCaptures({
if (typeof module !== 'undefined' && module.exports) {
module.exports = createUnfiledCaptures;
module.exports.summary = unfiledDraftSummary;
module.exports.displayTitle = unfiledDraftDisplayTitle;
module.exports.shouldFocusTitle = unfiledShouldFocusTitle;
}
+145
View File
@@ -0,0 +1,145 @@
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 ' + 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, limit);
elements.transcript.value = transcript;
elements.review.hidden = false;
elements.append.textContent = elements.draft.value.trim() ? 'Append to draft' : 'Use transcript';
elements.replace.hidden = !elements.draft.value.trim();
elements.status.textContent = recovered ?
'Recovered transcript. Review it before using or discarding it.' :
'Transcript ready. Review it before using it.';
if (!recovered && transcriptStore && login() && target) {
void transcriptStore.save(login(), transcript, target);
}
}
async function open(nextTarget) {
cancel();
target = String(nextTarget || '').trim();
const opening = ++generation;
elements.transcript.value = '';
elements.review.hidden = true;
elements.status.textContent = '';
if (!supported || !target || !transcriptStore || !login()) return;
const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, limit);
if (opening === generation && recovered) showReview(recovered, true);
}
function clearCheckpoint() {
if (transcriptStore && login() && target) void transcriptStore.clear(login(), target);
}
function commit(replace) {
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, limit);
elements.draft.dispatchEvent(createEvent('input'));
clearCheckpoint();
elements.review.hidden = true;
elements.status.textContent = 'Transcript added. Review the ' + label + ' before sending.';
}
elements.start.addEventListener('click', () => {
if (!supported || !target) return;
const sessionGeneration = generation;
const currentRecognition = new Recognition();
recognition = currentRecognition;
recognition.continuous = true;
recognition.interimResults = false;
recognition.onresult = event => {
if (recognition !== currentRecognition || generation !== sessionGeneration) return;
const finalText = Array.from(event.results || []).slice(event.resultIndex ?? 0)
.filter(result => result.isFinal)
.map(result => result[0]?.transcript || '').join(' ');
if (normalize(finalText)) {
showReview([elements.transcript.value, finalText].filter(Boolean).join(' '));
}
};
recognition.onerror = event => {
if (recognition !== currentRecognition || generation !== sessionGeneration) return;
elements.start.hidden = false;
elements.stop.hidden = true;
elements.status.textContent = ['not-allowed', 'service-not-allowed'].includes(event.error) ?
'Microphone permission was denied. Your draft and transcript are unchanged.' :
'Voice capture stopped. Your draft and transcript are unchanged.';
};
recognition.onend = () => {
if (recognition !== currentRecognition || generation !== sessionGeneration) return;
recognition = null;
elements.start.hidden = false;
elements.stop.hidden = true;
};
try {
recognition.start();
} catch (_) {
recognition = null;
elements.start.hidden = false;
elements.stop.hidden = true;
elements.status.textContent = 'Voice capture could not start. Your draft and transcript are unchanged.';
return;
}
elements.start.hidden = true;
elements.stop.hidden = false;
elements.status.textContent = 'Listening… Tap stop when you are finished.';
});
elements.stop.addEventListener('click', () => {
recognition?.stop();
elements.status.textContent = 'Finishing transcript…';
});
elements.append.addEventListener('click', () => commit(false));
elements.replace.addEventListener('click', () => commit(true));
elements.discard.addEventListener('click', () => {
clearCheckpoint();
elements.transcript.value = '';
elements.review.hidden = true;
elements.status.textContent = 'Transcript discarded.';
});
function cancel() {
generation += 1;
recognition?.abort();
recognition = null;
elements.start.hidden = false;
elements.stop.hidden = true;
}
return {supported, open, cancel};
}
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};
+120
View File
@@ -0,0 +1,120 @@
function mapVoiceTranscript(value) {
const transcript = String(value || '').replace(/\s+/g, ' ').trim();
if (!transcript) return {title:'', body:''};
const sentence = transcript.match(/^(.+?[.!?])(?:\s+|$)(.*)$/);
const title = (sentence ? sentence[1] : transcript).trim().slice(0, 255);
const body = (sentence ? sentence[2] : '').trim().slice(0, 10000);
return {title, body};
}
function createVoiceIssueCapture({
Recognition, elements, createEvent = name => new Event(name, {bubbles:true}),
transcriptStore = null, getLogin = () => '',
}) {
const supported = typeof Recognition === 'function';
const login = String(getLogin() || '').trim();
const ready = supported && login && transcriptStore ? Promise.resolve(transcriptStore.load(login)).then(value => {
const recovered = String(value || '').replace(/\s+/g, ' ').trim().slice(0, 10255);
if (recovered) showReview(recovered, {persist:false, recovered:true});
}) : Promise.resolve();
elements.root.hidden = !supported;
if (!supported) {
elements.status.textContent = 'Voice capture is unavailable; type the title and note instead.';
return {supported:false, ready, cancel() {}};
}
let recognition = null;
function showReview(value, {persist = true, recovered = false} = {}) {
elements.transcript.value = value;
const activeLogin = String(getLogin() || '').trim();
if (persist && activeLogin && transcriptStore) void transcriptStore.save(activeLogin, value);
const hasDraft = Boolean(elements.title.value.trim() || elements.body.value.trim());
elements.append.textContent = hasDraft ? 'Append to draft' : 'Use transcript';
elements.replace.hidden = !hasDraft;
elements.review.hidden = false;
elements.status.textContent = recovered ?
'Recovered transcript. Review it before using or discarding it.' :
'Transcript ready. Review it before using it.';
}
elements.start.addEventListener('click', () => {
recognition = new Recognition();
recognition.continuous = true;
recognition.interimResults = false;
recognition.onresult = event => {
const finalText = Array.from(event.results || [])
.slice(event.resultIndex ?? 0)
.filter(result => result.isFinal)
.map(result => result[0]?.transcript || '').join(' ').replace(/\s+/g, ' ').trim();
if (finalText) {
const accumulated = [elements.transcript.value.trim(), finalText]
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 10255);
showReview(accumulated);
}
};
recognition.onerror = event => {
elements.start.hidden = false;
elements.stop.hidden = true;
elements.status.textContent = event.error === 'not-allowed' || event.error === 'service-not-allowed' ?
'Microphone permission was denied. Your draft and transcript are unchanged.' :
'Voice capture stopped. Your draft and transcript are unchanged.';
};
recognition.onend = () => {
elements.start.hidden = false;
elements.stop.hidden = true;
};
try {
recognition.start();
} catch (_) {
recognition = null;
elements.start.hidden = false;
elements.stop.hidden = true;
elements.status.textContent = 'Voice capture could not start. Your draft and transcript are unchanged.';
return;
}
elements.start.hidden = true;
elements.stop.hidden = false;
elements.status.textContent = 'Listening… Tap stop when you are finished.';
});
elements.stop.addEventListener('click', () => {
recognition?.stop();
elements.status.textContent = 'Finishing transcript…';
});
function clearCheckpoint() {
const activeLogin = String(getLogin() || '').trim();
if (activeLogin && transcriptStore) void transcriptStore.clear(activeLogin);
}
function commit(mode) {
const mapped = mapVoiceTranscript(elements.transcript.value);
if (mode === 'replace') {
elements.title.value = mapped.title;
elements.body.value = mapped.body;
} else if (elements.title.value.trim()) {
elements.body.value = [elements.body.value.trim(), mapped.title, mapped.body]
.filter(Boolean).join('\n\n').slice(0, 10000);
} else {
elements.title.value = mapped.title;
elements.body.value = [elements.body.value.trim(), mapped.body]
.filter(Boolean).join('\n\n').slice(0, 10000);
}
elements.title.dispatchEvent(createEvent('input'));
elements.body.dispatchEvent(createEvent('input'));
clearCheckpoint();
elements.review.hidden = true;
elements.status.textContent = 'Transcript added. Review the title and note before saving.';
}
elements.append.addEventListener('click', () => commit('append'));
elements.replace.addEventListener('click', () => commit('replace'));
elements.discard?.addEventListener('click', () => {
clearCheckpoint();
elements.transcript.value = '';
elements.review.hidden = true;
elements.status.textContent = 'Recovered transcript discarded.';
});
return {supported:true, ready, cancel() { recognition?.abort(); recognition = null; }};
}
if (typeof module !== 'undefined') module.exports = {mapVoiceTranscript, createVoiceIssueCapture};
+93
View File
@@ -0,0 +1,93 @@
(function (root, factory) {
const createVoiceTranscriptStore = factory(root.indexedDB);
if (typeof module !== 'undefined' && module.exports) module.exports = createVoiceTranscriptStore;
else root.createVoiceTranscriptStore = createVoiceTranscriptStore;
})(typeof globalThis !== 'undefined' ? globalThis : this, function buildFactory(defaultIndexedDB) {
const dbName = 'stackchain-voice-transcripts-v1';
const STORE_NAME = 'checkpoints';
const MAX_TRANSCRIPT_LENGTH = 10255;
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Private transcript storage failed.'));
});
}
function createIndexedDbTransaction(indexedDB) {
if (!indexedDB) return null;
let databasePromise = null;
function openDatabase() {
if (databasePromise) return databasePromise;
databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME, {keyPath:'login'});
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Private transcript storage is unavailable.'));
});
return databasePromise;
}
return async (work, mode = 'readonly') => {
const database = await openDatabase();
const transaction = database.transaction(STORE_NAME, mode);
const store = transaction.objectStore(STORE_NAME);
const completed = new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error || new Error('Private transcript storage failed.'));
transaction.onabort = () => reject(transaction.error || new Error('Private transcript storage was interrupted.'));
});
const result = await work({
get:key => requestResult(store.get(key)),
put:value => requestResult(store.put(value)),
delete:key => requestResult(store.delete(key)),
});
await completed;
return result;
};
}
function normalizeLogin(value) {
return String(value || '').trim().toLowerCase();
}
function normalizeTranscript(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TRANSCRIPT_LENGTH);
}
function storageKey(login, target = '') {
const account = normalizeLogin(login);
const scope = String(target || '').trim().toLowerCase();
return account && scope ? account + '\u0000' + scope : account;
}
function createVoiceTranscriptStore({transaction = createIndexedDbTransaction(defaultIndexedDB)} = {}) {
async function load(login, target = '') {
const key = storageKey(login, target);
if (!key || !transaction) return '';
const record = await transaction(store => store.get(key));
return normalizeTranscript(record?.transcript);
}
async function save(login, transcript, target = '') {
const key = storageKey(login, target);
const value = normalizeTranscript(transcript);
if (!key || !value || !transaction) return;
await transaction(store => store.put({login:key, transcript:value}), 'readwrite');
}
async function clear(login, target = '') {
const key = storageKey(login, target);
if (!key || !transaction) return;
await transaction(store => store.delete(key), 'readwrite');
}
return {load, save, clear};
}
createVoiceTranscriptStore.databaseName = dbName;
return createVoiceTranscriptStore;
});
+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
+44 -10
View File
@@ -6,7 +6,14 @@
'use strict';
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'authored', 'update', 'later', 'draft', 'following'];
const sections = {
issue: ['overview', 'conversation', 'reply', 'actions'],
filed: ['overview', 'conversation', 'reply', 'actions'],
pull: ['overview', 'conversation', 'reply', 'review'],
review: ['overview', 'files', 'feedback', 'history'],
update: ['activity', 'conversation', 'context', 'reply'],
};
function positiveInteger(value) {
const number = Number(value);
@@ -23,27 +30,37 @@
if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
}
if (parts[2] === 'update' && parts.length === 4) {
if (parts[2] === 'update' && [4, 5].includes(parts.length)) {
const notificationId = positiveInteger(parts[3]);
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
const section = parts[4];
if (!notificationId || (section && !sections.update.includes(section))) return null;
return { kind: 'update', notification_id: notificationId, ...(section ? { section } : {}) };
}
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || ![6, 7].includes(parts.length)) return null;
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
const number = positiveInteger(parts[5]);
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
const section = parts[6];
if (!number || (section && !sections[parts[2]].includes(section))) return null;
return {
kind: parts[2], repository: parts[3] + '/' + parts[4], number,
...(section ? { section } : {}),
};
}
function serialize(item) {
if (item?.kind === 'update') {
const notificationId = positiveInteger(item.notification_id);
return notificationId ? '#/my-work/update/' + notificationId : '';
if (!notificationId || (item.section && !sections.update.includes(item.section))) return '';
return '#/my-work/update/' + notificationId + (item.section ? '/' + item.section : '');
}
const kind = item?.is_filed && !item?.is_assigned ? 'filed' : item?.kind;
if (!['issue', 'filed', 'pull', 'review'].includes(kind)) return '';
const repository = String(item.repository || '').split('/');
const number = positiveInteger(item.number);
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number;
if (item.section && !sections[kind].includes(item.section)) return '';
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number +
(item.section ? '/' + item.section : '');
}
function sameRoute(item, route) {
@@ -59,7 +76,7 @@
function createController({
location, history, eventTarget, onOpen, onClose, onInvalid,
onQueue = function () {},
onQueue = function () {}, onSection = function () {},
resolve, onResolving = function () {}, onError = function () {},
}) {
let items = [];
@@ -86,7 +103,7 @@
return;
}
active = fragment;
onOpen({ ...item, kind: route.kind });
onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) });
}).catch(error => {
if (request !== resolution || String(location.hash || '') !== fragment) return;
resolving = '';
@@ -128,10 +145,16 @@
return;
}
if (active === fragment) return;
const activeRoute = parse(active);
if (activeRoute && sameRoute(item, activeRoute)) {
active = fragment;
onSection(route.section || sections[route.kind][0], { restore: true });
return;
}
resolution += 1;
resolving = '';
active = fragment;
onOpen({ ...item, kind: route.kind });
onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) });
}
return {
@@ -156,6 +179,17 @@
onOpen(item);
return true;
},
section(name, options = {}) {
const route = parse(location.hash);
if (!route || route.kind === 'queue' || !sections[route.kind].includes(name)) return false;
if ((route.section || sections[route.kind][0]) === name) return true;
const fragment = serialize({ ...route, section: name });
const method = options.replace ? 'replaceState' : 'pushState';
history[method]({ workRoute: fragment }, '', fragment);
active = fragment;
onSection(name, { restore: false });
return true;
},
queue(filter) {
if (!queueFilters.includes(filter)) return false;
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
Loaded 100 of 319 files, more files were not shown because too many files have changed in this diff. Show more