Compare commits

...
Author SHA1 Message Date
timmy 1b448fef23 docs: add disk capacity incident runbook (Closes #1287)
CI / build-release (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 3m53s
CI / browser-journey (pull_request) Successful in 6m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 01:10:16 +00:00
timmy 881653eef1 Merge pull request 'Make calendar-import performance gates deterministic' (#1286) from timmy/1285-calendar-import-performance-gate into main
CI / lint (push) Successful in 5m17s
CI / build-release (push) Successful in 19s
CI / browser-journey (push) Failing after 12m8s
CI / release-candidate (push) Has been skipped
2026-08-23 00:32:32 +00:00
timmy d8476c6f7b perf: reuse calendar timezone formatters (Closes #1285)
CI / lint (pull_request) Successful in 4m53s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 5m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 00:19:34 +00:00
timmy 6eb2709477 Merge pull request 'Keep the installed app badge current from background Updates' (#1283) from timmy/1281-background-updates-app-badge into main
CI / lint (push) Successful in 4m14s
CI / build-release (push) Successful in 11s
CI / browser-journey (push) Failing after 9m59s
CI / release-candidate (push) Has been skipped
2026-08-22 22:36:24 +00:00
timmy 05405bf2e8 feat: keep background Updates badge current (Closes #1281)
CI / lint (pull_request) Successful in 4m52s
CI / build-release (pull_request) Successful in 15s
CI / browser-journey (pull_request) Successful in 7m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 22:11:52 +00:00
rockachopa b18a50fec5 Merge pull request 'Show unread Updates on the installed app icon' (#1279) from timmy/1278-unread-updates-app-icon-badge into main
CI / lint (push) Successful in 3m34s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m39s
CI / release-candidate (push) Successful in 8s
2026-08-22 21:04:51 +00:00
timmy be9de6596f feat: show unread Updates on app icon (Closes #1278)
CI / lint (pull_request) Successful in 3m47s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 20:39:09 +00:00
rockachopa 71631c6731 Merge pull request 'Preserve complete Today timing ledger across device handoff' (#1277) from timmy/1276-complete-today-ledger-handoff into main
CI / lint (push) Successful in 3m41s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m51s
CI / release-candidate (push) Successful in 8s
2026-08-22 19:49:04 +00:00
timmy 548b95ddb6 feat: preserve Today timing ledger across handoff (Closes #1276)
CI / lint (pull_request) Successful in 3m48s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 19:38:30 +00:00
rockachopa e0d8ab9f10 Merge pull request 'Recover active Today breaks after queue recovery' (#1275) from timmy/1274-recovered-today-break-status into main
CI / lint (push) Successful in 3m53s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 5m58s
CI / release-candidate (push) Successful in 10s
2026-08-22 18:39:57 +00:00
timmy a629b5a93e fix: recover server-confirmed Today breaks (Closes #1274)
CI / lint (pull_request) Successful in 4m11s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 18:28:29 +00:00
rockachopa ce9301f145 Merge pull request 'feat: CI regression: packaged mobile first-task coach remains hidden' (#1273) from timmy/1272-ci-regression-packaged-mobile-first-task-coach-r into main
CI / lint (push) Successful in 3m58s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 6m19s
CI / release-candidate (push) Has been skipped
Fix packaged first-task coach visibility (Closes #1272)
2026-08-22 17:57:05 +00:00
timmy d0e8378a02 fix: keep first-task coach host visible (Closes #1272)
CI / lint (pull_request) Successful in 3m41s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 17:28:29 +00:00
rockachopa 424ab2bb6d Merge pull request 'Persist exact Week Ahead task times' (#1271) from timmy/1270-persist-week-task-times into main
CI / lint (push) Successful in 4m11s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Failing after 6m23s
CI / release-candidate (push) Has been skipped
2026-08-22 16:57:39 +00:00
timmy 5fb4da30a4 feat: persist exact Week Ahead task times (Closes #1270)
CI / lint (pull_request) Successful in 4m3s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 16:39:24 +00:00
rockachopa f78da3dfc9 Merge pull request 'feat: CI regression: Plan Today remains visible after Save & start' (#1269) from timmy/1268-ci-regression-plan-today-remains-visible-after-s into main
CI / lint (push) Successful in 4m12s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 6m29s
CI / release-candidate (push) Successful in 8s
2026-08-22 16:06:12 +00:00
timmy 14b3fb4048 fix: close rollover planner before Today handoff (Closes #1268)
CI / lint (pull_request) Successful in 4m5s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 15:34:05 +00:00
rockachopa d27a481d96 Merge pull request 'Refresh Week Ahead calendar availability with atomic reflow' (#1267) from timmy/1266-refresh-week-calendar-atomic-reflow into main
CI / lint (push) Successful in 4m31s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Failing after 8m1s
CI / release-candidate (push) Has been skipped
2026-08-22 14:45:03 +00:00
timmy 24eb4d1e79 feat: refresh Week capacity with atomic reflow (Closes #1266)
CI / lint (pull_request) Successful in 4m25s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 7m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 14:32:08 +00:00
rockachopa 3c35aa31c1 Merge pull request 'Guard the Week Ahead reschedule journey from hidden issue sheets' (#1265) from timmy/1264-visible-week-reschedule-sheet into main
CI / lint (push) Successful in 4m7s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m57s
CI / release-candidate (push) Successful in 9s
2026-08-22 13:35:20 +00:00
timmy 363a97aa5e test: guard hidden Week Ahead journey sheet (Closes #1264)
CI / lint (pull_request) Successful in 5m1s
CI / build-release (pull_request) Successful in 10s
CI / browser-journey (pull_request) Successful in 6m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 13:22:21 +00:00
rockachopa 306a20c37f Merge pull request 'feat: CI regression: mobile Today break status is lost after recovery reload' (#1263) from timmy/1262-ci-regression-mobile-today-break-status-is-lost- into main
CI / lint (push) Successful in 3m52s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Failing after 7m7s
CI / release-candidate (push) Has been skipped
2026-08-22 12:26:36 +00:00
timmy e06bd8435d fix: restore timed break after unchanged fresh context (Closes #1262)
CI / lint (pull_request) Successful in 3m56s
CI / build-release (pull_request) Successful in 11s
CI / browser-journey (pull_request) Successful in 6m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 12:04:23 +00:00
rockachopa 76f89df609 Merge pull request 'Export a saved Week Ahead plan directly from the overview' (#1261) from timmy/1260-export-saved-week-calendar into main
CI / lint (push) Successful in 4m2s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Failing after 5m58s
CI / release-candidate (push) Has been skipped
2026-08-22 11:31:03 +00:00
timmy f0a966d3a3 feat: export saved Week Ahead from overview (Closes #1260)
CI / lint (pull_request) Successful in 4m18s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 11:15:10 +00:00
rockachopa 9eac3bb958 Merge pull request 'Persist privacy-safe calendar free windows with Week Ahead' (#1259)
CI / lint (push) Successful in 4m16s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 6m53s
CI / release-candidate (push) Successful in 10s
Closes #1258
2026-08-22 10:04:09 +00:00
timmy fc93620118 feat: persist calendar free windows with Week Ahead (Closes #1258)
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 09:49:04 +00:00
rockachopa 15739f7370 Merge pull request 'Persist weekly availability defaults for rolling Week Ahead dates' (#1257) from timmy/1256-weekly-availability-defaults into main
CI / lint (push) Successful in 4m26s
CI / build-release (push) Successful in 11s
CI / browser-journey (push) Successful in 6m16s
CI / release-candidate (push) Successful in 8s
Merge pull request 'Persist weekly availability defaults for rolling Week Ahead dates' (#1257)
2026-08-22 08:20:35 +00:00
timmy 1ae8533702 feat: persist weekly availability defaults (Closes #1256)
CI / lint (pull_request) Successful in 3m37s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 08:10:24 +00:00
timmy ea3204c78c Merge pull request 'Recover conflicted Week-to-Today transfers' (#1255) from timmy/1254-recover-conflicted-week-pulls into main
CI / lint (push) Successful in 3m39s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 5m28s
CI / release-candidate (push) Successful in 8s
Recover conflicted Week-to-Today transfers (#1255)
2026-08-22 07:28:12 +00:00
timmy 6e72f59fcf feat: recover conflicted Week-to-Today transfers (Closes #1254)
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 07:06:33 +00:00
timmy 24cadf88f8 Merge pull request 'Encrypt completed Filed review history at rest' (#1253) from timmy/1252-encrypt-completed-filed-reviews into main
CI / lint (push) Successful in 3m21s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Failing after 5m14s
CI / release-candidate (push) Has been skipped
2026-08-22 06:31:00 +00:00
timmy 4b645d8460 feat: encrypt completed Filed review history (Closes #1252)
CI / lint (pull_request) Successful in 3m8s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 06:09:06 +00:00
rockachopa a4498fdbbc Merge pull request 'Queue multiple offline Week-to-Today pulls' (#1250) from timmy/1249-queue-offline-week-pulls into main
CI / lint (push) Successful in 3m46s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Failing after 7m15s
CI / release-candidate (push) Has been skipped
2026-08-22 04:52:47 +00:00
44 changed files with 2221 additions and 229 deletions

No files matched your search

+15 -9
View File
@@ -141,7 +141,7 @@ concurrently changed plan is reported without overwriting newer work. Rescheduli
Week Ahead uses a bounded account-scoped FIFO on the device: multiple offline moves survive reload,
deliver oldest-first with stable operation IDs, and rebase each later move on the confirmed plan revisions
without concurrent requests. Starting a Today work session also
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. Cross-device **Continue here** handoff transfers the bounded per-item timing ledger—not only the active item—so the receiving phone keeps the complete recap and optional time-log durations; legacy single-item sessions migrate automatically. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
After wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it.
A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. When work is blocked, **Post blocker & move on** requires a future return time, admits the comment before any planning change, defers the item to Later, and continues the existing Today session. An admitted planning transition remains checkpointed for retry, so a storage or Today-removal failure cannot post the blocker twice. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
@@ -193,9 +193,13 @@ work that cannot fit is named and remains in My Work, while missing estimates bl
write, and apply stages one durable whole-week transition through the existing conflict-safe sync path.
Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
are not stranded behind an earlier request. From the same review, **Set capacity from calendar** reads a local
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and previews seven daily
availability totals before one atomic apply. IANA `TZID` values from Google and Outlook calendars are converted
are not stranded behind an earlier request. From the same review, **Refresh from calendar** reads a local
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and compares every current
capacity with refreshed availability and planned load. If refreshed availability creates an overload, the review
previews the deterministic destination of moved work and reports anything that cannot fit. One explicit
**Apply capacities & reflow** action stages the complete seven-day plan once—there is no transient overloaded
save—and preserves consented free windows alongside the redistributed work. A refresh that already fits keeps
the direct one-write capacity path. IANA `TZID` values from Google and Outlook calendars are converted
to the device's local workday, including daylight-saving transitions. Daily and weekly recurrence (`COUNT`,
`UNTIL`, `INTERVAL`, and weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated with work bounded
to the seven review dates; cancelled and transparent events do not consume capacity. If a time zone is unknown,
@@ -254,10 +258,12 @@ each envelope to its operation key and field purpose so rows and fields cannot b
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
likewise migrate on first read. Synchronized Saved Search collections use the private-state key and
authenticate each envelope to its normalized account, preventing rows from being substituted between
operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their
revision; missing, wrong, or modified key material returns no saved-view content. Other private stores
likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
startup without changing order or acknowledgement state. Missing, wrong, or modified key material
returns no saved-view or receipt content. Other private stores
are not encrypted at the application layer. Web
Push subscriptions use a third, independent
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
@@ -330,7 +336,7 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
# planning and Saved Search state, and the Security activity journal. Keep this key independent
# planning, Saved Search state, completed Filed review receipts, and the Security activity journal. Keep this key independent
# from the Draft key and inject the
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
# it. Missing, malformed, wrong-key, or modified state fails closed without
+17
View File
@@ -23,3 +23,20 @@
- API contracts + versioning
- independent deployability
- observability: metrics, traces, structured logs
# Disk capacity incident
Root filesystem usage at or above 85% is an operations incident.
1. Capture the initial state with `df -h / /var/lib/gitea`.
2. Inspect directory sizes and active processes before cleanup. Reclaim only
disposable caches and abandoned temporary environments. Responders must not
delete Gitea data, repositories, databases, secrets, or active release
artifacts.
3. Re-run `df -h / /var/lib/gitea`; usage must be below 85%.
4. Confirm the release path with `systemctl is-active gitea act_runner`, then run
the repository test suite.
Record the before and after usage in the incident ticket. If safe cleanup cannot
restore capacity below 85%, keep the incident open and escalate storage
expansion rather than removing durable data.
+32 -4
View File
@@ -241,6 +241,8 @@ textarea { resize: vertical; min-height: 120px; }
.offline-status[hidden] { display:none; }
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
.app-badge-setting { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.app-badge-control { min-height:44px; }
.push-update-control { min-height:44px; }
.offline-work-controls input { width:20px; height:20px; }
.offline-work-controls button { min-height:44px; }
@@ -329,6 +331,12 @@ textarea { resize: vertical; min-height: 120px; }
.week-conflict-plans { grid-template-columns:1fr; }
.week-conflict-actions { flex-direction:column; }
}
.week-pull-conflict-review { min-width:0; margin-top:14px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
.week-pull-conflict-item { margin-bottom:6px; color:#eff6ff; font-weight:700; }
.week-pull-conflict-actions { display:flex; gap:8px; margin-top:14px; }
.week-pull-conflict-actions button { min-height:44px; flex:1 1 0; }
.week-pull-conflict-status { min-height:1.4em; margin-top:8px; }
@media (max-width:480px) { .week-pull-conflict-actions { flex-direction:column; } }
.week-review { margin-top:14px; }
.week-review h2 { margin-bottom:6px; }
.week-review-days { display:grid; gap:12px; }
@@ -365,6 +373,7 @@ textarea { resize: vertical; min-height: 120px; }
#retry-week-live { min-height:44px; width:100%; margin-bottom:8px; border-color:#f59e0b; }
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
#edit-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
#export-saved-week-calendar { width:100%; min-height:48px; margin:0 0 8px; }
#open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; }
#open-week-reflow { width:100%; min-height:48px; margin:0 0 12px; border-color:#60a5fa; }
.week-reflow-review { box-sizing:border-box; width:100%; margin:0 0 14px; padding:14px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; overflow-x:hidden; }
@@ -385,7 +394,22 @@ textarea { resize: vertical; min-height: 120px; }
.week-capacity-day { display:grid; min-width:0; gap:3px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; overflow-wrap:anywhere; }
.week-capacity-day span { color:#bfdbfe; font-weight:700; }
.week-capacity-day small { color:#a9bdd3; }
.week-capacity-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
.week-capacity-day .week-capacity-over { color:#fde68a; font-weight:700; }
.week-free-times-consent { display:flex; align-items:flex-start; gap:10px; min-width:0; margin:12px 0; padding:10px; border:1px solid #31577f; border-radius:10px; }
.week-free-times-consent input { flex:0 0 44px; width:44px; margin:0; }
.week-free-times-consent span { display:grid; min-width:0; gap:4px; overflow-wrap:anywhere; }
.week-free-times-consent small { color:#a9bdd3; }
#apply-week-capacities { width:100%; min-height:48px; position:sticky; bottom:0; }
.week-availability-editor { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
#open-week-availability { min-height:44px; }
.week-availability-editor h3 { margin-top:0; }
.week-availability-grid { display:grid; grid-template-columns:1fr; gap:8px; }
.week-availability-grid label { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,120px); gap:10px; align-items:center; }
.week-availability-grid input { min-height:44px; min-width:0; box-sizing:border-box; }
.week-availability-actions { display:grid; grid-template-columns:1fr; gap:8px; margin-top:12px; }
.week-availability-actions button { min-height:44px; }
@media (min-width:600px) { .week-availability-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .week-availability-actions { grid-template-columns:repeat(3,minmax(0,1fr)); } }
@media (max-width:359px) { .week-capacity-hours { grid-template-columns:1fr; } }
.week-calendar-handoff h2 { margin-bottom:6px; }
.week-calendar-days { display:grid; gap:12px; margin:14px 0; }
@@ -393,13 +417,17 @@ textarea { resize: vertical; min-height: 120px; }
.week-calendar-day header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.week-calendar-day h3 { margin:0; }
.week-calendar-day input[type="time"] { min-height:44px; font-size:16px; }
.week-calendar-item { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; padding:8px 0; overflow-wrap:anywhere; }
.week-calendar-item input { width:22px; height:22px; }
.week-calendar-item span { display:grid; gap:3px; }
.week-calendar-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; min-width:0; padding:8px 0; overflow-wrap:anywhere; }
.week-calendar-choice { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; }
.week-calendar-choice input { width:22px; height:22px; }
.week-calendar-choice span { display:grid; gap:3px; }
.week-calendar-time { display:grid; gap:2px; font-size:12px; font-weight:700; }
.week-calendar-time input { box-sizing:border-box; width:7.5rem; min-height:44px; font-size:16px; }
.week-calendar-item small { color:#a9bdd3; }
.week-calendar-status { min-height:1.4em; margin:10px 0; }
.week-calendar-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 2fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#0b1526; }
.week-calendar-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#0b1526; }
.week-calendar-actions button { min-width:0; min-height:48px; }
@media(max-width:359px) { .week-calendar-item, .week-calendar-actions { grid-template-columns:1fr; } .week-calendar-time input { width:100%; } }
.plan-today-sheet.week-review-mode .week-plan-dates,
.plan-today-sheet.week-review-mode .mobile-plan-today-nav,
.plan-today-sheet.week-review-mode #plan-today-fit,
+46 -46
View File
@@ -3,6 +3,12 @@
const qs = (s, el=document) => el.querySelector(s);
const announceWork = message => qs('#my-work-action-status').textContent = message;
const fmt = (d) => new Date(d).toLocaleString();
const mobileAppBadge = createMobileAppBadge({
control:qs('#app-badge-control'), status:qs('#app-badge-status'),
container:qs('#app-badge-setting'), navigator, storage:localStorage,
serviceWorker:navigator.serviceWorker,
});
mobileAppBadge.start();
const cardPlanning = createCardPlanning(document);
const mobileComposerViewport = createMobileComposerViewport({
viewport: window.visualViewport,
@@ -460,46 +466,34 @@
escapeHtml,escapeAttribute:escAttr,todayWork,
t:v=>v?latestTodayPlan=v:latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline,
x:currentTodayProgressTarget});
const weekCalendar=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekItem,escapeHtml,escapeAttribute:escAttr,
onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();},
});
const todaySync = createTodaySync({
storage: localStorage,
getLogin: () => planningOwnerLogin,
fetchJson: fetchReviewJson,
coordinator: outboxCoordinator,
onRemoteIds: ids => {
if (!planningOwnerLogin || !todayWork.replace(ids)) return;
refreshMyWorkView();
warmTodayOffline();
},
onRemotePlan: plan => {
if (!planningOwnerLogin) return;
latestTodayPlan = plan;
const wc=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekFlow.i,escapeHtml,escapeAttribute:escAttr,
onSave:async byDate=>{Object.entries(byDate).forEach(([planDate,startTimes])=>weekPlan.stageStartTimes(planDate,startTimes));await weekPlan.flush();},
onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}});
const todaySync=createTodaySync({storage:localStorage,getLogin: () => planningOwnerLogin,fetchJson:fetchReviewJson,
coordinator:outboxCoordinator,
onRemoteIds:ids=>{if(!planningOwnerLogin||!todayWork.replace(ids))return;refreshMyWorkView();warmTodayOffline();},
onRemotePlan:plan=>{
if(!planningOwnerLogin)return;
latestTodayPlan=plan;
weekFlow.promote(plan).finally(() => {
if (window.location.hash !== '#/my-work/start-day') return;
window.history.replaceState({}, '', '#/my-work/today');
openMobileStartDay();
});
todayWork.replacePlanning({
capacity_minutes: plan.capacity_minutes ?? null,
estimates: plan.estimates || {},
});
const s = todayRollover.reviewState(plan);
if (!['stale', 'legacy'].includes(s)) {
rolloverReviewPlan = null;
qs('#plan-today').textContent = 'Plan Today';
todayWork.replacePlanning({capacity_minutes:plan.capacity_minutes??null,estimates:plan.estimates||{}});
const s=todayRollover.reviewState(plan);
if(!['stale','legacy'].includes(s)){
rolloverReviewPlan=null;
qs('#plan-today').textContent='Plan Today';
return;
}
rolloverReviewPlan = plan;
qs('#plan-today').textContent = 'Review new day';
qs('#my-work-action-status').textContent = 'Review yesterday’s unfinished work before starting today.';
const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`;
if (!localStorage.getItem(marker)) {
localStorage.setItem(marker, '1');
setTimeout(() => {
if (!workSession.checkpointed()) openPlanToday(qs('#plan-today'));
}, 0);
rolloverReviewPlan=plan;
qs('#plan-today').textContent='Review new day';
qs('#my-work-action-status').textContent='Review yesterday’s unfinished work before starting today.';
const marker=`stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`;
if(!localStorage.getItem(marker)){
localStorage.setItem(marker,'1');
setTimeout(()=>{if(!workSession.checkpointed())openPlanToday(qs('#plan-today'));},0);
}
},
onStatus: (state, detail = {}) => {
@@ -1499,6 +1493,7 @@
},
onPagination: pagination => {
notificationPagination = pagination;
mobileAppBadge.reconcile(pagination.total, {authoritative:true});
const loaded = Math.min(pagination.total, pagination.page * 50);
qs('#notification-page-status').textContent = pagination.total ?
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
@@ -1968,7 +1963,7 @@
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
onChange: snapshot => {
todaySessionSync?.publish(snapshot);
todaySessionSync?.publish(timer.sessionSnapshot());
queueMicrotask(() => {
if (todayLockScreen) todayLockScreen.sync(snapshot, workSession.checkpointed());
});
@@ -2232,6 +2227,7 @@
function performTodayTransition(action, item = null) {
qs('#today-readiness-sheet').hidden = true;
qs('#plan-today-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
if (taskOverlayHistory.current?.() === 'today-readiness') taskOverlayHistory.close();
const transitioned = ({
@@ -2706,7 +2702,7 @@
}
planToday.cancel();
planningTomorrow = false;
weekCalendar.close();
wc.close();
weekFlow.clear();
qs('#tomorrow-conflict-review').hidden = true;
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
@@ -5463,6 +5459,18 @@
});
}
}
if (snapshot.context) {
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating;
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
timerView.restore(todaySync.flush());
restoreReleaseReceipt();
updateDeliveryReceiptControls();
interruptionPrompt.restore();
}
}
if (snapshot.context && workChanged) {
setOfflineWorkMode(false);
const retainedPlanningLogin = !snapshot.context.error ?
@@ -5476,16 +5484,6 @@
laterSync.migrate(laterWork.read());
laterSync.flush();
}
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating;
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
timerView.restore(todaySync.flush());
restoreReleaseReceipt();
updateDeliveryReceiptControls();
interruptionPrompt.restore();
}
snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context);
if (contextFreshness?.stale) markMyWorkStale();
@@ -7926,7 +7924,7 @@
qs('#week-review-status').textContent=weekPlan.pending()?'Wait for Week Ahead to finish syncing before confirming.':'Move duplicated work to one date before confirming.';
return;
}
weekCalendar.open(weekPlan.state());
wc.open(weekPlan.state());
});
qs('#plan-today-sheet').addEventListener('click', event => {
if (event.target === qs('#plan-today-sheet')) closePlanToday();
@@ -8301,6 +8299,8 @@
protectStorage:() => deviceStorage.requestPersistence(),
notificationReadiness:() => pushController?.notificationReadiness()
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
appBadgeReadiness:() => mobileAppBadge.readiness(),
enableAppBadge:() => mobileAppBadge.enable(),
enablePush:async () => {
const controller = await pushControllerReady;
if (!controller) return;
+49 -4
View File
@@ -133,6 +133,10 @@
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
</li>
<li class="device-setup-step" id="device-setup-app-badge-step">
<div><strong>Show unread Updates on the app icon</strong><p class="small" id="device-setup-app-badge-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-app-badge" type="button">Enable</button>
</li>
<li class="device-setup-step">
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
<div class="device-setup-deadline-controls">
@@ -210,6 +214,10 @@
<div class="offline-work-controls">
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<span class="app-badge-setting" id="app-badge-setting">
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show unread Updates on the app icon</label>
<span class="small" id="app-badge-status" role="status" aria-live="polite"></span>
</span>
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
@@ -505,13 +513,44 @@
</div>
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
</section>
<section class="week-pull-conflict-review" id="week-pull-conflict-review" aria-labelledby="week-pull-conflict-title" tabindex="-1" hidden>
<div class="small">Saved transfer needs review</div>
<h3 id="week-pull-conflict-title">Resolve Today transfer</h3>
<p id="week-pull-conflict-item" class="week-pull-conflict-item"></p>
<p id="week-pull-conflict-context" class="small muted"></p>
<div class="week-pull-conflict-actions">
<button id="rebase-week-pull" type="button">Add using current plans</button>
<button id="keep-week-pull" type="button">Keep in Week Ahead</button>
</div>
<div class="small week-pull-conflict-status" id="week-pull-conflict-status" role="status" aria-live="assertive"></div>
</section>
<section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden>
<div class="small">Seven-day overview</div>
<h2 id="week-review-title">Week Ahead</h2>
<p class="small muted">See what is next and check each day’s load. Edit only when you are ready.</p>
<p class="week-offline-snapshot" id="week-offline-snapshot" role="status" aria-live="polite" hidden></p>
<button id="retry-week-live" type="button" hidden>Retry live Week Ahead</button>
<button id="open-week-capacity-import" type="button">Set capacity from calendar</button>
<button id="open-week-capacity-import" type="button">Refresh from calendar</button>
<button id="open-week-availability" type="button">Set weekly availability</button>
<section class="week-availability-editor" id="week-availability-editor" aria-labelledby="week-availability-title" hidden>
<h3 id="week-availability-title">Weekly availability</h3>
<p class="small muted">New Week Ahead dates use these defaults. Capacity set on a specific date remains an override.</p>
<div class="week-availability-grid">
<label>Monday <input data-weekday-capacity="0" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
<label>Tuesday <input data-weekday-capacity="1" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
<label>Wednesday <input data-weekday-capacity="2" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
<label>Thursday <input data-weekday-capacity="3" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
<label>Friday <input data-weekday-capacity="4" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
<label>Saturday <input data-weekday-capacity="5" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
<label>Sunday <input data-weekday-capacity="6" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
</div>
<div class="week-availability-actions">
<button id="cancel-week-availability" type="button">Cancel</button>
<button id="disable-week-availability" type="button">Disable defaults</button>
<button id="save-week-availability" type="button">Save weekly availability</button>
</div>
<div id="week-availability-status" class="small" role="status" aria-live="assertive"></div>
</section>
<button id="open-week-reflow" type="button" hidden>Reflow remaining week</button>
<section class="week-reflow-review" id="week-reflow-review" aria-labelledby="week-reflow-title" hidden>
<div class="small">Capacity-safe preview</div>
@@ -529,13 +568,14 @@
<div class="week-unplan-receipt" id="week-unplan-receipt" role="status" aria-live="polite" hidden></div>
<button id="undo-week-unplan" type="button" hidden>Undo</button>
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
<button id="export-saved-week-calendar" type="button" hidden>Add saved week to calendar</button>
<button id="edit-week-plan" type="button" hidden>Edit week</button>
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
</section>
<section class="week-capacity-import" id="week-capacity-import" aria-labelledby="week-capacity-title" hidden>
<header><div><div class="small">Private calendar import</div><h2 id="week-capacity-title">Set Week Ahead capacity</h2></div>
<header><div><div class="small">Private calendar refresh</div><h2 id="week-capacity-title">Refresh Week Ahead availability</h2></div>
<button id="cancel-week-capacity-import" type="button">Cancel</button></header>
<p class="small muted">Your calendar is read only on this device. Event titles, people, locations, and the file are never saved or uploaded.</p>
<p class="small muted">Compare refreshed availability with planned load before anything changes. Your calendar is read only on this device; event titles, people, locations, and the file are never saved or uploaded.</p>
<label>Calendar file
<input id="week-capacity-file" type="file" accept=".ics,text/calendar" />
</label>
@@ -547,17 +587,21 @@
<section id="week-capacity-review" aria-labelledby="week-capacity-review-title" hidden>
<h3 id="week-capacity-review-title">Review available time</h3>
<div id="week-capacity-days" class="week-capacity-days"></div>
<label class="week-free-times-consent"><input id="keep-week-free-times" type="checkbox" />
<span><strong>Keep these free times with Week Ahead</strong><small>Only seven days of start/end times are encrypted and synced. Calendar events and the file stay on this device.</small></span>
</label>
<button id="apply-week-capacities" type="button">Apply seven capacities</button>
</section>
</section>
<section class="week-calendar-handoff" id="week-calendar-handoff" aria-labelledby="week-calendar-title" hidden>
<div class="small">Calendar handoff</div>
<h2 id="week-calendar-title">Add Week Ahead to calendar</h2>
<p class="small muted">Titles, references, dates, and times are included in a local calendar file; nothing is uploaded. Imported busy time is used only until this page reloads; otherwise times follow the chosen start.</p>
<p class="small muted">Titles, references, dates, and times are included in a local calendar file; nothing is uploaded. Imported busy time is used only until this page reloads unless you consent to keep free times with the encrypted Week Ahead plan.</p>
<div class="week-calendar-days" id="week-calendar-days"></div>
<div class="small week-calendar-status" id="week-calendar-status" role="status" aria-live="assertive"></div>
<div class="week-calendar-actions">
<button id="back-to-week-review-from-calendar" type="button">Back to review</button>
<button id="save-week-calendar-times" type="button">Save exact times</button>
<button id="share-week-calendar" type="button">Share calendar blocks</button>
</div>
</section>
@@ -2137,6 +2181,7 @@
<script src="static/mobile-launch.js"></script>
<script src="static/mobile-insights.js"></script>
<script src="static/mobile-app-shortcuts.js"></script>
<script src="static/mobile-app-badge.js"></script>
<script src="static/install-app.js"></script>
<script src="static/private-device-data.js"></script>
<script src="static/device-storage.js"></script>
+103
View File
@@ -0,0 +1,103 @@
(function(root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileAppBadge = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileAppBadge({
control,
status,
container,
navigator,
storage,
serviceWorker,
}) {
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
let enabled = false;
let confirmedCount = 0;
let renderedCount = null;
function available() {
return typeof navigator?.setAppBadge === 'function'
&& typeof navigator?.clearAppBadge === 'function';
}
async function syncPreference() {
try {
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({type:'stackchain-app-badge-preference', enabled});
return Boolean(registration?.active);
} catch (_error) {
return false;
}
}
async function render() {
if (!enabled || !available() || renderedCount === confirmedCount) return true;
try {
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
else await navigator.clearAppBadge();
renderedCount = confirmedCount;
if (status) status.textContent = 'App icon badge is on and up to date.';
return true;
} catch (_error) {
if (status) status.textContent = 'Could not update the app icon badge. Stackchain still has the confirmed count.';
return false;
}
}
async function change() {
enabled = Boolean(control?.checked);
if (enabled) storage?.setItem(ENABLED_KEY, 'true');
else storage?.removeItem(ENABLED_KEY);
await syncPreference();
if (!enabled && available()) {
try {
await navigator.clearAppBadge();
renderedCount = null;
if (status) status.textContent = 'App icon badge is off.';
return true;
} catch (_error) {
if (status) status.textContent = 'App icon badge is off, but the browser could not clear the old count.';
return false;
}
}
return render();
}
function start() {
if (!available()) {
if (container) container.hidden = true;
if (control) control.disabled = true;
if (status) status.textContent = 'App icon badges are unavailable in this browser.';
return false;
}
enabled = storage?.getItem(ENABLED_KEY) === 'true';
if (enabled) void syncPreference();
if (control) {
control.checked = enabled;
control.addEventListener('change', change);
}
if (status) status.textContent = enabled ? 'App icon badge is on.' : 'App icon badge is off.';
return true;
}
function readiness() {
if (!available()) return {state:'unavailable', detail:'App icon badges are unavailable in this browser.'};
return enabled
? {state:'complete', detail:'The app icon shows the confirmed unread Updates count.'}
: {state:'incomplete', detail:'Show the confirmed unread Updates count without opening Stackchain.'};
}
async function enable() {
if (!available()) return false;
if (control) control.checked = true;
await change();
return true;
}
async function reconcile(count, {authoritative = false} = {}) {
if (!authoritative || !Number.isSafeInteger(count) || count < 0) return false;
confirmedCount = count;
return render();
}
return {start, change, reconcile, readiness, enable};
});
+6 -1
View File
@@ -9,8 +9,9 @@
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable'],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
];
].filter(([_name, button, status, action]) => button && status && action);
let trigger = options.launcher;
let ownsDetour = false;
let backgroundInert = null;
@@ -167,10 +168,12 @@ function mountMobileDeviceSetup(options) {
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
protectionButton:qs('#device-setup-protection'),
appBadgeButton:qs('#device-setup-app-badge'),
deadlineButton:qs('#device-setup-deadline'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
protectionStatus:qs('#device-setup-protection-status'),
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
appBadgeStatus:qs('#device-setup-app-badge-status'),
readyStatus:qs('#device-setup-ready-status'),
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
@@ -193,12 +196,14 @@ function mountMobileDeviceSetup(options) {
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
protection:options.storageProtectionReadiness(),
push:options.notificationReadiness(),
appBadge:options.appBadgeReadiness(),
deadline:options.deadlineReadiness(),
}),
install:() => options.installApp.install(),
enableOffline:options.enableOffline,
protectStorage:options.protectStorage,
enablePush:options.enablePush,
enableAppBadge:options.enableAppBadge,
enableDeadline:options.enableDeadline,
});
}
+4 -1
View File
@@ -62,7 +62,10 @@
function renderCoach() {
if (!options.coach) return;
options.coach.hidden = !(options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive());
const visible = options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive();
options.coach.hidden = !visible;
const host = options.coach.closest?.('[data-mobile-today-hud]');
if (visible && host) host.hidden = false;
}
function render() {
+1
View File
@@ -7,6 +7,7 @@
'stackchain-search-reply-drafts-v1',
'stackchain-conversation-reply-drafts-v1',
'stackchain-today-action-mailbox-v1',
'stackchain-app-badge-preference-v1',
]);
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
else root.stackchainPrivateDatabases = databases;
+76 -6
View File
@@ -9,6 +9,57 @@ const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
const TODAY_ACTION_TTL_MS = 2 * 60 * 1000;
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
function createAppBadgePreference() {
const dbName = 'stackchain-app-badge-preference-v1';
const storeName = 'preferences';
const open = () => new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
});
return {
async get() {
const database = await open();
const enabled = await new Promise((resolve, reject) => {
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('enabled');
request.onsuccess = () => resolve(request.result === true);
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
});
database.close();
return enabled;
},
async set(enabled) {
const database = await open();
await new Promise((resolve, reject) => {
const transaction = database.transaction(storeName, 'readwrite');
transaction.objectStore(storeName).put(enabled === true, 'enabled');
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error || new Error('App badge preference could not be saved.'));
transaction.onabort = transaction.onerror;
});
database.close();
},
};
}
const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference();
let renderedBackgroundBadgeCount = null;
async function reconcileBackgroundAppBadge(count) {
if (!Number.isSafeInteger(count) || count < 0 || count > 9999
|| typeof self.registration.setAppBadge !== 'function'
|| typeof self.registration.clearAppBadge !== 'function') return false;
let enabled = false;
try { enabled = await appBadgePreference.get(); } catch (_error) { return false; }
if (!enabled || renderedBackgroundBadgeCount === count) return false;
try {
if (count > 0) await self.registration.setAppBadge(count);
else await self.registration.clearAppBadge();
renderedBackgroundBadgeCount = count;
return true;
} catch (_error) { return false; }
}
function createTodayActionStore() {
const dbName = 'stackchain-today-action-mailbox-v1';
const storeName = 'commands';
@@ -190,6 +241,7 @@ const SHELL = [
BASE + 'static/mobile-launch.js',
BASE + 'static/mobile-insights.js',
BASE + 'static/mobile-app-shortcuts.js',
BASE + 'static/mobile-app-badge.js',
BASE + 'static/install-app.js',
BASE + 'static/private-data-inventory.js',
BASE + 'static/private-device-data.js',
@@ -523,6 +575,19 @@ async function updateTodayLockScreen(active, running, rawActionToken = '', rawBr
}
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-app-badge-preference') {
event.waitUntil((async () => {
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
const enabled = event.data.enabled === true;
try {
await appBadgePreference.set(enabled);
renderedBackgroundBadgeCount = null;
if (!enabled && typeof self.registration.clearAppBadge === 'function') {
await self.registration.clearAppBadge();
}
} catch (_error) { /* Page preference remains authoritative on next launch. */ }
})());
}
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
await issueSync.resume();
await flushAndNotify();
@@ -581,6 +646,7 @@ self.addEventListener('push', event => {
const tag = String(payload.tag || '');
const notificationId = Number(payload.notification_id);
const updateCount = Number(payload.update_count);
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
const deadlineCount = Number(payload.deadline_count);
const planDate = String(payload.plan_date || '');
if (
@@ -625,11 +691,12 @@ self.addEventListener('push', event => {
&& updateCount > 0
&& updateCount <= 50
) {
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
body: 'Tap to review them in Stackchain.',
tag,
data: {route},
}));
event.waitUntil(Promise.all([
reconcileBackgroundAppBadge(unreadCount),
self.registration.showNotification(updateCount + ' new work updates', {
body: 'Tap to review them in Stackchain.', tag, data: {route},
}),
]));
return;
}
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
@@ -649,7 +716,10 @@ self.addEventListener('push', event => {
];
options.data.notificationId = notificationId;
}
event.waitUntil(self.registration.showNotification('New work update', options));
event.waitUntil(Promise.all([
reconcileBackgroundAppBadge(unreadCount),
self.registration.showNotification('New work update', options),
]));
});
async function openWorkRoute(route) {
+12 -3
View File
@@ -1,5 +1,6 @@
function createTodaySessionSync({
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
onOwnedRestore = () => {},
setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval,
}) {
let current = null;
@@ -25,6 +26,8 @@ function createTodaySessionSync({
current = session;
if (session.device_id === deviceId()) {
ownedRevision = session.revision;
if (Number.isFinite(session.break_deadline_at) &&
timer?.restoreBreak?.(session.identity, session.break_deadline_at)) onOwnedRestore(session);
onRemote(null);
} else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) {
if (previousOwned) {
@@ -60,7 +63,7 @@ function createTodaySessionSync({
onStatus('syncing');
const body = {
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
elapsed_ms:current.elapsed_ms, running:true,
elapsed_ms:current.elapsed_ms, entries:current.entries, running:true,
};
if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null;
const session = await fetchJson(endpoint, {
@@ -68,7 +71,7 @@ function createTodaySessionSync({
body:JSON.stringify(body),
});
adopt(session);
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
timer?.adopt?.(session.identity, session.elapsed_ms, session.running, session.entries);
return session;
} catch (error) {
if (error?.status === 409 || error?.code === 'session_changed') {
@@ -89,6 +92,7 @@ function createTodaySessionSync({
const body = {
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
entries:Array.isArray(snapshot.entries) ? snapshot.entries : undefined,
running:Boolean(snapshot.running),
};
if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) {
@@ -112,7 +116,7 @@ function createTodaySessionSync({
}
}
function publish(snapshot = timer?.snapshot?.()) {
function publish(snapshot = timer?.sessionSnapshot?.() || timer?.snapshot?.()) {
snapshot = snapshot?.identity ? snapshot : latestSnapshot;
if (!snapshot?.identity || !deviceId()) return Promise.resolve(null);
latestSnapshot = snapshot;
@@ -163,6 +167,10 @@ function todaySessionHandoffSummary(session, item) {
return `On break until ${end} · ready to resume here`;
}
const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000));
if (Array.isArray(session?.entries) && session.entries.length > 1) {
const total = session.entries.reduce((sum, entry) => sum + Math.max(0, Number(entry?.elapsed_ms) || 0), 0);
return `${session.entries.length} tracked items · ${Math.floor(total / 60000)} min total`;
}
return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
}
@@ -186,6 +194,7 @@ function attachTodaySessionHandoff({
};
const sync = createTodaySessionSync({
fetchJson, getDeviceId, timer,
onOwnedRestore:renderTimer,
onStatus:state => showSessionStatus(
state === 'syncing' ? 'Session syncing…' :
(state === 'offline' ? 'Session offline · will retry.' : '')
+37 -9
View File
@@ -75,8 +75,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
entry.running = false;
return state;
};
const snapshot = (identity = '') => {
const snapshot = (identity = '', includeEntries = false) => {
const state = read();
const sampledAt = now();
const selected = identity || state.active_identity;
const entry = state.entries[selected];
const result = !selected || !entry ?
@@ -84,25 +85,42 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
{
identity:selected,
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0),
Math.max(0, sampledAt - Number(entry.started_at ?? sampledAt)) : 0),
running:Boolean(entry.running),
};
const timedBreak = validBreak(state);
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
if (includeEntries) {
result.entries = Object.entries(state.entries).slice(0, 20).map(([entryIdentity, value]) => ({
identity:entryIdentity,
elapsed_ms:Math.max(0, Math.floor(Number(value.elapsed_ms) || 0) + (value.running ?
Math.max(0, sampledAt - Number(value.started_at ?? sampledAt)) : 0)),
}));
}
return result;
};
return {
adopt(identity, elapsedMs, running) {
adopt(identity, elapsedMs, running, entries = null) {
if (!key() || typeof identity !== 'string' || !identity ||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
const incoming = entries === null ? [{identity, elapsed_ms:elapsedMs}] : entries;
if (!Array.isArray(incoming) || incoming.length > 20) return false;
const seen = new Set();
const ledger = {};
for (const entry of incoming) {
if (typeof entry?.identity !== 'string' || !entry.identity || entry.identity.length > 500 ||
seen.has(entry.identity) || !Number.isFinite(Number(entry.elapsed_ms)) || Number(entry.elapsed_ms) < 0) return false;
seen.add(entry.identity);
ledger[entry.identity] = {
elapsed_ms:Math.floor(Number(entry.elapsed_ms)), started_at:null, running:false,
};
}
if (!ledger[identity] || ledger[identity].elapsed_ms !== Math.floor(Number(elapsedMs))) return false;
const state = read();
settle(state);
state.active_identity = identity;
state.entries[identity] = {
elapsed_ms:Math.floor(Number(elapsedMs)),
started_at:running ? now() : null,
running:Boolean(running),
};
state.entries = ledger;
state.entries[identity].started_at = running ? now() : null;
state.entries[identity].running = Boolean(running);
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
@@ -159,6 +177,15 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.timed_break = { identity, deadline_at:now() + duration * 60000 };
return write(state) ? validBreak(state) : false;
},
restoreBreak(identity, deadlineAt) {
const deadline = Number(deadlineAt);
if (typeof identity !== 'string' || !identity || !Number.isFinite(deadline) || deadline <= now()) return false;
const state = read();
const entry = state.entries[identity];
if (state.active_identity !== identity || !entry || entry.running || validBreak(state)) return false;
state.timed_break = { identity, deadline_at:Math.floor(deadline) };
return write(state);
},
breakSnapshot() {
return validBreak(read());
},
@@ -388,6 +415,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
}, 0);
},
snapshot,
sessionSnapshot:(identity = '') => snapshot(identity, true),
};
}
+52 -17
View File
@@ -1,5 +1,6 @@
function createWeekCalendarImport() {
const maxBytes=1024*1024;
const zonedInstantFormatters=new Map(),zonePartFormatters=new Map(),supportedTimeZones=new Map();
function clock(value) {
const match=/^(\d{2}):(\d{2})$/.exec(String(value||''));
if(!match)return null;
@@ -19,9 +20,11 @@ function createWeekCalendarImport() {
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/.exec(value||'');
if(!match)return null;
try {
const wanted=match.slice(1).map(Number),formatter=new Intl.DateTimeFormat('en-CA',{
const wanted=match.slice(1).map(Number);
if(!zonedInstantFormatters.has(timeZone))zonedInstantFormatters.set(timeZone,new Intl.DateTimeFormat('en-CA',{
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23',
});
}));
const formatter=zonedInstantFormatters.get(timeZone);
let result=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5]);
for(let attempt=0;attempt<2;attempt+=1) {
const shown=Object.fromEntries(formatter.formatToParts(new Date(result)).map(part=>[part.type,part.value]));
@@ -33,15 +36,18 @@ function createWeekCalendarImport() {
}
function supportedTimeZone(timeZone) {
if(!timeZone)return true;
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);return true;}
catch(error) {return false;}
if(supportedTimeZones.has(timeZone))return supportedTimeZones.get(timeZone);
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);supportedTimeZones.set(timeZone,true);return true;}
catch(error) {supportedTimeZones.set(timeZone,false);return false;}
}
function zoneParts(value,timeZone) {
const date=new Date(value);
if(!timeZone)return {year:date.getFullYear(),month:date.getMonth()+1,day:date.getDate(),weekday:date.getDay(),
hour:date.getHours(),minute:date.getMinutes(),second:date.getSeconds()};
const formatter=new Intl.DateTimeFormat('en-US',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',
hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23'});
if(!zonePartFormatters.has(timeZone))zonePartFormatters.set(timeZone,new Intl.DateTimeFormat('en-US',{
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23',
}));
const formatter=zonePartFormatters.get(timeZone);
const parts=Object.fromEntries(formatter.formatToParts(date).map(part=>[part.type,part.value]));
return {year:Number(parts.year),month:Number(parts.month),day:Number(parts.day),
weekday:['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(parts.weekday),hour:Number(parts.hour),
@@ -197,14 +203,15 @@ function createWeekCalendarImport() {
return days;
}
function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
let reviewed=null,activeAvailability=null;
let reviewed=null,activeAvailability=null,needsReflow=false,reflowPreview=null;
const root=()=>qs('#week-capacity-import');
function clear() {
reviewed=null;
reviewed=null;needsReflow=false;reflowPreview=null;
qs('#week-capacity-file').value='';
qs('#week-capacity-review').hidden=true;
qs('#week-capacity-days').innerHTML='';
qs('#apply-week-capacities').disabled=false;
qs('#apply-week-capacities').textContent='Apply seven capacities';
}
function open() {
clear();root().hidden=false;qs('#week-capacity-status').textContent='';
@@ -215,22 +222,45 @@ function createWeekCalendarImport() {
const display=controller.dates();
reviewed=review(source,{dates:display.map(item=>item.date),workdayStart:qs('#week-capacity-start').value,
workdayEnd:qs('#week-capacity-end').value});
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>'<article class="week-capacity-day"><strong>'+
display[index].label+'</strong><span>'+day.capacity_minutes+' min available</span><small>'+day.busy_minutes+
' min busy during working hours</small></article>').join('');
const current=controller.review?.().days||[];
needsReflow=reviewed.some((day,index)=>Number(current[index]?.planned_minutes)>day.capacity_minutes);
const previewValues=reviewed.map(day=>({plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
free_windows:day.free_windows.map(window=>({...window}))}));
reflowPreview=needsReflow?controller.previewCapacityReflow?.(previewValues):null;
const destinations=new Map();
(reflowPreview?.days||[]).forEach(day=>(day.ids||[]).forEach(id=>destinations.set(id,day.plan_date)));
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>{
const previous=current[index]||{},planned=Number(previous.planned_minutes)||0,over=Math.max(0,planned-day.capacity_minutes);
const moved=[...new Set((previous.ids||[]).map(id=>destinations.get(id)).filter(date=>date&&date!==day.plan_date))]
.map(date=>display.find(item=>item.date===date)?.label||date);
return '<article class="week-capacity-day'+(over?' is-overloaded':'')+'"><strong>'+display[index].label+'</strong><span>'+
(Number.isInteger(previous.capacity_minutes)?previous.capacity_minutes+' → ':'')+day.capacity_minutes+' min available</span><small>'+
planned+' min planned · '+day.busy_minutes+' min busy</small>'+(over?'<small class="week-capacity-over">'+over+
' min over refreshed capacity</small>':'')+(moved.length?'<small>Moves to '+moved.join(', ')+'</small>':'')+'</article>';
}).join('');
qs('#week-capacity-review').hidden=false;
const unsupported=reviewed.unsupported_count||0;
qs('#apply-week-capacities').disabled=unsupported>0;
const reflowBlocked=Boolean(reflowPreview?.blockers?.length);
qs('#apply-week-capacities').disabled=unsupported>0||reflowBlocked;
qs('#apply-week-capacities').textContent=needsReflow?'Apply capacities & reflow':'Apply seven capacities';
const kind=reviewed.unsupported_timezone_count?'calendar event':'recurring event';
qs('#week-capacity-status').textContent=unsupported?
unsupported+' '+kind+(unsupported===1?'':'s')+' could not be counted. Apply is unavailable; export a simpler seven-day calendar and try again.':
'Review seven capacity totals. Calendar details stay on this device.';
(reflowBlocked?'Reflow needs an estimate for every planned item before refreshed capacity can be applied.':
(reflowPreview?.unscheduled?.length?reflowPreview.unscheduled.length+' planned item'+
(reflowPreview.unscheduled.length===1?'':'s')+' will return to My Work. Review before applying.':
'Review seven capacity totals. Calendar details stay on this device.'));
return reviewed.map(day=>({...day}));
}
async function apply() {
if(!reviewed||reviewed.unsupported_count||!controller.stageCapacities(reviewed.map(day=>({
if(!reviewed||reviewed.unsupported_count)return false;
const keep=Boolean(qs('#keep-week-free-times')?.checked);
const values=reviewed.map(day=>({
plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
}))))return false;
...(keep?{free_windows:day.free_windows.map(window=>({...window}))}:{}),
}));
const staged=needsReflow?controller.applyCapacityReflow?.(values):controller.stageCapacities(values);
if(!staged)return false;
qs('#apply-week-capacities').disabled=true;
try {await controller.flush();activeAvailability=reviewed.map(day=>({plan_date:day.plan_date,
free_windows:day.free_windows.map(window=>({...window}))}));cancel();onApplied();return true;}
@@ -247,9 +277,14 @@ function createWeekCalendarImport() {
qs('#apply-week-capacities')?.addEventListener('click',()=>apply().catch(error=>{
qs('#week-capacity-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Capacities remain saved on this phone.';
}));
function availability() {
const saved=(controller.state?.().days||[]).filter(day=>Array.isArray(day.free_windows));
const source=activeAvailability||saved;
return source.length?source.map(day=>({plan_date:day.plan_date,
free_windows:day.free_windows.map(window=>({...window}))})):null;
}
return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null,
availability:()=>activeAvailability?activeAvailability.map(day=>({plan_date:day.plan_date,
free_windows:day.free_windows.map(window=>({...window}))})):null};
availability};
}
function mount(controller,weekWorkflow,qs) {
const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview});
+47 -16
View File
@@ -24,22 +24,39 @@
const blocks=[],blockers=[],availableByDate=new Map((availability||[]).map(day=>[day.plan_date,day.free_windows||[]]));
(plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{
let cursor=startTimes?.[day.plan_date]||'09:00';
const windows=availableByDate.has(day.plan_date)?availableByDate.get(day.plan_date):(day.free_windows||null);
const candidates=[];
(day.ids||[]).forEach(id=>{
if(selected&& !selected.has(id))return;
if(selected&&!selected.has(id))return;
const minutes=Number(day.estimates?.[id]),item=getItem?.(id);
if(!item||!Number.isFinite(minutes)||minutes<=0)return;
const windows=availableByDate.get(day.plan_date);
if(windows){
const exact=startTimes?.[id]||day.start_times?.[id];
let start=exact||cursor;
if(!exact&&windows){
const fit=windows.find(window=>{
const candidate=Math.max(clockMinutes(cursor),clockMinutes(window.start_time));
if(candidate+minutes>clockMinutes(window.end_time))return false;
cursor=addMinutes('00:00',candidate);return true;
start=addMinutes('00:00',candidate);return true;
});
if(!fit){blockers.push({id,title:item.title||'Untitled work',plan_date:day.plan_date,minutes});return;}
}
const end=addMinutes(cursor,minutes);
blocks.push({...item,id,plan_date:day.plan_date,start_time:cursor,end_time:end,minutes});
cursor=end;
const startMinute=clockMinutes(start),endMinute=startMinute+minutes;
candidates.push({item,id,minutes,start,startMinute,endMinute,exact:Boolean(exact)});
if(!exact)cursor=addMinutes('00:00',endMinute);
});
const invalid=new Set();
candidates.forEach(candidate=>{
if(candidate.endMinute>1440||windows&&!windows.some(window=>clockMinutes(window.start_time)<=candidate.startMinute&&candidate.endMinute<=clockMinutes(window.end_time)))invalid.add(candidate.id);
});
candidates.forEach((left,index)=>candidates.slice(index+1).forEach(right=>{
if(left.startMinute<right.endMinute&&left.endMinute>right.startMinute){invalid.add(left.id);invalid.add(right.id);}
}));
candidates.forEach(candidate=>{
if(invalid.has(candidate.id)){
blockers.push({id:candidate.id,title:candidate.item.title||'Untitled work',plan_date:day.plan_date,minutes:candidate.minutes,reason:'invalid-exact-time'});return;
}
blocks.push({...candidate.item,id:candidate.id,plan_date:day.plan_date,start_time:candidate.start,
end_time:addMinutes('00:00',candidate.endMinute),minutes:candidate.minutes});
});
});
return {blocks,blockers};
@@ -70,12 +87,15 @@
finally{urlApi.revokeObjectURL(href);}
return 'downloaded';
}
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onDone,windowObject=root,navigatorObject=root.navigator,
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onSave,onDone,windowObject=root,navigatorObject=root.navigator,
documentObject=root.document,urlApi=root.URL,FileCtor=root.File}={}){
const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days');
let plan=null;
let plan=null,focusReturn=null;
const selected=()=>new Set(Array.from(daysRoot.querySelectorAll('[data-week-calendar-item]')).filter(input=>input.checked).map(input=>input.value));
const starts=()=>Object.fromEntries(Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]));
const starts=()=>Object.fromEntries([
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]),
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-exact]')).filter(input=>input.value).map(input=>[input.dataset.weekCalendarExact,input.value]),
]);
const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.());
const blocks=()=>schedule().blocks;
function update(){
@@ -86,6 +106,7 @@
(blocker?'Does not fit imported free time':'Excluded from calendar');
});
qs('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0;
qs('#save-week-calendar-times').disabled=!current.blocks.length||current.blockers.length>0;
if(current.blockers.length){
const blocker=current.blockers[0];
qs('#week-calendar-status').textContent=blocker.title+' does not fit free time on '+blocker.plan_date+'. Adjust the start, estimate, or calendar import.';
@@ -94,20 +115,30 @@
}
function close({back=false}={}){
handoff.hidden=true;
if(back){review.hidden=false;qs('#confirm-week-plan').focus?.();}
if(back){review.hidden=false;const target=focusReturn||qs('#confirm-week-plan');focusReturn=null;target.focus?.();}
}
function open(value){
plan=value;review.hidden=true;handoff.hidden=false;
function open(value,{returnFocus=null}={}){
plan=value;if(returnFocus)focusReturn=returnFocus;review.hidden=true;handoff.hidden=false;
const labels=new Map((value.days||[]).map(day=>[day.plan_date,new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(new Date(day.plan_date+'T12:00:00Z'))]));
daysRoot.innerHTML=(value.days||[]).filter(day=>day.ids?.length).map(day=>'<article class="week-calendar-day"><header><h3>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
'</h3><label>Start <input type="time" value="09:00" data-week-calendar-start="'+escapeAttribute(day.plan_date)+'"></label></header>'+
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96);
return '<label class="week-calendar-item"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label>';}).join('')+'</article>').join('');
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96),saved=day.start_times?.[id]||'';
return '<div class="week-calendar-item"><label class="week-calendar-choice"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label><label class="week-calendar-time">Start<input type="time" value="'+escapeAttribute(saved)+'" data-week-calendar-exact="'+escapeAttribute(id)+'" aria-label="Exact start for '+escapeAttribute(item?.title||'work')+'"></label></div>';}).join('')+'</article>').join('');
daysRoot.querySelectorAll('input').forEach(input=>input.addEventListener('change',update));
update();qs('#back-to-week-review-from-calendar').focus?.();return true;
}
qs('#back-to-week-review-from-calendar').addEventListener('click',()=>close({back:true}));
qs('#save-week-calendar-times').addEventListener('click',async()=>{
const button=qs('#save-week-calendar-times'),current=schedule();if(!current.blocks.length||current.blockers.length)return;
button.disabled=true;qs('#week-calendar-status').textContent='Saving exact task times…';
const byDate={};current.blocks.forEach(block=>(byDate[block.plan_date]||(byDate[block.plan_date]={}))[block.id]=block.start_time);
try{await onSave?.(byDate);plan={...plan,days:(plan.days||[]).map(day=>({...day,start_times:{...(byDate[day.plan_date]||{})}}))};
qs('#week-calendar-status').textContent='Exact task times saved to Week Ahead.';}
catch(error){qs('#week-calendar-status').textContent=(error?.message||'Exact times could not be saved.')+' Retry when connected.';}
finally{button.disabled=false;}
});
qs('#export-saved-week-calendar')?.addEventListener('click',event=>{focusReturn=event.currentTarget;qs('#confirm-week-plan').click();});
qs('#share-week-calendar').addEventListener('click',async()=>{
const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;
button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…';
+225 -29
View File
@@ -10,9 +10,17 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
const storagePrefix='stackchain.week-sync.v1.';
const confirmedPrefix='stackchain.week-confirmed.v1.';
const pullPrefix='stackchain.week-today-pull.v1.';
const cloneDefaults=value=>Array.isArray(value)&&value.length===7?value.slice():null;
const cloneWindows=value=>Array.isArray(value)?value.map(window=>({
start_time:window.start_time,end_time:window.end_time,
})):null;
const cloneStartTimes=day=>Object.fromEntries(Object.entries(day.start_times||{})
.filter(([id,value])=>(day.ids||[]).includes(id)&&/^\d{2}:\d{2}$/.test(value)));
const cloneDay=day=>({
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
estimates:{...(day.estimates||{})},
...(cloneWindows(day.free_windows)?{free_windows:cloneWindows(day.free_windows)}:{}),
...(Object.keys(cloneStartTimes(day)).length?{start_times:cloneStartTimes(day)}:{}),
});
const sanitizeItem=value=>value?{kind:value.kind==='pull'?'pull':'issue',
title:String(value.title||'').slice(0,500),repository:String(value.repository||'').slice(0,255),
@@ -25,6 +33,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
return sanitized;
}
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay),
...(cloneDefaults(week.availability_defaults)?{availability_defaults:cloneDefaults(week.availability_defaults)}:{}),
offline_snapshot:offlineSnapshot,...(refreshedAt?{refreshed_at:refreshedAt}:{}),
...(week.sync_pending?{base_revision:week.base_revision,sync_pending:true}:{})});
function storageKey() {
@@ -81,7 +90,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
const key=confirmedKey();
if(!key||!storage||week.sync_pending)return false;
const record={refreshed_at:new Date(now()).toISOString(),week:{revision:week.revision,
timezone:week.timezone,days:week.days.map(cloneDay)},items:{...confirmedItems}};
timezone:week.timezone,days:week.days.map(cloneDay),
...(cloneDefaults(week.availability_defaults)?{availability_defaults:cloneDefaults(week.availability_defaults)}:{})},items:{...confirmedItems}};
try {storage.setItem(key,JSON.stringify(record));refreshedAt=record.refreshed_at;return true;}
catch(_error){return false;}
}
@@ -96,6 +106,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
const value=JSON.parse(storage.getItem(key)||'null');
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{
revision:value.base_revision,base_revision:value.base_revision,timezone:value.timezone||null,
...(cloneDefaults(value.availability_defaults)?{availability_defaults:cloneDefaults(value.availability_defaults)}:{}),
days:value.days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),sync_pending:true,
items:sanitizeItems(value.days,value.items||{}),
...(Array.isArray(value.base_days)?{base_days:value.base_days.map(cloneDay)
@@ -108,6 +119,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
if(!Number.isInteger(value?.revision)||!Array.isArray(value?.days)) return false;
week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay)
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
if(cloneDefaults(value.availability_defaults))week.availability_defaults=cloneDefaults(value.availability_defaults);
offlineSnapshot=false;
pendingItems={};
lastConflict=null;
@@ -131,7 +143,12 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
}
function day(planDate) {
const found=week.days.find(item=>item.plan_date===planDate);
return found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}};
if(found&&found.capacity_minutes!==null&&found.capacity_minutes!==undefined)
return {...cloneDay(found),capacity_source:'override'};
const parsed=new Date(planDate+'T12:00:00Z'),weekday=(parsed.getUTCDay()+6)%7;
const inherited=cloneDefaults(week.availability_defaults)?.[weekday];
return {...(found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}}),
...(Number.isInteger(inherited)?{capacity_minutes:inherited,capacity_source:'default'}:{capacity_source:'unset'})};
}
function pass(planDate) {
const available=dates(),index=available.findIndex(item=>item.date===planDate);
@@ -166,6 +183,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
const queued={base_revision:Number.isInteger(week.base_revision)?week.base_revision:week.revision,
timezone:timeZone(),days:days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),
base_days:(week.base_days||week.days).map(cloneDay)};
if(cloneDefaults(week.availability_defaults))queued.availability_defaults=cloneDefaults(week.availability_defaults);
queued.items=sanitizeItems(queued.days,{...confirmedItems,...pendingItems,...(pending()?.items||{})});
try { storage.setItem(key,JSON.stringify(queued)); }
catch(_error) { return false; }
@@ -191,18 +209,51 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
return stageDays(week.days.filter(item=>item.plan_date!==planDate).concat([{
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
estimates:{...(value.estimates||{})},
...(cloneWindows(value.free_windows)?{free_windows:cloneWindows(value.free_windows)}:{}),
...(Object.keys(cloneStartTimes(value)).length?{start_times:cloneStartTimes(value)}:{}),
}]));
}
function stageStartTimes(planDate,startTimes) {
const value=day(planDate);
if(!value||!startTimes||typeof startTimes!=='object'||Array.isArray(startTimes))return false;
value.start_times=Object.fromEntries(value.ids.filter(id=>typeof startTimes[id]==='string').map(id=>[id,startTimes[id]]));
return stageDay(planDate,value);
}
function stageCapacities(values) {
const changed=capacityDays(values);
return changed&&stageDays(changed);
}
function capacityDays(values) {
const available=dates().map(item=>item.date);
if(!Array.isArray(values)||values.length!==available.length)return false;
const capacities=new Map(values.map(value=>[value?.plan_date,value?.capacity_minutes]));
const capacities=new Map(values.map(value=>[value?.plan_date,value]));
if(capacities.size!==available.length||available.some(date=>{
const value=capacities.get(date);return !Number.isInteger(value)||value<0||value>1440;
const value=capacities.get(date)?.capacity_minutes;
return !Number.isInteger(value)||value<0||value>1440;
}))return false;
return stageDays(available.map(planDate=>{
const value=day(planDate);value.capacity_minutes=capacities.get(planDate);return value;
}));
return available.map(planDate=>{
const value=day(planDate),capacity=capacities.get(planDate);
value.capacity_minutes=capacity.capacity_minutes;
if(Array.isArray(capacity.free_windows))value.free_windows=cloneWindows(capacity.free_windows);
else delete value.free_windows;
return value;
});
}
function stageAvailabilityDefaults(values) {
if(!cloneDefaults(values)||values.some(value=>!Number.isInteger(value)||value<0||value>1440)||offlineSnapshot)return false;
const previous=week.availability_defaults;
week.availability_defaults=values.slice();
const staged=stageDays(week.days);
if(!staged)week.availability_defaults=previous;
return staged;
}
function disableAvailabilityDefaults() {
if(offlineSnapshot)return false;
const previous=week.availability_defaults;
delete week.availability_defaults;
const staged=stageDays(week.days);
if(!staged&&previous)week.availability_defaults=previous;
return staged;
}
function review() {
const assigned=new Map();
@@ -227,8 +278,9 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
['over-capacity','missing-estimate','missing-capacity'].indexOf(left.type)-['over-capacity','missing-estimate','missing-capacity'].indexOf(right.type));
return {days,duplicates,blockers,can_confirm:duplicates.length===0&&blockers.length===0};
}
function previewReflow() {
const available=dates().map(item=>day(item.date));
function previewReflow(capacities=null) {
const available=capacities?capacityDays(capacities):dates().map(item=>day(item.date));
if(!available)return {days:[],blockers:[{type:'invalid-capacities'}],unscheduled:[]};
const seen=new Set(),work=[];
available.forEach(source=>(source.ids||[]).forEach(id=>{
if(seen.has(id))return;
@@ -241,7 +293,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
}));
if(blockers.length)return {days:[],blockers,unscheduled:[]};
const days=available.map(source=>({plan_date:source.plan_date,ids:[],
capacity_minutes:source.capacity_minutes??null,estimates:{}}));
capacity_minutes:source.capacity_minutes??null,estimates:{},
...(cloneWindows(source.free_windows)?{free_windows:cloneWindows(source.free_windows)}:{})}));
const unscheduled=[];let cursor=0;
if(!blockers.length)work.forEach(item=>{
const destination=days.slice(cursor).find(value=>value.ids.length<5&&Number(value.capacity_minutes)>0&&
@@ -257,6 +310,12 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
if(preview.blockers.length||offlineSnapshot)return false;
return stageDays(preview.days);
}
function previewCapacityReflow(values) { return previewReflow(values); }
function applyCapacityReflow(values) {
const preview=previewCapacityReflow(values);
if(preview.blockers.length||offlineSnapshot)return false;
return stageDays(preview.days);
}
function move(id,toDate) {
if(!dates().some(item=>item.date===toDate)) return false;
const sources=week.days.filter(item=>(item.ids||[]).includes(id));
@@ -322,7 +381,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
return Boolean(stageDays(changed));
}
function deliveryBody(value) {
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay)};
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay),
...(cloneDefaults(value.availability_defaults)?{availability_defaults:cloneDefaults(value.availability_defaults)}:{})};
}
function reconcile(local,remote) {
if(!Array.isArray(local.base_days)) return null;
@@ -359,14 +419,16 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
const merged=local&&reconcile(local,remote);
if(merged&&!merged.conflicts.length){
saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days})});
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days,
...(cloneDefaults(local.availability_defaults)?{availability_defaults:cloneDefaults(local.availability_defaults)}:{})})});
const current=pending();
if(current&&sameBody(current,deliveryBody(local)))storage.removeItem(key);
if(!pending())adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
return saved;
}
lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
days:remote.days.map(cloneDay)},merged,choices:{...(local?.resolutions||{})}};
days:remote.days.map(cloneDay),...(cloneDefaults(remote.availability_defaults)?
{availability_defaults:cloneDefaults(remote.availability_defaults)}:{})},merged,choices:{...(local?.resolutions||{})}};
} else {
const current=pending();
if(current&&!sameBody(current,body))return deliver();
@@ -399,13 +461,16 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
estimates:{...(value.estimates||{})},
}]).sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
const body={base_revision:local.revision,timezone:local.timezone,days:local.days};
const body={base_revision:local.revision,timezone:local.timezone,days:local.days,
...(cloneDefaults(week.availability_defaults)?{availability_defaults:cloneDefaults(week.availability_defaults)}:{})};
try {
return adoptConfirmed(await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
} catch(error) {
if(error?.status===409) {
const remote=await fetchJson('api/v1/week');
lastConflict={local,remote:{revision:remote.revision,timezone:remote.timezone,days:remote.days.map(cloneDay)}};
lastConflict={local:{...local,...(cloneDefaults(week.availability_defaults)?{availability_defaults:cloneDefaults(week.availability_defaults)}:{})},
remote:{revision:remote.revision,timezone:remote.timezone,days:remote.days.map(cloneDay),
...(cloneDefaults(remote.availability_defaults)?{availability_defaults:cloneDefaults(remote.availability_defaults)}:{})}};
}
throw error;
}
@@ -443,7 +508,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
const item=lastConflict.merged.conflicts.find(value=>value.plan_date===day.plan_date);
return item?cloneDay(choices[day.plan_date]==='account'?item.remote:item.local):cloneDay(day);
});
const local=lastConflict.local,body={base_revision:lastConflict.remote.revision,timezone:local.timezone,days};
const local=lastConflict.local,body={base_revision:lastConflict.remote.revision,timezone:local.timezone,days,
...(cloneDefaults(local.availability_defaults)?{availability_defaults:cloneDefaults(local.availability_defaults)}:{})};
try {
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const current=pending();
@@ -455,7 +521,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
if(error?.status===409){
const remote=await fetchJson('api/v1/week'),merged=reconcile(local,remote);
lastConflict={key:storageKey(),local,remote:{revision:remote.revision,timezone:remote.timezone||null,
days:remote.days.map(cloneDay)},merged,choices:{...(local.resolutions||{})}};
days:remote.days.map(cloneDay),...(cloneDefaults(remote.availability_defaults)?
{availability_defaults:cloneDefaults(remote.availability_defaults)}:{})},merged,choices:{...(local.resolutions||{})}};
}
throw error;
}
@@ -476,7 +543,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
if(error?.status===409){
const remote=await fetchJson('api/v1/week');
lastConflict={key:conflictKey,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
days:remote.days.map(cloneDay)}};
days:remote.days.map(cloneDay),...(cloneDefaults(remote.availability_defaults)?
{availability_defaults:cloneDefaults(remote.availability_defaults)}:{})}};
}
throw error;
}
@@ -527,6 +595,46 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
const result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
adoptConfirmed(result.week,{...confirmedItems,...pendingItems});return result;
}
function pullConflict() {
const queue=pullQueue(),entry=queue[0];
if(!entry?.conflict||!entry.current)return null;
const identity=entry.body.identity,today=entry.current.today;
const source=(entry.current.week.days||[]).find(day=>(day.ids||[]).includes(identity));
return {identity,title:(pendingItems[identity]||confirmedItems[identity])?.title||identity,
source_date:source?.plan_date||null,today_items:(today.ids||[]).length,
today_minutes:(today.ids||[]).reduce((total,id)=>total+(Number(today.estimates?.[id])||0),0),
today_capacity:Number(today.capacity_minutes)||0,operation_id:entry.body.operation_id,queue_length:queue.length};
}
async function resolvePullConflict(action) {
const queue=pullQueue(),current=queue[0];
if(!current?.conflict||!current.current||!['add','keep'].includes(action))return false;
const latest=current.current,identity=current.body.identity;
const alreadyToday=(latest.today.ids||[]).includes(identity);
const stillInWeek=(latest.week.days||[]).some(day=>(day.ids||[]).includes(identity));
let remaining=queue.slice(1);
if(action==='add'&&!alreadyToday&&stillInWeek){
if((latest.today.ids||[]).length>=5){const error=new Error('Today is full.');error.code='today_full';throw error;}
const source=latest.week.days.find(day=>(day.ids||[]).includes(identity));
const estimate=Number(source?.estimates?.[identity])||0;
const minutes=(latest.today.ids||[]).reduce((total,id)=>total+(Number(latest.today.estimates?.[id])||0),0);
const capacity=Number(latest.today.capacity_minutes)||0;
if(capacity&&minutes+estimate>capacity&&!current.body.allow_over_capacity){
const error=new Error('Today would exceed its current capacity.');error.code='today_over_capacity';throw error;
}
const rebased={...current,conflict:false,current:undefined,body:{...current.body,
today_revision:latest.today.revision,week_revision:latest.week.revision}};
remaining=[rebased,...remaining];
}
if(remaining.length){
const head=remaining[0];
remaining[0]={...head,conflict:false,current:undefined,body:{...head.body,
today_revision:latest.today.revision,week_revision:latest.week.revision}};
}
if(!writePullQueue(remaining))throw new Error('Could not update queued Week Ahead work on this device.');
adopt(latest.week);
if(!remaining.length)return {...latest,sync_pending:false,resolved:alreadyToday?'already-moved':(!stillInWeek?'no-longer-planned':'kept')};
return flushPull();
}
function flushPull() {
if(pulling)return pulling;
if(!pendingPull()||!pullKey())return Promise.resolve(false);
@@ -598,8 +706,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
return label+(pending()?' · sync pending':'');
}
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageStartTimes,stageCapacities,stageAvailabilityDefaults,disableAvailabilityDefaults,review,previewReflow,applyReflow,previewCapacityReflow,applyCapacityReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,pullConflict,resolvePullConflict,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
item:id=>pendingItems[id]||confirmedItems[id]||null,
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
}
@@ -629,7 +737,40 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
}
if(retry)retry.hidden=!offline;
if(capacity)capacity.hidden=offline;
const availability=qs('#open-week-availability');if(availability)availability.hidden=offline;
}
function closeAvailability({focus=false}={}) {
const panel=qs('#week-availability-editor');if(panel)panel.hidden=true;
if(focus)qs('#open-week-availability')?.focus?.();
}
function openAvailability() {
const panel=qs('#week-availability-editor'),defaults=controller.state().availability_defaults||[0,0,0,0,0,0,0];
panel?.querySelectorAll?.('[data-weekday-capacity]').forEach(input=>{input.value=String(defaults[Number(input.dataset.weekdayCapacity)]||0);});
if(panel){panel.hidden=false;panel.querySelector?.('[data-weekday-capacity]')?.focus?.();}
}
async function saveAvailability() {
const panel=qs('#week-availability-editor'),inputs=[...(panel?.querySelectorAll?.('[data-weekday-capacity]')||[])];
const values=inputs.map(input=>Number(input.value));
const status=qs('#week-availability-status');
if(values.length!==7||values.some(value=>!Number.isInteger(value)||value<0||value>1440)){
if(status)status.textContent='Enter 0 to 1440 minutes for every weekday.';return false;
}
if(!controller.stageAvailabilityDefaults(values))return false;
closeAvailability();renderReview();
try{await controller.flush();renderReview();if(status)status.textContent='Weekly availability saved.';
qs('#week-review-status').textContent='Weekly availability saved.';return true;}
catch(error){if(status)status.textContent=(error.message||'Sync is unavailable.')+' Weekly availability remains saved on this phone.';return false;}
}
async function disableAvailability() {
if(!controller.disableAvailabilityDefaults?.())return false;
closeAvailability();renderReview();
try{await controller.flush();renderReview();qs('#week-review-status').textContent='Weekly availability disabled.';return true;}
catch(error){qs('#week-review-status').textContent=(error.message||'Sync is unavailable.')+' Change remains saved on this phone.';return false;}
}
qs('#open-week-availability')?.addEventListener('click',openAvailability);
qs('#cancel-week-availability')?.addEventListener('click',()=>closeAvailability({focus:true}));
qs('#save-week-availability')?.addEventListener('click',saveAvailability);
qs('#disable-week-availability')?.addEventListener('click',disableAvailability);
function reconciliationDay(){
if(!reconciliation)return null;
const due=(reconciliation.week?.days||[]).find(day=>day.ids?.length);
@@ -791,7 +932,8 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
};
root.innerHTML=value.days.map(day=>{
const capacity=Number(day.capacity_minutes)||0;
const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+(day.overloaded?' · over capacity':'');
const source=day.capacity_source==='default'?' · Default':(day.capacity_source==='override'?' · Override':'');
const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+source+(day.overloaded?' · over capacity':'');
const move=id=>overviewing?'':'<div class="week-review-move"><label>Move to <select data-week-move-destination="'+escapeAttribute(id)+'">'+destinations+
'</select></label><button type="button" data-week-move="'+escapeAttribute(id)+'">Move</button></div>';
const unplan=id=>overviewing&&!readOnly?'<button type="button" class="week-review-unplan" data-week-unplan="'+escapeAttribute(id)+'">Remove from week</button>':'';
@@ -817,13 +959,22 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
const confirm=qs('#confirm-week-plan');
confirm.disabled=!value.can_confirm||pending;
const blocker=value.blockers?.[0];
qs('#week-review-status').textContent=readOnly?'Offline snapshot · viewing only. Retry for live editing.':(overviewing?'Week Ahead overview · '+(pending?'sync pending.':'no changes made.'):(value.duplicates.length?'Duplicate work must be moved to one date.':
const blockerMessage=blocker?(
blocker.type==='over-capacity'?blocker.plan_date+' is '+blocker.minutes+' min over capacity. Edit that day before confirming.':
blocker.type==='missing-estimate'?'Add an estimate for work on '+blocker.plan_date+' before confirming.':
'Add capacity for '+blocker.plan_date+' before confirming.'
):'';
qs('#week-review-status').textContent=readOnly?'Offline snapshot · viewing only. Retry for live editing.':(overviewing?(pending?'Week Ahead overview · sync pending.':
(blockerMessage||'Week Ahead overview · no changes made.')):(value.duplicates.length?'Duplicate work must be moved to one date.':
(pending?'Saving the latest week to your account…':blocker?(
blocker.type==='over-capacity'?blocker.plan_date+' is '+blocker.minutes+' min over capacity. Edit that day before confirming.':
blocker.type==='missing-estimate'?'Add an estimate for work on '+blocker.plan_date+' before confirming.':
'Add capacity for '+blocker.plan_date+' before confirming.'
blockerMessage
):'Week Ahead is balanced and ready.')));
confirm.hidden=overviewing||readOnly;
const exportSaved=qs('#export-saved-week-calendar');
if(exportSaved){
exportSaved.hidden=!overviewing||!value.days.some(day=>day.ids.length);
exportSaved.disabled=pending||!value.can_confirm;
}
const reflow=qs('#open-week-reflow');if(reflow)reflow.hidden=!overviewing||readOnly||pending||!value.days.some(day=>day.overloaded);
const editWeek=qs('#edit-week-plan');if(editWeek){editWeek.hidden=!overviewing||readOnly;editWeek.textContent=nextUp?'Edit week':'Plan Week Ahead';}
root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{
@@ -1013,19 +1164,63 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
qs('#my-work-action-status').textContent=message;
return value;
}
function renderPullConflict({open=false}={}) {
const detail=controller.pullConflict?.(),root=qs('#week-pull-conflict-review');
if(!root)return false;
root.hidden=!detail;
if(!detail)return false;
const item=qs('#week-pull-conflict-item'),context=qs('#week-pull-conflict-context');
if(item)item.textContent=detail.title;
if(context)context.textContent=(detail.source_date?'Planned for '+detail.source_date+' · ':'')+
'Today has '+detail.today_items+' item'+(detail.today_items===1?'':'s')+' · '+detail.today_minutes+
(detail.today_capacity?' of '+detail.today_capacity:'')+' min'+(detail.queue_length>1?' · '+detail.queue_length+' saved transfers waiting':'')+'.';
if(open){openPlanner(null,false);const title=qs('#plan-today-title');if(title)title.textContent='Resolve Today transfer';}
root.focus?.();
return detail;
}
async function resolvePullConflict(action) {
const status=qs('#week-pull-conflict-status'),add=qs('#rebase-week-pull'),keep=qs('#keep-week-pull');
if(add)add.disabled=true;if(keep)keep.disabled=true;
if(status)status.textContent=action==='add'?'Checking current plans…':'Keeping this work in Week Ahead…';
try{
const result=await controller.resolvePullConflict?.(action);
if(!result)return false;
adoptPull(result,action==='add'?'Saved transfer added using current plans.':'Work kept in Week Ahead.');
if(controller.pullConflict?.())return renderPullConflict();
const root=qs('#week-pull-conflict-review');if(root)root.hidden=true;
overviewing=true;reviewing=true;setReviewMode(true);
const reviewRoot=qs('#week-review');if(reviewRoot){reviewRoot.hidden=false;renderReview();}
return result;
}catch(error){
if(status)status.textContent=error?.code==='today_full'?'Today is full. Finish or move work, then retry.':
(error?.code==='today_over_capacity'?'Today would exceed its current capacity. Reopen Week Ahead and review Today first.':
(error.message||'Current plans could not be checked.')+' Your saved transfer is still waiting.');
return false;
}finally{if(add)add.disabled=false;if(keep)keep.disabled=false;}
}
qs('#rebase-week-pull')?.addEventListener('click',()=>controller.resolvePullConflict&&resolvePullConflict('add'));
qs('#keep-week-pull')?.addEventListener('click',()=>controller.resolvePullConflict&&resolvePullConflict('keep'));
function resumePull() {
const restored=controller.resumePull?.();
return restored?adoptPull(restored,'Week-to-Today pull saved on this phone · sync pending.'):false;
if(!restored)return false;
const adopted=adoptPull(restored,restored.conflict?'Today or Week Ahead changed. Choose how to finish the saved transfer.':
'Week-to-Today pull saved on this phone · sync pending.');
if(restored.conflict)renderPullConflict({open:true});
return adopted;
}
async function flushPull() {
const pendingPull=controller.pendingPull?.();
if(!pendingPull)return false;
if(pendingPull.conflict)return adoptPull(pendingPull.current,
'Today or Week Ahead changed on another device. Saved pull needs review.');
if(pendingPull.conflict){
const restored=adoptPull(pendingPull.current,'Today or Week Ahead changed on another device. Choose how to finish the saved transfer.');
renderPullConflict({open:true});return restored;
}
try {
const result=await controller.flushPull();
return adoptPull(result,result.conflict?'Today or Week Ahead changed on another device. Saved pull needs review.':
const adopted=adoptPull(result,result.conflict?'Today or Week Ahead changed on another device. Choose how to finish the saved transfer.':
'Week Ahead work added to Today.');
if(result.conflict)renderPullConflict({open:true});
return adopted;
} catch(error) {
qs('#my-work-action-status').textContent=(error.message||'Week-to-Today sync is unavailable.')+' Saved on this phone · sync pending.';
return false;
@@ -1052,6 +1247,7 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
}
}
const workflow={open,save,advance,confirm,finish,promote,retire,resumePull,flushPull,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
i:id=>getItem(id)||controller.item?.(id),
reviewing:()=>reviewing,overviewing:()=>overviewing,selectedDate:()=>selectedDate,
day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):(overviewing?{ids:[]}:null)),
copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}:
+13
View File
@@ -78,6 +78,19 @@ STORES = (
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
)),
Store(
"completed-filed-reviews",
"completed-filed-reviews",
"STACKCHAIN_COMPLETED_FILED_REVIEW_DB",
"completed-filed-reviews.sqlite3",
(
Table(
"completed_filed_review_collections",
("login",),
(Field("receipts", "receipts:{login}"),),
),
),
),
Store("security-events", "security-events", "STACKCHAIN_SECURITY_EVENT_DB", "security-events.sqlite3", (
Table("security_events", ("id",), (Field("payload", "event:{id}"),)),
)),
+93 -50
View File
@@ -1,4 +1,4 @@
"""Durable, account-scoped completed Filed review receipts."""
"""Durable, encrypted, account-scoped completed Filed review receipts."""
import re
import sqlite3
@@ -6,33 +6,68 @@ from datetime import datetime
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
class CompletedFiledReviewStore:
def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0):
def __init__(
self,
path: str | Path,
*,
limit: int = 200,
timeout: float = 1.0,
encryption_key: bytes | None = None,
):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="completed-filed-reviews",
)
self._initialize()
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA secure_delete=ON")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS completed_filed_reviews (
login TEXT NOT NULL,
repository TEXT NOT NULL,
issue_number INTEGER NOT NULL,
updated_at TEXT NOT NULL,
touched_at INTEGER NOT NULL,
PRIMARY KEY (login, repository, issue_number)
CREATE TABLE IF NOT EXISTS completed_filed_review_collections (
login TEXT PRIMARY KEY,
receipts TEXT NOT NULL
)
"""
)
legacy = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'completed_filed_reviews'"
).fetchone()
if legacy:
connection.execute("BEGIN IMMEDIATE")
rows = connection.execute(
"SELECT login, repository, issue_number, updated_at "
"FROM completed_filed_reviews ORDER BY login, touched_at"
).fetchall()
grouped: dict[str, list[dict]] = {}
for login, repository, number, updated_at in rows:
grouped.setdefault(login, []).append({
"repository": repository,
"number": int(number),
"updated_at": updated_at,
})
for login, receipts in grouped.items():
normalized = [self._receipt(item) for item in receipts][-self.limit:]
connection.execute(
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
(login, self._seal(login, normalized)),
)
connection.execute("DROP TABLE completed_filed_reviews")
with self._connect() as connection:
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
def _connect(self) -> sqlite3.Connection:
return connect_private_sqlite(self.path, timeout=self.timeout)
@@ -45,7 +80,7 @@ class CompletedFiledReviewStore:
return normalized
@staticmethod
def _receipt(raw: dict) -> tuple[str, int, str]:
def _receipt(raw: dict) -> dict:
if not isinstance(raw, dict):
raise ValueError("receipt must be an object")
repository = raw.get("repository")
@@ -63,23 +98,41 @@ class CompletedFiledReviewStore:
raise ValueError("updated_at is invalid") from error
if parsed.tzinfo is None:
raise ValueError("updated_at is invalid")
return repository, number, updated_at
return {"repository": repository, "number": number, "updated_at": updated_at}
def _open(self, login: str, payload: str | None) -> tuple[list[dict], bool]:
if payload is None:
return [], False
value, stale = self._cipher.open(payload, binding=f"receipts:{login}")
if not isinstance(value, list):
raise PrivateStateEncryptionError("private state could not be decrypted")
try:
return [self._receipt(item) for item in value], stale
except ValueError as error:
raise PrivateStateEncryptionError("private state could not be decrypted") from error
def _seal(self, login: str, receipts: list[dict]) -> str:
return self._cipher.seal(receipts, binding=f"receipts:{login}")
@staticmethod
def _snapshot(rows) -> dict:
return {"receipts": [
{"repository": row[0], "number": int(row[1]), "updated_at": row[2]}
for row in rows
]}
def _snapshot(receipts: list[dict]) -> dict:
return {"receipts": receipts}
def get(self, login: str) -> dict:
login = self._login(login)
with self._connect() as connection:
rows = connection.execute(
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
"WHERE login = ? ORDER BY touched_at",
(self._login(login),),
).fetchall()
return self._snapshot(rows)
row = connection.execute(
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
(login,),
).fetchone()
receipts, stale = self._open(login, row[0] if row else None)
if row is not None and stale:
connection.execute(
"UPDATE completed_filed_review_collections SET receipts = ? "
"WHERE login = ? AND receipts = ?",
(self._seal(login, receipts), login, row[0]),
)
return self._snapshot(receipts)
def merge(self, login: str, receipts: list[dict]) -> dict:
login = self._login(login)
@@ -88,35 +141,25 @@ class CompletedFiledReviewStore:
normalized = [self._receipt(receipt) for receipt in receipts]
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
touched = int(connection.execute(
"SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?",
row = connection.execute(
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
(login,),
).fetchone()[0])
for repository, number, updated_at in normalized:
current = connection.execute(
"SELECT updated_at FROM completed_filed_reviews "
"WHERE login = ? AND repository = ? AND issue_number = ?",
(login, repository, number),
).fetchone()
if current is not None and current[0] >= updated_at:
).fetchone()
current, _stale = self._open(login, row[0] if row else None)
for incoming in normalized:
match = next((
item for item in current
if item["repository"] == incoming["repository"] and item["number"] == incoming["number"]
), None)
if match is not None and match["updated_at"] >= incoming["updated_at"]:
continue
touched += 1
connection.execute(
"INSERT INTO completed_filed_reviews "
"(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(login, repository, issue_number) DO UPDATE SET "
"updated_at=excluded.updated_at, touched_at=excluded.touched_at",
(login, repository, number, updated_at, touched),
)
if match is not None:
current.remove(match)
current.append(incoming)
current = current[-self.limit:]
connection.execute(
"DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN ("
"SELECT rowid FROM completed_filed_reviews WHERE login = ? "
"ORDER BY touched_at DESC LIMIT ?)",
(login, login, self.limit),
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
(login, self._seal(login, current)),
)
rows = connection.execute(
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
"WHERE login = ? ORDER BY touched_at",
(login,),
).fetchall()
return self._snapshot(rows)
return self._snapshot(current)
+2 -2
View File
@@ -39,7 +39,7 @@ FEATURE_SOURCES = {
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
),
"today-timer": (
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
@@ -95,7 +95,7 @@ def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> Runti
digest = hashlib.sha256(content).hexdigest()[:16]
return RuntimeBundle(
runtime_bytes=content,
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
runtime_gzip_bytes=gzip.compress(content, compresslevel=9, mtime=0),
runtime_digest=digest,
runtime_name=f"{prefix}-{digest}.js",
)
+28 -2
View File
@@ -656,18 +656,33 @@ class TodayRecap(BaseModel):
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
class TodaySessionEntry(BaseModel):
identity: str = Field(min_length=1, max_length=500)
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
class TodaySessionUpdate(BaseModel):
base_revision: int = Field(ge=0)
device_id: str = Field(min_length=1, max_length=100)
identity: str = Field(max_length=500)
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
entries: list[TodaySessionEntry] | None = Field(default=None, max_length=20)
running: bool
break_deadline_at: int | None = Field(default=None, ge=0, le=10_000_000_000_000)
@model_validator(mode="after")
def validate_break_is_paused(self):
def validate_session_state(self):
if self.running and self.break_deadline_at is not None:
raise ValueError("break must remain paused")
if self.entries is not None:
identities = [entry.identity for entry in self.entries]
if len(set(identities)) != len(identities):
raise ValueError("Today session entry identities must be unique")
active = [entry for entry in self.entries if entry.identity == self.identity]
if self.identity and (len(active) != 1 or active[0].elapsed_ms != self.elapsed_ms):
raise ValueError("active Today session must match its ledger entry")
if not self.identity and self.entries:
raise ValueError("inactive Today session ledger must be empty")
return self
@@ -727,17 +742,27 @@ class TomorrowPromotion(BaseModel):
today_revision: int = Field(ge=0)
class WeekFreeWindow(BaseModel):
start_time: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
end_time: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
class WeekPlanDay(BaseModel):
plan_date: str = Field(min_length=10, max_length=10)
ids: list[str] = Field(max_length=5)
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
free_windows: list[WeekFreeWindow] | None = Field(default=None, max_length=16)
start_times: dict[str, str] = Field(default_factory=dict, max_length=5)
class WeekPlanUpdate(BaseModel):
base_revision: int = Field(ge=0)
days: list[WeekPlanDay] = Field(max_length=7)
timezone: str = Field(min_length=1, max_length=100)
availability_defaults: list[int] | None = Field(
default=None, min_length=7, max_length=7,
)
class WeekPromotion(BaseModel):
@@ -2826,8 +2851,9 @@ async def replace_week_plan(payload: WeekPlanUpdate):
return await asyncio.to_thread(
_today_store().replace_week, login,
base_revision=payload.base_revision,
days=[day.model_dump() for day in payload.days],
days=[day.model_dump(exclude_none=True) for day in payload.days],
timezone=payload.timezone,
availability_defaults=payload.availability_defaults,
)
except WeekPlanConflict as error:
raise HTTPException(
+3
View File
@@ -133,6 +133,7 @@ async def dispatch_unread_updates(
for item in page.get("items", [])
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
}
unread_count = min(len(thread_revisions), 9999)
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
if session_statuses is not None:
@@ -201,6 +202,7 @@ async def dispatch_unread_updates(
"route": f"#/my-work/update/{thread_id}",
"tag": f"stackchain-update-{thread_id}",
"notification_id": thread_id,
"unread_count": unread_count,
},
separators=(",", ":"),
)
@@ -258,6 +260,7 @@ async def dispatch_unread_updates(
"route": "#/my-work/updates",
"tag": "stackchain-update-digest",
"update_count": len(overflow_revisions),
"unread_count": unread_count,
},
separators=(",", ":"),
)
+135 -9
View File
@@ -519,7 +519,10 @@ class TodayStore:
)
return {"revision": 1, **payload}
def _normalize_week(self, *, days: list[dict], timezone: str) -> dict:
def _normalize_week(
self, *, days: list[dict], timezone: str,
availability_defaults: list[int] | None = None,
) -> dict:
if not isinstance(days, list) or len(days) > 7:
raise ValueError("Week Ahead is limited to seven dates")
if not isinstance(timezone, str) or not timezone.strip() or len(timezone) > 100:
@@ -548,6 +551,66 @@ class TodayStore:
ids=day.get("ids", []), capacity_minutes=day.get("capacity_minutes"),
estimates=day.get("estimates", {}), plan_date=plan_date, timezone=timezone,
)
if "free_windows" in day:
windows = day["free_windows"]
if not isinstance(windows, list) or len(windows) > 16:
raise ValueError("free windows must be a bounded list")
normalized_windows = []
previous_end = -1
for window in windows:
if not isinstance(window, dict) or set(window) != {"start_time", "end_time"}:
raise ValueError("free windows must contain only start and end times")
clock_values = []
for key in ("start_time", "end_time"):
value = window[key]
if (
not isinstance(value, str) or len(value) != 5 or value[2] != ":"
or not value[:2].isdigit() or not value[3:].isdigit()
or int(value[:2]) > 23 or int(value[3:]) > 59
):
raise ValueError("free windows must use HH:mm times")
clock_values.append(int(value[:2]) * 60 + int(value[3:]))
start, end = clock_values
if start < previous_end or end <= start:
raise ValueError("free windows must be ordered and non-overlapping")
normalized_windows.append({
"start_time": window["start_time"], "end_time": window["end_time"],
})
previous_end = end
plan["free_windows"] = normalized_windows
if "start_times" in day:
start_times = day["start_times"]
if not isinstance(start_times, dict) or len(start_times) > 5:
raise ValueError("task start times must be a bounded object")
if not set(start_times).issubset(plan["ids"]):
raise ValueError("task start times must belong to work on the same date")
occupied = []
for identity in plan["ids"]:
if identity not in start_times:
continue
value = start_times[identity]
if (
not isinstance(value, str) or len(value) != 5 or value[2] != ":"
or not value[:2].isdigit() or not value[3:].isdigit()
or int(value[:2]) > 23 or int(value[3:]) > 59
):
raise ValueError("task start times must use HH:mm times")
start = int(value[:2]) * 60 + int(value[3:])
minutes = plan["estimates"].get(identity)
if not isinstance(minutes, int) or start + minutes > 1440:
raise ValueError("task start times must fit their estimates within the date")
end = start + minutes
if any(start < other_end and end > other_start for other_start, other_end in occupied):
raise ValueError("task start times must not overlap")
windows = plan.get("free_windows")
if windows and not any(
int(window["start_time"][:2]) * 60 + int(window["start_time"][3:]) <= start
and end <= int(window["end_time"][:2]) * 60 + int(window["end_time"][3:])
for window in windows
):
raise ValueError("task start times must fit retained free windows")
occupied.append((start, end))
plan["start_times"] = {identity: start_times[identity] for identity in plan["ids"] if identity in start_times}
duplicate_ids = seen_ids.intersection(plan["ids"])
if duplicate_ids:
raise ValueError("work must be assigned to only one Week Ahead date")
@@ -555,13 +618,32 @@ class TodayStore:
plan.pop("timezone")
normalized.append(plan)
normalized.sort(key=lambda item: item["plan_date"])
return {"timezone": timezone, "days": normalized}
result = {"timezone": timezone, "days": normalized}
if availability_defaults is not None:
if (
not isinstance(availability_defaults, list)
or len(availability_defaults) != 7
or any(
not isinstance(value, int) or isinstance(value, bool)
or value < 0 or value > 1440
for value in availability_defaults
)
):
raise ValueError("availability defaults must contain seven weekday capacities")
result["availability_defaults"] = list(availability_defaults)
return result
def replace_week(self, login: str, *, base_revision: int, days: list[dict], timezone: str) -> dict:
def replace_week(
self, login: str, *, base_revision: int, days: list[dict], timezone: str,
availability_defaults: list[int] | None = None,
) -> dict:
login = self._normalize_login(login)
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
raise ValueError("base_revision must be a non-negative integer")
normalized = self._normalize_week(days=days, timezone=timezone)
normalized = self._normalize_week(
days=days, timezone=timezone,
availability_defaults=availability_defaults,
)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
@@ -649,7 +731,10 @@ class TodayStore:
and not allow_over_capacity
):
raise ValueError("explicit overload confirmation is required")
normalized_week = self._normalize_week(days=days, timezone=week["timezone"])
normalized_week = self._normalize_week(
days=days, timezone=week["timezone"],
availability_defaults=week.get("availability_defaults"),
)
today_ids = [item_id for item_id in today["ids"] if item_id != identity]
today_estimates = {
@@ -916,9 +1001,31 @@ class TodayStore:
def _empty_session() -> dict:
return {
"revision": 0, "device_id": "", "identity": "",
"elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
"elapsed_ms": 0, "entries": [], "running": False,
"break_deadline_at": None, "updated_at": None,
}
@staticmethod
def _normalize_session_entries(entries: list[dict]) -> list[dict]:
if not isinstance(entries, list) or len(entries) > 20:
raise ValueError("Today session entries are limited to 20 items")
normalized = []
seen = set()
for item in entries:
identity = item.get("identity", "") if isinstance(item, dict) else ""
elapsed_ms = item.get("elapsed_ms") if isinstance(item, dict) else None
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
raise ValueError("Today session entry identity is required and bounded")
identity = identity.strip()
if identity in seen:
raise ValueError("Today session entry identities must be unique")
if (not isinstance(elapsed_ms, int) or isinstance(elapsed_ms, bool)
or elapsed_ms < 0 or elapsed_ms > 7 * 24 * 60 * 60 * 1000):
raise ValueError("Today session elapsed time is invalid")
seen.add(identity)
normalized.append({"identity": identity, "elapsed_ms": elapsed_ms})
return normalized
def _session_snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None:
return self._empty_session(), False
@@ -926,17 +1033,28 @@ class TodayStore:
payload, legacy = self._cipher.open(row[1], binding=f"session:{login}")
if not isinstance(payload, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
if "entries" not in payload:
payload["entries"] = ([{
"identity": payload.get("identity", ""),
"elapsed_ms": int(payload.get("elapsed_ms", 0)),
}] if payload.get("identity") else [])
legacy = True
return {"revision": int(row[0]), **payload, "updated_at": row[6]}, legacy
return {
session = {
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
"elapsed_ms": int(row[3]), "running": bool(row[4]),
"break_deadline_at": row[5], "updated_at": row[6],
}, True
}
session["entries"] = ([{
"identity": session["identity"], "elapsed_ms": session["elapsed_ms"],
}] if session["identity"] else [])
return session, True
def _sealed_session(self, login: str, session: dict) -> str:
return self._cipher.seal({
"device_id": session["device_id"], "identity": session["identity"],
"elapsed_ms": session["elapsed_ms"], "running": session["running"],
"entries": session["entries"],
"break_deadline_at": session["break_deadline_at"],
}, binding=f"session:{login}")
@@ -959,6 +1077,7 @@ class TodayStore:
def update_session(
self, login: str, *, base_revision: int, device_id: str,
identity: str, elapsed_ms: int, running: bool, break_deadline_at: int | None = None,
entries: list[dict] | None = None,
) -> dict:
login = self._normalize_login(login)
updated_at = self.clock()
@@ -973,9 +1092,16 @@ class TodayStore:
if base_revision != current_revision:
raise TodaySessionConflict(current_session)
revision = current_revision + 1
normalized_entries = self._normalize_session_entries(
entries if entries is not None else
([{"identity": identity, "elapsed_ms": elapsed_ms}] if identity else [])
)
active_entries = [entry for entry in normalized_entries if entry["identity"] == identity]
if identity and (len(active_entries) != 1 or active_entries[0]["elapsed_ms"] != elapsed_ms):
raise ValueError("active Today session must match its ledger entry")
session = {
"revision": revision, "device_id": device_id, "identity": identity,
"elapsed_ms": elapsed_ms, "running": bool(running),
"elapsed_ms": elapsed_ms, "entries": normalized_entries, "running": bool(running),
"break_deadline_at": break_deadline_at, "updated_at": updated_at,
}
connection.execute(
+10 -1
View File
@@ -88,7 +88,16 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
expect(page.locator("#send-issue-comment-next")).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
expect(page.locator("#plan-today-sheet")).to_be_hidden()
try:
expect(page.locator("#plan-today-sheet")).to_be_hidden()
except AssertionError as error:
raise AssertionError(page.evaluate("""() => ({
hidden:document.querySelector('#plan-today-sheet').hidden,
state:history.state,
bodyClass:document.body.className,
status:document.querySelector('#my-work-action-status').textContent,
error:document.querySelector('#plan-today-error').textContent,
})""")) from error
page.locator("#close-issue-sheet").click()
if page.locator("#plan-today-sheet").is_visible():
@@ -69,7 +69,10 @@ def test_release_artifact_reschedules_active_today_into_week_ahead(
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
page.locator("#close-issue-sheet").click()
issue_sheet = page.locator("#issue-sheet")
if issue_sheet.is_visible():
page.locator("#close-issue-sheet").click()
expect(issue_sheet).to_be_hidden()
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
+60 -4
View File
@@ -65,6 +65,8 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": body["base_revision"] + 1,
"timezone": body["timezone"], "days": body["days"],
**({"availability_defaults": body["availability_defaults"]}
if "availability_defaults" in body else {}),
}))
return
route.fulfill(status=200, content_type="application/json", body=json.dumps({
@@ -103,10 +105,34 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
expect(cards).to_have_count(7)
expect(cards.first).to_contain_text("Next up")
expect(cards.first).to_contain_text("Ship mobile capture")
expect(page.locator("#week-review-status")).to_have_text(
"Week Ahead overview · no changes made."
)
expect(page.locator("#week-review-status")).to_contain_text("30 min over capacity")
expect(page.locator("#confirm-week-plan")).to_be_hidden()
blocked_export = page.locator("#export-saved-week-calendar")
expect(blocked_export).to_be_visible()
expect(blocked_export).to_be_disabled()
availability = page.locator("#open-week-availability")
expect(availability).to_be_visible()
availability_bounds = availability.bounding_box()
assert availability_bounds and availability_bounds["height"] >= 44
availability.click()
editor = page.locator("#week-availability-editor")
expect(editor).to_be_visible()
capacities = editor.locator("[data-weekday-capacity]")
expect(capacities).to_have_count(7)
for index, minutes in enumerate((480, 480, 480, 480, 360, 0, 0)):
capacities.nth(index).fill(str(minutes))
bounds = capacities.nth(index).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#save-week-availability").click()
expect(editor).to_be_hidden()
expect(page.locator("#week-review-status")).to_have_text("Weekly availability saved.")
assert saved[-1]["availability_defaults"] == [480, 480, 480, 480, 360, 0, 0]
availability.click()
page.locator("#disable-week-availability").click()
expect(editor).to_be_hidden()
expect(page.locator("#week-review-status")).to_have_text("Weekly availability disabled.")
assert "availability_defaults" not in saved[-1]
edit_week = page.locator("#edit-week-plan")
expect(edit_week).to_be_visible()
remove_actions = cards.first.locator("[data-week-unplan]")
@@ -167,6 +193,22 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
expect(overview).to_be_visible()
expect(planned_item).to_be_focused()
assert len(saved) == writes_before_reflow + 1, "opening and inspecting Week Ahead must not add a write"
export_saved = page.locator("#export-saved-week-calendar")
expect(export_saved).to_be_visible()
expect(export_saved).to_be_enabled()
export_bounds = export_saved.bounding_box()
assert export_bounds and export_bounds["height"] >= 44
writes_before_export = len(saved)
export_saved.click()
expect(page.locator("#week-calendar-handoff")).to_be_visible()
expect(overview).to_be_hidden()
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text(
"09:00–09:45 · 45 min"
)
assert len(saved) == writes_before_export, "exporting a saved week must not add a write"
page.locator("#back-to-week-review-from-calendar").click()
expect(overview).to_be_visible()
expect(export_saved).to_be_focused()
for control in (cards.first.locator("[data-week-edit-day]"), edit_week):
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
@@ -260,15 +302,20 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
page.locator("#week-capacity-file"),
page.locator("#week-capacity-start"),
page.locator("#week-capacity-end"),
page.locator("#keep-week-free-times"),
page.locator("#apply-week-capacities"),
):
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#keep-week-free-times").check()
page.locator("#apply-week-capacities").click()
expect(capacity_import).to_be_hidden()
expect(page.locator("#week-review")).to_be_visible()
expect(first_day).to_contain_text("45 of 420 min")
assert saved[-1]["days"][0]["free_windows"] == [
{"start_time": "10:00", "end_time": "17:00"}
]
page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled")
confirm = page.locator("#confirm-week-plan")
@@ -281,9 +328,18 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
expect(start).to_have_value("09:00")
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:00–10:45 · 45 min")
expect(page.locator("#week-calendar-status")).to_contain_text("Planning around imported busy time")
for control in (start, page.locator("#back-to-week-review-from-calendar"), page.locator("#share-week-calendar")):
exact = page.locator('[data-week-calendar-exact="issue:acme/mobile:41:"]')
expect(exact).to_have_value("")
exact.fill("14:00")
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("14:00–14:45 · 45 min")
save_times = page.locator("#save-week-calendar-times")
for control in (start, exact, page.locator("#back-to-week-review-from-calendar"), save_times, page.locator("#share-week-calendar")):
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
save_times.click()
expect(page.locator("#week-calendar-status")).to_have_text("Exact task times saved to Week Ahead.")
assert saved[-1]["days"][0]["start_times"] == {"issue:acme/mobile:41:": "14:00"}
page.locator("#back-to-week-review-from-calendar").click()
expect(page.locator("#week-review")).to_be_visible()
confirm.click()
@@ -1,3 +1,5 @@
import sqlite3
import httpx
import pytest
@@ -21,6 +23,30 @@ def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path)
assert store.get("alexander") == {"receipts": []}
def test_receipt_history_is_encrypted_and_bound_to_its_account(tmp_path):
database = tmp_path / "completed-filed.sqlite3"
store = CompletedFiledReviewStore(database, encryption_key=b"f" * 32)
timmy = receipt("private-timmy/repository", 912)
alexander = receipt("private-alexander/repository", 427)
store.merge("timmy-private-login", [timmy])
store.merge("alexander-private-login", [alexander])
raw = database.read_bytes()
assert b"private-timmy/repository" not in raw
assert b"private-alexander/repository" not in raw
with sqlite3.connect(database) as connection:
rows = connection.execute(
"SELECT login, receipts FROM completed_filed_review_collections ORDER BY login"
).fetchall()
connection.execute(
"UPDATE completed_filed_review_collections SET receipts = ? WHERE login = ?",
(rows[1][1], rows[0][0]),
)
with pytest.raises(RuntimeError, match="private state could not be decrypted"):
store.get("alexander-private-login")
def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2)
newer = receipt(updated_at="2026-08-16T12:00:00Z")
@@ -32,6 +58,42 @@ def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]}
def test_legacy_plaintext_receipts_migrate_for_every_account_without_loss(tmp_path):
database = tmp_path / "completed-filed.sqlite3"
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TABLE completed_filed_reviews ("
"login TEXT NOT NULL, repository TEXT NOT NULL, issue_number INTEGER NOT NULL, "
"updated_at TEXT NOT NULL, touched_at INTEGER NOT NULL, "
"PRIMARY KEY (login, repository, issue_number))"
)
connection.executemany(
"INSERT INTO completed_filed_reviews VALUES (?, ?, ?, ?, ?)",
[
("timmy", "legacy-private/first", 1, "2026-08-14T12:00:00Z", 1),
("timmy", "legacy-private/second", 2, "2026-08-15T12:00:00Z", 2),
("alexander", "legacy-private/other", 3, "2026-08-16T12:00:00Z", 1),
],
)
store = CompletedFiledReviewStore(database, encryption_key=b"m" * 32)
assert store.get("timmy") == {"receipts": [
receipt("legacy-private/first", 1, "2026-08-14T12:00:00Z"),
receipt("legacy-private/second", 2, "2026-08-15T12:00:00Z"),
]}
assert store.get("alexander") == {"receipts": [
receipt("legacy-private/other", 3, "2026-08-16T12:00:00Z")
]}
raw = database.read_bytes()
assert b"legacy-private/first" not in raw
assert b"legacy-private/other" not in raw
with sqlite3.connect(database) as connection:
assert connection.execute(
"SELECT count(*) FROM sqlite_master WHERE name = 'completed_filed_reviews'"
).fetchone()[0] == 0
@pytest.mark.parametrize("candidate", [
{"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"},
{"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"},
+5 -5
View File
@@ -364,7 +364,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [
@@ -392,7 +392,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
@@ -525,7 +525,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -550,7 +550,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -648,7 +648,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
+212
View File
@@ -0,0 +1,212 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
APP_BADGE = ROOT / "frontend" / "mobile-app-badge.js"
INDEX = ROOT / "frontend" / "index.html"
DASHBOARD = ROOT / "frontend" / "dashboard.js"
CSS = ROOT / "frontend" / "dashboard.css"
DEVICE_SETUP = ROOT / "frontend" / "mobile-device-setup.js"
def run_badge(scenario: str) -> dict:
assert APP_BADGE.exists(), "mobile app badge controller is not implemented"
script = f"""
const createMobileAppBadge = require({json.dumps(str(APP_BADGE))});
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(completed.stdout)
def test_app_badge_opt_in_reconciles_only_authoritative_unread_totals_without_churn():
result = run_badge("""
const listeners = {};
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
const status = {textContent:''};
const container = {hidden:false};
const values = [];
let clears = 0;
const storage = new Map();
const controller = createMobileAppBadge({
control, status, container,
navigator: {
async setAppBadge(value) { values.push(value); },
async clearAppBadge() { clears++; },
},
storage: {
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
setItem(key, value) { storage.set(key, value); },
removeItem(key) { storage.delete(key); },
},
});
controller.start();
await controller.reconcile(7, {authoritative:true});
await controller.reconcile(12, {authoritative:false});
control.checked = true;
await listeners.change();
await controller.reconcile(7, {authoritative:true});
await controller.reconcile(0, {authoritative:true});
console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-badge.enabled.v1'), status:status.textContent}));
""")
assert result == {
"values": [7],
"clears": 1,
"saved": "true",
"status": "App icon badge is on and up to date.",
}
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
result = run_badge("""
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
const status = {textContent:''};
const container = {hidden:false};
let reads = 0;
const controller = createMobileAppBadge({
control, status, container, navigator:{},
storage:{getItem() { reads++; return 'true'; }},
});
const started = controller.start();
console.log(JSON.stringify({started, hidden:container.hidden, disabled:control.disabled, reads, status:status.textContent}));
""")
assert result == {
"started": False,
"hidden": True,
"disabled": True,
"reads": 0,
"status": "App icon badges are unavailable in this browser.",
}
def test_app_badge_is_packaged_touch_safe_and_reconciled_from_server_total():
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert '<script src="static/mobile-app-badge.js"></script>' in index
assert 'id="app-badge-control"' in index
assert 'id="app-badge-status" role="status"' in index
assert ".app-badge-control { min-height:44px;" in css
assert "const mobileAppBadge = createMobileAppBadge({" in dashboard
assert "mobileAppBadge.start();" in dashboard
assert "mobileAppBadge.reconcile(pagination.total, {authoritative:true});" in dashboard
def test_app_badge_exposes_device_setup_readiness_and_enable_action():
result = run_badge("""
const control = {checked:false, disabled:false, addEventListener() {}};
const status = {textContent:''};
const saved = new Map();
const values = [];
const controller = createMobileAppBadge({
control, status, container:{hidden:false},
navigator:{async setAppBadge(value) { values.push(value); }, async clearAppBadge() {}},
storage:{
getItem(key) { return saved.get(key) || null; },
setItem(key, value) { saved.set(key, value); },
removeItem(key) { saved.delete(key); },
},
});
controller.start();
await controller.reconcile(4, {authoritative:true});
const before = controller.readiness();
await controller.enable();
const after = controller.readiness();
console.log(JSON.stringify({before, after, checked:control.checked, values}));
""")
assert result == {
"before": {
"state": "incomplete",
"detail": "Show the confirmed unread Updates count without opening Stackchain.",
},
"after": {
"state": "complete",
"detail": "The app icon shows the confirmed unread Updates count.",
},
"checked": True,
"values": [4],
}
def test_device_setup_offers_app_badge_as_an_optional_supported_step():
index = INDEX.read_text()
setup = DEVICE_SETUP.read_text()
dashboard = DASHBOARD.read_text()
assert 'id="device-setup-app-badge-status" role="status"' in index
assert 'id="device-setup-app-badge" type="button"' in index
assert "['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable']" in setup
assert "appBadge:options.appBadgeReadiness()," in setup
assert "appBadgeReadiness:() => mobileAppBadge.readiness()," in dashboard
assert "enableAppBadge:() => mobileAppBadge.enable()," in dashboard
def test_app_badge_api_failure_is_reported_without_rejecting_live_refresh():
result = run_badge("""
const control = {checked:true, disabled:false, addEventListener() {}};
const status = {textContent:''};
const controller = createMobileAppBadge({
control, status, container:{hidden:false},
navigator:{async setAppBadge() { throw new Error('platform failure'); }, async clearAppBadge() {}},
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
});
controller.start();
const reconciled = await controller.reconcile(3, {authoritative:true});
console.log(JSON.stringify({reconciled, status:status.textContent}));
""")
assert result == {
"reconciled": False,
"status": "Could not update the app icon badge. Stackchain still has the confirmed count.",
}
def test_disabling_badge_keeps_preference_off_when_platform_clear_fails():
result = run_badge("""
const listeners = {};
const control = {checked:true, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
const status = {textContent:''};
const saved = new Map([['stackchain.app-badge.enabled.v1', 'true']]);
const controller = createMobileAppBadge({
control, status, container:{hidden:false},
navigator:{async setAppBadge() {}, async clearAppBadge() { throw new Error('platform failure'); }},
storage:{getItem(key) { return saved.get(key) || null; }, setItem(key, value) { saved.set(key, value); }, removeItem(key) { saved.delete(key); }},
});
controller.start();
control.checked = false;
const changed = await listeners.change();
console.log(JSON.stringify({changed, saved:saved.has('stackchain.app-badge.enabled.v1'), status:status.textContent}));
""")
assert result == {
"changed": False,
"saved": False,
"status": "App icon badge is off, but the browser could not clear the old count.",
}
def test_app_badge_synchronizes_opt_in_with_the_active_service_worker():
result = run_badge("""
const listeners = {};
const messages = [];
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
const controller = createMobileAppBadge({
control, status:{textContent:''}, container:{hidden:false},
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
storage:{getItem() { return null; }, setItem() {}, removeItem() {}},
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
});
controller.start();
control.checked = true;
await listeners.change();
control.checked = false;
await listeners.change();
console.log(JSON.stringify(messages));
""")
assert result == [
{"type": "stackchain-app-badge-preference", "enabled": True},
{"type": "stackchain-app-badge-preference", "enabled": False},
]
+20
View File
@@ -190,6 +190,26 @@ process.stdout.write(JSON.stringify({offline, calls, sheetOpen:sheet.open}));
}
def test_first_task_coaching_reveals_its_mobile_today_host():
result = run_node(
"""
const values = new Map([['stackchain.first-task.v1:timmy', 'coaching']]);
const host = new Element(); host.hidden = true;
const coach = new Element(); coach.hidden = true; coach.closest = () => host;
const controller = createFirstTask({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)},
getLogin:()=> 'timmy',hasWork:()=>true,isTodayActive:()=>true,isOnline:()=>true,
mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach,receipt:new Element(),
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),status:new Element(),
});
controller.refresh();
process.stdout.write(JSON.stringify({coachHidden:coach.hidden,hostHidden:host.hidden}));
"""
)
assert result == {"coachHidden": False, "hostHidden": False}
def test_first_task_exposes_completion_operation_before_showing_local_success():
result = run_node(
"""
+16
View File
@@ -0,0 +1,16 @@
from pathlib import Path
RUNBOOK = Path(__file__).parents[1] / "docs" / "ops-fundamentals.md"
def test_disk_capacity_runbook_requires_safe_remediation_and_release_verification():
text = " ".join(RUNBOOK.read_text().split())
assert "Disk capacity incident" in text
assert "85%" in text
assert "df -h / /var/lib/gitea" in text
assert "disposable caches and abandoned temporary environments" in text
assert "must not delete Gitea data" in text
assert "systemctl is-active gitea act_runner" in text
assert "repository test suite" in text
+8
View File
@@ -407,6 +407,14 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
assert "taskOverlayHistory.leave();" in html
@pytest.mark.anyio
async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
html = await dashboard()
transition = html.split("function performTodayTransition", 1)[1].split("const todayReadiness", 1)[0]
assert "qs('#plan-today-sheet').hidden = true;" in transition
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
+1
View File
@@ -34,6 +34,7 @@ process.stdout.write(JSON.stringify(databases));
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
}
+1
View File
@@ -51,6 +51,7 @@ const clear = createPrivateDeviceDataPurger({{
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
]
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
+4
View File
@@ -613,6 +613,7 @@ async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path
"route": "#/my-work/update/42",
"tag": "stackchain-update-42",
"notification_id": 42,
"unread_count": 1,
}
assert "private/repo" not in json.dumps(sent)
assert "Secret title" not in json.dumps(sent)
@@ -692,7 +693,9 @@ async def test_update_burst_sends_bounded_individual_pushes_and_one_private_dige
"route": "#/my-work/updates",
"tag": "stackchain-update-digest",
"update_count": 3,
"unread_count": 5,
}
assert [payload["unread_count"] for payload in sent] == [5, 5, 5]
assert "private/repo" not in json.dumps(sent)
assert "Secret update" not in json.dumps(sent)
assert await dispatch_unread_updates(
@@ -792,6 +795,7 @@ async def test_production_notification_page_dispatches_one_unread_push(tmp_path)
"route": "#/my-work/update/42",
"tag": "stackchain-update-42",
"notification_id": 42,
"unread_count": 1,
}]
assert "private/repo" not in json.dumps(sent)
assert "Secret title" not in json.dumps(sent)
+16
View File
@@ -0,0 +1,16 @@
from pathlib import Path
ROOT = Path(__file__).parents[1]
def test_week_reschedule_journey_closes_only_a_visible_issue_sheet():
journey = (
ROOT / "tests" / "e2e" / "test_mobile_today_week_reschedule_release.py"
).read_text()
assert 'issue_sheet = page.locator("#issue-sheet")' in journey
assert "if issue_sheet.is_visible():" in journey
guarded_close = journey.split("if issue_sheet.is_visible():", 1)[1].split("\n", 2)[1]
assert 'page.locator("#close-issue-sheet").click()' in guarded_close
assert "expect(issue_sheet).to_be_hidden()" in journey
+30
View File
@@ -6,6 +6,7 @@ import subprocess
import sys
from pathlib import Path
from src.completed_filed_review_store import CompletedFiledReviewStore
from src.saved_search_store import SavedSearchStore
@@ -81,3 +82,32 @@ def test_rotation_command_fails_closed_and_never_prints_private_content(tmp_path
assert secret not in completed.stdout
assert "timmy" not in completed.stdout
assert completed.stderr == ""
def test_rotation_command_rewraps_completed_filed_history_without_printing_it(tmp_path):
state = tmp_path / "state"
path = state / "completed-filed-reviews.sqlite3"
private_receipt = {
"repository": "private-rotation/canary",
"number": 1252,
"updated_at": "2026-08-22T12:00:00Z",
}
CompletedFiledReviewStore(path, encryption_key=b"o" * 32).merge("timmy", [private_receipt])
completed = run_rotation(state)
assert completed.returncode == 0, completed.stderr
report = json.loads(completed.stdout)
assert report["completed-filed-reviews"] == {
"current": 0, "failed": 0, "migrated": 1, "total": 1
}
assert "private-rotation" not in completed.stdout
assert "timmy" not in completed.stdout
with sqlite3.connect(path) as connection:
payload = connection.execute(
"SELECT receipts FROM completed_filed_review_collections WHERE login = 'timmy'"
).fetchone()[0]
assert payload.startswith("v2:next:")
assert CompletedFiledReviewStore(
path, encryption_key=({"next": b"n" * 32}, "next")
).get("timmy") == {"receipts": [private_receipt]}
+45 -1
View File
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@@ -78,6 +78,10 @@ const context = {{
}},
purge: async () => {{ state.todayCommands = {{}}; }},
}},
__STACKCHAIN_APP_BADGE_PREFERENCE: {{
get: async () => state.badgeEnabled,
set: async enabled => {{ state.badgeEnabled = enabled; }},
}},
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{
@@ -88,6 +92,8 @@ const context = {{
registration: {{
showNotification: async (title, options) => state.notifications.push({{title,options}}),
getNotifications: async () => [{{close:()=>{{state.closedNotifications += 1;}}}}],
setAppBadge: async value => state.appBadges.push(value),
clearAppBadge: async () => {{ state.clearedAppBadges += 1; }},
}},
__issueSync: {{
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
@@ -479,6 +485,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert result["state"]["clientMessages"] == [
@@ -800,6 +807,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
]
assert result["replies"] == [{"ok": True}]
@@ -973,6 +981,40 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c
assert "must-not-render" not in json.dumps(result["notifications"])
def test_opted_in_background_updates_reconcile_authoritative_badge_without_churn():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:true}, [], {url:'https://forge.example/dashboard/'});
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:7});
await dispatchPush({tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:2, unread_count:7});
await dispatchPush({tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda', protect_route:'#/my-work/agenda/protect-today', deadline_count:1, unread_count:99});
await dispatchPush({tag:'stackchain-update-43', route:'#/my-work/update/43', notification_id:43, unread_count:'invalid'});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["badgeEnabled"] is True
assert result["appBadges"] == [7]
assert result["clearedAppBadges"] == 0
assert len(result["notifications"]) == 4
def test_disabling_background_badge_clears_it_and_future_updates_leave_it_off():
result = run_worker_scenario(
"""
state.badgeEnabled = true;
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3});
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:false}, [], {url:'https://forge.example/dashboard/'});
await dispatchPush({tag:'stackchain-update-43', route:'#/my-work/update/43', notification_id:43, unread_count:4});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["badgeEnabled"] is False
assert result["appBadges"] == [3]
assert result["clearedAppBadges"] == 1
def test_deadline_digest_push_offers_protect_today_and_snooze_without_rendering_private_copy():
result = run_worker_scenario(
"""
@@ -1402,6 +1444,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-launch.js",
"/dashboard/static/mobile-insights.js",
"/dashboard/static/mobile-app-shortcuts.js",
"/dashboard/static/mobile-app-badge.js",
"/dashboard/static/install-app.js",
"/dashboard/static/private-data-inventory.js",
"/dashboard/static/private-device-data.js",
@@ -1564,6 +1607,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert "private cached dashboard" not in result["body"]
+14
View File
@@ -267,6 +267,20 @@ restoring.then(result => {{
assert "timerView.restore(todaySync.flush());" in dashboard
def test_fresh_unchanged_context_restores_the_confirmed_break_owner():
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
snapshot_handler = dashboard.split("async function renderLiveSnapshot", 1)[1]
identity_restore, changed_render = snapshot_handler.split(
"if (snapshot.context && workChanged)", 1
)
assert "activeFlushLogin = contextIdentityFresh ?" in identity_restore
assert "confirmedOwnerLogin = activeFlushLogin;" in identity_restore
assert "timerView.restore(todaySync.flush());" in identity_restore
assert "activeFlushLogin = contextIdentityFresh ?" not in changed_render.split(
"if (eventsChanged", 1
)[0]
def test_timed_break_is_packaged_as_an_accessible_mobile_flow():
html = (ROOT / "frontend" / "index.html").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
+1 -1
View File
@@ -23,5 +23,5 @@ def test_dashboard_packages_a_mobile_today_session_handoff():
assert "Session offline · will retry." in today_bundle
assert "Session continued on another device." in today_bundle
assert "todaySessionSync = attachTodaySessionHandoff" in dashboard
assert "todaySessionSync?.publish(snapshot)" in dashboard
assert "todaySessionSync?.publish(timer.sessionSnapshot())" in dashboard
assert "min-height:44px" in css
+145
View File
@@ -4,6 +4,7 @@ from pathlib import Path
SOURCE = Path(__file__).parents[1] / "frontend" / "today-session-sync.js"
TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
def run_node(script: str) -> dict:
@@ -57,6 +58,43 @@ const sync = createTodaySessionSync({
}
def test_claim_adopts_the_complete_timing_ledger_for_recap():
result = run_node(
f"""
const createTimer = require({json.dumps(str(TIMER))});
const values = new Map();
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const timer = createTimer({{storage,getLogin:()=> 'timmy',now:()=>100000}});
const remote = {{
revision:3,device_id:'desktop-a',identity:'issue:r:2:',elapsed_ms:90000,running:true,
entries:[
{{identity:'issue:r:1:',elapsed_ms:60000}},
{{identity:'issue:r:2:',elapsed_ms:90000}},
],
}};
const sync=createTodaySessionSync({{
getDeviceId:()=> 'phone-b',timer,
fetchJson:async (_url,options={{}})=>options.method ?
{{...JSON.parse(options.body),revision:4,device_id:'phone-b'}} : remote,
}});
(async()=>{{
await sync.refresh();
await sync.claim();
const snapshot=timer.sessionSnapshot();
const recap=timer.recapEntries();
process.stdout.write(JSON.stringify({{recap,snapshot}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
)
assert result["recap"] == [
{"identity": "issue:r:1:", "elapsed_ms": 60_000},
{"identity": "issue:r:2:", "elapsed_ms": 90_000},
]
assert result["snapshot"]["identity"] == "issue:r:2:"
assert result["snapshot"]["running"] is True
def test_second_device_sees_and_resumes_a_timed_break():
result = run_node(
r"""
@@ -92,6 +130,43 @@ const sync=createTodaySessionSync({
assert result["claimed"]["device_id"] == "desktop-b"
def test_owner_refresh_recovers_a_server_confirmed_break_missing_from_device_state():
result = run_node(
f"""
const createTimer = require({json.dumps(str(TIMER))});
const values = new Map();
const storage = {{
getItem:key=>values.get(key)||null,
setItem:(key,value)=>values.set(key,value),
}};
const timer = createTimer({{storage,getLogin:()=> 'timmy',now:()=>100000}});
timer.adopt('issue:r:42:', 90000, false);
let rendered = 0;
const remote = {{
revision:3,device_id:'phone-a',identity:'issue:r:42:',elapsed_ms:90000,
running:false,break_deadline_at:400000,updated_at:10,
}};
const sync = createTodaySessionSync({{
getDeviceId:()=> 'phone-a',fetchJson:async()=>remote,timer,
onOwnedRestore:()=>{{rendered += 1;}},
}});
(async()=>{{
await sync.refresh();
process.stdout.write(JSON.stringify({{breakSnapshot:timer.breakSnapshot(),rendered}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
)
assert result == {
"breakSnapshot": {
"identity": "issue:r:42:",
"deadline_at": 400_000,
"expired": False,
},
"rendered": 1,
}
def test_break_handoff_summary_is_actionable_and_privacy_safe():
result = run_node(
r"""
@@ -107,6 +182,25 @@ process.stdout.write(JSON.stringify({
assert "private/repo" not in result["summary"]
def test_multi_item_handoff_summary_shows_scope_and_total_without_titles():
result = run_node(
r"""
const session={
identity:'issue:private/repo:42:',elapsed_ms:90000,running:true,
entries:[
{identity:'issue:private/repo:41:',elapsed_ms:60000},
{identity:'issue:private/repo:42:',elapsed_ms:90000},
],
};
process.stdout.write(JSON.stringify(todaySessionHandoffSummary(session,{title:'Secret issue'})));
"""
)
assert result == "2 tracked items · 2 min total"
assert "Secret" not in result
assert "private/repo" not in result
def test_previous_owner_pauses_after_another_device_claims():
result = run_node(
r"""
@@ -365,3 +459,54 @@ const sync = createTodaySessionSync({
assert result["calls"] == ["GET", "PATCH"]
assert result["statuses"][-1] == "offline"
assert "conflict" not in result["statuses"]
def test_owner_publish_includes_every_timed_item():
result = run_node(
f"""
const createTimer = require({json.dumps(str(TIMER))});
const values = new Map();
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
let now=0;
const timer=createTimer({{storage,getLogin:()=> 'timmy',now:()=>now}});
timer.activate('issue:r:1:'); now=60000;
timer.activate('issue:r:2:'); now=150000;
const bodies=[];
const owned={{revision:1,device_id:'desktop-a',identity:'issue:r:2:',elapsed_ms:90000,running:true}};
const sync=createTodaySessionSync({{
getDeviceId:()=> 'desktop-a',timer,
fetchJson:async (_url,options={{}})=>{{
if (!options.method) return owned;
const body=JSON.parse(options.body); bodies.push(body);
return {{...body,revision:2,updated_at:1}};
}},
}});
(async()=>{{
await sync.refresh();
await sync.publish();
process.stdout.write(JSON.stringify(bodies[0].entries));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
)
assert result == [
{"identity": "issue:r:1:", "elapsed_ms": 60_000},
{"identity": "issue:r:2:", "elapsed_ms": 90_000},
]
def test_timer_snapshot_samples_active_elapsed_once_for_a_consistent_ledger():
result = run_node(
f"""
const createTimer = require({json.dumps(str(TIMER))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
let ticks=0;
const timer=createTimer({{storage,getLogin:()=> 'timmy',now:()=>++ticks*1000}});
timer.activate('issue:r:1:');
const snapshot=timer.sessionSnapshot();
process.stdout.write(JSON.stringify(snapshot));
"""
)
assert result["elapsed_ms"] == result["entries"][0]["elapsed_ms"]
+60
View File
@@ -127,6 +127,9 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
"device_id": "phone-a",
"identity": "issue:stackchain/dashboard:42:",
"elapsed_ms": 90_000,
"entries": [{
"identity": "issue:stackchain/dashboard:42:", "elapsed_ms": 90_000,
}],
"running": True,
"break_deadline_at": None,
"updated_at": 1234.5,
@@ -137,12 +140,30 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
"device_id": "",
"identity": "",
"elapsed_ms": 0,
"entries": [],
"running": False,
"break_deadline_at": None,
"updated_at": None,
}
def test_active_session_preserves_complete_bounded_timing_ledger(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, clock=lambda: 1234.5)
entries = [
{"identity": "issue:r:1:", "elapsed_ms": 60_000},
{"identity": "issue:r:2:", "elapsed_ms": 90_000},
]
saved = store.update_session(
"timmy", base_revision=0, device_id="phone-a",
identity="issue:r:2:", elapsed_ms=90_000, running=True, entries=entries,
)
assert saved["entries"] == entries
assert TodayStore(path).get_session("timmy")["entries"] == entries
def test_timed_break_deadline_is_durable_in_the_account_session(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, clock=lambda: 1234.5)
@@ -198,6 +219,45 @@ async def test_active_session_api_persists_a_bounded_break_deadline(monkeypatch,
assert (await main.get_today_session()) == saved
@pytest.mark.anyio
async def test_active_session_api_persists_complete_timing_ledger(monkeypatch, tmp_path):
async def user():
return {"login": "timmy"}
store = TodayStore(tmp_path / "today.sqlite3")
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_today_store", lambda: store)
entries = [
{"identity": "issue:r:1:", "elapsed_ms": 5_000},
{"identity": "issue:r:2:", "elapsed_ms": 8_000},
]
payload = main.TodaySessionUpdate(
base_revision=0, device_id="phone-a", identity="issue:r:2:",
elapsed_ms=8_000, running=True, entries=entries,
)
saved = await main.update_today_session(payload)
assert saved["entries"] == entries
assert (await main.get_today_session())["entries"] == entries
def test_active_session_api_rejects_duplicate_or_inconsistent_ledger():
base = {
"base_revision": 0, "device_id": "phone-a", "identity": "issue:r:1:",
"elapsed_ms": 5_000, "running": True,
}
with pytest.raises(ValueError, match="unique"):
main.TodaySessionUpdate(**base, entries=[
{"identity": "issue:r:1:", "elapsed_ms": 5_000},
{"identity": "issue:r:1:", "elapsed_ms": 5_000},
])
with pytest.raises(ValueError, match="match"):
main.TodaySessionUpdate(**base, entries=[
{"identity": "issue:r:1:", "elapsed_ms": 4_999},
])
def test_active_session_api_rejects_a_running_break():
with pytest.raises(ValueError, match="break must remain paused"):
main.TodaySessionUpdate(
+33 -2
View File
@@ -6,6 +6,7 @@ from pathlib import Path
WEEK_CALENDAR = Path(__file__).parents[1] / "frontend" / "week-calendar.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
@@ -117,6 +118,26 @@ console.log(JSON.stringify(result));
}]
def test_week_calendar_honors_exact_saved_task_times_and_rejects_conflicts():
source = f"""
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
const items={{one:{{title:'First'}},two:{{title:'Second'}}}};
const plan={{days:[{{plan_date:'2026-08-21',ids:['one','two'],estimates:{{one:45,two:90}},
free_windows:[{{start_time:'09:00',end_time:'10:00'}},{{start_time:'13:00',end_time:'17:00'}}],
start_times:{{one:'09:00',two:'14:00'}}}}]}};
const saved=calendar.buildWeekSchedule(plan,{{}},id=>items[id]);
const conflict=calendar.buildWeekSchedule(plan,{{one:'14:30',two:'14:00'}},id=>items[id]);
console.log(JSON.stringify({{saved,conflict}}));
"""
result = run_node(source)
assert [(block["id"], block["start_time"], block["end_time"]) for block in result["saved"]["blocks"]] == [
("one", "09:00", "09:45"), ("two", "14:00", "15:30")
]
assert result["conflict"]["blocks"] == []
assert {blocker["id"] for blocker in result["conflict"]["blockers"]} == {"one", "two"}
def test_week_calendar_excludes_unselected_items_and_downloads_when_native_share_is_unavailable():
source = f"""
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
@@ -150,12 +171,22 @@ def test_week_calendar_handoff_is_wired_into_confirmation_with_mobile_privacy_co
assert 'id="week-calendar-handoff"' in index
assert 'id="week-calendar-days"' in index
assert 'id="share-week-calendar"' in index
assert 'id="save-week-calendar-times"' in index
assert 'data-week-calendar-exact' in WEEK_CALENDAR.read_text()
assert "onSave" in WEEK_CALENDAR.read_text()
assert 'min-height:44px' in CSS.read_text()[CSS.read_text().index('.week-calendar-time'):]
assert 'id="back-to-week-review-from-calendar"' in index
assert 'id="export-saved-week-calendar"' in index
assert "Add saved week to calendar" in index
assert "Titles, references, dates, and times are included" in index
assert "Imported busy time is used only until this page reloads" in index
assert "mountWeekCalendarHandoff" in WEEK_CALENDAR.read_text()
assert "weekCalendar.open(weekPlan.state())" in dashboard
assert "weekCalendar.close()" in dashboard
assert "wc.open(weekPlan.state())" in dashboard
assert "getItem:weekFlow.i" in dashboard
assert "i:id=>getItem(id)||controller.item?.(id)" in (WEEK_CALENDAR.parent / "week-plan.js").read_text()
assert "#export-saved-week-calendar" in WEEK_CALENDAR.read_text()
assert "focusReturn=event.currentTarget" in WEEK_CALENDAR.read_text()
assert "wc.close()" in dashboard
assert "StackchainWeekCalendar.setAvailabilityProvider(workflow.availability)" in (WEEK_CALENDAR.parent / "week-calendar-import.js").read_text()
assert "Planning around imported busy time" in WEEK_CALENDAR.read_text()
assert '"static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js"' in bundle
+101 -6
View File
@@ -118,12 +118,11 @@ process.env.TZ='UTC';
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n`;
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const started=Date.now();const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({elapsed:Date.now()-started,busy:days.map(day=>day.busy_minutes)}));
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
""")
assert result["busy"] == [60] * 7
assert result["elapsed"] < 1000
def test_calendar_import_fast_forwards_historical_weekly_series_to_seven_day_range():
@@ -132,12 +131,32 @@ process.env.TZ='UTC';
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2\r\nEND:VEVENT\r\n`;
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const started=Date.now();const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({elapsed:Date.now()-started,busy:days.map(day=>day.busy_minutes)}));
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
""")
assert result["busy"] == [0, 0, 0, 60, 0, 60, 0]
assert result["elapsed"] < 1000
def test_calendar_import_reuses_timezone_formatters_for_large_historical_calendars():
result = run_import("""
process.env.TZ='UTC';
const NativeDateTimeFormat=Intl.DateTimeFormat;
let formatterConstructions=0;
Intl.DateTimeFormat=function(...args) {
formatterConstructions+=1;
return new NativeDateTimeFormat(...args);
};
Intl.DateTimeFormat.prototype=NativeDateTimeFormat.prototype;
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2\r\nEND:VEVENT\r\n`;
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({formatterConstructions,busy:days.map(day=>day.busy_minutes)}));
""")
assert result["busy"] == [0, 0, 0, 60, 0, 60, 0]
assert result["formatterConstructions"] <= 3
def test_calendar_import_expands_bounded_daily_recurrence_and_honors_exdate():
@@ -303,16 +322,92 @@ console.log(JSON.stringify({reviewed,staged,flushes,appliedCalls,rootHidden:elem
assert "Do not retain me" not in json.dumps(result)
def test_calendar_import_persists_only_free_windows_with_explicit_consent_and_restores_them():
result = run_import("""
const elements=new Map();
const element=(value='')=>({value,checked:false,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
addEventListener(name,listener){this.listeners[name]=listener;},focus(){}});
for(const selector of ['#week-capacity-import','#week-capacity-review','#week-capacity-days','#week-capacity-status',
'#week-capacity-file','#week-capacity-start','#week-capacity-end','#keep-week-free-times',
'#apply-week-capacities','#cancel-week-capacity-import']) elements.set(selector,element());
elements.get('#week-capacity-start').value='09:00';elements.get('#week-capacity-end').value='17:00';
elements.get('#keep-week-free-times').checked=true;
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
let staged=null;
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+index})),state:()=>({days:staged||[]}),
stageCapacities:value=>{staged=value;return {sync_pending:true};},flush:async()=>({})};
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)});
workflow.review(`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Secret meeting\r\nATTENDEE:private@example.com\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nEND:VCALENDAR`);
await workflow.apply();
const restored=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)}).availability();
console.log(JSON.stringify({staged,restored}));
""")
windows = [
{"start_time": "09:00", "end_time": "10:00"},
{"start_time": "11:00", "end_time": "17:00"},
]
assert result["staged"][0]["free_windows"] == windows
assert result["restored"][0]["free_windows"] == windows
assert "Secret meeting" not in json.dumps(result)
assert "private@example.com" not in json.dumps(result)
def test_calendar_refresh_compares_load_and_applies_overloaded_capacity_with_one_atomic_reflow():
result = run_import("""
const elements=new Map();
const element=(value='')=>({value,checked:false,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
addEventListener(name,listener){this.listeners[name]=listener;},focus(){}});
for(const selector of ['#week-capacity-import','#week-capacity-review','#week-capacity-days','#week-capacity-status',
'#week-capacity-file','#week-capacity-start','#week-capacity-end','#keep-week-free-times',
'#apply-week-capacities','#cancel-week-capacity-import']) elements.set(selector,element());
elements.get('#week-capacity-start').value='09:00';elements.get('#week-capacity-end').value='17:00';
elements.get('#keep-week-free-times').checked=true;
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const current=dates.map((date,index)=>({plan_date:date,capacity_minutes:480,planned_minutes:index===0?90:index===1?30:0,
ids:index===0?['one','two']:index===1?['three']:[],estimates:index===0?{one:45,two:45}:index===1?{three:30}:{}}));
let atomicCalls=0,ordinaryCalls=0,flushes=0,received=null;
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),review:()=>({days:current}),
previewCapacityReflow:values=>({blockers:[],unscheduled:[],days:values.map((value,index)=>({...value,
ids:index===0?['one']:index===1?['two','three']:[],estimates:index===0?{one:45}:index===1?{two:45,three:30}:{}}))}),
applyCapacityReflow:values=>{atomicCalls+=1;received=values;return {sync_pending:true};},
stageCapacities:()=>{ordinaryCalls+=1;return {sync_pending:true};},flush:async()=>{flushes+=1;return {};}};
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)});
const source=`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Private meeting name\r\nATTENDEE:private@example.com\r\nDTSTART:20260821T090000\r\nDTEND:20260821T163000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
const reviewed=workflow.review(source);const label=elements.get('#apply-week-capacities').textContent;
const markup=elements.get('#week-capacity-days').innerHTML;await workflow.apply();
console.log(JSON.stringify({reviewed,label,markup,atomicCalls,ordinaryCalls,flushes,received}));
""")
assert result["reviewed"][0]["capacity_minutes"] == 30
assert "480 → 30 min available" in result["markup"]
assert "90 min planned" in result["markup"]
assert "60 min over refreshed capacity" in result["markup"]
assert "Moves to Day 2" in result["markup"]
assert result["label"] == "Apply capacities & reflow"
assert result["atomicCalls"] == 1
assert result["ordinaryCalls"] == 0
assert result["flushes"] == 1
assert result["received"][0]["free_windows"] == [
{"start_time": "16:30", "end_time": "17:00"}
]
assert "Private meeting name" not in json.dumps(result)
assert "private@example.com" not in json.dumps(result)
def test_week_ahead_exposes_private_calendar_capacity_import_in_the_packaged_planning_flow():
html = (FRONTEND / "index.html").read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="open-week-capacity-import"' in html
assert '>Refresh from calendar</button>' in html
assert '<h2 id="week-capacity-title">Refresh Week Ahead availability</h2>' in html
assert 'id="week-capacity-import"' in html
assert 'id="week-capacity-file"' in html and 'accept=".ics,text/calendar"' in html
assert 'id="week-capacity-start"' in html and 'id="week-capacity-end"' in html
assert 'id="week-capacity-review"' in html
assert 'id="keep-week-free-times"' in html
assert 'id="apply-week-capacities"' in html
assert '<script src="static/week-calendar-import.js"></script>' in html
assert html.index('static/week-calendar-import.js') < html.index('static/week-plan.js')
+113
View File
@@ -51,6 +51,117 @@ def test_week_plan_is_encrypted_account_scoped_revisioned_and_preserves_other_da
assert conflict.value.snapshot == updated
def test_week_plan_persists_bounded_weekly_availability_with_the_revisioned_plan(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, encryption_key=b"w" * 32)
defaults = [0, 480, 420, 360, 300, 240, 0]
saved = store.replace_week(
"timmy", base_revision=0, days=sample_days(), timezone="UTC",
availability_defaults=defaults,
)
assert saved["availability_defaults"] == defaults
assert store.get_week("timmy")["availability_defaults"] == defaults
retained = path.read_bytes()
assert b'"availability_defaults"' not in retained
with pytest.raises(ValueError, match="seven weekday capacities"):
store.replace_week(
"timmy", base_revision=1, days=sample_days(), timezone="UTC",
availability_defaults=[60] * 6,
)
def test_week_plan_persists_only_bounded_non_overlapping_calendar_free_windows(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, encryption_key=b"w" * 32)
days = sample_days()
days[0]["free_windows"] = [
{"start_time": "09:00", "end_time": "10:00"},
{"start_time": "11:00", "end_time": "17:00"},
]
saved = store.replace_week(
"timmy", base_revision=0, days=days, timezone="UTC",
)
assert saved["days"][0]["free_windows"] == days[0]["free_windows"]
assert store.get_week("timmy") == saved
assert b"09:00" not in path.read_bytes()
invalid = sample_days()
invalid[0]["free_windows"] = [
{"start_time": "09:00", "end_time": "12:00"},
{"start_time": "11:00", "end_time": "13:00"},
]
with pytest.raises(ValueError, match="free windows"):
store.replace_week(
"timmy", base_revision=1, days=invalid, timezone="UTC",
)
def test_week_plan_persists_only_valid_exact_task_start_times(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"w" * 32)
days = sample_days()
days[0]["start_times"] = {
"issue:secret/repo:3:": "09:00",
"issue:secret/repo:2:": "10:15",
}
saved = store.replace_week(
"timmy", base_revision=0, days=days, timezone="UTC",
)
assert saved["days"][0]["start_times"] == days[0]["start_times"]
assert store.get_week("timmy") == saved
assert b"09:00" not in (tmp_path / "today.sqlite3").read_bytes()
invalid = sample_days()
invalid[0]["start_times"] = {"issue:secret/repo:3:": "23:30"}
with pytest.raises(ValueError, match="task start times"):
store.replace_week(
"timmy", base_revision=1, days=invalid, timezone="UTC",
)
def test_week_update_model_accepts_zero_capacity_weekday_defaults():
payload = main.WeekPlanUpdate.model_validate({
"base_revision": 2, "timezone": "UTC", "days": [],
"availability_defaults": [0, 480, 480, 480, 480, 300, 0],
})
assert payload.model_dump()["availability_defaults"] == [0, 480, 480, 480, 480, 300, 0]
with pytest.raises(ValueError):
main.WeekPlanUpdate.model_validate({
"base_revision": 2, "timezone": "UTC", "days": [],
"availability_defaults": [60] * 8,
})
def test_week_update_model_accepts_only_bounded_calendar_free_windows():
payload = main.WeekPlanUpdate.model_validate({
"base_revision": 2, "timezone": "UTC", "days": [{
"plan_date": "2026-08-21", "ids": [], "estimates": {},
"free_windows": [
{"start_time": "09:00", "end_time": "10:00"},
{"start_time": "11:00", "end_time": "17:00"},
],
}],
})
assert payload.model_dump()["days"][0]["free_windows"][1] == {
"start_time": "11:00", "end_time": "17:00",
}
with pytest.raises(ValueError):
main.WeekPlanUpdate.model_validate({
"base_revision": 2, "timezone": "UTC", "days": [{
"plan_date": "2026-08-21", "ids": [], "estimates": {},
"free_windows": [{"start_time": "nine", "end_time": "10:00"}],
}],
})
def test_start_day_plan_selects_earliest_nonempty_week_date_without_mutation(tmp_path):
store = TodayStore(
tmp_path / "today.sqlite3", encryption_key=b"r" * 32,
@@ -507,6 +618,7 @@ def test_reschedule_today_item_atomically_moves_it_into_selected_week_day(tmp_pa
"timmy",
base_revision=0,
timezone="UTC",
availability_defaults=[0, 480, 480, 480, 480, 0, 0],
days=[{
"plan_date": "2026-08-24",
"ids": [active_id],
@@ -531,6 +643,7 @@ def test_reschedule_today_item_atomically_moves_it_into_selected_week_day(tmp_pa
)
assert result["today"]["ids"] == [other_id]
assert result["week"]["availability_defaults"] == [0, 480, 480, 480, 480, 0, 0]
assert result["week"]["days"] == [{
"plan_date": "2026-08-24", "ids": [], "capacity_minutes": 120, "estimates": {},
}, {
+264
View File
@@ -8,6 +8,8 @@ import pytest
FRONTEND = Path(__file__).parents[1] / "frontend"
CONTROLLER = FRONTEND / "week-plan.js"
COORDINATOR = FRONTEND / "outbox-coordinator.js"
INDEX = FRONTEND / "index.html"
CSS = FRONTEND / "dashboard.css"
def run_controller(scenario: str) -> dict:
@@ -20,6 +22,49 @@ const createOutboxCoordinator = require({json.dumps(str(COORDINATOR))});
return json.loads(completed.stdout)
def test_weekly_availability_editor_is_mobile_safe_and_wired_to_durable_defaults():
markup = INDEX.read_text()
styles = CSS.read_text()
source = CONTROLLER.read_text()
assert 'id="open-week-availability"' in markup
assert 'id="week-availability-editor"' in markup
assert markup.count('data-weekday-capacity=') == 7
assert 'id="save-week-availability"' in markup
assert 'id="disable-week-availability"' in markup
assert 'aria-labelledby="week-availability-title"' in markup
assert ".week-availability-grid input { min-height:44px" in styles
assert "grid-template-columns:1fr" in styles
assert "stageAvailabilityDefaults(values)" in source
assert "Weekly availability saved" in source
def test_conflicted_pull_review_has_accessible_touch_safe_recovery_actions():
markup = INDEX.read_text()
styles = CSS.read_text()
assert 'id="week-pull-conflict-review"' in markup
assert 'aria-labelledby="week-pull-conflict-title"' in markup
assert 'id="week-pull-conflict-item"' in markup
assert 'id="week-pull-conflict-context"' in markup
assert 'id="rebase-week-pull"' in markup
assert 'id="keep-week-pull"' in markup
assert "Add using current plans" in markup
assert "Keep in Week Ahead" in markup
assert ".week-pull-conflict-actions button { min-height:44px" in styles
assert "overflow-wrap:anywhere" in styles
def test_week_workflow_wires_both_conflict_decisions_to_the_durable_controller():
source = CONTROLLER.read_text()
assert "resolvePullConflict('add')" in source
assert "resolvePullConflict('keep')" in source
assert "controller.pullConflict?.()" in source
assert "renderPullConflict" in source
assert "#week-pull-conflict-review" in source
def test_week_controller_requires_executable_days_before_calendar_handoff():
result = run_controller("""
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
@@ -149,6 +194,36 @@ console.log(JSON.stringify({before,moved,after,pending:week.pending()}));
assert result["pending"]["days"][2]["estimates"] == {"shared": 30}
def test_week_controller_applies_weekday_defaults_to_new_dates_and_keeps_date_overrides():
result = run_controller("""
const values=new Map();const requests=[];
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const fetchJson=async(url,options={})=>{
requests.push(options.body?JSON.parse(options.body):null);
if(!options.method)return {revision:3,timezone:'UTC',availability_defaults:[60,120,180,240,300,0,0],days:[
{plan_date:'2026-08-22',ids:['override'],capacity_minutes:45,estimates:{override:30}}
]};
return {revision:4,...JSON.parse(options.body)};
};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
await week.load();
const inherited=week.day('2026-08-21');
const overridden=week.day('2026-08-22');
const staged=week.stageAvailabilityDefaults([90,120,180,240,300,0,0]);
await week.flush();
console.log(JSON.stringify({inherited,overridden,staged,request:requests.at(-1),state:week.state()}));
""")
assert result["inherited"]["capacity_minutes"] == 300
assert result["inherited"]["capacity_source"] == "default"
assert result["overridden"]["capacity_minutes"] == 45
assert result["overridden"]["capacity_source"] == "override"
assert result["staged"]["sync_pending"] is True
assert result["request"]["availability_defaults"] == [90, 120, 180, 240, 300, 0, 0]
assert result["state"]["availability_defaults"] == [90, 120, 180, 240, 300, 0, 0]
def test_week_controller_applies_seven_imported_capacities_in_one_local_transition():
result = run_controller("""
const values=new Map();let writes=0;
@@ -174,6 +249,56 @@ console.log(JSON.stringify({applied,writes,state:week.state(),pending:week.pendi
assert result["pending"]["days"] == result["state"]["days"]
def test_week_controller_keeps_consented_free_windows_through_pending_flush_and_reload():
result = run_controller("""
const values=new Map();const requests=[];
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const fetchJson=async(url,options={})=>{
if(!options.method)return {revision:4,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['one'],capacity_minutes:420,
estimates:{one:45},free_windows:[{start_time:'09:00',end_time:'10:00'},{start_time:'11:00',end_time:'17:00'}]}]};
const body=JSON.parse(options.body);requests.push(body);return {revision:5,timezone:body.timezone,days:body.days};
};
const options={storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
const first=createWeekPlan(options);await first.load();
const capacities=first.dates().map((item,index)=>({plan_date:item.date,capacity_minutes:index?480:420,
...(index?{}:{free_windows:[{start_time:'09:00',end_time:'10:00'},{start_time:'11:00',end_time:'17:00'}]})}));
first.stageCapacities(capacities);const pending=first.pending();await first.flush();
const restored=createWeekPlan(options);await restored.load();
console.log(JSON.stringify({pending,request:requests[0],restored:restored.state()}));
""")
windows = [
{"start_time": "09:00", "end_time": "10:00"},
{"start_time": "11:00", "end_time": "17:00"},
]
assert result["pending"]["days"][0]["free_windows"] == windows
assert result["request"]["days"][0]["free_windows"] == windows
assert result["restored"]["days"][0]["free_windows"] == windows
def test_week_controller_keeps_exact_task_times_through_pending_flush_and_reload():
result = run_controller("""
const values=new Map();const requests=[];let remote={revision:4,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['one','two'],capacity_minutes:420,
estimates:{one:45,two:90},start_times:{one:'09:00'}}]};
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const fetchJson=async(url,options={})=>{
if(!options.method)return remote;
const body=JSON.parse(options.body);requests.push(body);remote={revision:5,timezone:body.timezone,days:body.days};return remote;
};
const options={storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
const first=createWeekPlan(options);await first.load();
first.stageStartTimes('2026-08-21',{one:'09:00',two:'11:00'});
const pending=first.pending();await first.flush();
const restored=createWeekPlan(options);await restored.load();
console.log(JSON.stringify({pending,request:requests[0],restored:restored.state()}));
""")
expected = {"one": "09:00", "two": "11:00"}
assert result["pending"]["days"][0]["start_times"] == expected
assert result["request"]["days"][0]["start_times"] == expected
assert result["restored"]["days"][0]["start_times"] == expected
def test_week_controller_previews_and_atomically_applies_a_capacity_safe_reflow():
result = run_controller("""
const values=new Map();let writes=0;
@@ -209,6 +334,35 @@ console.log(JSON.stringify({before,preview,unchanged,applied,writes,pending:week
assert result["pending"]["days"] == result["preview"]["days"]
def test_week_controller_atomically_reflows_against_refreshed_calendar_capacity_and_keeps_windows():
result = run_controller("""
const values=new Map();let writes=0;
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>{writes+=1;values.set(key,value);},removeItem:key=>values.delete(key)};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>({}),
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt({revision:6,timezone:'UTC',days:[
{plan_date:'2026-08-21',ids:['one','two'],capacity_minutes:120,estimates:{one:45,two:45},free_windows:[{start_time:'09:00',end_time:'11:00'}]},
{plan_date:'2026-08-22',ids:['three'],capacity_minutes:120,estimates:{three:30}},
{plan_date:'2026-08-23',ids:[],capacity_minutes:120,estimates:{}}
]});
const capacities=week.dates().map((item,index)=>({plan_date:item.date,capacity_minutes:index===0?45:120,
free_windows:index===0?[{start_time:'10:00',end_time:'10:45'}]:[{start_time:'09:00',end_time:'11:00'}]}));
const before=week.state();const preview=week.previewCapacityReflow(capacities);const unchanged=week.state();
const applied=week.applyCapacityReflow(capacities);
console.log(JSON.stringify({before,preview,unchanged,applied,writes,pending:week.pending()}));
""")
assert result["unchanged"] == result["before"]
assert result["writes"] == 1
assert result["applied"]["sync_pending"] is True
assert [day["ids"] for day in result["preview"]["days"][:2]] == [["one"], ["two", "three"]]
assert result["preview"]["days"][0]["free_windows"] == [
{"start_time": "10:00", "end_time": "10:45"}
]
assert result["pending"]["days"] == result["preview"]["days"]
assert [item for day in result["pending"]["days"] for item in day["ids"]] == ["one", "two", "three"]
def test_week_controller_reflow_never_places_more_than_five_items_on_a_day():
result = run_controller("""
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
@@ -514,6 +668,116 @@ console.log(JSON.stringify({outcome,state:week.state(),pending:week.pendingPull(
]
def test_week_controller_describes_the_conflicted_transfer_from_current_plans():
result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const remoteToday={revision:9,ids:['active'],capacity_minutes:90,estimates:{active:30}};
const remoteWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:45}}]};
const fetchJson=async(url,options={})=>{if(options.method){const error=new Error('changed');error.status=409;throw error;}return url.endsWith('/today')?remoteToday:remoteWeek;};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:45}}]});
week.rememberItems({'pull':{title:'Ship recovery',kind:'issue',repository:'acme/app',number:7}});
await week.pullItem('pull',{revision:4,ids:['old'],capacity_minutes:90,estimates:{old:30}},'stable-pull');
console.log(JSON.stringify(week.pullConflict()));
""")
assert result == {
"identity": "pull", "title": "Ship recovery", "source_date": "2026-08-21",
"today_items": 1, "today_minutes": 30, "today_capacity": 90,
"operation_id": "stable-pull", "queue_length": 1,
}
def test_week_controller_rechecks_capacity_before_rebasing_a_conflicted_pull():
result = run_controller("""
const values=new Map();let posts=0;
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const remoteToday={revision:9,ids:['active'],capacity_minutes:50,estimates:{active:40}};
const remoteWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:20}}]};
const fetchJson=async(url,options={})=>{if(options.method){posts+=1;const error=new Error('changed');error.status=409;throw error;}return url.endsWith('/today')?remoteToday:remoteWeek;};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:20}}]});
await week.pullItem('pull',{revision:4,ids:['old'],capacity_minutes:90,estimates:{old:30}},'stable-pull');
let code=null;try{await week.resolvePullConflict('add');}catch(error){code=error.code;}
console.log(JSON.stringify({code,posts,pending:week.pendingPull()}));
""")
assert result["code"] == "today_over_capacity"
assert result["posts"] == 1
assert result["pending"]["conflict"] is True
def test_week_controller_rebases_a_conflicted_pull_and_drains_the_fifo_with_stable_operations():
result = run_controller("""
const values=new Map(),delivered=[];
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let offline=true;
let serverToday={revision:10,ids:['active'],capacity_minutes:180,estimates:{active:30}};
let serverWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]};
const fetchJson=async(url,options={})=>{
if(offline)throw new Error('offline');
if(!options.method)return url.endsWith('/today')?serverToday:serverWeek;
const body=JSON.parse(options.body);delivered.push(body);
if(body.today_revision!==serverToday.revision||body.week_revision!==serverWeek.revision){const error=new Error('changed');error.status=409;throw error;}
serverToday={...serverToday,revision:serverToday.revision+1,ids:[...serverToday.ids,body.identity],estimates:{...serverToday.estimates,[body.identity]:serverWeek.days[0].estimates[body.identity]}};
serverWeek={...serverWeek,revision:serverWeek.revision+1,days:serverWeek.days.map(day=>({...day,ids:day.ids.filter(id=>id!==body.identity),estimates:Object.fromEntries(Object.entries(day.estimates).filter(([id])=>id!==body.identity))}))};
return {today:serverToday,week:serverWeek};
};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]});
let today={revision:4,ids:['active'],capacity_minutes:180,estimates:{active:30}};
today=(await week.pullItem('first',today,'pull-first')).today;
await week.pullItem('second',today,'pull-second');
offline=false;await week.flushPull();
const resolved=await week.resolvePullConflict('add');
console.log(JSON.stringify({resolved,delivered,pending:week.pendingPull(),state:week.state()}));
""")
assert [request["operation_id"] for request in result["delivered"]] == [
"pull-first", "pull-first", "pull-second"
]
assert result["delivered"][1]["today_revision"] == 10
assert result["delivered"][1]["week_revision"] == 12
assert result["resolved"]["today"]["ids"] == ["active", "first", "second"]
assert result["pending"] is None
assert result["state"]["revision"] == 14
def test_week_controller_keeps_the_conflicted_item_in_week_and_advances_only_the_fifo_head():
result = run_controller("""
const values=new Map(),delivered=[];
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let offline=true;
let serverToday={revision:10,ids:['active'],capacity_minutes:180,estimates:{active:30}};
let serverWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]};
const fetchJson=async(url,options={})=>{
if(offline)throw new Error('offline');
if(!options.method)return url.endsWith('/today')?serverToday:serverWeek;
const body=JSON.parse(options.body);delivered.push(body);
if(body.today_revision!==serverToday.revision||body.week_revision!==serverWeek.revision){const error=new Error('changed');error.status=409;throw error;}
serverToday={...serverToday,revision:11,ids:[...serverToday.ids,body.identity],estimates:{...serverToday.estimates,[body.identity]:40}};
serverWeek={...serverWeek,revision:13,days:serverWeek.days.map(day=>({...day,ids:day.ids.filter(id=>id!==body.identity)}))};
return {today:serverToday,week:serverWeek};
};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]});
let today={revision:4,ids:['active'],capacity_minutes:180,estimates:{active:30}};
today=(await week.pullItem('first',today,'pull-first')).today;
await week.pullItem('second',today,'pull-second');
offline=false;await week.flushPull();
const resolved=await week.resolvePullConflict('keep');
console.log(JSON.stringify({resolved,delivered,pending:week.pendingPull()}));
""")
assert [request["operation_id"] for request in result["delivered"]] == ["pull-first", "pull-second"]
assert result["delivered"][1]["today_revision"] == 10
assert result["delivered"][1]["week_revision"] == 12
assert result["resolved"]["today"]["ids"] == ["active", "second"]
assert result["resolved"]["week"]["days"][0]["ids"] == ["first"]
assert result["pending"] is None
def test_week_controller_retires_completed_work_from_every_day_in_one_durable_transition():
result = run_controller("""
const values=new Map();let writes=0;