Compare commits

...
Author SHA1 Message Date
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 / lint (push) Successful in 3m11s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m34s
CI / release-candidate (push) Successful in 8s
2026-08-17 12:04:30 +00:00
timmy 241f0f3a2a feat: make Security Center Back-safe on mobile (Closes #1020)
CI / lint (pull_request) Successful in 3m17s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 2m35s
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 / lint (push) Successful in 3m10s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m21s
CI / release-candidate (push) Successful in 7s
2026-08-17 10:48:48 +00:00
timmy 3fde7cf3ad fix: foreground mobile delivery attention handoffs (Closes #1018)
CI / lint (pull_request) Successful in 3m1s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m22s
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 / lint (push) Successful in 3m7s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m13s
CI / release-candidate (push) Successful in 7s
2026-08-17 10:08:11 +00:00
timmy 437da72f3d fix: keep delivery recovery single-flight (Closes #1016)
CI / lint (pull_request) Successful in 3m2s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m20s
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 / lint (push) Successful in 2m49s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m13s
CI / release-candidate (push) Successful in 7s
2026-08-17 09:12:25 +00:00
timmy 1e4f101219 feat: guide mobile delivery recovery (Closes #1014)
CI / lint (pull_request) Successful in 2m43s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m4s
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 / lint (push) Successful in 2m55s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m9s
CI / release-candidate (push) Successful in 7s
2026-08-17 08:36:15 +00:00
timmy 246f995b56 feat: prioritize mobile delivery recovery (Closes #1012)
CI / lint (pull_request) Successful in 2m58s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m5s
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 / lint (push) Successful in 2m40s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 1m51s
CI / release-candidate (push) Successful in 6s
2026-08-17 08:07:58 +00:00
timmy 26aa12019e feat: guide blocked deliveries through Prepare Today (Closes #1010)
CI / lint (pull_request) Successful in 2m50s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m53s
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 / lint (push) Successful in 2m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m6s
CI / release-candidate (push) Successful in 7s
2026-08-17 07:39:03 +00:00
timmy d64c646b68 feat: continue mobile Work into Filed follow-up (Closes #1008)
CI / lint (pull_request) Successful in 2m43s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m56s
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 / lint (push) Successful in 2m40s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m53s
CI / release-candidate (push) Successful in 6s
2026-08-17 07:07:39 +00:00
timmy 09dbdb9961 feat: preserve mobile work section history (Closes #1006)
CI / lint (pull_request) Successful in 2m58s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 2m1s
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 / lint (push) Successful in 2m49s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m2s
CI / release-candidate (push) Successful in 7s
Closes #1004
2026-08-17 06:32:24 +00:00
timmy 7571a4aa2d fix: resume private share bundles after sign-in (Closes #1004)
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m30s
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 / lint (push) Successful in 2m54s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m55s
CI / release-candidate (push) Successful in 6s
2026-08-17 05:49:33 +00:00
timmy 386acda1dc feat: add mobile pull-to-refresh for My Work (Closes #1002)
CI / lint (pull_request) Successful in 2m57s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m1s
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 / lint (push) Successful in 2m58s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m9s
CI / release-candidate (push) Successful in 7s
Closes #1000
2026-08-17 04:45:00 +00:00
timmy a2c91e0296 fix: guard IndexedDB private work cleanup (Closes #1000)
CI / lint (pull_request) Successful in 3m1s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m9s
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 / lint (push) Successful in 3m1s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m12s
CI / release-candidate (push) Successful in 7s
2026-08-17 03:43:21 +00:00
timmy 70c8ea6584 feat: manage private device storage (Closes #998)
CI / lint (pull_request) Successful in 2m51s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m5s
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 / lint (push) Successful in 2m44s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m59s
CI / release-candidate (push) Successful in 6s
2026-08-17 02:52:39 +00:00
timmy cf76bc3636 test: model worker transport outage deterministically
CI / lint (pull_request) Successful in 2m40s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 02:46:42 +00:00
timmy 0fc152f623 fix: recover deferred workspace reconnects
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m45s
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 / lint (pull_request) Successful in 2m49s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m53s
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 / lint (pull_request) Successful in 2m49s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m44s
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 / lint (pull_request) Successful in 2m54s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 3m4s
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 / lint (pull_request) Successful in 2m49s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m43s
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 / lint (push) Successful in 3m2s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m53s
CI / release-candidate (push) Successful in 7s
Closes #994
2026-08-17 00:14:58 +00:00
timmy 0fcb7f5152 feat: make mobile Home task-first with Insights (Closes #994)
CI / lint (pull_request) Successful in 3m2s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m8s
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 / lint (push) Successful in 2m45s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m47s
CI / release-candidate (push) Successful in 7s
2026-08-16 23:20:55 +00:00
timmy 468676d2f4 feat: review wrap-up commitments in Today (Closes #992)
CI / lint (pull_request) Successful in 2m56s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m50s
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 / lint (push) Successful in 2m53s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m39s
CI / release-candidate (push) Successful in 7s
2026-08-16 22:12:24 +00:00
timmy 110fd860b3 feat: schedule unfinished work during Today wrap-up (Closes #990)
CI / lint (pull_request) Successful in 2m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 1m36s
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 / lint (push) Successful in 2m51s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m33s
CI / release-candidate (push) Successful in 7s
2026-08-16 21:09:47 +00:00
timmy e395878f92 security: block vulnerable runtime dependencies (Closes #988)
CI / lint (pull_request) Successful in 2m48s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m20s
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 / lint (push) Successful in 2m23s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m34s
CI / release-candidate (push) Successful in 6s
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 / lint (pull_request) Successful in 2m17s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m34s
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 / lint (push) Successful in 2m11s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m16s
CI / release-candidate (push) Successful in 6s
2026-08-16 19:38:34 +00:00
74 changed files with 4055 additions and 168 deletions

No files matched your search

+3 -1
View File
@@ -15,6 +15,8 @@ jobs:
- uses: actions/setup-python@v5
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:
@@ -55,7 +57,7 @@ jobs:
pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium
- name: Exercise packaged mobile work journeys
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py -q
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
release-candidate:
runs-on: ubuntu-latest
+98
View File
@@ -26,6 +26,7 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.app-menu { margin-left:auto; }
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
#open-insights { display:none; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
.notification-undo[hidden] { display:none; }
@@ -45,6 +46,11 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.active-devices-header h2, .active-devices-header p { margin-top:0; }
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
.security-section-nav { position:sticky; top:-18px; z-index:2; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:0 -2px; padding:10px 2px; background:#0b1526; }
.security-section-nav button { min-height:44px; min-width:0; }
.security-section-nav button[aria-current="page"] { border-color:#55d6be; color:#55d6be; }
.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:64px; }
.security-devices { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.device-setup-sheet[hidden] { display:none; }
@@ -59,6 +65,10 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
.device-storage { display:grid; gap:12px; margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0d1d31; }
.device-storage h3, .device-storage p { margin:0 0 5px; }
.device-storage-actions { display:flex; gap:8px; flex-wrap:wrap; }
.device-storage-actions button { min-height:44px; flex:1 1 180px; }
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
.device-readiness-summary { color:#bfdbfe; font-weight:700; }
@@ -76,6 +86,7 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
.security-event button { min-height:44px; margin-top:10px; }
.authentication-alert { border-color:#d69e2e; background:#241b09; }
.authentication-alert strong { color:#f6c453; }
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
@@ -91,6 +102,8 @@ button:hover { filter: brightness(1.15); }
.panel[open] > summary { margin-bottom: 8px; }
aside, section { position: relative; z-index: 1; }
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
.insights-shell { display:contents; }
.insights-header { display:none; }
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
.sidebar { display:flex; flex-direction:column; gap: 12px; }
.stack { display:flex; flex-direction:column; gap:10px; }
@@ -258,12 +271,39 @@ textarea { resize: vertical; min-height: 120px; }
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-recap-actions button { min-height:44px; width:100%; }
.today-wrap-up-sheet { position:fixed; inset:0; z-index:89; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-wrap-up-sheet[hidden] { display:none; }
.today-wrap-up-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-wrap-up-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-wrap-up-header h2 { margin:.2rem 0; }
.today-wrap-up-header button { min-height:44px; min-width:44px; }
.today-wrap-up-items { display:grid; gap:8px; margin:14px 0; }
.today-wrap-up-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; min-width:0; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233d; overflow-wrap:anywhere; }
.today-wrap-up-item span { display:block; }
.today-wrap-up-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; }
.today-wrap-up-item input { width:24px; min-height:24px; }
.today-wrap-up-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-wrap-up-actions button { min-height:44px; width:100%; }
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
.today-handoff-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-handoff-panel h2 { margin:.2rem 0; }
.today-handoff-panel header button { min-height:44px; min-width:44px; }
.today-handoff-items { display:grid; gap:8px; margin:14px 0; }
.today-handoff-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; padding:12px; border:1px solid #29486c; border-radius:12px; background:#101f35; }
.today-handoff-item span { display:block; }
.today-handoff-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; }
.today-handoff-item input { width:24px; min-height:24px; }
.today-handoff-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-handoff-actions button { min-height:44px; width:100%; }
@media (max-width:420px) {
.today-interruption-actions { grid-template-columns:1fr; }
.today-recap-actions { grid-template-columns:1fr; }
.today-recap-row { grid-template-columns:1fr; }
.today-recap-row input { width:min(100%,8rem); }
.today-recap-variance { grid-column:1; }
.today-wrap-up-item { grid-template-columns:1fr; }
}
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-readiness-sheet[hidden] { display:none; }
@@ -905,6 +945,12 @@ textarea { resize: vertical; min-height: 120px; }
.app-menu:not([open]) > .app-menu-panel { display:none; }
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
.app-menu-panel button { min-height:44px; width:100%; }
#open-insights { display:block; }
.insights-shell { display:none; }
.insights-shell[data-mobile-open="true"] { position:fixed; inset:0; z-index:90; display:grid; align-content:start; gap:12px; overflow:auto; overflow-x:hidden; padding:max(12px,env(safe-area-inset-top)) max(12px,env(safe-area-inset-right)) max(12px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left)); background:#050c15; }
.insights-header { position:sticky; top:0; z-index:2; display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; }
.insights-header h2, .insights-header p { margin:0 0 4px; }
.insights-header button { min-height:44px; flex:none; }
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
.device-setup-step { grid-template-columns:1fr; }
.device-setup-action { width:100%; }
@@ -991,6 +1037,14 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.mobile-queue-panel h2 { margin:0; }
.mobile-delivery-recovery { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-delivery-recovery::backdrop { background:rgba(3,9,18,.78); }
.mobile-delivery-recovery-panel { box-sizing:border-box; display:grid; gap:12px; width:100%; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
.mobile-delivery-recovery-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.mobile-delivery-recovery-panel h2, .mobile-delivery-recovery-panel p { margin:0; }
.mobile-delivery-recovery-panel button { min-height:48px; }
.mobile-delivery-recovery-panel > #mobile-delivery-recovery-action { width:100%; font-weight:800; }
.delivery-recovery-reason { padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1c30; }
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
.mobile-start-day h3, .mobile-start-day p { margin:0; }
.mobile-start-day p + p { margin-top:4px; }
@@ -1021,3 +1075,47 @@ textarea { resize: vertical; min-height: 120px; }
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
/* One-handed live-data recovery on the mobile Home surface. */
.mobile-pull-refresh {
display: none;
}
@media (max-width: 600px) {
.mobile-pull-refresh {
position: sticky;
top: calc(3.25rem + env(safe-area-inset-top));
z-index: 8;
display: block;
width: max-content;
max-width: calc(100vw - 2rem);
margin: 0 auto -2rem;
padding: .45rem .8rem;
border: 1px solid rgba(125, 211, 252, .45);
border-radius: 999px;
background: rgba(11, 18, 32, .96);
color: #dbeafe;
font-size: .78rem;
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .28);
transform: translateY(.5rem);
pointer-events: none;
}
.mobile-pull-refresh[hidden] {
display: none;
}
.mobile-pull-refresh[data-state="ready"],
.mobile-pull-refresh[data-state="success"] {
border-color: rgba(52, 211, 153, .7);
}
.mobile-pull-refresh[data-state="error"] {
border-color: rgba(251, 113, 133, .75);
}
}
@media (max-width: 600px) and (prefers-reduced-motion: no-preference) {
.mobile-pull-refresh {
transition: transform 120ms ease, border-color 120ms ease;
}
}
+116 -22
View File
@@ -1,4 +1,5 @@
(async function(){
const workspaceLifecycle = await loadWorkspace({ document, window });
const qs = (s, el=document) => el.querySelector(s);
const fmt = (d) => new Date(d).toLocaleString();
const cardPlanning = createCardPlanning(document);
@@ -26,6 +27,7 @@
actions:qs('#issue-planning'),
},
planning:qs('#issue-planning'),
onSectionChange:(section, options) => workRoute.section(section, options),
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
mobileIssueDetailNavigation.start();
@@ -40,6 +42,7 @@
review:qs('#pull-review'),
},
beforeNavigate:{review(target) { target.open = true; }},
onSectionChange:(section, options) => workRoute.section(section, options),
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
mobilePullDetailNavigation.start();
@@ -87,7 +90,23 @@
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
async function recoverMobileDelivery(item) {
if (!item?.outbox_id || !activeFlushLogin) throw new Error('Sign in again to retry.');
const authored = item.kind === 'authored-outbox';
const result = await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin);
(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result);
}
let mobileQueueCounts = {};
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
getItems: () => draftInbox.partition(lastDrafts).deliveries,
getIndex: item => lastDrafts.indexOf(item),
activate: recoverMobileDelivery,
beforeOpen: () => selectMobileQueue('draft'),
onComplete: () => {
if (!mobileStartDay.completePhase('delivery')) showMobileQueueCompletion('Delivery', true);
},
});
mobileDeliveryRecovery.start();
function openFiledFollowUp() {
selectMobileQueue('filed');
const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
@@ -101,6 +120,7 @@
return target.kind === 'update' ? 'opened-update' : 'opened-issue';
}
const mobileQueueLauncher = createMobileQueueLauncher({
openDelivery: () => mobileDeliveryRecovery.open(),
openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession,
openUpdates: openUpdateTriage,
@@ -204,6 +224,20 @@
},
});
mobileTaskDock.start();
const mobileInsights = createMobileInsights({
root: qs('#insights-sheet'),
launcher: qs('#open-insights'),
closeButton: qs('#close-insights'),
dock: qs('#mobile-task-dock'),
hud: qs('[data-mobile-today-hud]'),
backgrounds: [qs('header'), qs('#my-work')],
menu: qs('.app-menu'),
history: window.history,
location: window.location,
eventTarget: window,
mediaQuery: window.matchMedia('(max-width: 600px)'),
});
mobileInsights.start();
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
let liveMode = true;
@@ -323,7 +357,9 @@
const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`;
if (!localStorage.getItem(marker)) {
localStorage.setItem(marker, '1');
setTimeout(() => openPlanToday(qs('#plan-today')), 0);
setTimeout(() => {
if (!workSession.checkpointed()) openPlanToday(qs('#plan-today'));
}, 0);
}
},
onStatus: (state, detail = {}) => {
@@ -337,19 +373,36 @@
},
});
todaySync.startLifecycle({ window, document });
const todayHandoff = createTodayHandoff({
storage:localStorage,
getLogin:() => planningOwnerLogin,
identity:item => todayWork.identity(item),
items:() => [...todayMyWork, ...activeMyWork],
todayWork,
todaySync,
});
const todayHandoffView = todayHandoff.mount({ qs, escapeHtml,
onComplete:() => refreshMyWorkView(),
});
const promptTodayHandoff = () => todayHandoffView.open();
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
onChange: (action, itemId, wakeAt) => {
if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush();
onChange: (action, itemId, wakeAt, handoff) => {
if (laterSync.enqueue(action, itemId, wakeAt, handoff)) laterSync.flush();
},
onExpire: ids => {
onExpire: (ids, handoffs) => {
if (handoffs.length) {
todayHandoff.capture(handoffs);
todayHandoffView.reset();
}
const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
if (queued) laterSync.flush();
},
onWake: () => {
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
refreshMyWorkView();
setTimeout(promptTodayHandoff, 0);
},
});
const laterSync = createLaterSync({
@@ -1375,6 +1428,7 @@
},
replyComposer:qs('#update-reply'),
jumpToNewActivity:() => updateReadPosition.jump(),
onSectionChange:(section, options) => workRoute.section(section, options),
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
mobileUpdateDetailNavigation.start();
@@ -1388,6 +1442,7 @@
history:qs('#review-history-workspace'),
},
summaryComposer:qs('#review-summary'),
onSectionChange:(section, options) => workRoute.section(section, options),
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
mobileReviewDetailNavigation.start();
@@ -1631,6 +1686,25 @@
workRoute.open(routedWorkItem(item), options);
}
function navigateWorkSection(kind, section) {
if (['issue', 'filed'].includes(kind)) {
mobileIssueDetailNavigation.navigate(section, { focus:false });
} else if (kind === 'pull') {
mobilePullDetailNavigation.navigate(section, { focus:false });
} else if (kind === 'update') {
mobileUpdateDetailNavigation.navigate(section, { focus:false });
} else if (kind === 'review') {
mobileReviewDetailNavigation.navigate(section, { focus:false });
}
}
async function openRoutedWorkSection(item) {
if (item.kind === 'update') await notificationReader.open(item, lastMyWork);
else if (item.kind === 'review') await openReviewSheet(item, reviewTrigger);
else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);
else if (item.kind === 'pull') await openPullSheet(item, pullTrigger);
}
const workRoute = createWorkRoute.createController({
location: window.location,
history: window.history,
@@ -1648,14 +1722,17 @@
qs('#retry-work-route').hidden = true;
qs('#my-work-action-status').textContent = 'Loading shared work item…';
},
onOpen: item => {
onOpen: async item => {
qs('#retry-work-route').hidden = true;
qs('#my-work-action-status').textContent = '';
closeOpenWorkSheets();
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
await openRoutedWorkSection(item);
const route = createWorkRoute.parse(window.location.hash);
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
},
onSection: (section, options) => {
const route = createWorkRoute.parse(window.location.hash);
if (options.restore) navigateWorkSection(route?.kind, section);
},
onQueue: openWorkQueueRoute,
onClose: () => {
@@ -1765,11 +1842,18 @@
interruptionPrompt.resolve(button.dataset.todayInterruption)
);
});
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
onComplete:() => {
todayRecapView['completeReplan']();
refreshMyWorkView();
warmTodayOffline();
},
});
const todayRecapView = setupTodayRecap(
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
() => planningOwnerLogin,
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
actualMinutes => openPlanToday(qs('#plan-today'), true, actualMinutes)
actualMinutes => todayWrapUpView.open(todayMyWork, actualMinutes)
);
function updateDetailDeferLabels(active) {
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
@@ -2477,7 +2561,7 @@
function renderPlanIssueDependencies(detail, loading = false) {
const preview = planTodayPreview.snapshot();
const assignedIssue = Boolean(selectedIssue && selectedIssueDetail === detail);
const assignedIssue = Boolean(!preview.open && selectedIssue && selectedIssueDetail === detail);
if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return;
const panel = qs('#issue-blockers');
const list = qs('#issue-blocker-list');
@@ -2957,6 +3041,7 @@
counts.today = todayMyWork.length;
counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
counts.delivery = draftInbox.partition(lastDrafts).actionable;
if (!launchFilterResolved) {
selectedWorkFilter = mobileLaunch.chooseFilter({
saved: savedWorkFilter, today: counts.today, attention: counts.attention,
@@ -2972,7 +3057,10 @@
if (element) element.textContent = count;
});
mobileQueueCounts = counts;
mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh});
mobileStartDay.reconcile({
authoritative:authoritativeMyWorkRefresh,
authoritativePhases:['delivery'],
});
mobileStartDay.render();
mobileTaskDock.updateQueues(counts);
mobileTaskDock.updateWork(mobileWorkEntry.mode());
@@ -3005,6 +3093,7 @@
}
if (reconcileSession && workSession.active()) workSession.reconcile();
updateWorkSessionActions();
if (todayHandoff.pending().length) setTimeout(promptTodayHandoff, 0);
}
function activeWorkStreams() {
@@ -3097,7 +3186,7 @@
'<div class="draft-actions">' + outboxActions + '</div></article>';
};
const deliverySummary = '<section class="delivery-center" aria-labelledby="delivery-center-title">' +
'<div><h3 id="delivery-center-title">Delivery center</h3>' +
'<div><h3 id="delivery-center-title" tabindex="-1">Delivery center</h3>' +
'<p class="small">Waiting <strong data-delivery-count="waiting">' + deliveryCenter.counts.waiting + '</strong> · ' +
'Sending <strong data-delivery-count="sending">' + deliveryCenter.counts.sending + '</strong> · ' +
'Needs attention <strong data-delivery-count="attention">' + deliveryCenter.counts.attention + '</strong> · ' +
@@ -3116,14 +3205,14 @@
const button = event.currentTarget;
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
button.disabled = true;
qs('#my-work-action-status').textContent = 'Retrying safe waiting deliveries…';
qs('#my-work-action-status').textContent = 'Retrying waiting deliveries…';
const [issueResult, authoredResult] = await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)]);
applyOutboxResult(issueResult);
applyAuthoredOutboxResult(authoredResult);
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
qs('#my-work-action-status').textContent = 'Waiting deliveries retried; attention stayed queued.';
});
list.querySelectorAll('.draft-resume').forEach(button => {
button.addEventListener('click', async () => {
button.onclick=async()=>{
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item) return;
if (item.kind === 'unfiled-issue') {
@@ -3132,7 +3221,7 @@
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.route) workRoute.open(item.route);
});
};
});
list.querySelectorAll('.draft-continue').forEach(button => {
button.addEventListener('click', () => {
@@ -3179,7 +3268,7 @@
});
});
list.querySelectorAll('.draft-review-checklist').forEach(button => {
button.addEventListener('click', async () => {
button.onclick=async()=>{
const item = lastDrafts[Number(button.dataset.draftIndex)];
const queued = authoredOutbox.list().find(candidate => candidate.id === item?.outbox_id);
if (!item?.checklist_conflict || !queued || !activeFlushLogin) return;
@@ -3212,7 +3301,7 @@
} finally {
button.disabled = false;
}
});
};
});
list.querySelectorAll('.draft-authorize').forEach(button => {
button.addEventListener('click', async () => {
@@ -3233,12 +3322,12 @@
});
});
list.querySelectorAll('.draft-copy').forEach(button => {
button.addEventListener('click', async () => {
button.onclick=async()=>{
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.copy_text) return;
await navigator.clipboard.writeText(item.copy_text);
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
});
};
});
list.querySelectorAll('.draft-discard').forEach(button => {
button.addEventListener('click', async () => {
@@ -3257,6 +3346,7 @@
qs('#my-work-action-status').textContent = 'Draft discarded.';
});
});
mobileDeliveryRecovery.render();
}
function updateWorkPaginationControls() {
@@ -7141,6 +7231,7 @@
intervalMs: 8000,
});
function load() { return contextPoller.refresh({ force: true }); }
createMobilePullRefresh({refresh:load}).start();
const liveDataStatusSheet = qs('#live-data-status-sheet');
const liveDataStatusTrigger = qs('#open-live-data-status');
let latestLiveFreshness = {};
@@ -7289,6 +7380,9 @@
flushAuthored: flushAuthoredOutbox,
flushNotificationReads: flushNotificationReadOutbox,
});
const reconnectAfterOnline = () => setTimeout(reconnectOutboxes, 500);
window.addEventListener('online', reconnectAfterOnline);
workspaceLifecycle.replayOnline(reconnectAfterOnline);
async function setOfflineWorkEnabled(enabled) {
keepWorkOffline.checked = enabled;
offlineWorkStore.setEnabled(enabled);
@@ -7346,7 +7440,6 @@
updateDeliveryReceiptControls();
if (!navigator.onLine) await showOfflineStatus();
window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectOutboxes);
qs('#refresh').addEventListener('click', load);
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
@@ -7700,6 +7793,7 @@
await controller.enableDeadline();
},
});
await createDeviceStorage.mount(document).start();
await deviceSetup.start();
return deviceSetup;
}
+103
View File
@@ -0,0 +1,103 @@
(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;
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 refresh() {
let estimate = null;
try { estimate = await options.storageManager?.estimate?.(); }
catch (_error) { /* Storage estimates are optional. */ }
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 = `${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 = '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 };
});
+9 -2
View File
@@ -240,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' :
@@ -251,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) {
+94 -15
View File
@@ -16,6 +16,7 @@
<details class="app-menu">
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
<div class="app-menu-panel">
<button id="open-insights" type="button" aria-controls="insights-sheet">Insights</button>
<button id="refresh">Refresh</button>
<button id="open-palette">Command</button>
<button id="open-device-setup" type="button">Device setup</button>
@@ -47,22 +48,15 @@
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices and passkeys">
<div class="active-devices-header">
<div><h2>Active devices</h2><p class="small muted">Review signed-in devices and remove access you no longer trust.</p></div>
<div><h2>Security Center</h2><p class="small muted">Investigate activity, remove untrusted access, and verify the result.</p></div>
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
</div>
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
<div id="active-devices-list" class="active-devices-list"></div>
<section class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
<div class="enrolled-passkeys-header">
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
</div>
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
</section>
<section class="security-activity" aria-labelledby="security-activity-title">
<nav class="security-section-nav" aria-label="Security Center sections">
<button id="security-section-activity" type="button">Activity</button>
<button id="security-section-devices" type="button">Devices</button>
<button id="security-section-passkeys" type="button">Passkeys</button>
</nav>
<section id="security-activity-section" class="security-activity" aria-labelledby="security-activity-title">
<div class="security-activity-header">
<h3 id="security-activity-title">Security activity</h3>
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
@@ -71,6 +65,24 @@
<div id="security-activity-list" class="security-activity-list"></div>
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
</section>
<section id="security-devices-section" class="security-devices" aria-labelledby="security-devices-title">
<div class="security-activity-header">
<h3 id="security-devices-title">Active devices</h3>
<p class="small muted">Review signed-in devices and remove access you no longer trust.</p>
</div>
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
<div id="active-devices-list" class="active-devices-list"></div>
</section>
<section id="security-passkeys-section" class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
<div class="enrolled-passkeys-header">
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
</div>
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
</section>
</section>
</div>
@@ -107,11 +119,23 @@
</div>
</li>
</ol>
<section class="device-storage" aria-labelledby="device-storage-heading">
<div>
<h3 id="device-storage-heading">Private device storage</h3>
<p class="small" id="device-storage-summary" role="status" aria-live="polite">Checking private storage…</p>
<p class="small muted" id="device-storage-detail"></p>
</div>
<div class="device-storage-actions">
<button id="clear-device-caches" type="button">Clear cached copies</button>
<button id="clear-private-device-data" type="button">Clear all private data</button>
</div>
</section>
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
</section>
</div>
<main>
<div id="mobile-pull-refresh" class="mobile-pull-refresh" role="status" aria-live="polite" hidden></div>
<section class="panel my-work" id="my-work" tabindex="-1">
<div class="my-work-header">
<div>
@@ -273,6 +297,11 @@
</div>
</div>
</section>
<section class="insights-shell" id="insights-sheet" aria-labelledby="insights-heading">
<div class="insights-header">
<div><h2 id="insights-heading">Insights</h2><p class="small muted">Context, activity, live signals, and workspace tools.</p></div>
<button id="close-insights" type="button">Close</button>
</div>
<aside class="sidebar">
<details class="panel stack" data-panel-key="context" open>
<summary><h2>Context &amp; view</h2></summary>
@@ -334,6 +363,7 @@
<div id="md-preview" style="margin-top:8px;"></div>
</details>
</aside>
</section>
</main>
<div class="plan-today-sheet" id="plan-today-sheet" role="dialog" aria-modal="true" aria-labelledby="plan-today-title" hidden>
@@ -406,13 +436,38 @@
<div id="today-recap-history" class="today-recap-history"></div>
</section>
<div class="today-recap-actions">
<button id="save-today-recap" type="button">Save recap &amp; adjust plan</button>
<button id="save-today-recap" type="button">Save recap &amp; wrap up</button>
<button id="save-log-today-recap" type="button">Log selected time to Gitea</button>
<button id="discard-today-recap" type="button">Keep timer &amp; close</button>
</div>
</section>
</div>
<dialog class="today-handoff-dialog" id="today-handoff-dialog" aria-labelledby="today-handoff-title">
<section class="today-handoff-panel">
<header><div><div class="small">Yesterday’s commitments</div><h2 id="today-handoff-title">Review tomorrow’s plan</h2></div><button id="close-today-handoff" type="button">Not now</button></header>
<p class="small muted">Choose the work you still intend to do Today. Unchecked work stays safely in My Work.</p>
<div id="today-handoff-status" class="small" role="status" aria-live="polite"></div>
<div id="today-handoff-items" class="today-handoff-items"></div>
<div class="today-handoff-actions"><button id="confirm-today-handoff" type="button">Add selected to Today</button></div>
</section>
</dialog>
<div class="today-wrap-up-sheet" id="today-wrap-up-sheet" role="dialog" aria-modal="true" aria-labelledby="today-wrap-up-title" hidden>
<section class="today-wrap-up-panel">
<div class="today-wrap-up-header">
<div><div class="small">End your day intentionally</div><h2 id="today-wrap-up-title">Wrap up Today</h2></div>
<button id="close-today-wrap-up" type="button">Not now</button>
</div>
<p class="small muted">Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.</p>
<div id="today-wrap-up-status" class="small" role="status" aria-live="polite"></div>
<div id="today-wrap-up-items" class="today-wrap-up-items"></div>
<div class="today-wrap-up-actions">
<button id="finish-today-wrap-up" type="button">Finish wrap-up</button>
</div>
</section>
</div>
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
<section class="today-readiness-panel">
<div class="today-readiness-header">
@@ -1487,6 +1542,7 @@
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
@@ -1498,6 +1554,20 @@
</section>
</dialog>
<dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title">
<section class="mobile-delivery-recovery-panel">
<header>
<div><p class="small muted" id="mobile-delivery-recovery-progress"></p><h2 id="mobile-delivery-recovery-title">Recover delivery</h2></div>
<button id="close-mobile-delivery-recovery" type="button">Close</button>
</header>
<p id="mobile-delivery-recovery-destination" class="small"></p>
<p id="mobile-delivery-recovery-reason" class="delivery-recovery-reason"></p>
<p id="mobile-delivery-recovery-status" class="small" role="status" aria-live="polite"></p>
<button id="mobile-delivery-recovery-action" type="button">Recover delivery</button>
<details><summary>Other recovery options</summary><p class="small muted">Close this guide to edit, copy, or discard the saved delivery from Drafts.</p></details>
</section>
</dialog>
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
@@ -1547,6 +1617,8 @@
<script src="static/today-work.js"></script>
<script src="static/today-timer.js"></script>
<script src="static/today-recap.js"></script>
<script src="static/today-wrap-up.js"></script>
<script src="static/today-handoff.js"></script>
<script src="static/today-completion.js"></script>
<script src="static/today-readiness.js"></script>
<script src="static/comment-next.js"></script>
@@ -1589,8 +1661,10 @@
<script src="static/context-poller.js"></script>
<script src="static/live-data-status.js"></script>
<script src="static/mobile-task-dock.js"></script>
<script src="static/mobile-pull-refresh.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/mobile-delivery-recovery.js"></script>
<script src="static/mobile-start-day.js"></script>
<script src="static/update-triage-session.js"></script>
<script src="static/update-review-handoff.js"></script>
@@ -1601,8 +1675,12 @@
<script src="static/update-decision-transaction.js"></script>
<script src="static/agenda-session-launcher.js"></script>
<script src="static/mobile-launch.js"></script>
<script src="static/mobile-insights.js"></script>
<script src="static/mobile-app-shortcuts.js"></script>
<script src="static/install-app.js"></script>
<script src="static/private-data-inventory.js"></script>
<script src="static/private-device-data.js"></script>
<script src="static/device-storage.js"></script>
<script src="static/mobile-device-setup.js"></script>
<script src="static/mobile-search-viewport.js"></script>
<script src="static/mobile-composer-viewport.js"></script>
@@ -1615,6 +1693,7 @@
<script src="static/mobile-search-preview-nav.js"></script>
<script src="static/mobile-plan-today-nav.js"></script>
<script src="static/mobile-find-work-nav.js"></script>
<script src="static/workspace-bootstrap.js"></script>
<script src="static/dashboard.js"></script>
</body>
</html>
+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 };
+4 -2
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;
}
+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};
});
+74
View File
@@ -0,0 +1,74 @@
(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';
let active = false;
let previous = '#/my-work';
function attr(element, name, value) {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
}
function render(open, focus = false) {
active = Boolean(open && o.mediaQuery.matches);
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;
o.backgrounds.forEach(element => { element.inert = active; });
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 (o.location.pathname || '') + (o.location.search || '') + hash;
}
function open() {
if (!o.mediaQuery.matches || active) return false;
previous = o.location.hash && o.location.hash !== route ? o.location.hash : '#/my-work';
if (o.menu) o.menu.open = false;
o.history.pushState({ ...(o.history.state || {}), mobileInsights:true, previousHash:previous }, '', url(route));
render(true);
return true;
}
function sync() {
render(o.location.hash === route, active && o.location.hash !== route);
}
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 start() {
o.launcher.addEventListener('click', open);
o.closeButton.addEventListener('click', close);
o.eventTarget.addEventListener('popstate', sync);
o.eventTarget.addEventListener('hashchange', sync);
o.eventTarget.addEventListener('keydown', event => {
if (active && event.key === 'Escape') {
event.preventDefault?.();
close();
}
});
o.mediaQuery.addEventListener?.('change', sync);
sync();
}
return { start, open, close, current:() => active };
});
+8 -3
View File
@@ -14,7 +14,7 @@ function createMobileIssueDetailNavigation(options) {
});
}
function navigate(name) {
function navigate(name, navigationOptions = {}) {
const target = targets[name];
if (!target) return false;
const prepare = options.beforeNavigate && options.beforeNavigate[name];
@@ -24,7 +24,7 @@ function createMobileIssueDetailNavigation(options) {
block: 'start',
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
if (name === 'reply') target.focus();
if (name === 'reply' && navigationOptions.focus !== false) target.focus();
select(name);
return true;
}
@@ -36,6 +36,7 @@ function createMobileIssueDetailNavigation(options) {
const listener = event => {
event.preventDefault();
navigate(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
};
listeners.set(button, listener);
button.addEventListener('click', listener);
@@ -55,7 +56,11 @@ function createMobileIssueDetailNavigation(options) {
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));
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() {
+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();
},
};
});
+3
View File
@@ -9,10 +9,12 @@
draft: 'No drafts are ready to open.',
};
const continuation = [
['delivery', 'Recover Delivery'],
['attention', 'Start Attention'],
['today', 'Continue Today'],
['update', 'Resume Updates'],
['agenda', 'Open Agenda'],
['filed', 'Review Filed'],
['later', 'Start Later'],
['draft', 'Open Drafts'],
];
@@ -27,6 +29,7 @@
}
function open(name) {
if (name === 'delivery' && options.openDelivery) return options.openDelivery();
if (name === 'today') return options.openToday();
if (name === 'agenda') return options.openAgenda();
if (name === 'update' && options.openUpdates) return options.openUpdates();
+8 -3
View File
@@ -14,14 +14,14 @@ function createMobileReviewDetailNavigation(options) {
});
}
function navigate(name) {
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) {
if (name === 'feedback' && options.summaryComposer && navigationOptions.focus !== false) {
options.summaryComposer.focus({ preventScroll:true });
}
select(name);
@@ -39,6 +39,7 @@ function createMobileReviewDetailNavigation(options) {
const listener = event => {
event.preventDefault();
navigate(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
};
listeners.set(button, listener);
button.addEventListener('click', listener);
@@ -58,7 +59,11 @@ function createMobileReviewDetailNavigation(options) {
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));
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() {
+11 -4
View File
@@ -5,6 +5,7 @@
const checkpointKey = 'stackchain.mobile-start-day.v1';
const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
const reviewOrder = [
['delivery', 'Delivery recovery'],
['agenda', 'Agenda'],
['attention', 'Attention'],
['update', 'Updates'],
@@ -73,13 +74,19 @@
.filter(phase => phase.count > 0);
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
const today = count(counts.today);
const delivery = count(counts.delivery);
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' :
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'));
return {
total,
next,
label: nextLabel,
summary: total ? total + ' items before Today · ' + today + ' planned' :
summary: 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 +114,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);
+5 -4
View File
@@ -66,9 +66,9 @@
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',
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
}[mode] || 'Work';
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
if (options.workLabel) options.workLabel.textContent = text;
if (buttons.work) buttons.work.setAttribute('aria-label', queue
? (mode === 'update' ? 'Resume ' : 'Open ') + text
@@ -91,9 +91,9 @@
}
function updateQueues(counts) {
const names = 'today agenda attention update filed later draft'.split(' ');
const names = 'today agenda delivery attention update filed 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', 'attention', 'update', 'filed', '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);
@@ -117,6 +117,7 @@
? 'Queues: no active queues; no upcoming deadlines'
: 'Queues: Today ' + normalized.today
+ ', Agenda ' + normalized.agenda + ' due'
+ ', Delivery ' + normalized.delivery
+ ', Attention ' + normalized.attention
+ ', Updates ' + normalized.update
+ ', Filed ' + normalized.filed
+8 -3
View File
@@ -14,7 +14,7 @@ function createMobileUpdateDetailNavigation(options) {
});
}
function navigate(name) {
function navigate(name, navigationOptions = {}) {
if (name === 'activity') {
if (options.jumpToNewActivity) options.jumpToNewActivity();
select(name);
@@ -27,7 +27,7 @@ function createMobileUpdateDetailNavigation(options) {
block:'start',
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
});
if (name === 'reply' && options.replyComposer) {
if (name === 'reply' && options.replyComposer && navigationOptions.focus !== false) {
options.replyComposer.focus({ preventScroll:true });
}
select(name);
@@ -46,6 +46,7 @@ function createMobileUpdateDetailNavigation(options) {
const listener = event => {
event.preventDefault();
navigate(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
};
listeners.set(button, listener);
button.addEventListener('click', listener);
@@ -65,7 +66,11 @@ function createMobileUpdateDetailNavigation(options) {
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));
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() {
+1 -1
View File
@@ -5,7 +5,7 @@
function mode() {
if (options.isTodayActive()) return 'continue';
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';
+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 };
}
};
});
+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(
+20 -1
View File
@@ -5,7 +5,10 @@ function createReconnectOutboxes({
flushAuthored,
flushNotificationReads,
}) {
return async function reconnectOutboxes() {
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 &&
@@ -19,6 +22,22 @@ function createReconnectOutboxes({
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;
};
}
+59 -3
View File
@@ -12,8 +12,49 @@
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;
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();
});
root.addEventListener?.('popstate', event => {
const section = event.state?.stackchainSecuritySection;
if (section && !devicesSheet.hidden) {
navigate(section, { history: false });
return;
}
if (!devicesSheet.hidden) {
devicesSheet.hidden = true;
devicesButton?.focus();
}
});
const renderDevices = async () => {
devicesStatus.textContent = 'Loading active devices…';
devicesList.replaceChildren();
@@ -40,7 +81,9 @@
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();
else if (await boundary.revokeActiveDevice(device)) {
await Promise.all([renderDevices(), renderSecurityActivity()]);
}
});
row.append(details, revoke);
devicesList.append(row);
@@ -82,6 +125,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 +166,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);
});
}
@@ -150,6 +198,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;
@@ -169,6 +224,7 @@
if (!devicesSheet) return;
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 +242,5 @@
enrollPasskey.disabled = false;
}
});
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
return { open, navigate, renderDevices, renderPasskeys, renderSecurityActivity };
});
+22 -2
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v114';
const CACHE = 'stackchain-dashboard-shell-v117';
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;
@@ -16,6 +16,7 @@ const SHELL = [
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.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',
@@ -51,6 +52,8 @@ const SHELL = [
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',
BASE + 'static/today-recap.js',
BASE + 'static/today-wrap-up.js',
BASE + 'static/today-handoff.js',
BASE + 'static/today-completion.js',
BASE + 'static/today-readiness.js',
BASE + 'static/comment-next.js',
@@ -83,6 +86,7 @@ const SHELL = [
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',
@@ -102,6 +106,7 @@ const SHELL = [
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.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',
@@ -112,8 +117,12 @@ 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/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-composer-viewport.js',
@@ -379,6 +388,17 @@ async function warmOptionalFeature(cache, staleCacheNames, asset) {
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();
@@ -631,6 +651,6 @@ self.addEventListener('fetch', event => {
return;
}
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
event.respondWith(cachedOptionalFeature(request));
}
});
+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 };
});
+5 -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();
@@ -290,7 +290,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);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
} finally { button.disabled = false; }
@@ -310,7 +310,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);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
render();
@@ -345,8 +345,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,
+122
View File
@@ -0,0 +1,122 @@
function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
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 wake = laterWork.presetUntil('tomorrow');
let scheduled = 0;
for (const item of items) {
const identity = todayWork.identity(item);
if (!selected.has(identity) || !todayWork.contains(item)) continue;
const deferred = laterWork.defer(item, wake, {handoff:'today'});
if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.');
if (!todaySync.enqueue('remove', identity)) {
laterWork.restore?.(item);
throw new Error('Today sync could not be queued. Your Today plan is unchanged.');
}
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, wake_at:wake.toISOString() };
}
return { open, choose, snapshot, finish };
}
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
let actualMinutes = {};
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' : '') +
'> Tomorrow 09:00</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 = {}) {
actualMinutes = { ...recapActualMinutes };
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 result = await controller.finish();
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
close();
onComplete(result, actualMinutes);
} 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;
}
+43 -9
View File
@@ -7,6 +7,13 @@
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
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' : '');
+41
View File
@@ -0,0 +1,41 @@
async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader }) {
let cameOnline = false;
let replayed = false;
const captureOnline = () => { cameOnline = true; };
window?.addEventListener('online', captureOnline);
const status = document.querySelector('#my-work-action-status');
const url = document.querySelector(
'meta[name="stackchain-feature-today-timer"]'
)?.content || '';
const loader = createLoader({
document,
urls: { 'today-timer': url },
});
if (status) status.textContent = 'Starting workspace…';
try {
await loader.load('today-timer');
if (status) status.textContent = '';
return {
replayOnline(callback) {
if (replayed) return;
replayed = true;
window?.removeEventListener('online', captureOnline);
if (cameOnline) callback();
},
};
} catch (error) {
window?.removeEventListener('online', captureOnline);
if (window?.location?.reload) {
if (cameOnline) window.location.reload();
else window.addEventListener('online', () => window.location.reload(), { once: true });
}
if (status) {
status.textContent = window
? 'Workspace could not load. Reconnect to retry automatically, or reload now.'
: 'Workspace could not load. Check your connection, then reload to retry.';
}
throw error;
}
}
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
+1
View File
@@ -0,0 +1 @@
pip-audit==2.10.1
+1 -1
View File
@@ -2,7 +2,7 @@ fastapi==0.133.1
httpx==0.28.1
pydantic==2.13.4
Pillow==12.3.0
python-multipart==0.0.22
python-multipart==0.0.32
pytest==9.1.1
pywebpush==2.1.2
rjsmin==1.2.5
+9 -11
View File
@@ -21,17 +21,20 @@ COMMONJS_BROWSER_BRANCH = re.compile(
)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
"comment-actions": ("static/comment-actions.js",),
"issue-capture": (
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",),
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"device-setup": (
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
"static/device-storage.js", "static/mobile-device-setup.js",
),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js",
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
@@ -114,7 +117,6 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
dashboard_html = dashboard_html.replace(
"</body>",
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
f'<script src="{core.runtime_name}"></script>\n</body>',
)
@@ -122,15 +124,11 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
for source in sources:
if source != WORKER_RUNTIME_SOURCE:
worker = worker.replace(f" BASE + '{source}',\n", "")
eager_feature = feature_bundles["today-timer"]
optional_features = {
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
}
optional_features = feature_bundles
worker = worker.replace(
" BASE + 'static/dashboard.css',\n",
" BASE + 'static/dashboard.css',\n"
+ f" BASE + '{core.runtime_name}',\n"
+ f" BASE + '{eager_feature.runtime_name}',\n",
+ f" BASE + '{core.runtime_name}',\n",
)
worker = worker.replace(
"const OPTIONAL_FEATURES = [\n",
+8 -2
View File
@@ -131,6 +131,7 @@ class LaterStore:
item_id: str,
*,
wake_at: str | None = None,
handoff: str | None = None,
base_revision: int | None = None,
) -> dict:
result = self.apply_batch(login, [{
@@ -138,6 +139,7 @@ class LaterStore:
"action": action,
"item_id": item_id,
"wake_at": wake_at,
"handoff": handoff,
"base_revision": base_revision,
}])
return {"revision": result["revision"], "records": result["records"]}
@@ -181,6 +183,9 @@ class LaterStore:
wake_at = operation.get("wake_at")
if action == "defer":
wake_at = self._validate_wake_at(wake_at)
handoff = operation.get("handoff")
if handoff not in {None, "today"}:
raise ValueError("unsupported Later handoff")
duplicate = connection.execute(
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
(login, operation_id),
@@ -204,8 +209,9 @@ class LaterStore:
before = records.get(item_id)
if action == "defer":
records[item_id] = wake_at
changed = before != wake_at
record = {"wake_at": wake_at, "handoff": handoff} if handoff else wake_at
records[item_id] = record
changed = before != record
else:
changed = item_id in records
records.pop(item_id, None)
+2
View File
@@ -610,6 +610,7 @@ class LaterOperation(BaseModel):
action: Literal["defer", "restore"]
item_id: str = Field(min_length=1, max_length=500)
wake_at: str | None = Field(default=None, max_length=100)
handoff: Literal["today"] | None = None
base_revision: int | None = Field(default=None, ge=0)
@@ -2628,6 +2629,7 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
payload.action,
payload.item_id,
wake_at=payload.wake_at,
handoff=payload.handoff,
base_revision=payload.base_revision,
)
except ValueError as error:
+46 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Event
from urllib.parse import parse_qs, urlsplit
@@ -51,6 +52,10 @@ class FakeGiteaServer(ThreadingHTTPServer):
super().__init__(address, FakeGiteaHandler)
self.created_issues: list[dict] = []
self.issue_creation_enabled = False
self.issue_creation_ready = Event()
self.assigned_issue_numbers = [issue["number"] for issue in AVAILABLE_ISSUES]
self.comments: list[tuple[int, str]] = []
self.requests: list[tuple[str, str]] = []
class FakeGiteaHandler(BaseHTTPRequestHandler):
@@ -73,6 +78,7 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
parsed = urlsplit(self.path)
path = parsed.path
query = parse_qs(parsed.query)
self.server.requests.append(("GET", self.path))
if path == "/api/v1/user":
self._json(200, USER)
elif path == "/api/v1/user/repos":
@@ -82,17 +88,33 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
elif path == "/api/v1/repos/search":
self._json(200, {"data": [REPOSITORY], "ok": True})
elif path == "/api/v1/repos/issues/search":
is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"]
is_available_scan = not any(
name in query for name in ("assigned", "created", "review_requested", "q")
) and query.get("type") == ["issues"]
issues = AVAILABLE_ISSUES if is_available_scan else []
assigned = [
{**issue, "assignees": [USER]}
for issue in AVAILABLE_ISSUES
if issue["number"] in self.server.assigned_issue_numbers
]
available = [
issue for issue in AVAILABLE_ISSUES
if issue["number"] not in self.server.assigned_issue_numbers
]
issues = assigned if is_assigned_scan else available if is_available_scan else []
self._json(200, issues, **{"X-Total-Count": str(len(issues))})
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"):
self._json(200, [], **{"X-Total-Count": "0"})
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
self._json(200, [])
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):
try:
number = int(path.rsplit("/", 1)[-1])
except ValueError:
number = 0
issue = next((item for item in AVAILABLE_ISSUES if item["number"] == number), None)
if issue and number in self.server.assigned_issue_numbers:
issue = {**issue, "assignees": [USER]}
self._json(200, issue) if issue else self._json(404, {"message": "not found"})
elif path.startswith("/api/v1/"):
self._json(200, [], **{"X-Total-Count": "0"})
@@ -101,11 +123,34 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = urlsplit(self.path).path
self.server.requests.append(("POST", self.path))
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
if path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"):
try:
number = int(path.split("/")[-2])
except ValueError:
self._json(404, {"message": "not found"})
return
body = str(payload.get("body", ""))
self.server.comments.append((number, body))
self._json(
201,
{
"id": len(self.server.comments),
"body": body,
"user": USER,
"created_at": "2026-08-16T20:00:00Z",
"updated_at": "2026-08-16T20:00:00Z",
"html_url": f"http://127.0.0.1/acme/mobile/issues/{number}#issuecomment-1",
},
)
return
if path != "/api/v1/repos/acme/mobile/issues":
self._json(404, {"message": "not found"})
return
if not self.server.issue_creation_enabled:
self.server.issue_creation_ready.wait(timeout=45)
if not self.server.issue_creation_enabled:
self._json(503, {"message": "release journey is still offline"})
return
@@ -0,0 +1,252 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged mobile Home bootstrap runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
tmp_path: Path, width: int, height: int
):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
failed_responses: list[str] = []
workspace_requests: list[str] = []
live_requests: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on(
"console",
lambda message: browser_errors.append(message.text)
if message.type == "error"
else None,
)
page.on(
"response",
lambda response: failed_responses.append(f"{response.status} {response.url}")
if response.status >= 400
else None,
)
page.on(
"request",
lambda request: workspace_requests.append(request.url)
if "feature-today-timer-" in request.url
else None,
)
page.on(
"request",
lambda request: live_requests.append(request.url)
if "/api/v1/live" in request.url
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
expect(page.locator("#my-work-status")).to_contain_text("2")
initial_live_requests = len(live_requests)
page.evaluate(
"""
() => {
window.scrollTo(0, 0);
const surface = document.querySelector('#my-work');
surface.setPointerCapture = () => {};
surface.releasePointerCapture = () => {};
const pointer = (type, y) => surface.dispatchEvent(new PointerEvent(type, {
bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:y,
}));
pointer('pointerdown', 10);
pointer('pointermove', 90);
}
"""
)
expect(page.locator("#mobile-pull-refresh")).to_have_text("Release to refresh")
page.evaluate(
"document.querySelector('#my-work').dispatchEvent(new PointerEvent('pointerup', "
"{bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:90}))"
)
expect(page.locator("#mobile-pull-refresh")).to_have_text("My Work is up to date")
assert len(live_requests) == initial_live_requests + 1
dock = page.locator("#mobile-task-dock")
expect(dock).to_be_visible()
expect(dock.locator("button")).to_have_count(5)
for control in dock.locator("button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator('[data-mobile-task="queues"]').click()
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
expect(delivery_queue).to_be_visible()
expect(delivery_queue).to_contain_text("Delivery")
delivery_bounds = delivery_queue.bounding_box()
assert delivery_bounds and delivery_bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#close-mobile-queues").click()
page.locator("#app-menu-toggle").click()
page.locator("#open-insights").click()
expect(page.locator("#insights-sheet")).to_be_visible()
expect(page.locator("#insights-heading")).to_have_text("Insights")
page.locator("#close-insights").click()
expect(page.locator("#insights-sheet")).to_be_hidden()
expect(page.locator("#my-work")).to_be_visible()
expect(dock).to_be_visible()
page.locator("#app-menu-toggle").click()
page.locator("#open-device-setup").click()
expect(page.locator("#device-setup-sheet")).to_be_visible()
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
for selector in ("#clear-device-caches", "#clear-private-device-data"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#close-device-setup").click()
expect(page.locator("#device-setup-sheet")).to_be_hidden()
page.locator("#active-devices").click()
expect(page.locator("#active-devices-sheet")).to_be_visible()
expect(page.locator("#security-activity-section")).to_be_visible()
security_nav = page.locator(".security-section-nav")
expect(security_nav).to_be_visible()
for control in security_nav.locator("button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#security-section-devices").click()
expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
page.go_back()
expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
page.go_back()
expect(page.locator("#active-devices-sheet")).to_be_hidden()
expect(page.locator("#active-devices")).to_be_focused()
assert len(workspace_requests) == 1, workspace_requests
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert browser_errors == []
assert failed_responses == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": 390, "height": 844})
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Delivery recovery release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate(
"""
() => {
window.__deliveryAttempts = 0;
window.__deliverySettled = new Promise(resolve => { window.__finishDelivery = resolve; });
window.__releaseRecovery = createMobileDeliveryRecovery({
getItems: () => [{outbox_id:'release-retry', delivery_state:'waiting', title:'Post release note'}],
activate: async () => {
window.__deliveryAttempts += 1;
await window.__deliverySettled;
},
});
window.__releaseRecovery.open();
window.__firstRecovery = window.__releaseRecovery.activate();
window.__secondRecovery = window.__releaseRecovery.activate();
}
"""
)
action = page.locator("#mobile-delivery-recovery-action")
expect(page.locator("#mobile-delivery-recovery")).to_be_visible()
expect(action).to_be_disabled()
expect(page.locator("#mobile-delivery-recovery-status")).to_have_text("Working…")
assert page.evaluate("window.__deliveryAttempts") == 1
bounds = action.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.evaluate("window.__finishDelivery()")
page.evaluate("Promise.all([window.__firstRecovery, window.__secondRecovery])")
expect(action).to_be_enabled()
assert page.evaluate("window.__deliveryAttempts") == 1
page.evaluate(
"""
() => {
const destination = document.createElement('button');
destination.type = 'button';
destination.className = 'draft-resume';
destination.dataset.draftIndex = '0';
destination.textContent = 'Open current review';
window.__attentionAttempts = 0;
window.__attentionSettled = new Promise(resolve => { window.__finishAttention = resolve; });
destination.onclick = async () => {
window.__attentionAttempts += 1;
await window.__attentionSettled;
return true;
};
document.querySelector('#my-work-list').append(destination);
window.__attentionRecovery = createMobileDeliveryRecovery({
getItems: () => [{outbox_id:'review-attention', status:'attention', title:'Review feedback'}],
getIndex: () => 0,
});
window.__attentionRecovery.open();
window.__firstAttention = window.__attentionRecovery.activate();
window.__secondAttention = window.__attentionRecovery.activate();
}
"""
)
expect(page.locator("#mobile-delivery-recovery")).to_be_hidden()
expect(page.locator("#my-work-list .draft-resume")).to_be_focused()
assert page.evaluate("window.__attentionAttempts") == 1
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.evaluate("window.__finishAttention()")
page.evaluate("Promise.all([window.__firstAttention, window.__secondAttention])")
assert page.evaluate("window.__attentionAttempts") == 1
assert browser_errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
+23 -1
View File
@@ -232,6 +232,10 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
page.locator("#create-issue-repository").select_option("acme/mobile")
expect(page.locator("#submit-new-issue")).to_be_enabled()
# Page offline emulation can leave service-worker requests connected.
# Abort the mutation at the browser boundary too, matching a real
# transport outage without poisoning server-side idempotency state.
context.route("**/api/v1/repos/acme/mobile/issues", lambda route: route.abort())
context.set_offline(True)
page.locator("#submit-new-issue").click()
expect(page.locator("#issue-filing-review")).to_be_visible()
@@ -267,6 +271,8 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
browser_errors.clear() # Chromium reports expected network errors while the context is offline.
failed_responses.clear()
fake.issue_creation_enabled = True
fake.issue_creation_ready.set()
context.unroute("**/api/v1/repos/acme/mobile/issues")
context.set_offline(False)
page.evaluate("window.dispatchEvent(new Event('online'))")
# Containerized Actions runners can take longer than a local browser to wake the
@@ -275,7 +281,22 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
if fake.created_issues:
break
page.wait_for_timeout(250)
assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}]
local_debug = page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')")
durable_debug = indexed_issue_records(page)
assert fake.created_issues == [
{"title": TITLE, "body": BODY, "assignee": "timmy"}
], (
f"url={page.url} ready={page.evaluate('document.readyState')} "
f"status={page.locator('#my-work-action-status').inner_text()!r} "
f"errors={browser_errors[-5:]!r} responses={failed_responses[-10:]!r} "
f"local={local_debug!r} durable={durable_debug!r} "
f"requests={fake.requests[-20:]!r}"
)
# Deferred workspace startup can report its already-issued offline request
# after the pre-reconnect clear; retain every non-offline browser error.
browser_errors[:] = [
error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
]
for _ in range(40):
durable_completion = indexed_issue_records(page)
@@ -298,6 +319,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.issue_creation_ready.set()
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
@@ -0,0 +1,101 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Plan Today handoff journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
failed_responses: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
page.on(
"response",
lambda response: failed_responses.append(f"{response.status} {response.url}")
if response.status >= 400
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Plan Today release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
expect(page.locator("#my-work-status")).to_contain_text("2")
page.locator('[data-mobile-task="work"]').click()
expect(page.locator("#plan-today-sheet")).to_be_visible()
expect(page.locator("#plan-today-candidates .plan-today-item")).to_have_count(2)
navigation = page.locator(".mobile-plan-today-nav")
expect(navigation).to_be_visible()
for control in navigation.locator("button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#plan-today-available").fill("90")
page.locator("#plan-today-available").press("Tab")
page.locator("#build-today-plan").click()
missing_estimates = page.locator("[data-plan-missing-estimate]")
expect(missing_estimates).to_have_count(2)
for _ in range(2):
missing_estimates.nth(0).fill("30")
missing_estimates.nth(0).press("Tab")
expect(page.locator("#plan-today-list .plan-today-item")).to_have_count(2)
estimates = page.locator("[data-plan-estimate]")
expect(estimates).to_have_count(2)
page.locator("#save-and-start-today").click()
try:
expect(page.locator("#plan-today-sheet")).to_be_hidden(timeout=5_000)
except AssertionError as error:
raise AssertionError(page.evaluate("({hidden:document.querySelector('#plan-today-sheet').hidden, state:history.state, bodyClass:document.body.className})")) from error
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
expect(page.locator("#send-issue-comment-next")).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
expect(page.locator("#plan-today-sheet")).to_be_hidden()
page.locator('[data-issue-section="reply"]').click()
page.locator("#issue-comment").fill("Handoff complete; continuing with the next Today item.")
page.locator("#send-issue-comment-next").click()
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
expect(page.locator("#issue-comment")).to_have_value("")
expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true")
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
assert fake.comments == [(41, "Handoff complete; continuing with the next Today item.")]
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
assert today == ["issue:acme/mobile:42:"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert browser_errors == []
assert failed_responses == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
@@ -0,0 +1,99 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Today wrap-up journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
tmp_path: Path, width: int, height: int
):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on(
"console",
lambda message: browser_errors.append(message.text)
if message.type == "error"
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Today wrap-up release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate(
"""
() => {
const items=[
{kind:'issue',repository:'acme/mobile',number:41,title:'Ship mobile capture',key:'acme/mobile#41'},
{kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'},
];
const identity=item => `${item.kind}:${item.repository}:${item.number}:`;
const today=items.map(identity); const later={}; const operations=[];
const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{
const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true;
}};
const laterWork={presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:(item,wake)=>{
later[identity(item)]=wake.toISOString(); return 'deferred';
}};
const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)};
const controller=createTodayWrapUp({todayWork,laterWork,todaySync});
const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
window.__wrapTest={view,today,later,operations};
view.open(items,{});
}
"""
)
sheet = page.locator("#today-wrap-up-sheet")
expect(sheet).to_be_visible()
expect(page.locator(".today-wrap-up-item")).to_have_count(2)
for control in sheet.locator("button, label").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator('[data-wrap-up-identity="issue:acme/mobile:41:"]').check()
page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))")
expect(sheet).to_be_hidden()
result = page.evaluate(
"({today:window.__wrapTest.today,later:window.__wrapTest.later,operations:window.__wrapTest.operations})"
)
assert result == {
"today": ["issue:acme/mobile:42:"],
"later": {"issue:acme/mobile:41:": "2026-08-17T09:00:00.000Z"},
"operations": [["remove", "issue:acme/mobile:41:"]],
}
assert browser_errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
@@ -0,0 +1,65 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged next-day Today handoff runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1
fake = FakeGiteaServer(("127.0.0.1", 0))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
errors: list[str] = []
try:
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": 390, "height": 844})
page.on("pageerror", lambda error: errors.append(error.stack or str(error)))
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Tomorrow review phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate("""localStorage.setItem('stackchain.today-handoff.v1.timmy', JSON.stringify([
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
]))""")
page.reload(wait_until="networkidle")
dialog = page.locator("#today-handoff-dialog")
expect(dialog).to_be_visible()
expect(page.locator("#today-handoff-items .today-handoff-item")).to_have_count(2)
for control in dialog.locator("button, input").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 24
buttons = dialog.locator("button")
for control in buttons.all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator('[data-today-handoff="issue:acme/mobile:42:"]').uncheck()
page.locator("#confirm-today-handoff").click()
expect(dialog).to_be_hidden()
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy'))") == [
"issue:acme/mobile:41:"
]
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-handoff.v1.timmy'))") == [
"issue:acme/mobile:42:"
]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)
+22 -1
View File
@@ -17,6 +17,23 @@ def test_ci_installs_declared_requirements_before_tests():
assert install < tests
def test_ci_audits_pinned_runtime_dependencies_before_tests_and_release_build():
text = WORKFLOW.read_text()
lint = text[text.index(" lint:") : text.index(" build-release:")]
build = text[text.index(" build-release:") : text.index(" browser-journey:")]
audit_install = lint.index("pip install -r requirements-audit.txt")
audit = lint.index("python3 -m pip_audit -r requirements.txt --strict")
tests = lint.index("python3 -m pytest tests/ -q")
assert audit_install < audit < tests
assert "needs: lint" in build
def test_runtime_multipart_parser_uses_advisory_fixed_release():
requirements = Path("requirements.txt").read_text().splitlines()
assert "python-multipart==0.0.32" in requirements
def test_release_promotion_waits_for_tests_and_bundle():
text = WORKFLOW.read_text()
release = text[text.index(" release-candidate:") :]
@@ -41,6 +58,10 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
assert (
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
"tests/e2e/test_mobile_search_preview_navigation.py "
"tests/e2e/test_mobile_find_work_release.py -q"
"tests/e2e/test_mobile_find_work_release.py "
"tests/e2e/test_mobile_home_bootstrap_release.py "
"tests/e2e/test_mobile_today_handoff_release.py "
"tests/e2e/test_mobile_today_wrap_up_release.py "
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
) in browser
assert "needs: [lint, build-release, browser-journey]" in release
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v114" in worker
assert "stackchain-dashboard-shell-v117" in worker
+12
View File
@@ -850,10 +850,22 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
assert 'aria-labelledby="enrolled-passkeys-title"' in html
assert 'id="enrolled-passkeys-list"' in html
assert 'A recovery token will be required after sign-out if you remove this device’s passkey.' in html
assert 'class="security-section-nav" aria-label="Security Center sections"' in html
assert 'id="security-section-activity"' in html
assert 'id="security-section-devices"' in html
assert 'id="security-section-passkeys"' in html
assert 'id="security-activity-section"' in html
assert 'id="security-devices-section"' in html
assert 'id="security-passkeys-section"' in html
assert html.index('id="security-activity-section"') < html.index('id="security-devices-section"')
assert 'aria-labelledby="security-activity-title"' in html
assert 'id="security-activity-list"' in html
assert '>Load older activity</button>' in html
assert '#sign-out-all { min-height:44px; }' in html
assert '.security-section-nav { position:sticky;' in html
assert '.security-section-nav button { min-height:44px;' in html
assert '.security-event button { min-height:44px;' in html
assert '.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:' in html
def test_security_activity_names_confirmed_gitea_time_logging():
+153
View File
@@ -0,0 +1,153 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "device-storage.js"
def run_scenario(script: str) -> dict:
harness = r"""
const createDeviceStorage = require(__MODULE__);
class Target {
constructor() { this.listeners={}; this.textContent=''; this.hidden=false; this.disabled=false; this.dataset={}; }
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
async dispatch(name) { for (const callback of this.listeners[name] || []) await callback(); }
}
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
let purgeCalls=0, purgeError='';
let inventory={recordCount:0, unavailable:false};
const localStorage={
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
get length(){return this.values.size;},
key(index){return Array.from(this.values.keys())[index] || null;},
};
const caches={
values:new Set(['stackchain-dashboard-shell-a1','other-app']),
async keys(){return Array.from(this.values);},
async delete(key){return this.values.delete(key);},
};
const controller=createDeviceStorage({
summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
inspectPrivateDatabases:async()=>inventory,
storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})},
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
});
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);});
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_inventory_reports_private_categories_and_quota_without_content():
result = run_scenario("""
await controller.refresh();
process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.textContent}));
""")
assert result == {
"summary": "2 MB of 10 MB browser storage used.",
"detail": "1 private browser item · 0 private work records · 1 cached app copy",
}
def test_cache_only_cleanup_preserves_private_work_and_other_apps():
result = run_scenario("""
await controller.start();
await clearCachesButton.dispatch('click');
process.stdout.write(JSON.stringify({
caches:Array.from(caches.values),
storage:Array.from(localStorage.values.keys()),
status:summary.textContent,
detail:detail.textContent,
}));
""")
assert result == {
"caches": ["other-app"],
"storage": ["stackchain.draft.1", "other.preference"],
"status": "Cached app copies cleared. Private work was kept.",
"detail": "1 private browser item · 0 private work records · 0 cached app copies",
}
def test_full_cleanup_requires_explicit_second_press_when_private_work_exists():
result = run_scenario("""
await controller.start();
await clearAllButton.dispatch('click');
const warning={calls:purgeCalls,label:clearAllButton.textContent,status:summary.textContent};
await clearAllButton.dispatch('click');
process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.textContent,detail:detail.textContent}));
""")
assert result == {
"warning": {
"calls": 0,
"label": "Confirm: clear private work",
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
},
"calls": 1,
"status": "All Stackchain private data was cleared from this device.",
"detail": "0 private browser items · 0 private work records · 0 cached app copies",
}
def test_indexeddb_only_work_requires_confirmation_before_purge():
result = run_scenario("""
localStorage.values.clear();
inventory={recordCount:3, unavailable:false};
await controller.start();
await clearAllButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:purgeCalls,
label:clearAllButton.textContent,
status:summary.textContent,
detail:detail.textContent,
}));
""")
assert result == {
"calls": 0,
"label": "Confirm: clear private work",
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
"detail": "0 private browser items · 3 private work records · 1 cached app copy",
}
def test_unavailable_indexeddb_inventory_requires_confirmation_before_purge():
result = run_scenario("""
localStorage.values.clear();
inventory={recordCount:0, unavailable:true};
await controller.start();
await clearAllButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:purgeCalls,
label:clearAllButton.textContent,
detail:detail.textContent,
}));
""")
assert result == {
"calls": 0,
"label": "Confirm: clear private work",
"detail": "0 private browser items · private work status unknown · 1 cached app copy",
}
def test_full_cleanup_reports_blocked_deletion_without_claiming_success():
result = run_scenario("""
purgeError='IndexedDB deletion was blocked.';
await controller.start();
await clearAllButton.dispatch('click');
let escaped='';
try { await clearAllButton.dispatch('click'); } catch (error) { escaped=error.message; }
process.stdout.write(JSON.stringify({escaped,status:summary.textContent,disabled:clearAllButton.disabled}));
""")
assert result == {
"escaped": "",
"status": "Private data was not fully cleared: IndexedDB deletion was blocked.",
"disabled": False,
}
+35
View File
@@ -213,6 +213,37 @@ process.stdout.write(JSON.stringify({{item,partition:inbox.partition()}}));
assert output["partition"]["retryable"] == []
def test_delivery_partition_counts_human_action_and_orders_it_before_waiting_work():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[
{{id:'waiting',repository:'o/r',title:'Waiting',body:'',ownerLogin:'timmy',status:'queued',queuedAt:500}},
{{id:'uncertain',repository:'o/r',title:'Verify',body:'',ownerLogin:'timmy',status:'queued',deliveryState:'uncertain',queuedAt:100}},
{{id:'sending',repository:'o/r',title:'Sending',body:'',ownerLogin:'timmy',status:'sending',queuedAt:400}},
{{id:'attention',repository:'o/r',title:'Fix me',body:'',ownerLogin:'timmy',status:'attention',queuedAt:200}},
]}})],
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
{{id:'authorize',kind:'pull-review',repository:'o/r',number:8,body:'Review',ownerLogin:'timmy',status:'authorization',queuedAt:300}},
]}})],
]);
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const partition=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).partition();
process.stdout.write(JSON.stringify({{
actionable:partition.actionable,
titles:partition.deliveries.map(item=>item.title),
waiting:partition.counts.waiting,
sending:partition.counts.sending,
}}));
"""
assert run_node(script) == {
"actionable": 3,
"titles": ["o/r#8", "Fix me", "Verify", "Waiting", "Sending"],
"waiting": 2,
"sending": 1,
}
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
@@ -347,6 +378,10 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
assert '.delivery-center button { min-height:44px;' in html
assert "counts.delivery = draftInbox.partition(lastDrafts).actionable;" in html
assert "openDelivery: () => mobileDeliveryRecovery.open()" in html
assert "getItems: () => draftInbox.partition(lastDrafts).deliveries" in html
assert "authoritativePhases:['delivery']" in html
@pytest.mark.anyio
+11 -12
View File
@@ -26,8 +26,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
assert first.dashboard_html.count("<script src=") == 2
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' in first.dashboard_html
assert first.dashboard_html.count("<script src=") == 1
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' not in first.dashboard_html
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
assert first.runtime_name.startswith("runtime-")
assert first.runtime_name.endswith(".js")
@@ -51,10 +51,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
"today-timer", "security-center",
}
assert (
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
f'<script src="{first.runtime_name}"></script>'
) in first.dashboard_html
assert first.dashboard_html.count("<script src=") == 1
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
capture = first.feature_bundles["issue-capture"]
pull_workflow = first.feature_bundles["pull-workflow"]
assert b"function createIssueCapture" not in first.runtime_bytes
@@ -72,6 +70,10 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createQueueToday" not in first.runtime_bytes
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
@@ -87,13 +89,10 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
"const OPTIONAL_FEATURES = [", 1
)
optional_block = optional_block.split("];", 1)[0]
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block
for name, bundle in first.feature_bundles.items():
if name == "today-timer":
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
else:
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
assert f"BASE + '{bundle.runtime_name}'" in optional_block
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
assert f"BASE + '{bundle.runtime_name}'" in optional_block
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
+24
View File
@@ -65,6 +65,30 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
) == {"revision": 2, "records": {}}
def test_next_day_today_handoff_is_preserved_without_changing_legacy_deferrals(tmp_path):
store = LaterStore(tmp_path / "later.sqlite3")
legacy = store.apply(
"timmy", "legacy", "defer", "issue:r:1:",
wake_at="2026-08-17T09:00:00.000Z",
)
handoff = store.apply(
"timmy", "wrap-up", "defer", "issue:r:2:",
wake_at="2026-08-17T09:00:00.000Z",
handoff="today",
)
assert legacy["records"]["issue:r:1:"] == "2026-08-17T09:00:00.000Z"
assert handoff["records"] == {
"issue:r:1:": "2026-08-17T09:00:00.000Z",
"issue:r:2:": {
"wake_at": "2026-08-17T09:00:00.000Z",
"handoff": "today",
},
}
assert LaterStore(store.path).get("timmy") == handoff
def test_initialized_later_reads_remain_available_during_a_planning_write(tmp_path):
path = tmp_path / "later.sqlite3"
store = LaterStore(path, timeout=0.05)
+92 -4
View File
@@ -97,6 +97,35 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
assert result["pending"] == []
def test_today_handoff_intent_replays_through_account_sync():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map(); const requests=[];
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'wrap-up-op',
fetchJson:async(_url,options={{}})=>{{
const body=JSON.parse(options.body); requests.push(body);
return {{revision:1,records:{{[body.operations[0].item_id]:{{wake_at:body.operations[0].wake_at,handoff:body.operations[0].handoff}}}},accepted_operation_ids:['wrap-up-op'],duplicate_operation_ids:[],rejected_operations:[]}};
}},onRemoteRecords:records=>{{globalThis.records=records}},onStatus:()=>{{}},
}});
sync.enqueue('defer','issue:r:2:','2026-08-17T09:00:00.000Z','today');
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,records:globalThis.records}}));}})();
"""
result = run_node(script)
assert result["requests"] == [{"operations": [{
"operation_id": "wrap-up-op",
"action": "defer",
"item_id": "issue:r:2:",
"wake_at": "2026-08-17T09:00:00.000Z",
"handoff": "today",
"base_revision": 0,
}]}]
assert result["records"] == {"issue:r:2:": {
"wake_at": "2026-08-17T09:00:00.000Z", "handoff": "today",
}}
def test_later_queue_expires_ancient_edits_before_replay():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
@@ -294,6 +323,26 @@ sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:
}
def test_unsynced_wrap_up_handoff_migrates_after_browser_restart():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map();const requests=[];
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'migrated-handoff',
fetchJson:async(_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:1,records:{{}},accepted_operation_ids:['migrated-handoff'],duplicate_operation_ids:[],rejected_operations:[]}}}},
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
}});
sync.migrate({{'issue:r:1:':{{wake_at:'2026-08-17T09:00:00.000Z',handoff:'today'}}}});
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify(requests));}})();
"""
assert run_node(script) == [{"operations": [{
"operation_id": "migrated-handoff", "action": "defer",
"item_id": "issue:r:1:", "wake_at": "2026-08-17T09:00:00.000Z",
"handoff": "today", "base_revision": 0,
}]}]
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
script = f"""
const createLaterWork=require({json.dumps(str(LATER_WORK))});
@@ -324,6 +373,45 @@ process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:
assert result["expired"] == [["issue:stackchain/api:17:"]]
def test_later_work_preserves_synced_today_handoff_until_it_wakes():
script = f"""
const createLaterWork=require({json.dumps(str(LATER_WORK))});
const values=new Map(); const changes=[]; const expired=[];
let clock=new Date('2026-08-16T20:00:00Z');
const item={{kind:'issue',repository:'stackchain/dashboard',number:992,title:'Commitment'}};
const work=createLaterWork({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
onChange:(action,id,wakeAt,handoff)=>changes.push([action,id,wakeAt,handoff||null]),
onExpire:(ids,handoffs)=>expired.push([ids,handoffs]),
}});
work.defer(item,new Date('2026-08-17T09:00:00Z'),{{handoff:'today'}});
const stored=work.read();
clock=new Date('2026-08-17T09:00:01Z');
const awake=work.partition([item]);
process.stdout.write(JSON.stringify({{stored,changes,expired,awake:awake.active}}));
"""
result = run_node(script)
assert result["stored"] == {
"issue:stackchain/dashboard:992:": {
"wake_at": "2026-08-17T09:00:00.000Z",
"handoff": "today",
}
}
assert result["changes"] == [[
"defer", "issue:stackchain/dashboard:992:",
"2026-08-17T09:00:00.000Z", "today",
]]
assert result["expired"] == [[
["issue:stackchain/dashboard:992:"],
["issue:stackchain/dashboard:992:"],
]]
assert result["awake"] == [{
"kind": "issue", "repository": "stackchain/dashboard",
"number": 992, "title": "Commitment",
}]
@pytest.mark.anyio
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
html = await dashboard()
@@ -332,9 +420,9 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
assert 'id="later-sync-status"' in html
assert "const laterSync = createLaterSync({" in html
assert "onRemoteRecords: records =>" in html
assert "onChange: (action, itemId, wakeAt) =>" in html
assert "onExpire: ids =>" in html
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
assert "onChange: (action, itemId, wakeAt, handoff) =>" in html
assert "onExpire: (ids, handoffs) =>" in html
assert "laterSync.enqueue(action, itemId, wakeAt, handoff)" in html
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
assert "laterSync.migrate(laterWork.read());" in html
assert "laterSync.flush();" in html
@@ -347,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/later-sync.js'" in source
+45
View File
@@ -569,6 +569,51 @@ async function destination(continuation) {{
}
def test_token_and_passkey_login_resume_only_the_private_share_bundle():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const credential = {{
id:'credential-id', type:'public-key', rawId:Uint8Array.from([1]).buffer,
response:{{authenticatorData:Uint8Array.from([2]).buffer,
clientDataJSON:Uint8Array.from([3]).buffer,
signature:Uint8Array.from([4]).buffer,userHandle:null}},
}};
async function destination(continuation, authentication) {{
let replaced = null;
const controller = createLoginController({{
form:{{reset:()=>{{}}}}, status:{{textContent:''}}, button:{{disabled:false}},
passkeyButton:{{disabled:false}}, credentials:{{get:async()=>credential}},
fetchImpl:async url => url.endsWith('/options')
? new Response(JSON.stringify({{challenge:'AQID',allowCredentials:[]}}),
{{status:200,headers:{{'Content-Type':'application/json'}}}})
: new Response('{{}}',{{status:200}}),
location:{{replace:value=>replaced=value}}, continuation,
}});
if (authentication === 'passkey') await controller.signInWithPasskey('Phone');
else await controller.submit('operator-token');
return replaced;
}}
(async()=>process.stdout.write(JSON.stringify({{
token:await destination('./?launch=new&shared=bundle','token'),
passkey:await destination('./?launch=new&shared=bundle','passkey'),
extra:await destination('./?launch=new&shared=bundle&title=leak','token'),
duplicate:await destination('./?launch=new&shared=bundle&shared=bundle','token'),
wrongLaunch:await destination('./?launch=agenda&shared=bundle','token'),
}})))().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"token": "./?launch=new&shared=bundle",
"passkey": "./?launch=new&shared=bundle",
"extra": "./",
"duplicate": "./",
"wrongLaunch": "./",
}
@pytest.mark.anyio
async def test_login_page_loads_rate_limit_controller():
html = await login()
+1 -1
View File
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v114" in worker
assert "stackchain-dashboard-shell-v117" in worker
+1 -1
View File
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v114" in worker
assert "stackchain-dashboard-shell-v117" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+350
View File
@@ -0,0 +1,350 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).resolve().parents[1]
RECOVERY = ROOT / "frontend" / "mobile-delivery-recovery.js"
def run_node(script: str) -> dict:
result = subprocess.run(
["node", "-e", script],
cwd=ROOT,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_recovery_orders_deliveries_by_safest_required_intervention():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
const recovery = createRecovery({{
getItems: () => [
{{outbox_id:'waiting', delivery_state:'waiting', title:'Waiting'}},
{{outbox_id:'uncertain', delivery_state:'uncertain', title:'Uncertain'}},
{{outbox_id:'attention', status:'attention', title:'Attention'}},
{{outbox_id:'authorization', status:'authorization', title:'Authorization'}},
],
}});
process.stdout.write(JSON.stringify(recovery.snapshot()));
"""
assert run_node(script) == {
"count": 4,
"current": {
"id": "authorization",
"state": "authorization",
"primary": "Authorize & send",
"position": 1,
"total": 4,
},
}
def test_recovery_advances_only_after_the_current_delivery_is_resolved():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
(async () => {{
let items = [
{{outbox_id:'authorize', status:'authorization', title:'Approve review'}},
{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}},
];
let resolves = false;
const recovery = createRecovery({{
getItems: () => items,
activate: async item => {{
if (resolves) items = items.filter(candidate => candidate.outbox_id !== item.outbox_id);
return resolves;
}},
}});
recovery.open();
const retained = await recovery.activate();
resolves = true;
const advanced = await recovery.activate();
process.stdout.write(JSON.stringify({{retained, advanced}}));
}})();
"""
assert run_node(script) == {
"retained": {
"count": 2,
"current": {
"id": "authorize",
"state": "authorization",
"primary": "Authorize & send",
"position": 1,
"total": 2,
},
},
"advanced": {
"count": 1,
"current": {
"id": "retry",
"state": "waiting",
"primary": "Retry now",
"position": 1,
"total": 1,
},
},
}
def test_recovery_keeps_one_attempt_locked_until_delivery_settles():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
(async () => {{
let finish;
let attempts = 0;
const settled = new Promise(resolve => {{finish = resolve}});
const element = () => ({{textContent:'', disabled:false}});
const elements = {{
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
title:element(), destination:element(), reason:element(), progress:element(),
action:element(), status:element(),
}};
const recovery = createRecovery({{
getItems: () => [{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}}],
activate: async () => {{attempts += 1; await settled; return true}},
elements,
}});
recovery.open();
const first = recovery.activate();
const second = recovery.activate();
await Promise.resolve();
const pending = {{attempts, disabled:elements.action.disabled, status:elements.status.textContent}};
finish();
await Promise.all([first, second]);
process.stdout.write(JSON.stringify({{
pending, attempts, disabled:elements.action.disabled,
}}));
}})();
"""
assert run_node(script) == {
"pending": {"attempts": 1, "disabled": True, "status": "Working…"},
"attempts": 1,
"disabled": False,
}
def test_attention_handoff_closes_modal_focuses_destination_and_awaits_work():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
(async () => {{
let finish;
let handoffs = 0;
const settled = new Promise(resolve => {{finish = resolve}});
const destination = {{
focused:false, scrolled:false,
focus(){{this.focused=true}}, scrollIntoView(){{this.scrolled=true}},
async onclick(){{handoffs += 1; await settled; return true}},
}};
global.document = {{
getElementById: () => null,
querySelector: selector => selector.includes('draft-resume') ? destination : null,
}};
const element = () => ({{textContent:'', disabled:false}});
const elements = {{
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
title:element(), destination:element(), reason:element(), progress:element(),
action:element(), status:element(),
}};
const recovery = createRecovery({{
getItems: () => [{{outbox_id:'review', status:'attention', title:'Review feedback'}}],
getIndex: () => 3,
elements,
}});
recovery.open();
const first = recovery.activate();
const second = recovery.activate();
await Promise.resolve();
const pending = {{
dialogOpen:elements.dialog.open, disabled:elements.action.disabled,
focused:destination.focused, scrolled:destination.scrolled, handoffs,
}};
finish();
await Promise.all([first, second]);
process.stdout.write(JSON.stringify({{pending, handoffs, dialogOpen:elements.dialog.open}}));
}})();
"""
assert run_node(script) == {
"pending": {
"dialogOpen": False,
"disabled": True,
"focused": True,
"scrolled": True,
"handoffs": 1,
},
"handoffs": 1,
"dialogOpen": False,
}
def test_failed_attention_handoff_reopens_the_same_retryable_item():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
(async () => {{
const destination = {{
focus(){{}}, scrollIntoView(){{}},
async onclick(){{throw new Error('Latest checklist unavailable')}},
}};
global.document = {{
getElementById: () => null,
querySelector: () => destination,
}};
const element = () => ({{textContent:'', disabled:false}});
const elements = {{
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
title:element(), destination:element(), reason:element(), progress:element(),
action:element(), status:element(),
}};
const recovery = createRecovery({{
getItems: () => [{{outbox_id:'conflict', checklist_conflict:true, title:'Checklist conflict'}}],
getIndex: () => 0,
elements,
}});
recovery.open();
const result = await recovery.activate();
process.stdout.write(JSON.stringify({{
result, dialogOpen:elements.dialog.open, status:elements.status.textContent,
action:elements.action.textContent,
}}));
}})();
"""
assert run_node(script) == {
"result": {
"count": 1,
"current": {
"id": "conflict",
"state": "attention",
"primary": "Review changes",
"position": 1,
"total": 1,
},
},
"dialogOpen": True,
"status": "Latest checklist unavailable Try again.",
"action": "Review changes",
}
def test_recovery_retains_failed_delivery_with_actionable_status():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
(async () => {{
const element = () => ({{textContent:'', disabled:false}});
const elements = {{
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
title:element(), destination:element(), reason:element(), progress:element(),
action:element(), status:element(),
}};
const recovery = createRecovery({{
getItems: () => [{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}}],
activate: async () => {{throw new Error('Network unavailable')}},
elements,
}});
recovery.open();
const result = await recovery.activate();
process.stdout.write(JSON.stringify({{
result, disabled:elements.action.disabled, status:elements.status.textContent,
}}));
}})();
"""
assert run_node(script) == {
"result": {
"count": 1,
"current": {
"id": "retry",
"state": "waiting",
"primary": "Retry now",
"position": 1,
"total": 1,
},
},
"disabled": False,
"status": "Network unavailable Try again.",
}
def test_recovery_renders_delivery_context_and_hands_off_when_cleared():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
(async () => {{
let items = [{{
outbox_id:'close-7', status:'authorization', outbox_kind:'issue-close',
title:'Close resolved incident', repository:'stackchain/ops',
details:'#7', last_attempt_error:'Fresh authorization expired',
}}];
let completed = 0;
const element = () => ({{textContent:'', hidden:false}});
const elements = {{
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
title: element(), destination: element(), reason: element(), progress: element(),
action: element(), status: element(),
}};
const recovery = createRecovery({{
getItems: () => items,
activate: async () => {{items = []; return true}},
onComplete: () => {{completed += 1}},
elements,
}});
const opened = recovery.open();
const rendered = {{
opened, dialogOpen:elements.dialog.open, title:elements.title.textContent,
destination:elements.destination.textContent, reason:elements.reason.textContent,
progress:elements.progress.textContent, action:elements.action.textContent,
}};
const cleared = await recovery.activate();
process.stdout.write(JSON.stringify({{rendered, cleared, completed, dialogOpen:elements.dialog.open}}));
}})();
"""
assert run_node(script) == {
"rendered": {
"opened": "opened",
"dialogOpen": True,
"title": "Close resolved incident",
"destination": "stackchain/ops · #7",
"reason": "Fresh authorization expired",
"progress": "Delivery 1 of 1 · Authorization required",
"action": "Authorize & close",
},
"cleared": {"count": 0, "current": None},
"completed": 1,
"dialogOpen": False,
}
@pytest.mark.anyio
async def test_dashboard_packages_phone_safe_guided_delivery_recovery():
html = await dashboard()
service_worker = (ROOT / "frontend" / "service-worker.js").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert '<dialog id="mobile-delivery-recovery"' in html
assert 'aria-labelledby="mobile-delivery-recovery-title"' in html
assert 'id="mobile-delivery-recovery-action"' in html
assert '<script src="static/mobile-delivery-recovery.js"></script>' in html
assert "const mobileDeliveryRecovery = createMobileDeliveryRecovery({" in html
assert "activate: recoverMobileDelivery," in html
assert "button.onclick=async()=>" in html
assert "await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin)" in html
assert "(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result)" in html
assert "openDelivery: () => mobileDeliveryRecovery.open()" in html
assert "mobileStartDay.completePhase('delivery')" in html
assert "BASE + 'static/mobile-delivery-recovery.js'" in service_worker
assert '"static/mobile-delivery-recovery.js"' in bundle
assert ".mobile-delivery-recovery-panel" in html
assert "min-height:48px" in html
assert "env(safe-area-inset-bottom)" in html
+10 -1
View File
@@ -197,6 +197,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
storage_module = (root / "frontend" / "device-storage.js").read_text()
worker = (root / "frontend" / "service-worker.js").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
@@ -208,13 +209,21 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert 'id="device-setup-sheet"' in html
assert 'aria-labelledby="device-setup-heading"' in html
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline"))
assert 'id="device-storage-summary"' in html
assert 'id="device-storage-detail"' in html
assert 'id="clear-device-caches"' in html
assert 'id="clear-private-device-data"' in html
assert '<script src="static/private-device-data.js"></script>' in html
assert '<script src="static/device-storage.js"></script>' in html
assert 'id="device-setup-deadline-hour"' in html
assert '<script src="static/mobile-device-setup.js"></script>' in html
assert "createMobileDeviceSetup.mount({" in dashboard
assert "createDeviceStorage.mount(document)" in dashboard
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v114" in worker
assert "stackchain-dashboard-shell-v117" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+178
View File
@@ -0,0 +1,178 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-insights.js"
def run_node(script: str) -> dict:
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_mobile_insights_opens_as_addressable_modal_and_hides_home_chrome():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = () => ({{
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
removeAttribute(name) {{ delete this.attributes[name]; }},
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
focus() {{ this.focuses += 1; }},
}});
const root = element(); const launcher = element(); const closeButton = element();
const dock = element(); const hud = element(); const home = element(); const header = element();
const menu = {{open:true}};
const location = {{pathname:'/dashboard/', search:'', hash:'#/my-work/today'}};
const history = {{state:null, pushes:[], pushState(state,unused,url) {{
this.state=state; this.pushes.push(url); location.hash=url.slice(url.indexOf('#'));
}}, replaceState() {{}}, back() {{}}}};
const controller = createInsights({{
root, launcher, closeButton, dock, hud, backgrounds:[header,home], menu, history, location,
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
mediaQuery:{{matches:true, addEventListener() {{}}}},
}});
controller.start();
launcher.listeners.click();
process.stdout.write(JSON.stringify({{
hash:location.hash, pushes:history.pushes, open:root.attributes['data-mobile-open'],
role:root.attributes.role, modal:root.attributes['aria-modal'], rootInert:root.inert,
homeInert:home.inert, headerInert:header.inert, dockHidden:dock.hidden,
hudHidden:hud.attributes['data-overlay-hidden'], closeFocuses:closeButton.focuses,
menuOpen:menu.open,
}}));
"""
assert run_node(script) == {
"hash": "#/insights",
"pushes": ["/dashboard/#/insights"],
"open": "true",
"role": "dialog",
"modal": "true",
"rootInert": False,
"homeInert": True,
"headerInert": True,
"dockHidden": True,
"hudHidden": "true",
"closeFocuses": 1,
"menuOpen": False,
}
def test_direct_insights_route_closes_locally_and_restores_launcher_focus():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = () => ({{
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
removeAttribute(name) {{ delete this.attributes[name]; }},
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
focus() {{ this.focuses += 1; }},
}});
const root=element(), launcher=element(), closeButton=element(), dock=element(), hud=element(), home=element();
const location={{pathname:'/dashboard/', search:'', hash:'#/insights'}};
const history={{state:null, backs:0, replacements:[], pushState() {{}}, back() {{ this.backs += 1; }},
replaceState(state,unused,url) {{ this.state=state; this.replacements.push(url); location.hash=url.slice(url.indexOf('#')); }}
}};
const controller=createInsights({{
root,launcher,closeButton,dock,hud,backgrounds:[home],history,location,
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
mediaQuery:{{matches:true,addEventListener() {{}}}},
}});
controller.start();
closeButton.listeners.click();
process.stdout.write(JSON.stringify({{
current:controller.current(), hash:location.hash, backs:history.backs,
replacements:history.replacements, rootInert:root.inert, homeInert:home.inert,
dockHidden:dock.hidden, hudHidden:hud.attributes['data-overlay-hidden'] || null,
launcherFocuses:launcher.focuses,
}}));
"""
assert run_node(script) == {
"current": False,
"hash": "#/my-work",
"backs": 0,
"replacements": ["/dashboard/#/my-work"],
"rootInert": True,
"homeInert": False,
"dockHidden": False,
"hudHidden": None,
"launcherFocuses": 1,
}
def test_browser_back_closes_insights_without_resetting_the_work_route():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const element=()=>({{hidden:false,inert:false,attributes:{{}},focuses:0,listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{this.focuses+=1;}}}});
const root=element(),launcher=element(),closeButton=element(),dock=element(),hud=element(),home=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/update'}};
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{}}}};
const controller=createInsights({{root,launcher,closeButton,dock,hud,backgrounds:[home],history,location,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start(); launcher.listeners.click();
location.hash='#/my-work/update'; history.state=null; listeners.popstate?.({{state:null}});
process.stdout.write(JSON.stringify({{current:controller.current(),hash:location.hash,launcherFocuses:launcher.focuses,homeInert:home.inert,dockHidden:dock.hidden}}));
"""
assert run_node(script) == {
"current": False,
"hash": "#/my-work/update",
"launcherFocuses": 1,
"homeInert": False,
"dockHidden": False,
}
def test_escape_requests_back_navigation_only_while_insights_is_open():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
const root=element(),launcher=element(),closeButton=element(),dock=element(),hud=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work'}};
const history={{state:null,backs:0,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{this.backs+=1;}}}};
const controller=createInsights({{root,launcher,closeButton,dock,hud,backgrounds:[],history,location,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start(); launcher.listeners.click();
let prevented=0; listeners.keydown?.({{key:'Escape',preventDefault(){{prevented+=1;}}}}); listeners.keydown?.({{key:'Enter',preventDefault(){{prevented+=1;}}}});
process.stdout.write(JSON.stringify({{backs:history.backs,prevented}}));
"""
assert run_node(script) == {"backs": 1, "prevented": 1}
@pytest.mark.anyio
async def test_mobile_home_progressively_discloses_secondary_panels_as_insights():
html = await dashboard()
assert 'id="open-insights"' in html
assert 'id="insights-sheet"' in html
assert 'id="close-insights"' in html
assert html.index('id="my-work"') < html.index('id="insights-sheet"')
assert html.index('id="insights-sheet"') < html.index('data-panel-key="context"')
assert html.index('data-panel-key="markdown"') < html.index('</section>\n</main>')
assert '<script src="static/mobile-insights.js"></script>' in html
assert "createMobileInsights({" in html
assert "mediaQuery: window.matchMedia('(max-width: 600px)')" in html
assert ".insights-shell { display:contents; }" in html
assert "#open-insights { display:none; }" in html
assert '.insights-shell[data-mobile-open="true"]' in html
assert "position:fixed; inset:0;" in html
assert "overflow-x:hidden" in html
assert ".insights-header button { min-height:44px;" in html
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v117" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
@@ -116,6 +116,45 @@ process.stdout.write(JSON.stringify({{current,disconnected}}));
}
def test_mobile_issue_navigation_distinguishes_taps_scrolls_and_keyboard_safe_restore():
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.focuses=0; }}
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
removeEventListener() {{}}
click() {{ this.listeners.click({{preventDefault() {{}}}}); }}
setAttribute(name, value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
scrollIntoView() {{}}
focus() {{ this.focuses += 1; }}
}}
const names=['overview','conversation','reply','actions'];
const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
const targets=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
const changes=[];
let observer;
const navigation=createNavigation({{
buttons, targets,
onSectionChange:(name, options) => changes.push([name, options.replace]),
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
}});
navigation.start();
buttons.reply.click();
observer([{{target:targets.actions,isIntersecting:true,intersectionRatio:1}}]);
navigation.navigate('reply', {{focus:false}});
process.stdout.write(JSON.stringify({{changes,replyFocuses:targets.reply.focuses,current:buttons.reply.attributes['aria-current']}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"changes": [["reply", False], ["actions", True]],
"replyFocuses": 1,
"current": "location",
}
def test_issue_sheet_ships_a_mobile_only_safe_area_navigation_rail():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
+188
View File
@@ -0,0 +1,188 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.frontend_bundle import build_frontend
from tests.dashboard_bundle import dashboard
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-pull-refresh.js"
def run_scenario(source: str) -> dict:
script = f"""
const createPullRefresh = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor() {{
this.listeners = {{}};
this.dataset = {{}};
this.hidden = true;
this.textContent = '';
}}
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
removeEventListener(name) {{ delete this.listeners[name]; }}
setPointerCapture() {{}}
releasePointerCapture() {{}}
}}
const surface = new FakeElement();
const indicator = new FakeElement();
const plain = {{closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100}};
const event = (x, y, target=plain, id=1) => ({{
pointerId:id, pointerType:'touch', clientX:x, clientY:y, target,
preventDefault() {{ this.prevented = true; }},
}});
{source}
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_mobile_pull_refresh_runs_one_refresh_after_vertical_threshold_and_announces_success():
result = run_scenario("""
let calls = 0;
const controller = createPullRefresh({
surface, indicator, threshold:64,
refresh: async () => { calls += 1; },
isMobile: () => true,
getScrollY: () => 0,
isBlocked: () => false,
});
controller.start();
surface.listeners.pointerdown(event(100, 10));
surface.listeners.pointermove(event(102, 90));
const ready = {hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent};
const completion = surface.listeners.pointerup(event(102, 90));
Promise.resolve(completion).then(() => process.stdout.write(JSON.stringify({
calls, ready,
final:{hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent},
})));
""")
assert result == {
"calls": 1,
"ready": {"hidden": False, "state": "ready", "text": "Release to refresh"},
"final": {"hidden": False, "state": "success", "text": "My Work is up to date"},
}
def test_mobile_pull_refresh_ignores_ineligible_gestures_and_short_pulls():
result = run_scenario("""
let calls = 0;
let mobile = true;
let scrollY = 0;
let blocked = false;
const controller = createPullRefresh({
surface, indicator, threshold:64,
refresh: async () => { calls += 1; },
isMobile: () => mobile,
getScrollY: () => scrollY,
isBlocked: () => blocked,
});
controller.start();
async function drag(fromX, fromY, toX, toY, target=plain) {
surface.listeners.pointerdown(event(fromX, fromY, target));
surface.listeners.pointermove(event(toX, toY, target));
await surface.listeners.pointerup(event(toX, toY, target));
}
(async () => {
await drag(100, 10, 102, 50);
await drag(100, 10, 180, 30);
mobile = false; await drag(100, 10, 100, 100);
mobile = true; scrollY = 1; await drag(100, 10, 100, 100);
scrollY = 0; blocked = true; await drag(100, 10, 100, 100);
blocked = false;
const interactive = {closest: selector => selector ? interactive : null, parentElement:null, scrollWidth:100, clientWidth:100};
await drag(100, 10, 100, 100, interactive);
const scroller = {closest: () => null, parentElement:null, scrollWidth:300, clientWidth:100};
await drag(100, 10, 100, 100, scroller);
process.stdout.write(JSON.stringify({calls}));
})();
""")
assert result == {"calls": 0}
def test_mobile_pull_refresh_stays_single_flight_and_keeps_refreshing_status():
result = run_scenario("""
let calls = 0;
let finish;
const pending = new Promise(resolve => { finish = resolve; });
const controller = createPullRefresh({
surface, indicator, threshold:64,
refresh: () => { calls += 1; return pending; },
isMobile: () => true,
getScrollY: () => 0,
isBlocked: () => false,
});
controller.start();
function pull(id) {
surface.listeners.pointerdown(event(100, 10, plain, id));
surface.listeners.pointermove(event(100, 90, plain, id));
return surface.listeners.pointerup(event(100, 90, plain, id));
}
const first = pull(1);
Promise.resolve().then(async () => {
const second = pull(2);
const during = {calls, state:indicator.dataset.state, text:indicator.textContent};
finish();
await first;
await second;
process.stdout.write(JSON.stringify({calls, during, final:indicator.dataset.state}));
});
""")
assert result == {
"calls": 1,
"during": {"calls": 1, "state": "refreshing", "text": "Refreshing My Work…"},
"final": "success",
}
def test_mobile_pull_refresh_announces_failure_and_allows_a_retry():
result = run_scenario("""
let calls = 0;
const controller = createPullRefresh({
surface, indicator, threshold:64,
refresh: async () => { calls += 1; if (calls === 1) throw new Error('offline secret'); },
isMobile: () => true,
getScrollY: () => 0,
isBlocked: () => false,
});
controller.start();
async function pull(id) {
surface.listeners.pointerdown(event(100, 10, plain, id));
surface.listeners.pointermove(event(100, 90, plain, id));
try { await surface.listeners.pointerup(event(100, 90, plain, id)); } catch (_) {}
}
(async () => {
await pull(1);
const failed = {state:indicator.dataset.state, text:indicator.textContent};
await pull(2);
process.stdout.write(JSON.stringify({calls, failed, final:indicator.dataset.state}));
})();
""")
assert result == {
"calls": 2,
"failed": {"state": "error", "text": "Could not refresh · pull to retry"},
"final": "success",
}
@pytest.mark.anyio
async def test_mobile_pull_refresh_is_wired_to_live_refresh_and_packaged_offline():
html = await dashboard()
frontend = CONTROLLER.parent
build = build_frontend(frontend)
today_bundle = build.feature_bundles["today-timer"].runtime_bytes.decode()
assert 'id="mobile-pull-refresh"' in html
assert 'role="status"' in html
assert html.index('static/mobile-pull-refresh.js') < html.index('static/dashboard.js')
assert "createMobilePullRefresh" in html
assert "contextPoller.refresh({ force: true })" in html
assert "createMobilePullRefresh" in today_bundle
assert "liveDataStatus" in today_bundle
assert build.feature_bundles["today-timer"].runtime_name in build.service_worker_source
@@ -116,6 +116,38 @@ process.stdout.write(JSON.stringify({{current,rootMatches:observedRoot === panel
}
def test_review_navigation_routes_taps_and_scrolls_without_focusing_on_restore():
script = f"""
const createNavigation=require({json.dumps(str(CONTROLLER))});
const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0,
addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}},
setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }},
scrollIntoView() {{}}, focus() {{ this.focuses += 1; }},
}});
const names=['overview','files','feedback','history'];
const buttons=Object.fromEntries(names.map(name => [name,element(name)]));
const targets=Object.fromEntries(names.map(name => [name,element(name)]));
const composer=element('composer'); const changes=[]; let observer;
const navigation=createNavigation({{buttons,targets,summaryComposer:composer,
onSectionChange:(name,options)=>changes.push([name,options.replace]),
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
}});
navigation.start();
buttons.feedback.listeners.click({{preventDefault() {{}}}});
observer([{{target:targets.history,isIntersecting:true,intersectionRatio:1}}]);
navigation.navigate('feedback',{{focus:false}});
process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.feedback.attributes['aria-current']}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"changes": [["feedback", False], ["history", True]],
"focuses": 1,
"current": "location",
}
def test_review_sheet_wires_a_mobile_safe_area_navigation_rail_and_offline_asset():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
+41 -2
View File
@@ -72,6 +72,44 @@ process.stdout.write(JSON.stringify({{first, second, ready, opened}}));
}
def test_prepare_today_puts_actionable_delivery_recovery_before_other_work():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
let counts = {{delivery:2, agenda:1, attention:0, update:0, filed:0, today:4}};
const opened = [];
const handoffs = [];
const saved = new Map();
const controller = createStartDay({{
getCounts: () => counts,
getLogin: () => 'timmy',
getDay: () => '2026-08-17',
storage: {{getItem:key=>saved.get(key)||null,setItem:(key,value)=>saved.set(key,value),removeItem:key=>saved.delete(key)}},
openQueue: name => opened.push(name),
onHandoff: state => handoffs.push(state.next),
}});
const blocked = controller.briefing();
controller.startNext();
counts = {{delivery:0, agenda:1, attention:0, update:0, filed:0, today:4}};
const handed = controller.reconcile({{authoritative:true}});
process.stdout.write(JSON.stringify({{blocked, handed, opened, handoffs}}));
"""
output = run_node(script)
assert output["blocked"] == {
"total": 3,
"next": "delivery",
"label": "Review Delivery",
"summary": "2 deliveries need action before Today · 1 other item · 4 planned",
"phases": [
{"name": "delivery", "label": "Delivery recovery", "count": 2},
{"name": "agenda", "label": "Agenda", "count": 1},
],
}
assert output["handed"] is True
assert output["opened"] == ["delivery"]
assert output["handoffs"] == ["agenda"]
def test_start_day_view_renders_refreshed_phases_and_launches_primary_action():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
@@ -273,7 +311,8 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert "mobileStartDay.completePhase('agenda')" in html
assert "mobileStartDay.completePhase('update')" in html
assert "mobileStartDay.completePhase('filed')" in html
assert "mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh})" in html
assert "authoritative:authoritativeMyWorkRefresh" in html
assert "authoritativePhases:['delivery']" in html
assert "mobileStartDay.finish()" in html
assert "openQueue: name =>" in html
assert "if (sheet.open) sheet.close();" in html
@@ -283,4 +322,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v114" in service_worker
assert "stackchain-dashboard-shell-v117" in service_worker
+112 -7
View File
@@ -85,6 +85,30 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
}
def test_mobile_work_entry_plans_eligible_work_before_find_fallback():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
const calls = [];
const entry = createEntry({{
isTodayActive: () => false,
isTodayResumable: () => false,
getTodayCount: () => 0,
getEligibleCount: () => 2,
queueLauncher: {{recommend: () => ({{name:'find'}}), open: () => {{}}}},
continueToday: () => {{}},
resumeToday: () => {{}},
startToday: () => {{}},
planToday: () => calls.push('plan'),
findWork: () => calls.push('find'),
}});
process.stdout.write(JSON.stringify({{mode:entry.open(), calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"mode": "plan", "calls": ["plan"]}
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
@@ -331,7 +355,7 @@ sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
const close = new FakeElement();
const badge = new FakeElement();
const deadline = new FakeElement();
const rows = Object.fromEntries(['today','agenda','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
const rows = Object.fromEntries(['today','agenda','delivery','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
const selected = [];
const utilities = [];
@@ -342,7 +366,7 @@ const dock = createDock({{
observe() {{}},
}});
dock.start();
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, filed:2, later:3, draft:4}});
dock.updateQueues({{today:2, agenda:5, delivery:1, attention:1, update:5, filed:2, later:3, draft:4}});
queues.click();
const opened = sheet.open;
rows.update.click();
@@ -387,9 +411,9 @@ process.stdout.write(JSON.stringify({{
"utilities": ["recaps:trigger"],
"badge": "0 active",
"populated": {
"badge": "6 active",
"badge": "7 active",
"badgeHidden": False,
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 6 active queues",
"badgeLabel": "Queues: Today 2, Agenda 5 due, Delivery 1, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 7 active queues",
"deadline": "5 due",
"deadlineHidden": False,
"agendaDue": "true",
@@ -398,13 +422,13 @@ process.stdout.write(JSON.stringify({{
"badge": "1 active",
"badgeHidden": False,
"deadlineHidden": True,
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
"badgeLabel": "Queues: Today 0, Agenda 0 due, Delivery 0, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
},
"clearedBadgeHidden": True,
"clearedDeadlineHidden": True,
"clearedAgendaDue": None,
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
"counts": {"today": "0", "agenda": "0", "delivery": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
"updateLabel": "Updates, 0 unread conversations",
"queueFocuses": 3,
"columnState": None,
@@ -461,6 +485,82 @@ process.stdout.write(JSON.stringify({{result, calls}}));
}
def test_mobile_queue_launcher_uses_dedicated_delivery_recovery_flow():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
const launcher = createLauncher({{
openDelivery: () => {{ calls.push('delivery-recovery'); return 'opened'; }},
selectFilter: name => calls.push('generic-filter:' + name),
firstAction: () => {{ throw new Error('generic card launch must not run'); }},
}});
const result = launcher.open('delivery');
process.stdout.write(JSON.stringify({{result, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"result": "opened", "calls": ["delivery-recovery"]}
def test_mobile_work_prioritizes_and_revalidates_actionable_delivery_recovery():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
let counts = {{delivery:2, attention:3, today:1, update:4}};
const launcher = createLauncher({{
getCounts: () => counts,
openDelivery: () => {{ calls.push('delivery-recovery'); return 'opened-delivery'; }},
openUpdates: () => {{ calls.push('updates'); return 'opened-updates'; }},
selectFilter: name => calls.push('filter:' + name),
firstAction: () => null,
announce: () => {{}},
openFindWork: () => calls.push('find'),
}});
const delivery = launcher.recommend();
const opened = launcher.continueWork();
counts = {{delivery:0, attention:0, today:0, update:4}};
const afterRecovery = launcher.recommend();
process.stdout.write(JSON.stringify({{delivery, opened, afterRecovery, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"delivery": {"name": "delivery", "count": 2, "label": "Recover Delivery (2)"},
"opened": "opened-delivery",
"afterRecovery": {"name": "update", "count": 4, "label": "Resume Updates (4)"},
"calls": ["delivery-recovery"],
}
def test_mobile_work_continues_into_filed_follow_up_before_later_work():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
let counts = {{attention:0, today:0, update:0, agenda:0, filed:2, later:4, draft:3}};
const launcher = createLauncher({{
getCounts: () => counts,
openFiled: () => {{ calls.push('filed-follow-up'); return 'opened-filed'; }},
selectFilter: name => calls.push('filter:' + name),
firstAction: () => null,
announce: () => {{}},
openFindWork: () => calls.push('find'),
}});
const recommendation = launcher.recommend();
const opened = launcher.continueWork();
process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"recommendation": {"name": "filed", "count": 2, "label": "Review Filed (2)"},
"opened": "opened-filed",
"calls": ["filed-follow-up"],
}
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
@@ -531,7 +631,7 @@ const work = {{attributes: {{}}, setAttribute(name, value) {{ this.attributes[na
const workLabel = {{textContent:''}};
const dock = createDock({{nav:{{}}, buttons:{{work}}, workLabel}});
const labels = {{}};
for (const mode of ['attention','update','agenda','later','draft']) {{
for (const mode of ['delivery','attention','update','agenda','filed','later','draft']) {{
dock.updateWork(mode);
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
}}
@@ -541,9 +641,11 @@ process.stdout.write(JSON.stringify(labels));
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"delivery": ["Delivery", "Open Delivery"],
"attention": ["Attention", "Open Attention"],
"update": ["Updates", "Resume Updates"],
"agenda": ["Agenda", "Open Agenda"],
"filed": ["Filed", "Open Filed"],
"later": ["Later", "Open Later"],
"draft": ["Drafts", "Open Drafts"],
}
@@ -576,6 +678,9 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
assert "counts.agenda = agendaItems.length;" in html
assert 'data-agenda-group' in html
assert 'data-mobile-queue="attention"' in html
assert 'data-mobile-queue="delivery"' in html
assert '<strong>Delivery</strong><small>Needs recovery</small>' in html
assert 'data-mobile-queue-count="delivery"' in html
assert 'data-mobile-queue="update"' in html
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
assert 'data-mobile-queue="filed"' in html
@@ -123,6 +123,38 @@ process.stdout.write(JSON.stringify({{current,disconnected}}));
}
def test_update_navigation_routes_taps_and_scrolls_without_focusing_on_restore():
script = f"""
const createNavigation=require({json.dumps(str(CONTROLLER))});
const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0,open:false,
addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}},
setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }},
scrollIntoView() {{}}, focus() {{ this.focuses += 1; }},
}});
const names=['activity','conversation','context','reply'];
const buttons=Object.fromEntries(names.map(name => [name,element(name)]));
const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,element(name)]));
const composer=element('composer'); const changes=[]; let observer;
const navigation=createNavigation({{buttons,targets,replyComposer:composer,jumpToNewActivity() {{}},
onSectionChange:(name,options)=>changes.push([name,options.replace]),
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
}});
navigation.start();
buttons.reply.listeners.click({{preventDefault() {{}}}});
observer([{{target:targets.context,isIntersecting:true,intersectionRatio:1}}]);
navigation.navigate('reply',{{focus:false}});
process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.reply.attributes['aria-current']}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"changes": [["reply", False], ["context", True]],
"focuses": 1,
"current": "location",
}
def test_update_sheet_wires_a_mobile_only_safe_area_navigation_rail():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
+81 -1
View File
@@ -1477,6 +1477,68 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
def test_work_route_sections_are_validated_back_safe_and_do_not_reopen_the_item():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/issue/stackchain/api/17'}};
const calls = [];
const stack = [location.hash];
let cursor = 0;
const history = {{
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; calls.push(['push', hash]); }},
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; calls.push(['replace', hash]); }},
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
}};
const controller = routes.createController({{
location, history,
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
onOpen: item => calls.push(['open', item.number, item.section || null]),
onSection: (section, options) => calls.push(['section', section, options.restore]),
onClose() {{}}, onInvalid() {{}},
}});
controller.start();
controller.setItems([{{kind:'issue', repository:'stackchain/api', number:17}}]);
controller.section('reply');
controller.section('actions', {{replace:true}});
history.back();
process.stdout.write(JSON.stringify({{
calls,
hash:location.hash,
valid:[
routes.parse('#/my-work/issue/stackchain/api/17/reply'),
routes.parse('#/my-work/update/42/context'),
],
invalid:[
routes.parse('#/my-work/issue/stackchain/api/17/files'),
routes.parse('#/my-work/update/42/overview'),
routes.parse('#/my-work/review/stackchain/api/17/feedback/extra'),
],
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": [
["open", 17, None],
["push", "#/my-work/issue/stackchain/api/17/reply"],
["section", "reply", False],
["replace", "#/my-work/issue/stackchain/api/17/actions"],
["section", "actions", False],
["section", "overview", True],
],
"hash": "#/my-work/issue/stackchain/api/17",
"valid": [
{"kind": "issue", "repository": "stackchain/api", "number": 17, "section": "reply"},
{"kind": "update", "notification_id": 42, "section": "context"},
],
"invalid": [None, None, None],
}
def test_updates_inbox_route_survives_hydration_and_detail_back_navigation():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
@@ -1690,7 +1752,7 @@ const calls = [];
async def test_dashboard_opens_delegated_filings_with_follow_up_only_capabilities():
html = await dashboard()
assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html
assert "else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);" in html
assert "const readOnly = issueController.readOnly(item);" in html
assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html
assert "#issue-sheet.read-only .issue-comment-composer" not in html
@@ -1730,6 +1792,24 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share
assert "openDeliveryReceiptRoute" not in html
@pytest.mark.anyio
async def test_dashboard_wires_mobile_section_routes_to_each_detail_navigation():
html = await dashboard()
assert html.count(
"onSectionChange:(section, options) => workRoute.section(section, options)"
) == 4
assert "function navigateWorkSection(kind, section)" in html
assert "mobileIssueDetailNavigation.navigate(section, { focus:false })" in html
assert "mobilePullDetailNavigation.navigate(section, { focus:false })" in html
assert "mobileUpdateDetailNavigation.navigate(section, { focus:false })" in html
assert "mobileReviewDetailNavigation.navigate(section, { focus:false })" in html
assert "onSection: (section, options) => {" in html
assert "if (options.restore) navigateWorkSection(route?.kind, section);" in html
assert "await openRoutedWorkSection(item);" in html
assert "navigateWorkSection(item.kind, item.section);" in html
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
payload = {
"user": {"login": "timmy"},
+1 -1
View File
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+79
View File
@@ -0,0 +1,79 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "private-data-inventory.js"
def run_inventory(databases: dict[str, list[int]], registered: list[str]) -> dict:
script = r"""
const createInspector = require(__MODULE__);
const databases = __DATABASES__;
const indexedDB = {
async databases() { return Object.keys(databases).map(name => ({name})); },
open(name) {
const request = {};
queueMicrotask(() => {
const stores = databases[name];
const db = {
objectStoreNames: stores.map((_records, index) => `store-${index}`),
transaction(names) {
return {
objectStore(storeName) {
return {
count() {
const countRequest = {};
const index = Number(storeName.split('-')[1]);
queueMicrotask(() => {
countRequest.result = stores[index];
countRequest.onsuccess?.();
});
return countRequest;
},
};
},
};
},
close() {},
};
request.result = db;
request.onsuccess?.();
});
return request;
},
};
createInspector(indexedDB)(__REGISTERED__)
.then(result => process.stdout.write(JSON.stringify(result)))
.catch(error => { console.error(error); process.exit(1); });
"""
script = (
script.replace("__MODULE__", json.dumps(str(MODULE)))
.replace("__DATABASES__", json.dumps(databases))
.replace("__REGISTERED__", json.dumps(registered))
)
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_inventory_counts_records_without_creating_absent_registered_databases():
result = run_inventory(
{"private-a": [2, 1], "other-app": [99]},
["private-a", "private-empty", "private-missing"],
)
assert result == {"recordCount": 3, "unavailable": False}
def test_inventory_is_conservatively_unavailable_when_enumeration_is_unsupported():
script = f"""
const createInspector = require({json.dumps(str(MODULE))});
createInspector({{}})(['private-a'])
.then(result => process.stdout.write(JSON.stringify(result)))
.catch(error => {{ console.error(error.message); process.exit(1); }});
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {"recordCount": 0, "unavailable": True}
+9
View File
@@ -44,3 +44,12 @@ def test_every_private_data_purge_context_consumes_the_shared_registry():
html = (FRONTEND / "index.html").read_text()
assert html.index('static/private-data-registry.js') < html.index('static/session.js')
def test_private_inventory_loads_after_registry_and_before_storage_controller():
html = (FRONTEND / "index.html").read_text()
registry = html.index('static/private-data-registry.js')
inventory = html.index('static/private-data-inventory.js')
controller = html.index('static/device-storage.js')
assert registry < inventory < controller
+4 -3
View File
@@ -9,12 +9,12 @@ PRIVATE_DATA_JS = Path(__file__).resolve().parents[1] / "frontend" / "private-de
def test_private_device_data_purger_waits_for_owned_outbox_and_cache_deletion():
harness = f"""
const createPrivateDeviceDataPurger = require({json.dumps(str(PRIVATE_DATA_JS))});
const state = {{removed:[], databases:[], caches:[], workerMessages:[], complete:false}};
const state = {{removed:[], databases:[], caches:[], workerMessages:[], order:[], complete:false}};
const storage = {{
values:new Map([['stackchain.draft','private'],['other.preference','keep']]),
get length(){{return this.values.size;}},
key(index){{return Array.from(this.values.keys())[index] || null;}},
removeItem(key){{state.removed.push(key);this.values.delete(key);}},
removeItem(key){{state.order.push('storage');state.removed.push(key);this.values.delete(key);}},
}};
const clear = createPrivateDeviceDataPurger({{
localStorage:storage, sessionStorage:storage,
@@ -25,7 +25,7 @@ const clear = createPrivateDeviceDataPurger({{
return request;
}}}},
caches:{{keys:async()=>['stackchain-dashboard-shell-v37','other-app'],delete:async key=>state.caches.push(key)}},
serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}},
serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.order.push('worker');state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}},
MessageChannel:class{{constructor(){{
const first={{onmessage:null,postMessage:data=>queueMicrotask(()=>second.onmessage?.({{data}}))}};
const second={{onmessage:null,postMessage:data=>queueMicrotask(()=>first.onmessage?.({{data}}))}};
@@ -53,4 +53,5 @@ const clear = createPrivateDeviceDataPurger({{
]
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert state["order"][0] == "worker"
assert state["complete"] is True
+41 -1
View File
@@ -41,6 +41,36 @@ reconnect().then(result => process.stdout.write(JSON.stringify({{result, calls}}
}
def test_concurrent_reconnect_requests_queue_one_follow_up_flush():
result = run_node(
f"""
const createReconnectOutboxes = require({json.dumps(str(RECONNECT))});
(async () => {{
let releaseFirst; let issueCalls=0; let active=0; let maxActive=0;
const reconnect = createReconnectOutboxes({{
refresh: async () => ({{context:{{user:{{login:'timmy'}}}}}}),
restoreIdentity: () => {{}},
flushIssue: async () => {{
issueCalls++; active++; maxActive=Math.max(maxActive,active);
if (issueCalls === 1) await new Promise(resolve => {{ releaseFirst=resolve; }});
active--;
}},
flushAuthored: async () => {{}},
flushNotificationReads: async () => {{}},
}});
const first=reconnect();
while (!releaseFirst) await new Promise(resolve=>setTimeout(resolve,0));
const second=reconnect();
releaseFirst();
await Promise.all([first,second]);
process.stdout.write(JSON.stringify({{issueCalls,maxActive}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
)
assert result == {"issueCalls": 2, "maxActive": 1}
def test_dashboard_uses_reconnect_flush_after_live_identity_refresh():
index = (ROOT / "frontend" / "index.html").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
@@ -48,4 +78,14 @@ def test_dashboard_uses_reconnect_flush_after_live_identity_refresh():
assert '<script src="static/reconnect-outboxes.js"></script>' in index
assert "const reconnectOutboxes = createReconnectOutboxes({" in dashboard
assert "restoreIdentity: login => { activeFlushLogin = login; confirmedOwnerLogin = login; }" in dashboard
assert "window.addEventListener('online', reconnectOutboxes);" in dashboard
assert "window.addEventListener('online', reconnectAfterOnline);" in dashboard
assert "setTimeout(reconnectOutboxes, 500)" in dashboard
def test_dashboard_registers_reconnect_before_offline_startup_can_wait():
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
listener = dashboard.index("window.addEventListener('online', reconnectAfterOnline);")
offline_startup = dashboard.index("await updateOfflineWorkControls();")
assert listener < offline_startup
+176 -1
View File
@@ -83,6 +83,181 @@ const boundary={{
["node", "-e", harness], check=True, capture_output=True, text=True
)
title, detail = json.loads(completed.stdout)
title, detail, action = json.loads(completed.stdout)
assert title == "Passkey counter anomaly"
assert "Remove and re-enroll this passkey" in detail
assert action == "Review passkeys"
def test_activity_alert_hands_off_to_addressable_devices_section():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const listeners={{}};
const element=(id='')=>({{
id, hidden:true, disabled:false, textContent:'', children:[], className:'', dataset:{{}}, attributes:{{}},
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
click(){{this.listeners?.click?.({{preventDefault(){{}}}});}},
focus(){{}}, replaceChildren(){{this.children=[];}}, append(...values){{this.children.push(...values);}},
setAttribute(name,value){{this.attributes[name]=String(value);}}, removeAttribute(name){{delete this.attributes[name];}},
scrollIntoView(){{this.scrolled=true;}},
}});
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
'security-activity-status','load-more-security-activity','close-active-devices',
'security-section-activity','security-section-devices','security-section-passkeys',
'security-activity-section','security-devices-section','security-passkeys-section'];
const ids=Object.fromEntries(names.map(id=>[id,element(id)]));
const pushed=[];
const root={{
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
history:{{state:null,pushState(state,_title,url){{this.state=state;pushed.push({{state,url}});}}}},
location:{{pathname:'/dashboard/',search:'',hash:''}},
addEventListener:(type,listener)=>listeners[type]=listener,
}};
const boundary={{
listActiveDevices:async()=>[], listPasskeys:async()=>[],
listSecurityEvents:async()=>({{events:[],authentication_alerts:[{{}}],next_cursor:null}}),
formatAuthenticationAlert:()=>({{title:'Failed sign-ins',detail:'Three attempts'}}),
}};
(async()=>{{
const controller=attachSecurityCenter({{root,boundary}});
await controller.open();
const alert=ids['security-activity-list'].children[0];
const action=alert.children[2];
action.click();
console.log(JSON.stringify({{
initial:pushed[0], latest:pushed.at(-1), action:action.textContent,
current:ids['security-section-devices'].attributes['aria-current'],
scrolled:ids['security-devices-section'].scrolled||false,
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], check=True, capture_output=True, text=True
)
output = json.loads(completed.stdout)
assert output["initial"]["state"]["stackchainSecuritySection"] == "activity"
assert output["initial"]["url"] == "/dashboard/#security/activity"
assert output["latest"]["state"]["stackchainSecuritySection"] == "devices"
assert output["action"] == "Review devices"
assert output["current"] == "page"
assert output["scrolled"] is True
def test_device_revocation_refreshes_inventory_and_activity_for_verification():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const element=()=>({{
hidden:true, disabled:false, textContent:'', children:[], className:'', attributes:{{}},
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
click(){{return this.listeners?.click?.();}}, focus(){{}}, replaceChildren(){{this.children=[];}},
append(...values){{this.children.push(...values);}}, setAttribute(name,value){{this.attributes[name]=value;}},
removeAttribute(name){{delete this.attributes[name];}}, scrollIntoView(){{}},
}});
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
'security-activity-status','load-more-security-activity','close-active-devices'];
const ids=Object.fromEntries(names.map(id=>[id,element()]));
const calls={{devices:0,activity:0,revoke:0}};
const root={{document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}}};
const boundary={{
listActiveDevices:async()=>{{calls.devices++;return [{{device_label:'Lost phone',created_at:1,expires_at:2,current:false}}];}},
listPasskeys:async()=>[],
listSecurityEvents:async()=>{{calls.activity++;return {{events:[],authentication_alerts:[],next_cursor:null}};}},
revokeActiveDevice:async()=>{{calls.revoke++;return true;}},
}};
(async()=>{{
await attachSecurityCenter({{root,boundary}}).open();
await ids['active-devices-list'].children[0].children[1].click();
console.log(JSON.stringify({{calls,status:ids['security-activity-status'].textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], check=True, capture_output=True, text=True
)
output = json.loads(completed.stdout)
assert output["calls"] == {"devices": 2, "activity": 2, "revoke": 1}
assert output["status"] == "No security activity yet."
def test_back_unwinds_security_sections_then_closes_and_restores_focus():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const listeners={{}};
const element=()=>({{
hidden:true, textContent:'', children:[], attributes:{{}},
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}}, focus(){{this.focused=true;}},
replaceChildren(){{this.children=[];}}, append(...values){{this.children.push(...values);}},
setAttribute(name,value){{this.attributes[name]=value;}}, removeAttribute(name){{delete this.attributes[name];}}, scrollIntoView(){{}},
}});
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
'security-activity-status','load-more-security-activity','close-active-devices',
'security-section-activity','security-section-devices','security-section-passkeys',
'security-activity-section','security-devices-section','security-passkeys-section'];
const ids=Object.fromEntries(names.map(id=>[id,element()]));
const pushed=[];
const root={{
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
history:{{state:null,pushState(state,_title,url){{this.state=state;pushed.push(url);}}}},
location:{{pathname:'/dashboard/',search:''}},
addEventListener:(type,listener)=>listeners[type]=listener,
}};
const boundary={{
listActiveDevices:async()=>[],listPasskeys:async()=>[],
listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}}),
}};
(async()=>{{
const controller=attachSecurityCenter({{root,boundary}});
await controller.open();
controller.navigate('devices');
listeners.popstate({{state:{{stackchainSecuritySection:'activity'}}}});
const afterFirst={{hidden:ids['active-devices-sheet'].hidden,current:ids['security-section-activity'].attributes['aria-current']}};
listeners.popstate({{state:null}});
console.log(JSON.stringify({{afterFirst,hidden:ids['active-devices-sheet'].hidden,focused:ids['active-devices'].focused||false,pushed}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], check=True, capture_output=True, text=True
)
output = json.loads(completed.stdout)
assert output["afterFirst"] == {"hidden": False, "current": "page"}
assert output["hidden"] is True
assert output["focused"] is True
assert output["pushed"] == ["/dashboard/#security/activity", "/dashboard/#security/devices"]
def test_close_uses_history_back_to_remove_security_center_route():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const element=()=>({{
hidden:true,textContent:'',children:[],attributes:{{}},
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
click(){{return this.listeners?.click?.({{preventDefault(){{}}}});}},focus(){{}},replaceChildren(){{this.children=[];}},
append(...values){{this.children.push(...values);}},setAttribute(){{}},removeAttribute(){{}},scrollIntoView(){{}},
}});
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
'security-activity-status','load-more-security-activity','close-active-devices'];
const ids=Object.fromEntries(names.map(id=>[id,element()]));
let backs=0;
const root={{
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
history:{{state:null,pushState(state){{this.state=state;}},back(){{backs++;}}}},
location:{{pathname:'/dashboard/',search:''}},addEventListener(){{}},
}};
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
(async()=>{{
await attachSecurityCenter({{root,boundary}}).open();
ids['close-active-devices'].click();
console.log(JSON.stringify({{backs}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], check=True, capture_output=True, text=True
)
assert json.loads(completed.stdout) == {"backs": 1}
+42 -14
View File
@@ -165,13 +165,13 @@ async function dispatchPush(payload) {{
def test_offline_activation_migration_rolls_the_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -180,7 +180,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@@ -189,7 +189,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@@ -197,14 +197,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -212,7 +212,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -220,7 +220,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -230,14 +230,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -246,21 +246,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -930,7 +930,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -953,6 +953,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",
"/dashboard/static/feature-loader.js",
"/dashboard/static/workspace-bootstrap.js",
"/dashboard/static/conversation-action-hydrator.js",
"/dashboard/static/security-center.js",
"/dashboard/static/markdown.js",
@@ -988,6 +989,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/today-work.js",
"/dashboard/static/today-timer.js",
"/dashboard/static/today-recap.js",
"/dashboard/static/today-wrap-up.js",
"/dashboard/static/today-handoff.js",
"/dashboard/static/today-completion.js",
"/dashboard/static/today-readiness.js",
"/dashboard/static/comment-next.js",
@@ -1020,6 +1023,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-search-preview-nav.js",
"/dashboard/static/mobile-plan-today-nav.js",
"/dashboard/static/mobile-find-work-nav.js",
"/dashboard/static/mobile-pull-refresh.js",
"/dashboard/static/checklist-conflict.js",
"/dashboard/static/voice-transcript-store.js",
"/dashboard/static/voice-issue-capture.js",
@@ -1039,6 +1043,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/mobile-delivery-recovery.js",
"/dashboard/static/mobile-start-day.js",
"/dashboard/static/update-triage-session.js",
"/dashboard/static/update-review-handoff.js",
@@ -1049,8 +1054,12 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/update-decision-transaction.js",
"/dashboard/static/agenda-session-launcher.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/mobile-insights.js",
"/dashboard/static/mobile-app-shortcuts.js",
"/dashboard/static/install-app.js",
"/dashboard/static/private-data-inventory.js",
"/dashboard/static/private-device-data.js",
"/dashboard/static/device-storage.js",
"/dashboard/static/mobile-device-setup.js",
"/dashboard/static/mobile-search-viewport.js",
"/dashboard/static/mobile-composer-viewport.js",
@@ -1104,6 +1113,25 @@ def test_offline_activation_migrates_cached_optional_feature_before_deleting_old
assert state["claimed"] is True
def test_successful_optional_feature_fetch_is_cached_for_offline_reopen():
result = run_worker_scenario(
"""
const asset = '/dashboard/feature-today-timer-test.js';
context.self.__testOptionalFeatures.push(asset);
const response = await dispatch('fetch', {
method: 'GET', mode: 'cors',
url: 'https://forge.example/dashboard/feature-today-timer-test.js',
});
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
"""
)
assert result["body"] == "network"
assert result["state"]["puts"] == [
"https://forge.example/dashboard/feature-today-timer-test.js"
]
def test_activate_warms_optional_features_without_blocking_siblings_or_claim():
result = run_worker_scenario(
"""
+84
View File
@@ -0,0 +1,84 @@
import json
import subprocess
from pathlib import Path
HANDOFF = Path(__file__).parents[1] / "frontend" / "today-handoff.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_due_wrap_up_commitments_are_reviewed_in_order_and_added_durably():
script = f"""
const createHandoff=require({json.dumps(str(HANDOFF))});
const values=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const items=[
{{kind:'issue',repository:'r',number:2,title:'Second'}},
{{kind:'issue',repository:'r',number:1,title:'First'}},
];
const identity=item=>'issue:'+item.repository+':'+item.number+':';
const today=[]; const operations=[];
const controller=createHandoff({{
storage,getLogin:()=> 'timmy',identity,
todayWork:{{contains:item=>today.includes(identity(item)),add:item=>{{if(today.length>=1)return 'full';today.push(identity(item));return 'added';}}}},
todaySync:{{enqueue:(action,id)=>{{operations.push([action,id]);return true;}},flush:async()=>true}},
}});
controller.capture(['issue:r:1:','issue:r:2:','issue:r:1:']);
const review=controller.review(items);
controller.choose('issue:r:2:',false);
const first=await controller.commit(items);
controller.choose('issue:r:2:',true);
const blocked=await controller.commit(items);
process.stdout.write(JSON.stringify({{review,first,blocked,today,operations,pending:controller.pending()}}));
"""
result = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
assert [row["identity"] for row in result["review"]] == ["issue:r:1:", "issue:r:2:"]
assert all(row["selected"] for row in result["review"])
assert result["first"] == {"scheduled": 1, "blocked": 0, "remaining": 1}
assert result["blocked"] == {"scheduled": 0, "blocked": 1, "remaining": 1}
assert result["today"] == ["issue:r:1:"]
assert result["operations"] == [["add", "issue:r:1:"]]
assert result["pending"] == ["issue:r:2:"]
def test_handoff_pending_state_is_account_scoped_and_capture_is_idempotent():
script = f"""
const createHandoff=require({json.dumps(str(HANDOFF))});
const values=new Map();let login='timmy';
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const options={{storage,getLogin:()=>login,identity:item=>item.id,todayWork:{{contains:()=>false,add:()=> 'added'}},todaySync:{{enqueue:()=>true,flush:async()=>true}}}};
const controller=createHandoff(options);
controller.capture(['one','two']);controller.capture(['two','one']);
const timmy=controller.pending();login='alexander';const isolated=controller.pending();
login='timmy';const resumed=createHandoff(options).pending();
process.stdout.write(JSON.stringify({{timmy,isolated,resumed}}));
"""
assert run_node(f"(async()=>{{{script}}})()") == {
"timmy": ["one", "two"], "isolated": [], "resumed": ["one", "two"],
}
def test_dashboard_packages_accessible_mobile_commitment_review_and_account_sync():
from src import main
html = main.FRONTEND_BUILD.dashboard_html
dashboard = (HANDOFF.parents[0] / "dashboard.js").read_text()
css = (HANDOFF.parents[0] / "dashboard.css").read_text()
service_worker = (HANDOFF.parents[0] / "service-worker.js").read_text()
assert 'id="today-handoff-dialog"' in html
assert 'aria-labelledby="today-handoff-title"' in html
assert 'id="today-handoff-items"' in html
assert 'id="confirm-today-handoff"' in html
assert '<script src="static/today-handoff.js"></script>' in (HANDOFF.parents[0] / "index.html").read_text()
assert "createTodayHandoff({" in dashboard
assert "laterSync.enqueue(action, itemId, wakeAt, handoff)" in dashboard
assert "todayHandoff.capture(handoffs)" in dashboard
assert ".today-handoff-actions button { min-height:44px;" in css
assert "BASE + 'static/today-handoff.js'" in service_worker
assert "static/today-handoff.js" in main.FRONTEND_BUILD.page_sources
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v114';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v117';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+3 -3
View File
@@ -876,13 +876,13 @@ async def test_dashboard_renders_mobile_today_recap_flow():
assert ".today-recap-header button { min-height:44px;" in css
assert ".today-recap-actions button { min-height:44px;" in css
assert "overflow-x:hidden" in css
assert "Save recap &amp; adjust plan" in html
assert "Save recap &amp; wrap up" in html
assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source
assert 'class="today-recap-variance"' in recap_source
assert "await recap.saveForReplan()" in recap_source
assert "adjustPlan(handoff.actual_minutes)" in recap_source
assert "openWrapUp(handoff.actual_minutes)" in recap_source
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
assert "openPlanToday(qs('#plan-today'), true, actualMinutes)" in dashboard
assert "todayWrapUpView.open(todayMyWork, actualMinutes)" in dashboard
assert ".today-recap-row { grid-template-columns:1fr; }" in css
+1 -1
View File
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v114" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/today-sync.js'" in source
+93
View File
@@ -0,0 +1,93 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
TODAY_WRAP_UP = ROOT / "frontend" / "today-wrap-up.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_wrap_up_schedules_selected_work_for_tomorrow_and_leaves_other_work_in_order():
script = f"""
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
const items = [
{{kind:'issue', repository:'stackchain/dashboard', number:1, title:'First'}},
{{kind:'issue', repository:'stackchain/dashboard', number:2, title:'Second'}},
{{kind:'pull', repository:'stackchain/dashboard', number:3, title:'Third'}},
];
const ids = items.map(item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':');
const today = [...ids];
const later = {{}};
const todayOps = [];
const laterOps = [];
const todayWork = {{
identity:item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':',
read:() => [...today],
contains:item => today.includes((item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'),
remove:item => {{
const id=(item.kind || 'work') + ':' + item.repository + ':' + item.number + ':';
const index=today.indexOf(id); if(index < 0) return false; today.splice(index,1); return true;
}},
}};
const laterWork = {{
identity:todayWork.identity,
presetUntil:preset => new Date('2026-08-17T09:00:00-04:00'),
defer:(item, until, options) => {{ later[todayWork.identity(item)] = until.toISOString(); laterOps.push([todayWork.identity(item), options]); return 'deferred'; }},
}};
const todaySync = {{enqueue:(action,id) => {{todayOps.push([action,id]); return true;}}, flush:()=>Promise.resolve(true)}};
const wrap = createWrapUp({{todayWork,laterWork,todaySync}});
wrap.open(items);
wrap.choose(ids[1], true);
const result = await wrap.finish();
process.stdout.write(JSON.stringify({{result,today,later,todayOps,laterOps}}));
"""
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
assert output["result"] == {"scheduled": 1, "left": 2, "wake_at": "2026-08-17T13:00:00.000Z"}
assert output["today"] == ["issue:stackchain/dashboard:1:", "pull:stackchain/dashboard:3:"]
assert output["later"] == {"issue:stackchain/dashboard:2:": "2026-08-17T13:00:00.000Z"}
assert output["todayOps"] == [["remove", "issue:stackchain/dashboard:2:"]]
assert output["laterOps"] == [["issue:stackchain/dashboard:2:", {"handoff": "today"}]]
def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_save():
from src import main
html = main.FRONTEND_BUILD.dashboard_html
css = (ROOT / "frontend" / "dashboard.css").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
recap = (ROOT / "frontend" / "today-recap.js").read_text()
assert 'id="today-wrap-up-sheet" role="dialog"' in html
assert 'id="today-wrap-up-items"' in html
assert 'id="finish-today-wrap-up"' in html
assert 'static/today-wrap-up.js' in main.FRONTEND_BUILD.page_sources
assert 'setupTodayWrapUp({' in dashboard
assert 'todayWrapUpView.open' in dashboard
assert 'openWrapUp(handoff.actual_minutes)' in recap
assert '.today-wrap-up-actions button { min-height:44px;' in css
assert '.today-wrap-up-panel' in css and 'overflow-x:hidden' in css
assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
def test_wrap_up_repeat_confirmation_does_not_repeat_later_or_today_operations():
script = f"""
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Ship'}};
const id='issue:acme/mobile:41:';
let present=true; let deferred=0; let removed=0;
const todayWork={{identity:()=>id,read:()=>present?[id]:[],contains:()=>present,remove:()=>{{present=false;return true;}}}};
const laterWork={{presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:()=>{{deferred+=1;return 'deferred';}}}};
const todaySync={{enqueue:()=>{{removed+=1;return true;}},flush:()=>Promise.resolve(true)}};
const wrap=createWrapUp({{todayWork,laterWork,todaySync}});
wrap.open([item]); wrap.choose(id,true);
await wrap.finish(); await wrap.finish();
process.stdout.write(JSON.stringify({{deferred,removed,present}}));
"""
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
assert output == {"deferred": 1, "removed": 1, "present": False}
+124
View File
@@ -0,0 +1,124 @@
import json
import subprocess
from pathlib import Path
BOOTSTRAP = Path(__file__).parents[1] / "frontend" / "workspace-bootstrap.js"
def run_bootstrap(scenario: str) -> dict:
harness = f"""
const loadWorkspace = require({json.dumps(str(BOOTSTRAP))});
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], check=True, capture_output=True, text=True
)
return json.loads(completed.stdout)
def test_workspace_bootstrap_loads_content_addressed_feature_before_startup():
result = run_bootstrap("""
const status={textContent:''};
const document={
querySelector(selector) {
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
if (selector === '#my-work-action-status') return status;
return null;
}
};
let requested='';
const createLoader=options=>({load:async name=>{requested=name + ':' + options.urls[name];}});
await loadWorkspace({document,createLoader});
console.log(JSON.stringify({requested,status:status.textContent}));
""")
assert result == {"requested": "today-timer:feature-workspace-abc.js", "status": ""}
def test_workspace_bootstrap_keeps_shell_and_announces_retry_when_feature_fails():
result = run_bootstrap("""
const status={textContent:''};
const document={
querySelector(selector) {
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
if (selector === '#my-work-action-status') return status;
return null;
}
};
const createLoader=()=>({load:async()=>{throw new Error('network failed');}});
let message='';
try { await loadWorkspace({document,createLoader}); } catch (error) { message=error.message; }
console.log(JSON.stringify({message,status:status.textContent}));
""")
assert result == {
"message": "network failed",
"status": "Workspace could not load. Check your connection, then reload to retry.",
}
def test_workspace_bootstrap_reloads_after_a_failed_offline_load_reconnects():
result = run_bootstrap("""
const status={textContent:''}; const listeners={}; let reloads=0;
const document={querySelector(selector) {
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
if (selector === '#my-work-action-status') return status;
return null;
}};
const window={
location:{reload(){reloads++;}},
addEventListener(name,callback) { listeners[name]=callback; },
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
};
const createLoader=()=>({load:async()=>{throw new Error('offline');}});
try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
const waiting=Boolean(listeners.online);
if (listeners.online) listeners.online();
console.log(JSON.stringify({waiting,reloads,status:status.textContent}));
""")
assert result == {
"waiting": True,
"reloads": 1,
"status": "Workspace could not load. Reconnect to retry automatically, or reload now.",
}
def test_workspace_bootstrap_reloads_when_reconnect_arrives_before_load_failure():
result = run_bootstrap("""
const status={textContent:''}; const listeners={}; let reloads=0;
const document={querySelector(selector) {
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
if (selector === '#my-work-action-status') return status;
return null;
}};
const window={
location:{reload(){reloads++;}},
addEventListener(name,callback) { listeners[name]=callback; },
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
};
const createLoader=()=>({load:async()=>{listeners.online(); throw new Error('offline request');}});
try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
console.log(JSON.stringify({reloads,waiting:Boolean(listeners.online)}));
""")
assert result == {"reloads": 1, "waiting": False}
def test_workspace_bootstrap_replays_online_event_after_dashboard_registers_handlers():
result = run_bootstrap("""
const status={textContent:''}; const listeners={};
const document={querySelector(selector) {
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
if (selector === '#my-work-action-status') return status;
return null;
}};
const window={
addEventListener(name,callback) { listeners[name]=callback; },
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
};
const createLoader=()=>({load:async()=>{listeners.online();}});
const lifecycle=await loadWorkspace({document,window,createLoader});
let reconnects=0;
lifecycle.replayOnline(()=>{reconnects++;});
lifecycle.replayOnline(()=>{reconnects++;});
console.log(JSON.stringify({reconnects,listening:Boolean(listeners.online)}));
""")
assert result == {"reconnects": 1, "listening": False}