Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e8efdb2bb | ||
|
|
e92e476f49 | ||
|
|
4a6a829401 | ||
|
|
c03d0a091f | ||
|
|
2f5df33020 | ||
|
|
5d517861f0 | ||
|
|
59c87f06a2 | ||
|
|
f76b2f8289 | ||
|
|
7c4e798776 | ||
|
|
317fc7371a | ||
|
|
c7d38593dd | ||
|
|
a13a13d90c | ||
|
|
666671ee59 | ||
|
|
d01f502e70 | ||
|
|
3fd5983df6 | ||
|
|
6afdad6fe4 | ||
|
|
449ab78991 | ||
|
|
95887d7493 | ||
|
|
965a73ab49 | ||
|
|
067fb76ebd | ||
|
|
d47205a561 | ||
|
|
1be9da3685 | ||
|
|
a8c1329676 | ||
|
|
d63765a9b1 | ||
|
|
c87d56c939 | ||
|
|
e234d6b6c3 | ||
|
|
edaeba4a4e | ||
|
|
9d8a362583 | ||
|
|
c63df3c46d | ||
|
|
ffba6f1212 | ||
|
|
b7dd9757aa | ||
|
|
f64ce3acc4 | ||
|
|
25597d1f05 | ||
|
|
9f5baaef6e | ||
|
|
a3861da125 | ||
|
|
2b874d8427 | ||
|
|
ba50784022 | ||
|
|
37950bd0ed | ||
|
|
bb6a4c4305 | ||
|
|
42e97b4a79 | ||
|
|
45e2798d37 | ||
|
|
218dd43764 | ||
|
|
4d9c936b86 | ||
|
|
69a3a2c568 | ||
|
|
7a9347bd2a | ||
|
|
4e275b1518 | ||
|
|
865083a835 | ||
|
|
15be0ec026 | ||
|
|
ab74426cbc | ||
|
|
f1adffef6b | ||
|
|
359d4337eb | ||
|
|
e77ad7c809 | ||
|
|
aa46196d1e | ||
|
|
e5ccfb0c5e | ||
|
|
e3c7df25c8 | ||
|
|
f11fc17a2c | ||
|
|
ecc79ce50f | ||
|
|
a10592c349 | ||
|
|
5ac90d01e6 | ||
|
|
dd3016b96b | ||
|
|
875f4eea29 | ||
|
|
78839a50fe | ||
|
|
f557f06bc5 | ||
|
|
c8998eb1ba | ||
|
|
b5b1a3b0d7 | ||
|
|
ea1b5a77f5 | ||
|
|
5c74051aab | ||
|
|
daffa894d8 | ||
|
|
67a3285412 | ||
|
|
9f40a4c5ac | ||
|
|
356f11b9c1 | ||
|
|
d5f13c4afb | ||
|
|
69532bdc1f | ||
|
|
55e1c3d6dd | ||
|
|
6718160381 | ||
|
|
e0f7c711ed | ||
|
|
43066a0822 | ||
|
|
f0706e2e6f | ||
|
|
3edc4e36b3 | ||
|
|
47cad77312 | ||
|
|
99a2f28440 | ||
|
|
53c4340d76 | ||
|
|
5a9cce4a08 | ||
|
|
4fbc9c62d9 | ||
|
|
a1b4698988 | ||
|
|
e01681af09 | ||
|
|
880d1c4a1f | ||
|
|
7c1292faa3 | ||
|
|
286e691fd2 | ||
|
|
c99d03b9bc | ||
|
|
58c2d3f5d8 | ||
|
|
988f6646c2 | ||
|
|
f56ca6373c | ||
|
|
3cfe6b9f75 | ||
|
|
65180cbb94 | ||
|
|
928184c581 | ||
|
|
e8b78de1a5 | ||
|
|
6fb547e8ce | ||
|
|
d86e84253a | ||
|
|
99b60e5f5f | ||
|
|
059722de6c | ||
|
|
16a6ee1136 | ||
|
|
f9c4938401 | ||
|
|
bdf1ddaada | ||
|
|
cf94624aa9 | ||
|
|
f42f89ca21 | ||
|
|
ae632e3562 | ||
|
|
0fd6314391 | ||
|
|
1ae49df1b0 | ||
|
|
16841f18e1 | ||
|
|
80863182d4 | ||
|
|
7c2e9861d7 | ||
|
|
7deb1c1de9 | ||
|
|
559fd2dc89 | ||
|
|
0cb7328aa1 | ||
|
|
664e5e999f | ||
|
|
37e919f43f | ||
|
|
d0780a72a7 | ||
|
|
f785134dfe | ||
|
|
de405efdbc | ||
|
|
fa47ded3ab | ||
|
|
e2be14d7c7 | ||
|
|
e2b5f6042c | ||
|
|
941a508d4b |
No files matched your search
@@ -57,7 +57,7 @@ jobs:
|
||||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -75,6 +75,23 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
|
||||
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
||||
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
||||
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
||||
Eligible open issue previews also expose **Plan ahead**: choose one of the next seven local dates, review
|
||||
that day's current load and capacity, and enter a required estimate without leaving Search. Existing Week
|
||||
Ahead placement is moved only after an explicit day change, never duplicated; full days are blocked and
|
||||
over-capacity admission requires a second confirmation. The action claims unassigned work only after these
|
||||
checks, stages the canonical issue through the account-bound Week Ahead outbox, and preserves the Search
|
||||
preview through cancel or browser Back. Offline saves report **sync pending**; if assignment succeeds but
|
||||
local planning fails, the dashboard reports **Assigned, not planned** and leaves the issue recoverable in My Work.
|
||||
The live mobile Week Ahead overview also offers **Remove from week** for active planned work. It removes only the
|
||||
private planning placement—not assignment, issue state, or Gitea content—and immediately recalculates the day's
|
||||
load. A 10-second **Undo** receipt restores the exact day, list position, and estimate. Both transitions use the
|
||||
account-bound Week Ahead outbox, remain **sync pending** after delivery failure, and stay unavailable in a read-only
|
||||
offline snapshot.
|
||||
Search selection mode extends that flow across several open issues with **Plan Week Ahead**. One phone-safe
|
||||
review assigns each issue a future day and estimate, validates five-item limits and daily capacity before any
|
||||
claim, and requires a second confirmation for overload. Confirmed rows are staged into one canonical Week
|
||||
Ahead update and flushed together; partial assignment or offline sync is reported without discarding the
|
||||
selection, so claimed work remains recoverable from My Work.
|
||||
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
|
||||
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
|
||||
can queue photo-only, text-only, or mixed replies without leaving their Search pass, including while offline.
|
||||
@@ -120,7 +137,10 @@ explicit **Add blocked item anyway** override, while an unavailable dependency l
|
||||
as unknown rather than unblocked. Completing any Today item now exposes a 10-second, touch-safe
|
||||
**Undo** receipt. Undo restores the item's original order and estimate, queues the inverse cross-device
|
||||
plan changes, and leaves the already-advanced work session on its current item; expiry, capacity, or a
|
||||
concurrently changed plan is reported without overwriting newer work. Starting a Today work session also
|
||||
concurrently changed plan is reported without overwriting newer work. Rescheduling Today work into
|
||||
Week Ahead uses a bounded account-scoped FIFO on the device: multiple offline moves survive reload,
|
||||
deliver oldest-first with stable operation IDs, and rebase each later move on the confirmed plan revisions
|
||||
without concurrent requests. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **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.
|
||||
|
||||
@@ -149,7 +169,42 @@ failure preserves both the reply draft and checkpoint. Finishing or choosing **E
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
||||
offline operations. **Plan Tomorrow** remains independent from active Today work and durably admits the exact
|
||||
ordered plan, capacity, estimates, next local date, timezone, and server revision to account-bound browser
|
||||
storage before closing on a phone. The Queues summary marks it **sync pending** while offline; reconnect,
|
||||
foreground, and midnight lifecycle checks share one delivery flight. A successful account receipt removes
|
||||
the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for
|
||||
review. Unsynced Tomorrow work is never promoted into Today.
|
||||
**Week Ahead** opens as a read-first seven-day mobile overview, so operators can inspect the next planned day,
|
||||
work titles and references, load versus capacity, overloads, and pending sync without staging a change. When Today
|
||||
is empty and no work session is active, **Start this day early** confirms the next planned date, item count, minutes,
|
||||
and capacity before atomically moving only that day into Today; offline, pending, stale, or non-empty plans remain
|
||||
unchanged. **Edit day** enters one date and returns to the refreshed overview; **Edit week** starts the continuous
|
||||
planning pass. Closing an issue from its Week Ahead detail retires that identity and estimate from every future
|
||||
week day in one durable update while preserving sibling order and daily capacity. The open overview recalculates
|
||||
immediately; an unavailable save remains visibly **sync pending** and retries through the existing lifecycle.
|
||||
**Plan Week Ahead** continues through seven local dates and now finishes on a mobile review step instead of
|
||||
closing after the seventh save. The review shows planned minutes against each day’s capacity, marks overloads,
|
||||
and flags work assigned to more than one date. Operators can move an item to another date without copying it;
|
||||
the estimate follows the item and the server rejects duplicate cross-day assignments without advancing the
|
||||
week revision. When capacity changes create overload, **Reflow remaining week** previews a deterministic,
|
||||
order-preserving redistribution across the seven visible dates. No day exceeds its capacity or five-item limit;
|
||||
work that cannot fit is named and remains in My Work, while missing estimates block apply. Cancel performs no
|
||||
write, and apply stages one durable whole-week transition through the existing conflict-safe sync path.
|
||||
Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
|
||||
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
|
||||
are not stranded behind an earlier request. From the same review, **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
|
||||
to the device's local workday, including daylight-saving transitions. Daily and weekly recurrence (`COUNT`,
|
||||
`UNTIL`, `INTERVAL`, and weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated with work bounded
|
||||
to the seven review dates; cancelled and transparent events do not consume capacity. If a time zone is unknown,
|
||||
or a recurring event that could affect the week uses another frequency or unsupported rule part, the review
|
||||
reports only an affected-event count and disables apply rather than understating busy time. Export a simpler
|
||||
seven-day calendar to proceed. Raw calendar data and event
|
||||
metadata are never persisted, rendered in diagnostics, or sent; only the reviewed capacity-minute totals use
|
||||
the existing encrypted, account-bound Week Ahead sync.
|
||||
Planning edits can remain offline for up to 30 days. After that, the
|
||||
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
||||
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||
receipts older than the same 30-day window; client base revisions keep a pruned replay from
|
||||
@@ -281,7 +336,18 @@ export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts
|
||||
# it. Missing, malformed, wrong-key, or modified state fails closed without
|
||||
# returning content. Legacy Today/Later, Saved Search, and Security activity rows
|
||||
# migrate atomically on first use without changing logical revisions or journal IDs.
|
||||
# The single-key setting remains the compatible first deployment and writes v1 envelopes.
|
||||
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||
# After all workers run keyring-capable code, configure at most four named keys and
|
||||
# select the active write key. Keep the old key present during the rolling deployment.
|
||||
# Key IDs use 1-32 letters, digits, underscores, or hyphens.
|
||||
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS='{"legacy":"<old-base64-key>","2026-08":"<new-base64-key>"}'
|
||||
export STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID='2026-08'
|
||||
# New writes use authenticated v2 envelopes carrying the active key ID. Rewrap every
|
||||
# shared store with the restart-safe command; output contains store-level counts only.
|
||||
# Run it again until every store reports failed=0, migrated=0, and current=total,
|
||||
# then remove the old key from every worker. Retain all keys and investigate if it exits 1.
|
||||
python3 scripts/rotate_private_state.py
|
||||
# Required for cross-device unfiled Draft sync. The single-key setting remains
|
||||
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
|
||||
# Inject the base64 encoding of exactly 32 random bytes from a secret manager.
|
||||
|
||||
@@ -50,6 +50,20 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
#open-insights { display:none; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.search-week-plan-sheet { position:fixed; inset:0; z-index:118; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.search-week-plan-sheet[hidden] { display:none; }
|
||||
.search-week-plan-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.search-week-plan-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.search-week-plan-panel h2 { margin:.2rem 0; }
|
||||
.search-week-plan-panel fieldset { min-width:0; margin:12px 0; padding:0; border:0; }
|
||||
.search-week-plan-days { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.search-week-plan-days button { display:grid; min-width:0; min-height:44px; text-align:left; overflow-wrap:anywhere; }
|
||||
.search-week-plan-days button[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 1px #60a5fa inset; }
|
||||
.search-week-plan-days small { color:#9fb3c8; }
|
||||
.search-week-plan-estimate { display:grid; gap:6px; }
|
||||
.search-week-plan-estimate input, #confirm-search-week-plan, #cancel-search-week-plan { box-sizing:border-box; min-height:44px; }
|
||||
#confirm-search-week-plan { width:100%; }
|
||||
@media (max-width:359px) { .search-week-plan-days { grid-template-columns:1fr; } }
|
||||
.notification-undo { position:fixed; }
|
||||
.notification-undo button { min-height:44px; }
|
||||
.notification-undo, .today-completion-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
@@ -198,6 +212,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-batch-estimate-review input { min-height:44px; width:76px; }
|
||||
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-batch-estimate-actions button { min-height:44px; }
|
||||
.search-week-batch-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; overflow-x:hidden; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.search-week-batch-review[hidden] { display:none; }
|
||||
.search-week-batch-review > div:first-child, #search-week-batch-list { display:grid; gap:10px; }
|
||||
.search-week-batch-row { display:grid; gap:8px; padding:10px 0; border-bottom:1px solid #2a496e; overflow-wrap:anywhere; }
|
||||
.search-week-batch-row-controls { display:grid; grid-template-columns:minmax(0,1fr) minmax(96px,.45fr); gap:8px; }
|
||||
.search-week-batch-row select, .search-week-batch-row input, .search-week-batch-actions button { min-height:44px; width:100%; }
|
||||
.search-week-batch-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.search-release-review[hidden] { display:none; }
|
||||
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
|
||||
@@ -241,6 +262,153 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||
.plan-today-header button { min-width:44px; min-height:44px; }
|
||||
.week-plan-progress { margin:8px 0 4px; color:#bfdbfe; font-weight:700; }
|
||||
.week-plan-dates { display:flex; gap:8px; margin:8px 0 12px; padding:2px 0 8px; overflow-x:auto; overscroll-behavior-inline:contain; }
|
||||
.week-plan-dates[hidden] { display:none; }
|
||||
.week-plan-dates button { min-height:44px; min-width:86px; flex:0 0 auto; padding:6px 10px; }
|
||||
.week-plan-dates button[aria-current="date"] { color:#bfdbfe; background:#17365a; border-color:#60a5fa; }
|
||||
.today-week-reschedule { box-sizing:border-box; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:inherit; }
|
||||
.today-week-reschedule::backdrop { background:rgba(5,12,21,.82); }
|
||||
.today-week-reschedule-panel { width:min(100%,560px); box-sizing:border-box; min-height:100%; margin-left:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow-x:hidden; }
|
||||
.today-week-reschedule-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-week-reschedule-panel h2, .today-week-reschedule-panel header p { margin-top:0; }
|
||||
.today-week-reschedule-panel input { box-sizing:border-box; width:100%; min-height:44px; margin:6px 0 10px; font-size:16px; }
|
||||
.today-week-reschedule-days { display:grid; gap:8px; margin:14px 0; }
|
||||
.today-week-reschedule-days button { min-height:44px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; text-align:left; overflow-wrap:anywhere; }
|
||||
.today-week-reschedule-days button[aria-checked="true"] { border-color:#60a5fa; background:#17365a; color:#eff6ff; }
|
||||
.today-week-reschedule-days button:disabled { opacity:.58; }
|
||||
#cancel-today-week-reschedule { min-height:44px; }
|
||||
#confirm-today-week-reschedule { position:sticky; bottom:0; width:100%; min-height:48px; }
|
||||
#today-week-reschedule-status { min-height:1.4em; color:#fde68a; }
|
||||
.back-to-week-review { width:100%; min-height:44px; margin:4px 0 12px; }
|
||||
.tomorrow-conflict-review { margin-top:14px; }
|
||||
.tomorrow-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||||
.tomorrow-conflict-plans > section { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.tomorrow-conflict-plans h4 { margin:0 0 8px; }
|
||||
.tomorrow-conflict-plan-summary { margin:0 0 8px; color:#bfdbfe; }
|
||||
.tomorrow-conflict-plan-list { margin:0; padding-left:22px; overflow-wrap:anywhere; }
|
||||
.tomorrow-conflict-plan-list li + li { margin-top:6px; }
|
||||
.tomorrow-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
||||
.tomorrow-conflict-actions button { min-height:44px; flex:1 1 0; }
|
||||
.tomorrow-conflict-status { min-height:1.4em; margin-top:8px; }
|
||||
.plan-today-sheet.tomorrow-conflict-mode .mobile-plan-today-nav,
|
||||
.plan-today-sheet.tomorrow-conflict-mode #plan-today-fit,
|
||||
.plan-today-sheet.tomorrow-conflict-mode #plan-today-selected,
|
||||
.plan-today-sheet.tomorrow-conflict-mode #plan-today-available-work,
|
||||
.plan-today-sheet.tomorrow-conflict-mode .plan-today-actions { display:none; }
|
||||
@media (max-width:480px) {
|
||||
.tomorrow-conflict-plans { grid-template-columns:1fr; }
|
||||
.tomorrow-conflict-actions { flex-direction:column; }
|
||||
}
|
||||
.week-conflict-review { margin-top:14px; }
|
||||
.week-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||||
.week-conflict-plans > section { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-conflict-plans h4 { margin:0 0 8px; }
|
||||
.week-conflict-day { margin:0 0 10px; padding-bottom:10px; border-bottom:1px solid #31577f; }
|
||||
.week-conflict-day:last-child { margin-bottom:0; padding-bottom:0; border-bottom:0; }
|
||||
.week-conflict-day strong { display:block; color:#bfdbfe; }
|
||||
.week-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
||||
.week-conflict-actions button { min-height:44px; flex:1 1 0; }
|
||||
.week-conflict-days { display:grid; gap:12px; }
|
||||
.week-conflict-date { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-conflict-date h4 { margin:0 0 8px; color:#bfdbfe; }
|
||||
.week-conflict-choice { min-height:44px; display:flex; align-items:center; gap:10px; padding:8px; border-radius:8px; }
|
||||
.week-conflict-choice:has(input:checked) { background:#17365a; outline:1px solid #60a5fa; }
|
||||
.week-conflict-choice input { min-width:20px; min-height:20px; }
|
||||
.week-conflict-choice span { display:grid; min-width:0; }
|
||||
.week-conflict-choice small { color:#a9bdd3; }
|
||||
#save-merged-week { width:100%; min-height:44px; margin-top:14px; }
|
||||
.week-conflict-status { min-height:1.4em; margin-top:8px; }
|
||||
.plan-today-sheet.week-conflict-mode .week-plan-dates,
|
||||
.plan-today-sheet.week-conflict-mode .mobile-plan-today-nav,
|
||||
.plan-today-sheet.week-conflict-mode #plan-today-fit,
|
||||
.plan-today-sheet.week-conflict-mode #plan-today-selected,
|
||||
.plan-today-sheet.week-conflict-mode #plan-today-available-work,
|
||||
.plan-today-sheet.week-conflict-mode .plan-today-actions { display:none; }
|
||||
@media (max-width:480px) {
|
||||
.week-conflict-plans { grid-template-columns:1fr; }
|
||||
.week-conflict-actions { flex-direction:column; }
|
||||
}
|
||||
.week-review { margin-top:14px; }
|
||||
.week-review h2 { margin-bottom:6px; }
|
||||
.week-review-days { display:grid; gap:12px; }
|
||||
.week-review-day { padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-review-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
|
||||
.week-review-day.is-next-up { border-color:#60a5fa; box-shadow:0 0 0 1px #60a5fa inset; }
|
||||
.week-next-up { display:inline-block; margin-left:6px; padding:2px 7px; border-radius:999px; background:#1d4f7a; color:#dbeafe; font-size:11px; vertical-align:middle; }
|
||||
.week-review-day header { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||||
.week-review-day header button { flex:0 0 auto; min-height:44px; }
|
||||
.week-review-day h3, .week-review-day p { margin:0 0 8px; }
|
||||
.week-review-load { color:#bfdbfe; font-weight:700; }
|
||||
.week-review-day ul { display:grid; gap:10px; margin:0; padding:0; list-style:none; }
|
||||
.week-review-day li { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; padding-top:8px; border-top:1px solid #31577f; overflow-wrap:anywhere; }
|
||||
.week-review-item-copy { display:grid; min-width:0; gap:3px; }
|
||||
.week-review-item-open { display:block; width:100%; min-width:0; min-height:44px; padding:8px; border:0; border-radius:8px; background:transparent; color:inherit; text-align:left; }
|
||||
.week-review-item-open:hover { background:#173453; }
|
||||
.week-review-item-open:focus-visible { outline:3px solid #93c5fd; outline-offset:2px; }
|
||||
.week-review-unplan { width:100%; min-height:44px; border-color:#6b87a6; background:transparent; color:#d7e5f5; }
|
||||
.week-review-pull { grid-column:1/-1; width:100%; min-height:44px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
|
||||
.week-review-unplan { grid-column:1/-1; }
|
||||
.week-unplan-receipt { margin:12px 0 6px; padding:10px 12px; border:1px solid #60a5fa; border-radius:10px; background:#112d4d; color:#dbeafe; }
|
||||
#undo-week-unplan { width:100%; min-height:44px; margin-bottom:8px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
|
||||
.week-start-early { display:block; width:100%; min-height:44px; margin-top:12px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
|
||||
.week-start-early:focus-visible { outline:3px solid #bfdbfe; outline-offset:2px; }
|
||||
.week-review-item-copy strong, .week-review-item-copy small { overflow-wrap:anywhere; }
|
||||
.week-review-item-copy small { color:#a9bdd3; }
|
||||
.week-review-move { display:flex; align-items:end; gap:8px; }
|
||||
.week-review-day label { display:grid; gap:4px; font-size:12px; }
|
||||
.week-review-day select, .week-review-day button { min-height:44px; }
|
||||
.week-review-day button { min-height:44px; }
|
||||
.week-review-duplicates { margin:12px 0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; }
|
||||
.week-review-status { min-height:1.4em; margin:10px 0; }
|
||||
.week-offline-snapshot { margin:10px 0 8px; padding:10px 12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; color:#fde68a; overflow-wrap:anywhere; }
|
||||
#retry-week-live { min-height:44px; width:100%; margin-bottom:8px; border-color:#f59e0b; }
|
||||
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
#edit-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
#open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; }
|
||||
#open-week-reflow { width:100%; min-height:48px; margin:0 0 12px; border-color:#60a5fa; }
|
||||
.week-reflow-review { box-sizing:border-box; width:100%; margin:0 0 14px; padding:14px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; overflow-x:hidden; }
|
||||
.week-reflow-review h3 { margin:.25rem 0; }
|
||||
.week-reflow-days { display:grid; gap:6px; margin:12px 0; }
|
||||
.week-reflow-day { display:flex; justify-content:space-between; gap:10px; min-width:0; padding:8px; border-radius:8px; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.week-reflow-unscheduled { min-height:1.4em; color:#fde68a; overflow-wrap:anywhere; }
|
||||
.week-reflow-actions { display:grid; grid-template-columns:1fr 2fr; gap:8px; margin-top:12px; }
|
||||
.week-reflow-actions button { min-width:0; min-height:48px; }
|
||||
.week-capacity-import { position:fixed; z-index:121; inset:0; box-sizing:border-box; width:100%; max-width:560px; margin-inline:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow:auto; overflow-x:hidden; }
|
||||
.week-capacity-import[hidden] { display:none; }
|
||||
.week-capacity-import > header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.week-capacity-import h2 { margin:.2rem 0; }
|
||||
.week-capacity-import > label, .week-capacity-hours label { display:grid; min-width:0; gap:6px; margin:12px 0; }
|
||||
.week-capacity-import input, .week-capacity-import button { box-sizing:border-box; min-width:0; min-height:44px; max-width:100%; }
|
||||
.week-capacity-hours { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
||||
.week-capacity-days { display:grid; gap:8px; margin:10px 0 14px; }
|
||||
.week-capacity-day { display:grid; min-width:0; gap:3px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.week-capacity-day span { color:#bfdbfe; font-weight:700; }
|
||||
.week-capacity-day small { color:#a9bdd3; }
|
||||
#apply-week-capacities { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
@media (max-width:359px) { .week-capacity-hours { grid-template-columns:1fr; } }
|
||||
.week-calendar-handoff h2 { margin-bottom:6px; }
|
||||
.week-calendar-days { display:grid; gap:12px; margin:14px 0; }
|
||||
.week-calendar-day { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-calendar-day header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.week-calendar-day h3 { margin:0; }
|
||||
.week-calendar-day input[type="time"] { min-height:44px; font-size:16px; }
|
||||
.week-calendar-item { display:grid; grid-template-columns: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 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 button { min-width:0; min-height:48px; }
|
||||
.plan-today-sheet.week-review-mode .week-plan-dates,
|
||||
.plan-today-sheet.week-review-mode .mobile-plan-today-nav,
|
||||
.plan-today-sheet.week-review-mode #plan-today-fit,
|
||||
.plan-today-sheet.week-review-mode #plan-today-selected,
|
||||
.plan-today-sheet.week-review-mode #plan-today-available-work,
|
||||
.plan-today-sheet.week-review-mode .plan-today-actions { display:none; }
|
||||
@media (max-width:480px) {
|
||||
.week-review-day li { grid-template-columns:1fr; }
|
||||
}
|
||||
.mobile-plan-today-nav { position:sticky; top:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px; margin:0 -18px 10px; padding:4px 18px; background:rgba(11,21,38,.98); border-block:1px solid #2a496e; }
|
||||
.mobile-plan-today-nav button { min-width:0; min-height:44px; padding:4px; border-color:transparent; font-size:12px; }
|
||||
.mobile-plan-today-nav button[aria-current="location"] { color:#bfdbfe; background:#17365a; border-color:#31577f; }
|
||||
@@ -630,6 +798,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.task-overlay-open .issue-sheet.open, .task-overlay-open .pull-sheet.open { z-index:76; }
|
||||
.issue-sheet.open { display:flex; }
|
||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
@@ -768,6 +937,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.plan-tomorrow { min-height:44px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.resume-today-session, .end-today-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
@@ -1072,6 +1242,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
@media (max-width: 600px) {
|
||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||
body.mobile-today-active { padding-bottom:calc(var(--mobile-today-clearance, 166px) + env(safe-area-inset-bottom)); }
|
||||
body:has(.mobile-today-hud:not([hidden]):not([data-overlay-hidden="true"])) { padding-bottom:calc(var(--mobile-today-clearance, 166px) + env(safe-area-inset-bottom)); }
|
||||
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
|
||||
.app-brand .muted, #clock { display:none; }
|
||||
.app-live-status { margin-left:auto; }
|
||||
@@ -1206,6 +1377,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-update-outcome p { margin:0 0 10px; }
|
||||
.mobile-update-outcome button { width:100%; min-height:44px; }
|
||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:4px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
||||
.mobile-first-task-coach { grid-column:1 / -1; display:grid; gap:2px; padding:8px 10px; border-left:3px solid #60a5fa; border-radius:6px; background:#172f4d; }
|
||||
.mobile-first-task-coach[hidden] { display:none; }
|
||||
.mobile-first-task-receipt { position:fixed; left:12px; right:12px; bottom:calc(var(--mobile-today-clearance, 166px) + 8px + env(safe-area-inset-bottom)); z-index:46; padding:12px; border:1px solid #34d399; border-radius:10px; background:#0d3b35; font-weight:700; }
|
||||
.mobile-first-task-receipt[hidden] { display:none; }
|
||||
.mobile-today-summary { grid-column:1 / 4; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
||||
.mobile-today-hud [data-work-session-progress] { grid-column:4 / 7; align-self:center; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mobile-today-hud [data-mobile-today-complete] { grid-column:1 / 3; }
|
||||
|
||||
+342
-25
@@ -1,6 +1,7 @@
|
||||
(async function(){
|
||||
const workspaceLifecycle = await loadWorkspace({ document, window });
|
||||
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 cardPlanning = createCardPlanning(document);
|
||||
const mobileComposerViewport = createMobileComposerViewport({
|
||||
@@ -128,7 +129,7 @@
|
||||
openFiled: openFiledFollowUp,
|
||||
selectFilter: selectMobileQueue,
|
||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
announce: announceWork,
|
||||
getCounts: () => mobileQueueCounts,
|
||||
openFindWork: () => qs('#find-work').click(),
|
||||
});
|
||||
@@ -226,8 +227,10 @@
|
||||
),
|
||||
queueBadge: qs('#mobile-queue-count'),
|
||||
deadlineBadge: qs('#mobile-deadline-count'),
|
||||
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() :
|
||||
name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name),
|
||||
onSelectQueue:(name, row) => name === 'recaps' ? qs('#open-today-recaps').click() :
|
||||
name === 'find' ? qs('#find-work').click() :
|
||||
name === 'week' ? openWeekPlanner(row) :
|
||||
name === 'tomorrow' ? openTomorrowPlanner(row) : mobileQueueLauncher.open(name),
|
||||
detour:() => timerView,
|
||||
overlays: mobileTaskOverlays,
|
||||
actions: {
|
||||
@@ -235,6 +238,7 @@
|
||||
find: () => qs('#find-work').click(),
|
||||
new: () => qs('#new-issue').click(),
|
||||
search: () => qs('#open-palette').click(),
|
||||
queues: () => refreshTomorrowQueueSummary(),
|
||||
},
|
||||
observe(callback, overlays) {
|
||||
const observer = new MutationObserver(callback);
|
||||
@@ -375,10 +379,90 @@
|
||||
let rolloverReviewPlan = null;
|
||||
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
|
||||
const todayRollover = createTodayRollover();
|
||||
let planningTomorrow = false;
|
||||
let latestTodayPlan = null;
|
||||
|
||||
const tomorrowPlan = createTomorrowPlan({
|
||||
fetchJson:fetchReviewJson,
|
||||
localDate:todayRollover.localDate,
|
||||
timeZone:todayRollover.timeZone,
|
||||
storage:localStorage,
|
||||
getLogin:() => planningOwnerLogin,
|
||||
});
|
||||
const weekPlan = createWeekPlan({
|
||||
fetchJson:fetchReviewJson,localDate:todayRollover.localDate,timeZone:todayRollover.timeZone,
|
||||
storage:localStorage,getLogin:() => planningOwnerLogin,coordinator:outboxCoordinator,
|
||||
});
|
||||
function openWeekPlanner(trigger) { planningTomorrow=false; return weekFlow.open(trigger); }
|
||||
function renderTomorrowQueueSummary(value) {
|
||||
qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary();
|
||||
}
|
||||
function syncPendingTomorrow() {
|
||||
weekFlow.resumePull()&&weekFlow.flushPull();
|
||||
if (!tomorrowPlan.pending()) return Promise.resolve(false);
|
||||
return tomorrowPlan.flush().then(saved => {
|
||||
renderTomorrowQueueSummary(saved);
|
||||
qs('#my-work-action-status').textContent = saved.ids.length ?
|
||||
`Tomorrow saved for ${saved.plan_date} without changing Today.` :
|
||||
`Tomorrow cleared for ${saved.plan_date}.`;
|
||||
return saved;
|
||||
}).catch(error => {
|
||||
const conflict = tomorrowPlan.conflict();
|
||||
if (conflict) renderTomorrowQueueSummary({ids:[],sync_pending:false,conflict:true});
|
||||
qs('#mobile-tomorrow-summary').textContent = conflict ? 'Conflict · review required' : qs('#mobile-tomorrow-summary').textContent;
|
||||
qs('#my-work-action-status').textContent = conflict ?
|
||||
'Another device changed Tomorrow. Review it.' :
|
||||
`${error.message || 'Tomorrow sync is unavailable.'} Saved on this phone · sync pending.`;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
async function refreshTomorrowQueueSummary() {
|
||||
try {
|
||||
const loaded = await tomorrowPlan.load();
|
||||
if (tomorrowPlan.conflict()) qs('#mobile-tomorrow-summary').textContent = 'Conflict · review required';
|
||||
else renderTomorrowQueueSummary(loaded);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
qs('#mobile-tomorrow-summary').textContent = 'Unavailable · tap to retry';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function openTomorrowPlanner(trigger) {
|
||||
if (!planningOwnerLogin) {
|
||||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||||
return false;
|
||||
}
|
||||
trigger.disabled = true;
|
||||
qs('#mobile-tomorrow-summary').textContent = 'Loading Tomorrow…';
|
||||
try {
|
||||
const loaded = await tomorrowPlan.load();
|
||||
renderTomorrowQueueSummary(loaded);
|
||||
planningTomorrow = true;
|
||||
openPlanToday(trigger);
|
||||
qs('#my-work-action-status').textContent = '';
|
||||
return true;
|
||||
} catch (error) {
|
||||
qs('#mobile-tomorrow-summary').textContent = 'Unavailable · tap to retry';
|
||||
qs('#my-work-action-status').textContent = `${error.message || 'Tomorrow is unavailable.'} Retry when connected.`;
|
||||
return false;
|
||||
} finally {
|
||||
trigger.disabled = false;
|
||||
}
|
||||
}
|
||||
const todayWork = createTodayWork({
|
||||
storage: localStorage,
|
||||
getLogin: () => planningOwnerLogin,
|
||||
});
|
||||
const weekItem=id=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===id);
|
||||
const weekFlow=createWeekPlanWorkflow({controller:weekPlan,qs,
|
||||
getItem:weekItem,openItem:openRoutedWork,
|
||||
openPlanner:openPlanToday,setReviewMode:v=>qs('#plan-today-sheet').classList.toggle('week-review-mode',v),
|
||||
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,
|
||||
@@ -391,12 +475,18 @@
|
||||
},
|
||||
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 reviewState = todayRollover.reviewState(plan);
|
||||
if (!['stale', 'legacy'].includes(reviewState)) {
|
||||
const s = todayRollover.reviewState(plan);
|
||||
if (!['stale', 'legacy'].includes(s)) {
|
||||
rolloverReviewPlan = null;
|
||||
qs('#plan-today').textContent = 'Plan Today';
|
||||
return;
|
||||
@@ -424,6 +514,15 @@
|
||||
},
|
||||
});
|
||||
todaySync.startLifecycle({ window, document });
|
||||
tomorrowPlan.startLifecycle({
|
||||
windowObject:window,
|
||||
documentObject:document,
|
||||
check:() => Promise.all([
|
||||
syncPendingTomorrow(),
|
||||
weekPlan.pending() ? weekPlan.flush().catch(() => false) : false,
|
||||
latestTodayPlan ? weekFlow.promote(latestTodayPlan) : false,
|
||||
]),
|
||||
});
|
||||
const todayHandoff = createTodayHandoff({
|
||||
storage:localStorage,
|
||||
getLogin:() => planningOwnerLogin,
|
||||
@@ -1700,7 +1799,7 @@
|
||||
hasCheckpoint: () => updateTriage.resumable(),
|
||||
resume: () => updateTriage.resume(),
|
||||
start: () => updateTriage.start(),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
announce: announceWork,
|
||||
});
|
||||
|
||||
function openUpdateTriage() {
|
||||
@@ -1942,11 +2041,13 @@
|
||||
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
|
||||
});
|
||||
todaySummaryView.resume();
|
||||
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
|
||||
const todayWrapUpView = setupTodayWrapUp({ todayWork, tomorrowPlan, todaySync, qs, escapeHtml,
|
||||
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
|
||||
todayRecapView['completeReplan']();
|
||||
refreshMyWorkView();
|
||||
warmTodayOffline();
|
||||
renderTomorrowQueueSummary();
|
||||
syncPendingTomorrow();
|
||||
todaySummaryView.open(workedItems, tomorrowItems);
|
||||
},
|
||||
});
|
||||
@@ -2051,7 +2152,7 @@
|
||||
hasCheckpoint: () => Boolean(agendaSessionCheckpoint.read()),
|
||||
resume: () => workSession.resume(),
|
||||
start: () => workSession.start(),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
announce: announceWork,
|
||||
});
|
||||
function openAgendaSession() {
|
||||
return agendaSessionLauncher.open();
|
||||
@@ -2193,7 +2294,7 @@
|
||||
qs('[data-work-filter="today"]').click();
|
||||
workSession.start(item);
|
||||
},
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
announce: announceWork,
|
||||
});
|
||||
function renderCreateStartCapacity() {
|
||||
const result = createAndStart.capacity(qs('#create-issue-estimate').value);
|
||||
@@ -2219,7 +2320,7 @@
|
||||
qs('#my-work-action-status').textContent = 'Checking Today readiness…';
|
||||
return todayReadiness.run('start', workSession.items(), item);
|
||||
},
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
announce: announceWork,
|
||||
});
|
||||
|
||||
const selectedSessionItem = kind => ({
|
||||
@@ -2234,8 +2335,8 @@
|
||||
workSession,
|
||||
refresh: () => refreshMyWorkView({ reconcileSession:false }),
|
||||
warm: warmTodayOffline,
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
advance: () => runTodayTransition('complete'),
|
||||
announce: announceWork,
|
||||
advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete')),
|
||||
});
|
||||
todayLockScreen = createTodayLockScreen({
|
||||
storage:localStorage,
|
||||
@@ -2604,8 +2705,18 @@
|
||||
return;
|
||||
}
|
||||
planToday.cancel();
|
||||
planningTomorrow = false;
|
||||
weekCalendar.close();
|
||||
weekFlow.clear();
|
||||
qs('#tomorrow-conflict-review').hidden = true;
|
||||
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
||||
qs('#week-conflict-review').hidden = true;
|
||||
qs('#plan-today-sheet').classList.remove('week-conflict-mode');
|
||||
qs('#plan-today-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
if (planTodayTrigger?.dataset.mobileQueue === 'tomorrow') {
|
||||
if (!qs('#mobile-queue-sheet').open) qs('#mobile-queue-sheet').showModal();
|
||||
}
|
||||
planTodayTrigger?.focus();
|
||||
}
|
||||
|
||||
@@ -2637,10 +2748,26 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function saveTomorrowPlan(plan) {
|
||||
const normalized = Array.isArray(plan) ?
|
||||
{ids:plan, capacity_minutes:null, estimates:{}} : plan;
|
||||
const staged = tomorrowPlan.stage(normalized);
|
||||
if (!staged) {
|
||||
qs('#my-work-action-status').textContent =
|
||||
'Tomorrow could not be saved on this phone. Free browser storage and retry.';
|
||||
return false;
|
||||
}
|
||||
renderTomorrowQueueSummary(staged);
|
||||
qs('#my-work-action-status').textContent = 'Tomorrow saved on this phone · sync pending.';
|
||||
syncPendingTomorrow();
|
||||
return true;
|
||||
}
|
||||
|
||||
const planToday = createPlanToday({
|
||||
identity: item => todayWork.identity(item),
|
||||
limit: todayWork.limit,
|
||||
save: saveTodayPlan,
|
||||
save: plan => weekFlow.active() ? weekFlow.save(plan) :
|
||||
(planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)),
|
||||
start: () => {
|
||||
qs('[data-work-filter="today"]').click();
|
||||
startTodaySession();
|
||||
@@ -2912,29 +3039,97 @@
|
||||
});
|
||||
|
||||
let pendingPlanActualMinutes = null;
|
||||
function tomorrowConflictPlanMarkup(value) {
|
||||
const ids = value?.ids || [];
|
||||
const estimates = value?.estimates || {};
|
||||
const estimated = ids.reduce((total, id) => total + (Number(estimates[id]) || 0), 0);
|
||||
const capacity = Number(value?.capacity_minutes) || 0;
|
||||
const summary = ids.length ? `${ids.length} planned` +
|
||||
(estimated && capacity ? ` · ${estimated} of ${capacity} min` : '') : 'Nothing planned';
|
||||
const available = [...todayMyWork, ...activeMyWork];
|
||||
const items = ids.map(id => {
|
||||
const item = available.find(candidate => todayWork.identity(candidate) === id);
|
||||
const label = item?.title || item?.key || id;
|
||||
const estimate = Number(estimates[id]) || 0;
|
||||
return `<li><strong>${escapeHtml(label)}</strong>${estimate ? ` <span>· ${estimate} min</span>` : ''}</li>`;
|
||||
}).join('');
|
||||
return `<p class="tomorrow-conflict-plan-summary">${summary}</p>` +
|
||||
(items ? `<ol class="tomorrow-conflict-plan-list">${items}</ol>` : '<p class="muted">No work selected.</p>');
|
||||
}
|
||||
|
||||
function showTomorrowConflict(conflict) {
|
||||
qs('#plan-today-title').textContent = 'Resolve Tomorrow conflict';
|
||||
qs('#tomorrow-conflict-phone-plan').innerHTML = tomorrowConflictPlanMarkup(conflict.local);
|
||||
qs('#tomorrow-conflict-server-plan').innerHTML = tomorrowConflictPlanMarkup(conflict.remote);
|
||||
qs('#tomorrow-conflict-status').textContent = '';
|
||||
qs('#tomorrow-conflict-review').hidden = false;
|
||||
qs('#plan-today-sheet').classList.add('tomorrow-conflict-mode');
|
||||
qs('#plan-today-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
const keep = qs('#keep-phone-tomorrow');
|
||||
keep.focus();
|
||||
requestAnimationFrame(() => keep.focus());
|
||||
}
|
||||
|
||||
function showWeekConflict(conflict) {
|
||||
qs('#plan-today-title').textContent='Resolve Week Ahead conflict';
|
||||
const focus=weekFlow.renderConflict(conflict);
|
||||
qs('#week-conflict-status').textContent='';
|
||||
qs('#week-conflict-review').hidden=false;
|
||||
qs('#plan-today-sheet').classList.add('week-conflict-mode');
|
||||
qs('#plan-today-sheet').hidden=false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
focus.focus();requestAnimationFrame(()=>focus.focus());
|
||||
}
|
||||
|
||||
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
|
||||
if (!planningOwnerLogin) {
|
||||
if (!planningOwnerLogin && !weekFlow.active()) {
|
||||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||||
return;
|
||||
}
|
||||
if (trigger) planTodayTrigger = trigger;
|
||||
qs('#plan-today-title').textContent = pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today');
|
||||
const weekCopy=weekFlow.copy();
|
||||
qs('#plan-today-title').textContent = weekCopy ? weekCopy.title : (planningTomorrow ? 'Plan Tomorrow' :
|
||||
(pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today')));
|
||||
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
|
||||
if (navigate) {
|
||||
taskOverlayHistory.open('plan-today');
|
||||
return;
|
||||
}
|
||||
const conflict = planningTomorrow ? tomorrowPlan.conflict() : null;
|
||||
if (conflict) {
|
||||
showTomorrowConflict(conflict);
|
||||
return;
|
||||
}
|
||||
const weekConflict=weekFlow.active()?weekPlan.conflict():null;
|
||||
if(weekConflict){showWeekConflict(weekConflict);return;}
|
||||
qs('#tomorrow-conflict-review').hidden = true;
|
||||
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
||||
qs('#week-conflict-review').hidden=true;
|
||||
qs('#plan-today-sheet').classList.remove('week-conflict-mode');
|
||||
const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes;
|
||||
pendingPlanActualMinutes = null;
|
||||
const protectProposal = pendingProtectToday;
|
||||
const selected = protectProposal?.selected || todayMyWork;
|
||||
planToday.open(selected, activeMyWork, todayWork.planning(), recommendations);
|
||||
const tomorrow = planningTomorrow ? tomorrowPlan.state() : null;
|
||||
const weekDay = weekFlow.day();
|
||||
const availablePlanningItems = [...todayMyWork, ...activeMyWork].filter((item, index, items) =>
|
||||
items.findIndex(candidate => todayWork.identity(candidate) === todayWork.identity(item)) === index
|
||||
);
|
||||
const selected = (planningTomorrow || weekCopy) ? availablePlanningItems.filter(item =>
|
||||
(weekDay || tomorrow).ids.includes(todayWork.identity(item))
|
||||
) : (protectProposal?.selected || todayMyWork);
|
||||
planToday.open(selected, activeMyWork, weekDay || (planningTomorrow ? tomorrow : todayWork.planning()), recommendations);
|
||||
qs('#today-plan-heading').textContent = weekCopy ? weekCopy.heading : (planningTomorrow ? 'Tomorrow, in order' : 'Today, in order');
|
||||
qs('.plan-today-available').firstChild.textContent = weekCopy ? weekCopy.available : (planningTomorrow ? 'Available this day' : 'Available today');
|
||||
qs('#build-today-plan').textContent = weekCopy ? weekCopy.build : (planningTomorrow ? 'Build my Tomorrow' : 'Build my Today');
|
||||
qs('#save-and-start-today').hidden = planningTomorrow || Boolean(weekCopy);
|
||||
pendingProtectToday = null;
|
||||
qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan();
|
||||
qs('#plan-today-error').textContent = '';
|
||||
qs('#plan-today-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
renderPlanToday();
|
||||
weekFlow.renderDates();
|
||||
if (protectProposal) qs('#plan-today-build-status').textContent = protectProposal.summary +
|
||||
(protectProposal.displaced.length ? '. Displaced work remains unchanged until you save.' : '. Review estimates and capacity before saving.');
|
||||
qs('#cancel-plan-today').focus();
|
||||
@@ -2971,7 +3166,7 @@
|
||||
close: () => workRoute.close(),
|
||||
refresh: refreshMyWorkView,
|
||||
focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
announce: announceWork,
|
||||
formatTime: fmt,
|
||||
});
|
||||
const laterPickerElement = qs('#later-picker');
|
||||
@@ -4227,7 +4422,7 @@
|
||||
selectedIssueOffline = false;
|
||||
selectedIssueDetail = null;
|
||||
issueConversation = null;
|
||||
if (issueTrigger?.isConnected) issueTrigger.focus();
|
||||
setTimeout(()=>issueTrigger?.focus());
|
||||
}
|
||||
|
||||
function closeIssueEditorFromHistory() {
|
||||
@@ -5275,6 +5470,7 @@
|
||||
planningOwnerLogin = retainedPlanningLogin;
|
||||
updatePlanningAvailability();
|
||||
if (planningOwnerLogin) {
|
||||
syncPendingTomorrow();
|
||||
todaySync.migrate(todayWork.read());
|
||||
todaySync.flush();
|
||||
laterSync.migrate(laterWork.read());
|
||||
@@ -5285,6 +5481,7 @@
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
timerView.restore(todaySync.flush());
|
||||
restoreReleaseReceipt();
|
||||
updateDeliveryReceiptControls();
|
||||
interruptionPrompt.restore();
|
||||
@@ -5420,6 +5617,7 @@
|
||||
const claimButton = qs('#claim-search-result');
|
||||
const deferButton = qs('#defer-search-result');
|
||||
const queueButton = qs('#queue-search-result');
|
||||
const planButton = qs('#plan-search-result');
|
||||
const startButton = qs('#start-search-result');
|
||||
const shareButton = qs('#share-search-result');
|
||||
|
||||
@@ -5436,6 +5634,8 @@
|
||||
deferButton.hidden = true;
|
||||
deferButton.disabled = false;
|
||||
queueButton.hidden = true;
|
||||
planButton.hidden = true;
|
||||
planButton.disabled = false;
|
||||
startButton.hidden = true;
|
||||
startButton.disabled = false;
|
||||
shareButton.disabled = true;
|
||||
@@ -5484,6 +5684,9 @@
|
||||
queueButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
|
||||
(detail.claimable || detail.assigned_to_me));
|
||||
queueButton.textContent = detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today';
|
||||
planButton.hidden = !searchWeekPlan.eligible(detail);
|
||||
planButton.textContent = detail.assigned_to_me ? 'Plan ahead' : 'Assign & plan ahead';
|
||||
planButton.disabled = searchWeekPlan.pending();
|
||||
startButton.hidden = !(detail.reviewable || (detail.kind === 'issue' &&
|
||||
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)))));
|
||||
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
|
||||
@@ -5541,10 +5744,19 @@
|
||||
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
|
||||
onState: renderSearchPreview,
|
||||
});
|
||||
const searchWeekPlan = createSearchWeekPlan({
|
||||
week:weekPlan,claim:detail=>searchPreview.claim(detail),accept:acceptClaimedIssue,
|
||||
identity:item=>todayWork.identity(item),
|
||||
});
|
||||
createSearchWeekPlanUI({planner:searchWeekPlan,document,window,escapeHtml,escapeAttribute:escAttr,
|
||||
getDetail:()=>searchPreviewDetail,
|
||||
announce:message=>{qs('#search-preview-status').textContent=message;},
|
||||
onPlanned:item=>{searchPreviewDetail={...item,assigned_to_me:true,claimable:false};},
|
||||
});
|
||||
searchBatchPlanning = mountSearchBatchPlanning(
|
||||
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
|
||||
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, i=>commandItems[i]?.result,
|
||||
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker
|
||||
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker, weekPlan, localStorage
|
||||
);
|
||||
searchDefer = createSearchDefer({
|
||||
claim: detail => searchPreview.claim(detail),
|
||||
@@ -5898,6 +6110,7 @@
|
||||
(!detail.claimable && !detail.assigned_to_me) || searchDefer.pending()) return;
|
||||
laterPicker.open(detail, event.currentTarget, 'search');
|
||||
});
|
||||
|
||||
qs('#queue-search-result').addEventListener('click', async () => {
|
||||
const detail = searchPreviewDetail;
|
||||
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
|
||||
@@ -6903,11 +7116,11 @@
|
||||
closeIssueSheet();
|
||||
refreshMyWorkView({ reconcileSession:false });
|
||||
if (!outcome.advanced) {
|
||||
qs('#my-work-action-status').textContent = 'Issue closure queued, but Today still needs completion.';
|
||||
qs('#my-work-action-status').textContent = 'Issue close queued; Today still needs completion.';
|
||||
} else if (outcome.admission.background) {
|
||||
qs('#my-work-action-status').textContent = 'Issue closure queued for reconnect. Next Today item opened.';
|
||||
qs('#my-work-action-status').textContent = 'Issue close queued. Next Today item opened.';
|
||||
} else {
|
||||
qs('#my-work-action-status').textContent = 'Issue closure saved for next launch. Next Today item opened.';
|
||||
qs('#my-work-action-status').textContent = 'Issue close saved. Next Today item opened.';
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in Today; retry.';
|
||||
@@ -7521,6 +7734,7 @@
|
||||
planningOwnerLogin = confirmedOwnerLogin;
|
||||
interruptionPrompt.restore();
|
||||
updatePlanningAvailability();
|
||||
syncPendingTomorrow();
|
||||
saved.notifications = notificationReadOutbox.suppress(saved.notifications || []);
|
||||
lastNotifications = saved.notifications;
|
||||
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
|
||||
@@ -7632,7 +7846,88 @@
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
|
||||
qs('#plan-tomorrow').addEventListener('click', event => openTomorrowPlanner(event.currentTarget));
|
||||
qs('#keep-phone-tomorrow').addEventListener('click', async () => {
|
||||
const keep = qs('#keep-phone-tomorrow');
|
||||
const use = qs('#use-server-tomorrow');
|
||||
keep.disabled = true;
|
||||
use.disabled = true;
|
||||
qs('#tomorrow-conflict-status').textContent = 'Saving this phone’s plan…';
|
||||
try {
|
||||
const saved = await tomorrowPlan.keepLocal();
|
||||
renderTomorrowQueueSummary(saved);
|
||||
qs('#my-work-action-status').textContent = 'This phone’s Tomorrow plan is saved to your account. Today was not changed.';
|
||||
closePlanToday();
|
||||
} catch (error) {
|
||||
const conflict = tomorrowPlan.conflict();
|
||||
if (conflict) showTomorrowConflict(conflict);
|
||||
qs('#tomorrow-conflict-status').textContent = error?.status === 409 ?
|
||||
'Tomorrow changed again. Both latest plans are still preserved; choose again.' :
|
||||
`${error.message || 'Tomorrow could not be saved.'} Both plans are still preserved.`;
|
||||
} finally {
|
||||
keep.disabled = false;
|
||||
use.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#use-server-tomorrow').addEventListener('click', () => {
|
||||
const adopted = tomorrowPlan.useRemote();
|
||||
if (!adopted) return;
|
||||
renderTomorrowQueueSummary(adopted);
|
||||
qs('#my-work-action-status').textContent = 'Saved account Tomorrow plan selected. Today was not changed.';
|
||||
closePlanToday();
|
||||
});
|
||||
qs('#keep-phone-week').addEventListener('click', async () => {
|
||||
const keep=qs('#keep-phone-week'),use=qs('#use-server-week');
|
||||
keep.disabled=true;use.disabled=true;
|
||||
qs('#week-conflict-status').textContent='Saving this phone’s complete week…';
|
||||
try {
|
||||
await weekPlan.keepLocal();
|
||||
qs('#mobile-week-summary').textContent=weekPlan.summary();
|
||||
qs('#my-work-action-status').textContent='This phone’s Week Ahead plan is saved. Today was not changed.';
|
||||
closePlanToday();
|
||||
} catch(error) {
|
||||
const conflict=weekPlan.conflict();
|
||||
if(conflict)showWeekConflict(conflict);
|
||||
qs('#week-conflict-status').textContent=error?.status===409?
|
||||
'Week Ahead changed again. Both latest weeks are preserved; choose again.':
|
||||
`${error.message||'Week Ahead could not be saved.'} Both weeks are still preserved.`;
|
||||
} finally {keep.disabled=false;use.disabled=false;}
|
||||
});
|
||||
qs('#use-server-week').addEventListener('click', () => {
|
||||
const adopted=weekPlan.useRemote();
|
||||
if(!adopted)return;
|
||||
qs('#mobile-week-summary').textContent=weekPlan.summary();
|
||||
qs('#my-work-action-status').textContent='Saved account Week Ahead plan selected. Today was not changed.';
|
||||
closePlanToday();
|
||||
});
|
||||
qs('#save-merged-week').addEventListener('click', async () => {
|
||||
const save=qs('#save-merged-week');
|
||||
save.disabled=true;
|
||||
qs('#week-conflict-status').textContent='Saving combined Week Ahead plan…';
|
||||
try {
|
||||
await weekPlan.saveMerged();
|
||||
qs('#mobile-week-summary').textContent=weekPlan.summary();
|
||||
qs('#my-work-action-status').textContent='Combined Week Ahead changes are saved. Today was not changed.';
|
||||
closePlanToday();
|
||||
} catch(error) {
|
||||
const conflict=weekPlan.conflict();
|
||||
if(conflict)showWeekConflict(conflict);
|
||||
qs('#week-conflict-status').textContent=error?.status===409?
|
||||
'Week Ahead changed again. The latest days are preserved; review the remaining choices.':
|
||||
`${error.message||'The combined week could not be saved.'} Your phone plan is still preserved.`;
|
||||
} finally {
|
||||
const conflict=weekPlan.conflict();
|
||||
save.disabled=Boolean(conflict?.conflicts?.some(item=>!item.choice));
|
||||
}
|
||||
});
|
||||
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
|
||||
qs('#confirm-week-plan').addEventListener('click', () => {
|
||||
if(!weekFlow.confirm()){
|
||||
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());
|
||||
});
|
||||
qs('#plan-today-sheet').addEventListener('click', event => {
|
||||
if (event.target === qs('#plan-today-sheet')) closePlanToday();
|
||||
});
|
||||
@@ -7657,7 +7952,7 @@
|
||||
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
|
||||
if (result === 'saved') {
|
||||
button.dataset.confirmOverCapacity = '';
|
||||
taskOverlayHistory.leave();
|
||||
if (!weekFlow.active() || !weekFlow.advance()) taskOverlayHistory.leave();
|
||||
} else if (result === 'confirm-over-capacity') {
|
||||
button.dataset.confirmOverCapacity = 'true';
|
||||
qs('#plan-today-error').textContent = 'This plan exceeds your available time. Press again to save over capacity.';
|
||||
@@ -7889,7 +8184,10 @@
|
||||
if (workSession.active()) workSession.reconcile();
|
||||
});
|
||||
let pushController = null;
|
||||
for (const selector of [qs('#push-deadline-hour'), qs('#device-setup-deadline-hour')]) {
|
||||
for (const selector of [
|
||||
qs('#push-deadline-hour'), qs('#device-setup-deadline-hour'),
|
||||
qs('#push-start-day-hour'), qs('#device-setup-start-day-hour'),
|
||||
]) {
|
||||
for (let hour = 0; hour < 24; hour += 1) {
|
||||
const option = document.createElement('option');
|
||||
option.value = String(hour);
|
||||
@@ -7912,6 +8210,13 @@
|
||||
qs('#device-setup-deadline-days').addEventListener('change', event => {
|
||||
qs('#push-deadline-days').value = event.target.value;
|
||||
});
|
||||
qs('#push-start-day-hour').addEventListener('change', event => {
|
||||
qs('#device-setup-start-day-hour').value = event.target.value;
|
||||
if (qs('#push-start-day').checked) pushController?.changeStartDay();
|
||||
});
|
||||
qs('#device-setup-start-day-hour').addEventListener('change', event => {
|
||||
qs('#push-start-day-hour').value = event.target.value;
|
||||
});
|
||||
let pushControllerReady = Promise.resolve(null);
|
||||
if ('serviceWorker' in navigator) {
|
||||
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
|
||||
@@ -7924,6 +8229,9 @@
|
||||
deadlineStatus:qs('#push-deadline-status'),
|
||||
deadlineHour:qs('#push-deadline-hour'),
|
||||
deadlineDays:qs('#push-deadline-days'),
|
||||
startDayControl:qs('#push-start-day'),
|
||||
startDayStatus:qs('#push-start-day-status'),
|
||||
startDayHour:qs('#push-start-day-hour'),
|
||||
deadlineSnooze:qs('#deadline-snooze'),
|
||||
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
|
||||
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
|
||||
@@ -7942,6 +8250,7 @@
|
||||
pushController = controller;
|
||||
qs('#device-setup-deadline-hour').value = qs('#push-deadline-hour').value;
|
||||
qs('#device-setup-deadline-days').value = qs('#push-deadline-days').value;
|
||||
qs('#device-setup-start-day-hour').value = qs('#push-start-day-hour').value;
|
||||
return controller;
|
||||
}).catch(error => {
|
||||
qs('#push-updates').disabled = true;
|
||||
@@ -7952,6 +8261,14 @@
|
||||
qs('#push-updates').disabled = true;
|
||||
qs('#push-update-status').textContent = 'This browser does not support update notifications.';
|
||||
}
|
||||
qs('#device-setup-start-day').addEventListener('click', async () => {
|
||||
const controller = await pushControllerReady;
|
||||
if (!controller) return;
|
||||
qs('#push-start-day-hour').value = qs('#device-setup-start-day-hour').value;
|
||||
qs('#push-start-day').checked = true;
|
||||
await controller.changeStartDay();
|
||||
qs('#device-setup-start-day-status').textContent = qs('#push-start-day-status').textContent;
|
||||
});
|
||||
let deferredInstallPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', event => {
|
||||
event.preventDefault();
|
||||
|
||||
+173
-1
@@ -143,6 +143,14 @@
|
||||
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Start my planned day</strong><p class="small" id="device-setup-start-day-status" role="status">Get one private reminder when Tomorrow is ready.</p></div>
|
||||
<div class="device-setup-deadline-controls">
|
||||
<label for="device-setup-start-day-hour">Local hour</label>
|
||||
<select id="device-setup-start-day-hour" aria-label="Start-day reminder local hour"></select>
|
||||
<button class="device-setup-action" id="device-setup-start-day" type="button">Enable</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<section class="device-storage" aria-labelledby="device-storage-heading">
|
||||
<div>
|
||||
@@ -192,6 +200,7 @@
|
||||
<button class="work-filter" data-work-filter="later" aria-pressed="false">Later <span data-work-count="later">0</span></button>
|
||||
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
|
||||
</div>
|
||||
<button class="plan-tomorrow" id="plan-tomorrow" type="button">Plan Tomorrow</button>
|
||||
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
|
||||
<select class="work-milestone-filter" id="work-milestone-filter">
|
||||
<option value="all">All milestones</option>
|
||||
@@ -208,6 +217,9 @@
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-start-day"><input id="push-start-day" type="checkbox" /> Remind me when my planned day is ready</label>
|
||||
<label for="push-start-day-hour">Start-day hour <select id="push-start-day-hour" aria-label="Start-day reminder local hour"></select></label>
|
||||
<span class="small" id="push-start-day-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="today-lock-screen"><input id="today-lock-screen" type="checkbox" /> Show active Today controls on my lock screen</label>
|
||||
<span class="small" id="today-lock-screen-status" role="status" aria-live="polite"></span>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
@@ -448,6 +460,107 @@
|
||||
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
|
||||
<button id="cancel-plan-today" type="button">Cancel</button>
|
||||
</div>
|
||||
<p class="week-plan-progress" id="week-plan-progress" aria-live="polite" hidden></p>
|
||||
<nav class="week-plan-dates" id="week-plan-dates" aria-label="Week Ahead dates" hidden></nav>
|
||||
<button class="back-to-week-review" id="back-to-week-review" type="button" hidden>Back to week review</button>
|
||||
<section class="tomorrow-conflict-review" id="tomorrow-conflict-review" aria-labelledby="tomorrow-conflict-title" hidden>
|
||||
<div class="small">Cross-device change</div>
|
||||
<h3 id="tomorrow-conflict-title">Choose which Tomorrow plan to keep</h3>
|
||||
<p class="small muted">Both plans are preserved until one choice succeeds. Today will not change.</p>
|
||||
<div class="tomorrow-conflict-plans">
|
||||
<section aria-labelledby="tomorrow-conflict-phone-title">
|
||||
<h4 id="tomorrow-conflict-phone-title">This phone</h4>
|
||||
<div id="tomorrow-conflict-phone-plan"></div>
|
||||
</section>
|
||||
<section aria-labelledby="tomorrow-conflict-server-title">
|
||||
<h4 id="tomorrow-conflict-server-title">Saved on your account</h4>
|
||||
<div id="tomorrow-conflict-server-plan"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="tomorrow-conflict-actions">
|
||||
<button id="keep-phone-tomorrow" type="button">Keep this phone’s plan</button>
|
||||
<button id="use-server-tomorrow" type="button">Use saved account plan</button>
|
||||
</div>
|
||||
<div class="small tomorrow-conflict-status" id="tomorrow-conflict-status" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="week-conflict-review" id="week-conflict-review" aria-labelledby="week-conflict-title" hidden>
|
||||
<div class="small">Cross-device change</div>
|
||||
<h3 id="week-conflict-title">Resolve Week Ahead changes</h3>
|
||||
<p class="small muted">Changes on different days are combined automatically. Choose only where both devices changed the same day.</p>
|
||||
<div class="week-conflict-days" id="week-conflict-days" hidden></div>
|
||||
<button id="save-merged-week" type="button" hidden disabled>Save combined week</button>
|
||||
<div class="week-conflict-plans" id="week-conflict-legacy-plans">
|
||||
<section aria-labelledby="week-conflict-phone-title">
|
||||
<h4 id="week-conflict-phone-title">This phone</h4>
|
||||
<div id="week-conflict-phone-plan"></div>
|
||||
</section>
|
||||
<section aria-labelledby="week-conflict-server-title">
|
||||
<h4 id="week-conflict-server-title">Saved on your account</h4>
|
||||
<div id="week-conflict-server-plan"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="week-conflict-actions" id="week-conflict-legacy-actions">
|
||||
<button id="keep-phone-week" type="button">Keep this phone’s week</button>
|
||||
<button id="use-server-week" type="button">Use saved account week</button>
|
||||
</div>
|
||||
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="week-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-reflow" type="button" hidden>Reflow remaining week</button>
|
||||
<section class="week-reflow-review" id="week-reflow-review" aria-labelledby="week-reflow-title" hidden>
|
||||
<div class="small">Capacity-safe preview</div>
|
||||
<h3 id="week-reflow-title">Reflow remaining week?</h3>
|
||||
<p class="small muted">Work keeps its current order. Anything that cannot fit stays unscheduled in My Work.</p>
|
||||
<div class="week-reflow-days" id="week-reflow-days"></div>
|
||||
<div class="week-reflow-unscheduled" id="week-reflow-unscheduled" role="status"></div>
|
||||
<div class="week-reflow-actions">
|
||||
<button id="cancel-week-reflow" type="button">Cancel</button>
|
||||
<button id="apply-week-reflow" type="button">Apply reflow</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
|
||||
<div class="week-review-days" id="week-review-days"></div>
|
||||
<div class="week-unplan-receipt" id="week-unplan-receipt" role="status" aria-live="polite" hidden></div>
|
||||
<button id="undo-week-unplan" type="button" hidden>Undo</button>
|
||||
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
|
||||
<button id="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>
|
||||
<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>
|
||||
<label>Calendar file
|
||||
<input id="week-capacity-file" type="file" accept=".ics,text/calendar" />
|
||||
</label>
|
||||
<div class="week-capacity-hours">
|
||||
<label>Workday starts <input id="week-capacity-start" type="time" value="09:00" required /></label>
|
||||
<label>Workday ends <input id="week-capacity-end" type="time" value="17:00" required /></label>
|
||||
</div>
|
||||
<div id="week-capacity-status" class="small" role="status" aria-live="assertive"></div>
|
||||
<section id="week-capacity-review" aria-labelledby="week-capacity-review-title" hidden>
|
||||
<h3 id="week-capacity-review-title">Review available time</h3>
|
||||
<div id="week-capacity-days" class="week-capacity-days"></div>
|
||||
<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>
|
||||
<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="share-week-calendar" type="button">Share calendar blocks</button>
|
||||
</div>
|
||||
</section>
|
||||
<nav class="mobile-plan-today-nav" aria-label="Plan Today sections">
|
||||
<button type="button" data-plan-today-section="fit" aria-current="location">Fit</button>
|
||||
<button type="button" data-plan-today-section="today">Today</button>
|
||||
@@ -535,7 +648,7 @@
|
||||
<div><div class="small">End your day intentionally</div><h2 id="today-wrap-up-title">Wrap up Today</h2></div>
|
||||
<button id="close-today-wrap-up" type="button">Not now</button>
|
||||
</div>
|
||||
<p class="small muted">Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.</p>
|
||||
<p class="small muted">Carry selected work into the ordered Tomorrow plan. Unselected work stays in Today.</p>
|
||||
<div id="today-wrap-up-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-wrap-up-items" class="today-wrap-up-items"></div>
|
||||
<div class="today-wrap-up-actions">
|
||||
@@ -672,6 +785,7 @@
|
||||
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
|
||||
<button id="cancel-search-selection" type="button">Cancel</button>
|
||||
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
|
||||
<button id="week-selected-search-results" type="button" disabled>Plan Week Ahead</button>
|
||||
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
|
||||
<button id="queue-selected-search-results" type="button" disabled>Assign & add to Today</button>
|
||||
</div>
|
||||
@@ -685,6 +799,16 @@
|
||||
<button id="confirm-search-batch-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="search-week-batch-review" id="search-week-batch-review" role="dialog" aria-modal="true"
|
||||
aria-labelledby="search-week-batch-heading" hidden>
|
||||
<div><strong id="search-week-batch-heading">Plan selected issues into Week Ahead</strong>
|
||||
<span class="small" id="search-week-batch-summary" aria-live="polite"></span></div>
|
||||
<div id="search-week-batch-list"></div>
|
||||
<div class="search-week-batch-actions">
|
||||
<button id="cancel-search-week-batch" type="button">Back</button>
|
||||
<button id="confirm-search-week-batch" type="button">Assign & plan</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="search-release-review" id="search-release-review"
|
||||
aria-labelledby="search-release-heading" hidden>
|
||||
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
|
||||
@@ -787,6 +911,7 @@
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="defer-search-result" type="button" hidden>Assign & defer</button>
|
||||
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
||||
<button id="plan-search-result" type="button" hidden>Plan ahead</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
|
||||
</div>
|
||||
@@ -796,6 +921,23 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="search-week-plan-sheet" id="search-week-plan-sheet" role="dialog" aria-modal="true"
|
||||
aria-labelledby="search-week-plan-title" hidden>
|
||||
<div class="search-week-plan-panel">
|
||||
<header>
|
||||
<div><div class="small">Week Ahead</div><h2 id="search-week-plan-title">Plan this issue</h2></div>
|
||||
<button id="cancel-search-week-plan" type="button">Cancel</button>
|
||||
</header>
|
||||
<p id="search-week-plan-copy" class="muted"></p>
|
||||
<fieldset><legend>Choose a day</legend><div class="search-week-plan-days" id="search-week-plan-days"></div></fieldset>
|
||||
<label class="search-week-plan-estimate" for="search-week-plan-estimate">Estimate in minutes
|
||||
<input id="search-week-plan-estimate" type="number" inputmode="numeric" min="1" max="1440" step="5" required />
|
||||
</label>
|
||||
<p id="search-week-plan-status" class="small" aria-live="polite"></p>
|
||||
<button id="confirm-search-week-plan" type="button">Plan</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="whiteboard-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-actions">
|
||||
@@ -1655,12 +1797,19 @@
|
||||
</section>
|
||||
|
||||
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
|
||||
<div class="mobile-first-task-coach small" data-mobile-first-task-coach hidden>
|
||||
<strong>Complete your first task</strong>
|
||||
<span>Open it, then use Done & next after a real outcome.</span>
|
||||
</div>
|
||||
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
|
||||
<div class="small" data-work-session-progress aria-live="polite"></div>
|
||||
<button data-mobile-today-complete type="button" hidden>Done & next</button>
|
||||
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
|
||||
<button data-mobile-today-more type="button" aria-controls="mobile-today-actions" aria-haspopup="dialog">More</button>
|
||||
</section>
|
||||
<div class="mobile-first-task-receipt" id="mobile-first-task-receipt" role="status" aria-live="polite" hidden>
|
||||
First task completed · continue Today
|
||||
</div>
|
||||
<dialog class="mobile-today-actions" id="mobile-today-actions" aria-modal="true" aria-labelledby="mobile-today-actions-title">
|
||||
<section class="mobile-today-actions-panel">
|
||||
<header><h2 id="mobile-today-actions-title">Today session</h2><button data-mobile-today-actions-close type="button">Close</button></header>
|
||||
@@ -1673,10 +1822,25 @@
|
||||
<button class="mobile-today-blocked" data-mobile-today-blocked type="button" hidden>Report blocker</button>
|
||||
<button data-today-break-open type="button">Take break</button>
|
||||
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
|
||||
<button data-work-session-reschedule-week type="button">Reschedule to Week Ahead</button>
|
||||
<button data-mobile-today-end type="button">End session</button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog class="today-week-reschedule" id="today-week-reschedule" aria-labelledby="today-week-reschedule-title">
|
||||
<section class="today-week-reschedule-panel">
|
||||
<header>
|
||||
<div><p class="small muted">Active Today item</p><h2 id="today-week-reschedule-title">Reschedule to Week Ahead</h2></div>
|
||||
<button id="cancel-today-week-reschedule" type="button">Cancel</button>
|
||||
</header>
|
||||
<p class="small">Choose a future day after reviewing its current planned load. Today will continue with the next ready item.</p>
|
||||
<div class="today-week-reschedule-days" id="today-week-reschedule-days" role="radiogroup" aria-label="Week Ahead destination"></div>
|
||||
<label for="today-week-reschedule-estimate">Estimate in minutes</label>
|
||||
<input id="today-week-reschedule-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" required />
|
||||
<p id="today-week-reschedule-status" class="small" role="status" aria-live="assertive"></p>
|
||||
<button id="confirm-today-week-reschedule" type="button" disabled>Move to Week Ahead & continue</button>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog class="today-progress-sheet" id="today-progress-sheet" aria-labelledby="today-progress-title">
|
||||
<section class="today-progress-panel">
|
||||
<header><div><p class="small muted">Active Today item</p><h2 id="today-progress-title">Add progress update</h2></div><button id="cancel-today-progress" type="button">Cancel</button></header>
|
||||
@@ -1802,6 +1966,8 @@
|
||||
</section>
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
|
||||
<button data-mobile-queue="week" type="button"><span><strong>Week Ahead</strong><small id="mobile-week-summary" aria-live="polite">Nothing planned</small></span></button>
|
||||
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
@@ -1911,6 +2077,12 @@
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-readiness.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/tomorrow-plan.js"></script>
|
||||
<script src="static/week-calendar.js"></script>
|
||||
<script src="static/week-calendar-import.js"></script>
|
||||
<script src="static/week-plan.js"></script>
|
||||
<script src="static/today-week-reschedule.js"></script>
|
||||
<script src="static/search-week-plan.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/today-rollover.js"></script>
|
||||
<script src="static/update-ownership.js"></script>
|
||||
|
||||
@@ -572,6 +572,7 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
globalThis.dispatchEvent?.(new CustomEvent('stackchain:issue-closed',{detail:item}));
|
||||
return result;
|
||||
}).finally(() => { closeRequest = null; });
|
||||
return closeRequest;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
isOnline: () => typeof navigator === 'undefined' || navigator.onLine,
|
||||
mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false},
|
||||
eventTarget: win,
|
||||
setTimer: (...args) => globalThis.setTimeout(...args),
|
||||
sheet: doc?.querySelector('#mobile-first-task'),
|
||||
title: doc?.querySelector('#mobile-first-task-title'),
|
||||
findButton: doc?.querySelector('#mobile-first-task-find'),
|
||||
@@ -16,6 +17,8 @@
|
||||
setupButton: doc?.querySelector('#mobile-first-task-setup'),
|
||||
closeButton: doc?.querySelector('#close-mobile-first-task'),
|
||||
status: doc?.querySelector('#mobile-first-task-status'),
|
||||
coach: doc?.querySelector('[data-mobile-first-task-coach]'),
|
||||
receipt: doc?.querySelector('#mobile-first-task-receipt'),
|
||||
isTodayActive: () => false,
|
||||
onFind: () => doc?.querySelector('#find-work')?.click(),
|
||||
onCreate: () => doc?.querySelector('#new-issue')?.click(),
|
||||
@@ -32,22 +35,34 @@
|
||||
return login ? prefix + login : '';
|
||||
}
|
||||
|
||||
function completed() {
|
||||
function state() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return false;
|
||||
try { return options.storage.getItem(currentKey) === 'complete'; }
|
||||
catch (_error) { return false; }
|
||||
if (!currentKey) return '';
|
||||
try { return options.storage.getItem(currentKey) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
}
|
||||
|
||||
function store(value) {
|
||||
const currentKey = key();
|
||||
const current = state();
|
||||
if (!currentKey || current === value || current === 'complete') return false;
|
||||
try {
|
||||
options.storage.setItem(currentKey, value);
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function adoptRemote(value) {
|
||||
return ['coaching', 'complete'].includes(value) && store(value);
|
||||
}
|
||||
|
||||
function required() {
|
||||
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !completed());
|
||||
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !state());
|
||||
}
|
||||
|
||||
function markComplete() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return;
|
||||
try { options.storage.setItem(currentKey, 'complete'); }
|
||||
catch (_error) {}
|
||||
function renderCoach() {
|
||||
if (!options.coach) return;
|
||||
options.coach.hidden = !(options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive());
|
||||
}
|
||||
|
||||
function render() {
|
||||
@@ -72,16 +87,30 @@
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (account() && options.isTodayActive() && !completed()) {
|
||||
markComplete();
|
||||
if (account() && options.isTodayActive() && state() !== 'complete') {
|
||||
store('coaching');
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
return 'completed';
|
||||
renderCoach();
|
||||
return 'coaching';
|
||||
}
|
||||
renderCoach();
|
||||
if (options.sheet.open) render();
|
||||
if (required() && options.hasWork()) return 'awaiting-start';
|
||||
return required() ? 'required' : 'inactive';
|
||||
}
|
||||
|
||||
function completeOutcome() {
|
||||
if (state() !== 'coaching') return false;
|
||||
store('complete');
|
||||
options.eventTarget?.dispatchEvent?.(new CustomEvent('stackchain:first-task-complete'));
|
||||
renderCoach();
|
||||
if (options.receipt) {
|
||||
options.receipt.hidden = false;
|
||||
options.setTimer?.(() => { options.receipt.hidden = true; }, 6000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handoff(callback, requiresOnline = false) {
|
||||
if (requiresOnline && !options.isOnline()) return;
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
@@ -99,5 +128,6 @@
|
||||
options.eventTarget?.addEventListener('offline', render);
|
||||
}
|
||||
|
||||
return {required, open, refresh, render, start};
|
||||
globalThis.stackchainFirstTaskCompleting = () => state() === 'coaching';
|
||||
return {required, open, refresh, render, start, completeOutcome, adoptRemote};
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
'stackchain-voice-transcripts-v1',
|
||||
'stackchain-search-reply-drafts-v1',
|
||||
'stackchain-conversation-reply-drafts-v1',
|
||||
'stackchain-today-action-mailbox-v1',
|
||||
]);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
|
||||
else root.stackchainPrivateDatabases = databases;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
else root.createPushNotifications = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
||||
notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
@@ -127,8 +128,10 @@
|
||||
control.checked = false;
|
||||
if (testControl) testControl.hidden = true;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
if (startDayControl) startDayControl.checked = false;
|
||||
configuration.subscribed = false;
|
||||
configuration.deadline_enabled = false;
|
||||
configuration.start_day_enabled = false;
|
||||
pendingIntent = null;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
@@ -225,13 +228,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStartDay() {
|
||||
startDayControl.disabled = true;
|
||||
if (startDayHour) startDayHour.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (startDayControl.checked) pendingIntent = 'start-day';
|
||||
if (startDayControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (startDayControl.checked && !subscription) {
|
||||
startDayControl.checked = false;
|
||||
startDayStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const reminderHour = Number(startDayHour?.value ?? configuration?.start_day_reminder_hour ?? 9);
|
||||
await fetchJson('api/v1/push-subscription/start-day', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:startDayControl.checked, timezone, reminder_hour:reminderHour}),
|
||||
});
|
||||
configuration.start_day_enabled = startDayControl.checked;
|
||||
configuration.start_day_timezone = timezone;
|
||||
configuration.start_day_reminder_hour = reminderHour;
|
||||
pendingIntent = null;
|
||||
startDayStatus.textContent = startDayControl.checked
|
||||
? `Start-day reminder enabled for ${formattedHour(reminderHour)} local time.`
|
||||
: 'Start-day reminders are off for this device.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
startDayControl.checked = !startDayControl.checked;
|
||||
startDayStatus.textContent = 'Could not change start-day reminders. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
startDayControl.disabled = false;
|
||||
if (startDayHour) startDayHour.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDeadline() {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function recoverPermission(intent = null) {
|
||||
if (!pendingIntent && (intent === 'updates' || intent === 'deadline')) pendingIntent = intent;
|
||||
if (!pendingIntent && ['updates', 'deadline', 'start-day'].includes(intent)) pendingIntent = intent;
|
||||
if (!pendingIntent || notification.permission !== 'granted') return false;
|
||||
if (recoveryPromise) return recoveryPromise;
|
||||
recoveryPromise = (async () => {
|
||||
@@ -239,6 +280,10 @@
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
if (pendingIntent === 'start-day') {
|
||||
startDayControl.checked = true;
|
||||
return changeStartDay();
|
||||
}
|
||||
return Boolean(await enable());
|
||||
})();
|
||||
try {
|
||||
@@ -253,24 +298,31 @@
|
||||
control.addEventListener('change', change);
|
||||
testControl?.addEventListener('click', testDelivery);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
startDayControl?.addEventListener('change', changeStartDay);
|
||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
if (startDayControl) startDayControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
|
||||
renderDeliveryHealth();
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
|
||||
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
|
||||
: 'Start-day reminders are off for this device.';
|
||||
renderDeadlineSnooze();
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
});
|
||||
@@ -64,10 +64,100 @@
|
||||
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
|
||||
}
|
||||
|
||||
function createWeekBatchPlan({
|
||||
week,
|
||||
prepare = item => Promise.resolve(item),
|
||||
accept = item => item,
|
||||
identity = item => String(item?.repository || '') + '#' + String(item?.number || ''),
|
||||
maxItems = 5,
|
||||
onProgress = () => {},
|
||||
} = {}) {
|
||||
let running = null;
|
||||
const load = async () => {
|
||||
await week.load();
|
||||
return week.dates().map(day => ({ ...day, ...week.day(day.date) }));
|
||||
};
|
||||
function validate(items, assignments, days, confirmOverload = false) {
|
||||
const allowed = new Set(days.map(day => day.date));
|
||||
const errors = [];
|
||||
const simulated = new Map(days.map(day => [day.date, {
|
||||
ids:[...(day.ids || [])], estimates:{...(day.estimates || {})},
|
||||
capacity_minutes:Number(day.capacity_minutes) || 0,
|
||||
}]));
|
||||
items.forEach(item => {
|
||||
const id = identity(item), choice = assignments?.[id] || {};
|
||||
const estimate = Number(choice.estimate);
|
||||
if (!allowed.has(choice.date)) errors.push({ id, reason:'date-required' });
|
||||
if (!Number.isFinite(estimate) || estimate <= 0) errors.push({ id, reason:'estimate-required' });
|
||||
if (!allowed.has(choice.date) || !Number.isFinite(estimate) || estimate <= 0) return;
|
||||
simulated.forEach((day, date) => {
|
||||
if (day.ids.includes(id) && choice.date !== date) {
|
||||
day.ids = day.ids.filter(value => value !== id); delete day.estimates[id];
|
||||
}
|
||||
});
|
||||
const destination = simulated.get(choice.date);
|
||||
if (!destination.ids.includes(id) && destination.ids.length >= maxItems) {
|
||||
errors.push({ id, reason:'day-full', date:choice.date, limit:maxItems }); return;
|
||||
}
|
||||
if (!destination.ids.includes(id)) destination.ids.push(id);
|
||||
destination.estimates[id] = estimate;
|
||||
});
|
||||
const overloads = [];
|
||||
simulated.forEach((day, date) => {
|
||||
const planned = day.ids.reduce((sum, id) => sum + (Number(day.estimates[id]) || 0), 0);
|
||||
if (day.capacity_minutes > 0 && planned > day.capacity_minutes) {
|
||||
overloads.push({ date, planned_minutes:planned, capacity_minutes:day.capacity_minutes });
|
||||
}
|
||||
});
|
||||
if (errors.length) return { status:'invalid', errors, overloads:[] };
|
||||
if (overloads.length && !confirmOverload) return { status:'overload-confirmation-required', errors:[], overloads };
|
||||
return { status:'ready', errors:[], overloads };
|
||||
}
|
||||
async function preview(items) {
|
||||
const days = await load();
|
||||
return { days, placements:Object.fromEntries(items.map(item => [identity(item), week.placement(identity(item))])) };
|
||||
}
|
||||
function run(items, assignments, { confirmOverload = false } = {}) {
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
const selected = Array.isArray(items) ? items.slice() : [];
|
||||
const days = await load();
|
||||
const checked = validate(selected, assignments, days, confirmOverload);
|
||||
if (checked.status !== 'ready') return checked;
|
||||
const failed = [], planned = [];
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
const source = selected[index], originalId = identity(source), choice = assignments[originalId];
|
||||
try {
|
||||
const confirmed = accept(await prepare(source)) || source;
|
||||
const id = identity(confirmed);
|
||||
const existing = week.placement(id);
|
||||
if (!week.place(id, choice.date, Number(choice.estimate), {move:Boolean(existing && existing.date !== choice.date)})) {
|
||||
failed.push({id:originalId, reason:'assigned-not-planned'});
|
||||
} else {
|
||||
week.rememberPendingItem?.(id, confirmed);
|
||||
planned.push(id);
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({id:originalId, reason:error?.message || 'assignment failed'});
|
||||
}
|
||||
onProgress({status:'running',processed:index + 1,selected:selected.length});
|
||||
}
|
||||
if (planned.length) {
|
||||
try { await week.flush(); }
|
||||
catch (_error) { return {status:'planned-pending',planned:planned.length,failed}; }
|
||||
}
|
||||
return {status:failed.length?'partial':'planned',planned:planned.length,failed};
|
||||
})().finally(() => { running = null; });
|
||||
return running;
|
||||
}
|
||||
return { preview, validate, run, pending:() => Boolean(running) };
|
||||
}
|
||||
createSearchBatchPlan.createWeekBatchPlan = createWeekBatchPlan;
|
||||
|
||||
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
|
||||
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
|
||||
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
|
||||
laterWork = null, laterPicker = null
|
||||
laterWork = null, laterPicker = null, weekPlan = null, storage = null
|
||||
) {
|
||||
const get = selector => document.querySelector(selector);
|
||||
let processor;
|
||||
@@ -87,6 +177,7 @@
|
||||
get('#queue-selected-search-results').disabled = state.count === 0;
|
||||
get('#defer-selected-search-results').disabled = state.count === 0;
|
||||
get('#plan-selected-search-results').disabled = state.count === 0;
|
||||
if (get('#week-selected-search-results')) get('#week-selected-search-results').disabled = state.count === 0;
|
||||
get('#search-selection-status').textContent = state.count ?
|
||||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
|
||||
render();
|
||||
@@ -194,6 +285,81 @@
|
||||
}
|
||||
},
|
||||
});
|
||||
const weekProcessor = weekPlan ? createWeekBatchPlan({
|
||||
week:weekPlan,prepare:item=>plan.prepare(item),accept:acceptIssue,
|
||||
identity:item=>todayWork.identity(item),
|
||||
onProgress:progress=>{
|
||||
if (progress.status === 'running') get('#search-week-batch-summary').textContent =
|
||||
'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
},
|
||||
storage,
|
||||
}) : null;
|
||||
let weekOverload = false;
|
||||
function weekAssignments() {
|
||||
const values = {};
|
||||
document.querySelectorAll('[data-search-week-batch-date]').forEach(select => {
|
||||
const id = select.dataset.searchWeekBatchDate;
|
||||
const estimate = document.querySelector('[data-search-week-batch-estimate="' + id + '"]');
|
||||
values[id] = {date:select.value,estimate:Number(estimate?.value)};
|
||||
});
|
||||
return values;
|
||||
}
|
||||
function closeWeekReview() {
|
||||
if (!get('#search-week-batch-review')) return;
|
||||
get('#search-week-batch-review').hidden = true;
|
||||
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||
weekOverload = false;
|
||||
get('#confirm-search-week-batch').textContent = 'Assign & plan';
|
||||
}
|
||||
async function openWeekReview() {
|
||||
const items = plan.snapshot().items;
|
||||
if (!weekProcessor || !items.length) return;
|
||||
const button = get('#week-selected-search-results');
|
||||
button.disabled = true;
|
||||
get('#search-selection-status').textContent = 'Loading Week Ahead…';
|
||||
try {
|
||||
const preview = await weekProcessor.preview(items);
|
||||
const options = selected => preview.days.map(day => '<option value="' + escapeAttribute(day.date) + '"' +
|
||||
(day.date === selected ? ' selected' : '') + '>' + escapeHtml(day.label + ' · ' + (day.ids || []).length + ' planned') + '</option>').join('');
|
||||
get('#search-week-batch-list').innerHTML = items.map(item => {
|
||||
const id = todayWork.identity(item), existing = preview.placements[id];
|
||||
return '<div class="search-week-batch-row"><div><span class="small">' + escapeHtml(id) + '</span><strong>' +
|
||||
escapeHtml(item.title || 'Untitled work') + '</strong></div><div class="search-week-batch-row-controls"><label>Day<select data-search-week-batch-date="' +
|
||||
escapeAttribute(id) + '">' + options(existing?.date || preview.days[0]?.date) + '</select></label><label>Minutes<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
|
||||
escapeAttribute(existing?.estimate || '') + '" data-search-week-batch-estimate="' + escapeAttribute(id) +
|
||||
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes"></label></div></div>';
|
||||
}).join('');
|
||||
get('#search-week-batch-summary').textContent = items.length + ' selected · review every day and estimate before assignment.';
|
||||
get('#search-batch-actions').hidden = true;
|
||||
get('#search-week-batch-review').hidden = false;
|
||||
document.querySelector('[data-search-week-batch-date]')?.focus();
|
||||
} catch (error) {
|
||||
get('#search-selection-status').textContent = error?.message || 'Week Ahead is unavailable. Retry when connected.';
|
||||
} finally { button.disabled = false; }
|
||||
}
|
||||
async function confirmWeekReview() {
|
||||
if (!weekProcessor || weekProcessor.pending()) return;
|
||||
const button = get('#confirm-search-week-batch');
|
||||
button.disabled = true;
|
||||
const outcome = await weekProcessor.run(plan.snapshot().items, weekAssignments(), {confirmOverload:weekOverload});
|
||||
if (outcome.status === 'invalid') {
|
||||
const reasons = [...new Set(outcome.errors.map(error => error.reason))];
|
||||
get('#search-week-batch-summary').textContent = reasons.includes('day-full') ?
|
||||
'A day already has five items. Choose another day.' : 'Choose a valid day and estimate for every issue.';
|
||||
} else if (outcome.status === 'overload-confirmation-required') {
|
||||
weekOverload = true;
|
||||
button.textContent = 'Confirm over capacity';
|
||||
get('#search-week-batch-summary').textContent = outcome.overloads.map(day =>
|
||||
day.planned_minutes + ' of ' + day.capacity_minutes + ' min on ' + day.date).join(' · ') + '. Confirm to plan anyway.';
|
||||
} else {
|
||||
const pending = outcome.status === 'planned-pending';
|
||||
get('#cmd-search-action-status').textContent = outcome.planned + ' planned' +
|
||||
(pending ? ' · sync pending' : '') + (outcome.failed.length ? ' · ' + outcome.failed.length + ' need attention.' : '.');
|
||||
closeWeekReview();
|
||||
if (!outcome.failed.length) plan.cancel();
|
||||
}
|
||||
button.disabled = false;
|
||||
}
|
||||
function estimateValues() {
|
||||
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
|
||||
[input.dataset.searchBatchEstimate, Number(input.value)]
|
||||
@@ -274,6 +440,9 @@
|
||||
) }, event.currentTarget, 'search-batch');
|
||||
});
|
||||
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
|
||||
get('#week-selected-search-results')?.addEventListener('click', openWeekReview);
|
||||
get('#cancel-search-week-batch')?.addEventListener('click', closeWeekReview);
|
||||
get('#confirm-search-week-batch')?.addEventListener('click', confirmWeekReview);
|
||||
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
|
||||
get('#confirm-search-release').addEventListener('click', () => {
|
||||
const milestoneSelect = get('#search-release-milestone');
|
||||
@@ -322,7 +491,7 @@
|
||||
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
||||
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
||||
}
|
||||
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
|
||||
return { plan, processor, laterProcessor, releaseProcessor, weekProcessor, restore, resultHtml };
|
||||
};
|
||||
|
||||
return createSearchBatchPlan;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}={}) {
|
||||
let running=null;
|
||||
const eligible=detail=>Boolean(detail&&detail.kind==='issue'&&detail.state==='open'&&
|
||||
(detail.claimable||detail.assigned_to_me));
|
||||
const loadDay=date=>{
|
||||
const value=week.day(date),estimates=value.estimates||{};
|
||||
return {...value,planned_minutes:(value.ids||[]).reduce((total,id)=>total+(Number(estimates[id])||0),0)};
|
||||
};
|
||||
async function preview(detail) {
|
||||
if(!eligible(detail))return {eligible:false,days:[]};
|
||||
await week.load();
|
||||
const id=identity(detail),existing=week.placement(id);
|
||||
return {eligible:true,existing,days:week.dates().map(item=>({...item,...loadDay(item.date)}))};
|
||||
}
|
||||
function plan(detail,options={}) {
|
||||
if(running)return running;
|
||||
const perform=async()=>{
|
||||
if(!eligible(detail))return {status:'ineligible'};
|
||||
const estimate=Number(options.estimate);
|
||||
if(!Number.isFinite(estimate)||estimate<=0)return {status:'estimate-required'};
|
||||
await week.load();
|
||||
if(!week.dates().some(item=>item.date===options.date))return {status:'date-required'};
|
||||
const originalId=identity(detail),existing=week.placement(originalId);
|
||||
if(existing&&existing.date!==options.date&&!options.move)
|
||||
return {status:'already-planned',date:existing.date,estimate:existing.estimate};
|
||||
const destination=loadDay(options.date);
|
||||
const alreadyThere=(destination.ids||[]).includes(originalId);
|
||||
if(!alreadyThere&&(destination.ids||[]).length>=maxItems)return {status:'day-full',limit:maxItems};
|
||||
const previous=alreadyThere?(Number(destination.estimates?.[originalId])||0):0;
|
||||
const plannedMinutes=destination.planned_minutes-previous+estimate;
|
||||
if(Number(destination.capacity_minutes)>0&&plannedMinutes>Number(destination.capacity_minutes)&&!options.confirmOverload)
|
||||
return {status:'overload-confirmation-required',planned_minutes:plannedMinutes,
|
||||
capacity_minutes:Number(destination.capacity_minutes)};
|
||||
let item=detail,assigned=false;
|
||||
if(detail.claimable){item=await claim(detail);assigned=true;}
|
||||
item=accept(item)||item;
|
||||
const id=identity(item);
|
||||
if(!week.place(id,options.date,estimate,{move:Boolean(options.move)}))
|
||||
return {status:assigned?'assigned-not-planned':'not-planned',item};
|
||||
week.rememberPendingItem?.(id,item);
|
||||
try {
|
||||
await week.flush();
|
||||
return {status:'planned',date:options.date,estimate,item};
|
||||
} catch(_error) {
|
||||
return {status:'planned-pending',date:options.date,estimate};
|
||||
}
|
||||
};
|
||||
running=perform().finally(()=>{running=null;});
|
||||
return running;
|
||||
}
|
||||
return {eligible,preview,plan,pending:()=>Boolean(running)};
|
||||
}
|
||||
function createSearchWeekPlanUI({planner,document,window,getDetail,onPlanned=()=>{},announce=()=>{},escapeHtml,escapeAttribute}={}) {
|
||||
const qs=selector=>document.querySelector(selector);
|
||||
let trigger=null,detail=null,preview=null,overload=false;
|
||||
function close(navigate=false) {
|
||||
if(navigate&&window.history.state?.searchWeekPlan){window.history.back();return;}
|
||||
qs('#search-week-plan-sheet').hidden=true;
|
||||
detail=null;preview=null;overload=false;trigger?.focus?.();trigger=null;
|
||||
}
|
||||
function selectDate(date) {
|
||||
qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(button=>
|
||||
button.setAttribute('aria-pressed',String(button.dataset.searchWeekDate===date)));
|
||||
qs('#search-week-plan-days').dataset.selected=date;
|
||||
overload=false;qs('#confirm-search-week-plan').textContent='Plan';
|
||||
}
|
||||
async function open(button) {
|
||||
const selected=getDetail();
|
||||
if(!selected||planner.pending())return false;
|
||||
trigger=button;detail=selected;overload=false;
|
||||
const sheet=qs('#search-week-plan-sheet'),status=qs('#search-week-plan-status');
|
||||
sheet.hidden=false;status.textContent='Loading Week Ahead…';qs('#confirm-search-week-plan').disabled=true;
|
||||
window.history.pushState({...window.history.state,searchWeekPlan:true},'',window.location.href);
|
||||
qs('#search-week-plan-copy').textContent=detail.title||'Untitled issue';
|
||||
try {
|
||||
preview=await planner.preview(detail);
|
||||
const existing=preview.existing;
|
||||
qs('#search-week-plan-days').innerHTML=preview.days.map((day,index)=>
|
||||
'<button type="button" data-search-week-date="'+escapeAttribute(day.date)+'" aria-pressed="'+String(index===0)+'"><strong>'+escapeHtml(day.label)+
|
||||
'</strong><small>'+escapeHtml(day.planned_minutes+(day.capacity_minutes?' of '+day.capacity_minutes:'')+' min · '+day.ids.length+' planned')+'</small></button>').join('');
|
||||
qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(dayButton=>
|
||||
dayButton.addEventListener('click',()=>selectDate(dayButton.dataset.searchWeekDate)));
|
||||
selectDate(existing?.date||preview.days[0].date);
|
||||
qs('#search-week-plan-estimate').value=existing?.estimate||'';
|
||||
status.textContent=existing?'Already planned for '+existing.date+'. Choose another day to move it.':'Choose a day and add an estimate.';
|
||||
qs('#confirm-search-week-plan').disabled=false;qs('#search-week-plan-estimate').focus();return true;
|
||||
} catch(error) {status.textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;}
|
||||
}
|
||||
async function confirm() {
|
||||
if(!detail||planner.pending())return;
|
||||
const date=qs('#search-week-plan-days').dataset.selected,estimate=Number(qs('#search-week-plan-estimate').value);
|
||||
const existing=preview?.existing,move=Boolean(existing&&existing.date!==date);
|
||||
const button=qs('#confirm-search-week-plan'),status=qs('#search-week-plan-status');button.disabled=true;
|
||||
const outcome=await planner.plan(detail,{date,estimate,move,confirmOverload:overload});
|
||||
if(outcome.status==='overload-confirmation-required'){
|
||||
overload=true;button.textContent='Confirm over capacity';
|
||||
status.textContent=outcome.planned_minutes+' min exceeds '+outcome.capacity_minutes+' min capacity. Confirm to plan anyway.';
|
||||
} else if(outcome.status==='estimate-required')status.textContent='Enter an estimate in minutes.';
|
||||
else if(outcome.status==='day-full')status.textContent='That day already has '+outcome.limit+' items. Choose another day.';
|
||||
else if(outcome.status==='already-planned')status.textContent='Already planned for '+outcome.date+'. Choose Move to change the day.';
|
||||
else if(outcome.status==='assigned-not-planned')status.textContent='Assigned, not planned. Find it in My Work and retry.';
|
||||
else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');onPlanned(detail,true);close(true);}
|
||||
else if(outcome.status==='planned'){announce('Planned for '+date+'.');onPlanned(detail,false);close(true);}
|
||||
else status.textContent='Could not plan this issue. Retry.';
|
||||
button.disabled=false;
|
||||
}
|
||||
qs('#plan-search-result').addEventListener('click',event=>open(event.currentTarget));
|
||||
qs('#cancel-search-week-plan').addEventListener('click',()=>close(true));
|
||||
qs('#confirm-search-week-plan').addEventListener('click',confirm);
|
||||
window.addEventListener('popstate',()=>{if(!qs('#search-week-plan-sheet').hidden)close();});
|
||||
document.addEventListener('keydown',event=>{
|
||||
if(event.key==='Escape'&&!qs('#search-week-plan-sheet').hidden){event.preventDefault();close(true);}
|
||||
});
|
||||
return {open,close,confirm};
|
||||
}
|
||||
if(typeof module!=='undefined'&&module.exports){module.exports=createSearchWeekPlan;module.exports.UI=createSearchWeekPlanUI;}
|
||||
+115
-11
@@ -1,12 +1,67 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v123';
|
||||
const CACHE = 'stackchain-dashboard-shell-v130';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
||||
const TODAY_ACTION_TTL_MS = 2 * 60 * 1000;
|
||||
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
|
||||
|
||||
function createTodayActionStore() {
|
||||
const dbName = 'stackchain-today-action-mailbox-v1';
|
||||
const storeName = 'commands';
|
||||
const open = () => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(storeName, {keyPath:'clientId'});
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Today action mailbox unavailable.'));
|
||||
});
|
||||
return {
|
||||
async put(clientId, command) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put({...command, clientId});
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Today action could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
async claim(clientId, now) {
|
||||
const database = await open();
|
||||
let command = null;
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.get(clientId);
|
||||
request.onsuccess = () => {
|
||||
command = request.result || null;
|
||||
if (command) store.delete(clientId);
|
||||
};
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Today action could not be claimed.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
return command && command.expiresAt > now ? command : null;
|
||||
},
|
||||
async purge() {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).clear();
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Today actions could not be purged.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
const todayActionStore = self.__STACKCHAIN_TODAY_ACTION_STORE || createTodayActionStore();
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
@@ -68,6 +123,12 @@ const SHELL = [
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-readiness.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/tomorrow-plan.js',
|
||||
BASE + 'static/week-calendar.js',
|
||||
BASE + 'static/week-calendar-import.js',
|
||||
BASE + 'static/week-plan.js',
|
||||
BASE + 'static/today-week-reschedule.js',
|
||||
BASE + 'static/search-week-plan.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
@@ -472,6 +533,7 @@ self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
await todayActionStore.purge();
|
||||
await deletePrivateDatabases();
|
||||
await updateTodayLockScreen(false, false);
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
@@ -480,12 +542,33 @@ self.addEventListener('message', event => {
|
||||
}
|
||||
})());
|
||||
if (event.data?.type === 'stackchain-today-lock-screen') {
|
||||
event.waitUntil(updateTodayLockScreen(
|
||||
event.data.active === true,
|
||||
event.data.running === true,
|
||||
String(event.data.actionToken || ''),
|
||||
Number(event.data.breakDeadlineAt || 0)
|
||||
));
|
||||
event.waitUntil((async () => {
|
||||
const active = event.data.active === true;
|
||||
if (!active) await todayActionStore.purge();
|
||||
await updateTodayLockScreen(
|
||||
active,
|
||||
event.data.running === true,
|
||||
String(event.data.actionToken || ''),
|
||||
Number(event.data.breakDeadlineAt || 0)
|
||||
);
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-claim-today-action') {
|
||||
event.waitUntil((async () => {
|
||||
const source = event.source;
|
||||
if (!source?.id || !String(source.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
let command;
|
||||
try { command = await todayActionStore.claim(source.id, Date.now()); }
|
||||
catch (_error) { return; }
|
||||
if (!command || !['pause', 'resume', 'complete'].includes(command.action)) return;
|
||||
if (command.actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(command.actionToken)) return;
|
||||
if (command.action === 'complete' && !command.actionToken) return;
|
||||
source.postMessage?.({
|
||||
type:'stackchain-today-timer-action',
|
||||
action:command.action,
|
||||
...(command.actionToken ? {actionToken:command.actionToken} : {}),
|
||||
});
|
||||
})());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -499,6 +582,20 @@ self.addEventListener('push', event => {
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
const planDate = String(payload.plan_date || '');
|
||||
if (
|
||||
route === '#/my-work/start-day'
|
||||
&& /^\d{4}-\d{2}-\d{2}$/.test(planDate)
|
||||
&& tag === 'stackchain-start-day-' + planDate
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification('Your planned day is ready', {
|
||||
body: 'Open Stackchain to prepare Today.',
|
||||
tag,
|
||||
actions: [{ action: 'prepare-today', title: 'Prepare Today' }],
|
||||
data: {route},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/agenda'
|
||||
&& protectRoute === '#/my-work/agenda/protect-today'
|
||||
@@ -593,10 +690,17 @@ async function applyTodayTimerAction(action, actionToken = '') {
|
||||
});
|
||||
return client.focus?.();
|
||||
}
|
||||
const query = '?today_timer_action=' + action +
|
||||
(actionToken ? '&today_action_token=' + encodeURIComponent(actionToken) : '');
|
||||
const target = new URL(BASE + query + route, self.location.origin).href;
|
||||
return self.clients.openWindow(target);
|
||||
const target = new URL(BASE + route, self.location.origin).href;
|
||||
const opened = await self.clients.openWindow(target);
|
||||
if (!opened?.id) return opened;
|
||||
try {
|
||||
await todayActionStore.put(opened.id, {
|
||||
action,
|
||||
...(actionToken ? {actionToken} : {}),
|
||||
expiresAt:Date.now() + TODAY_ACTION_TTL_MS,
|
||||
});
|
||||
} catch (_error) { /* The clean Today route remains safe; fail the action closed. */ }
|
||||
return opened;
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null,
|
||||
completeActivation = () => globalThis.stackchainFirstTaskCompleting?.(),
|
||||
now = Date.now, ttlMs = 10000, onOffer = null, onClear = null,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout,
|
||||
undo = [globalThis.document?.getElementById('today-completion-undo'),
|
||||
@@ -23,7 +24,18 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
|
||||
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.enqueue('remove', todayWork.identity(item));
|
||||
const operations = [
|
||||
{action:'remove', item_id:identity},
|
||||
...(completeActivation() ? [{action:'activate', item_id:'first-task', activation_state:'complete'}] : []),
|
||||
];
|
||||
const admitted = todaySync.enqueueBatch ? todaySync.enqueueBatch(operations) :
|
||||
todaySync.enqueue('remove', identity);
|
||||
if (!admitted) {
|
||||
todayWork.restore?.(snapshot);
|
||||
announce(options.admissionFailureMessage ||
|
||||
'Device storage is full. Free space and try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.flush();
|
||||
refresh();
|
||||
warm();
|
||||
|
||||
@@ -149,16 +149,9 @@ function createTodayLockScreen({
|
||||
},
|
||||
consumeAction,
|
||||
async consumeLaunchAction() {
|
||||
let url;
|
||||
try { url = new URL(locationRef?.href || ''); }
|
||||
catch (_error) { return false; }
|
||||
const action = url.searchParams.get('today_timer_action');
|
||||
if (!['pause', 'resume', 'complete'].includes(action)) return false;
|
||||
const token = url.searchParams.get('today_action_token') || '';
|
||||
url.searchParams.delete('today_timer_action');
|
||||
url.searchParams.delete('today_action_token');
|
||||
historyRef?.replaceState?.(null, '', url.pathname + url.search + url.hash);
|
||||
return await consumeAction(action, token);
|
||||
if (!enabled()) return false;
|
||||
await post({type:'stackchain-claim-today-action'});
|
||||
return true;
|
||||
},
|
||||
render,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,13 @@ function createTodayRollover(options = {}) {
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
|
||||
|
||||
function timeZone() {
|
||||
return options.timeZone?.() || resolvedTimeZone();
|
||||
const candidate = options.timeZone?.() || resolvedTimeZone();
|
||||
try {
|
||||
new Intl.DateTimeFormat('en', { timeZone: candidate }).format(now());
|
||||
return candidate;
|
||||
} catch (_error) {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
function localDate() {
|
||||
|
||||
+65
-22
@@ -61,12 +61,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
};
|
||||
if (['coaching', 'complete'].includes(plan.first_task_state)) {
|
||||
snapshot.first_task_state = plan.first_task_state;
|
||||
}
|
||||
if (plan.plan_date) {
|
||||
snapshot.plan_date = plan.plan_date;
|
||||
snapshot.timezone = plan.timezone || null;
|
||||
}
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
const activationKey = login && 'stackchain.first-task.v1:' + login;
|
||||
const current = activationKey && storage?.getItem(activationKey);
|
||||
const rank = {'': 0, coaching: 1, complete: 2};
|
||||
if (activationKey && rank[snapshot.first_task_state] > (rank[current] || 0)) {
|
||||
storage?.setItem(activationKey, snapshot.first_task_state);
|
||||
}
|
||||
} catch (_error) {
|
||||
// A storage quota failure must not prevent the current tab from using server truth.
|
||||
}
|
||||
@@ -121,11 +131,14 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
discardedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const operation = record?.operation;
|
||||
const valid = operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) &&
|
||||
typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at));
|
||||
if (valid) records.push({ ...record, recordKey });
|
||||
const operations = Array.isArray(record?.operations) ? record.operations : [record?.operation];
|
||||
const valid = operations.length && operations.every(operation => operation &&
|
||||
typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) &&
|
||||
typeof operation.item_id === 'string') && Number.isFinite(Number(record.queued_at));
|
||||
if (valid) operations.forEach((operation, index) => {
|
||||
records.push({operation, queued_at:Number(record.queued_at) + index / 1000, recordKey});
|
||||
});
|
||||
else {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
@@ -151,7 +164,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||
}))
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
@@ -176,7 +189,25 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null) {
|
||||
function saveOperations(operations, queuedAt = now()) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const batched = operations.length > 1;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operations[0].operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify(batched ?
|
||||
{operations, queued_at:queuedAt} : {operation:operations[0], queued_at:queuedAt}));
|
||||
knownOperationKeys.add(recordKey);
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null, fields = {}) {
|
||||
const operations = pending();
|
||||
if (action === 'remove' && operations.some(operation =>
|
||||
operation.action === 'remove' && operation.item_id === itemId
|
||||
@@ -186,20 +217,17 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
}
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, direction,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
base_revision: Math.max(0, savedRevision()), ...fields,
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() + operations.length }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('today');
|
||||
} catch (_error) { /* Report the persistence failure below. */ }
|
||||
onStatus?.(saved ? 'pending' : 'error');
|
||||
return saved;
|
||||
return saveOperations([operation], now() + operations.length);
|
||||
}
|
||||
|
||||
function enqueueBatch(specifications) {
|
||||
const operations = specifications.map(specification => ({
|
||||
operation_id: operationId(), direction: specification.direction ?? null,
|
||||
base_revision: Math.max(0, savedRevision()), ...specification,
|
||||
}));
|
||||
return saveOperations(operations);
|
||||
}
|
||||
|
||||
function enqueueConfiguration(capacityMinutes, estimates) {
|
||||
@@ -223,6 +251,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueActivation(state) {
|
||||
if (!['coaching', 'complete'].includes(state)) return false;
|
||||
const rank = {coaching: 1, complete: 2};
|
||||
const queued = pending().filter(operation => operation.action === 'activate');
|
||||
if (queued.some(operation => rank[operation.activation_state] >= rank[state])) return true;
|
||||
queued.forEach(operation => removeOperation(operation.operation_id));
|
||||
return enqueue('activate', 'first-task', null, {activation_state: state});
|
||||
}
|
||||
|
||||
function enqueueRollover(proposed) {
|
||||
if (!proposed || proposed.action !== 'rollover') return false;
|
||||
const operation = {
|
||||
@@ -266,7 +303,10 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
let operations = pending();
|
||||
let plan;
|
||||
let hadConflict = false;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/today');
|
||||
if (!operations.length) {
|
||||
plan = await fetchJson('api/v1/today');
|
||||
operations = pending();
|
||||
}
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
const batch = operations.slice(0, 50);
|
||||
@@ -323,6 +363,9 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
|
||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||
windowObject?.addEventListener?.('online', flush);
|
||||
windowObject?.addEventListener?.('stackchain:first-task-complete', () => {
|
||||
if (enqueueActivation('complete')) flush();
|
||||
});
|
||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||
documentObject.hidden ? false : flush()
|
||||
);
|
||||
@@ -332,7 +375,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
return { enqueue, enqueueBatch, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
@@ -542,6 +542,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||
},
|
||||
finishCapture() { return captureView.finish(); },
|
||||
transferCapture() { return captureView.transfer(); },
|
||||
restore(recovery) {
|
||||
render();
|
||||
return Promise.resolve(recovery).catch(() => false).then(() => { render(); return !!timer.breakSnapshot?.(); });
|
||||
},
|
||||
render,
|
||||
};
|
||||
queryAll('#new-issue').forEach(button => button.addEventListener('click', () => {
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
function createTodayWeekReschedule({
|
||||
week,
|
||||
api,
|
||||
getToday,
|
||||
adoptToday = () => {},
|
||||
operationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()),
|
||||
storage = null,
|
||||
getLogin = () => '',
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
let current = null;
|
||||
let flushing = null;
|
||||
const storagePrefix = 'stackchain.today-week-reschedule.v1.';
|
||||
|
||||
function storageKey() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? storagePrefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function validRecord(value) {
|
||||
return typeof value?.body?.operation_id === 'string' &&
|
||||
Array.isArray(value?.today?.ids) && Array.isArray(value?.week?.days);
|
||||
}
|
||||
|
||||
function readQueue() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (Array.isArray(value?.records)) return value.records.filter(validRecord);
|
||||
return validRecord(value) ? [value] : [];
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function saveQueue(records) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (!records.length) storage.removeItem(key);
|
||||
else storage.setItem(key, JSON.stringify({version:2, records}));
|
||||
return true;
|
||||
}
|
||||
catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function persist(record) { return saveQueue([...readQueue(), record]); }
|
||||
|
||||
function pending() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return null;
|
||||
return readQueue()[0] || null;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
const records = readQueue();
|
||||
const record = records[records.length - 1];
|
||||
if (!record) return null;
|
||||
adoptToday(record.today);
|
||||
week.adopt(record.week);
|
||||
return {...record, sync_pending:true, pending_count:records.length};
|
||||
}
|
||||
|
||||
async function restoreConflict(error) {
|
||||
if (error?.status !== 409) throw error;
|
||||
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
||||
adoptToday(today);
|
||||
week.adopt(weekState);
|
||||
const conflict = new Error('Plans changed on another device. Review and retry this saved move.');
|
||||
conflict.status = 409;
|
||||
throw conflict;
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (flushing) return flushing;
|
||||
if (!pending()) return Promise.resolve(false);
|
||||
flushing = (async () => {
|
||||
let result = null;
|
||||
while (true) {
|
||||
const records = readQueue();
|
||||
const record = records[0];
|
||||
if (!record) return result ? {...result, sync_pending:false, pending_count:0} : false;
|
||||
try {
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body),
|
||||
});
|
||||
} catch (error) { await restoreConflict(error); }
|
||||
adoptToday(result.today);
|
||||
week.adopt(result.week);
|
||||
const latest = readQueue();
|
||||
if (latest[0]?.body?.operation_id !== record.body.operation_id) continue;
|
||||
const remaining = latest.slice(1).map((entry, index) => index ? entry : ({
|
||||
...entry,
|
||||
body:{...entry.body, today_revision:result.today.revision, week_revision:result.week.revision},
|
||||
}));
|
||||
if (!saveQueue(remaining)) throw new Error('Move synced, but its local queue could not be updated.');
|
||||
}
|
||||
})().finally(() => { flushing = null; });
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function optimisticSnapshots(planDate, estimate) {
|
||||
const today = {
|
||||
...current.today,
|
||||
ids: current.today.ids.filter(id => id !== current.identity),
|
||||
estimates: {...(current.today.estimates || {})},
|
||||
};
|
||||
delete today.estimates[current.identity];
|
||||
const days = (current.week.days || []).map(day => {
|
||||
const estimates = {...(day.estimates || {})}; delete estimates[current.identity];
|
||||
return {...day, ids:(day.ids || []).filter(id => id !== current.identity), estimates};
|
||||
});
|
||||
let destination = days.find(day => day.plan_date === planDate);
|
||||
if (!destination) {
|
||||
destination = {plan_date:planDate, ids:[], capacity_minutes:null, estimates:{}};
|
||||
days.push(destination);
|
||||
}
|
||||
destination.ids.push(current.identity);
|
||||
destination.estimates[current.identity] = estimate;
|
||||
return {today, week:{revision:current.week_revision, timezone:current.week.timezone || null, days}};
|
||||
}
|
||||
|
||||
async function open(identity) {
|
||||
let today;
|
||||
const queued = readQueue().length;
|
||||
let offline = Boolean(queued);
|
||||
if (queued) {
|
||||
today = getToday?.();
|
||||
} else {
|
||||
try { today = await api('api/v1/today'); }
|
||||
catch (error) {
|
||||
if (error?.status === 401 || error?.status === 403) throw error;
|
||||
today = getToday?.(); offline = true;
|
||||
}
|
||||
}
|
||||
if (!identity || !today?.ids?.includes(identity)) {
|
||||
throw new Error('The active Today item changed. Reopen rescheduling.');
|
||||
}
|
||||
const loaded = queued ? week.state?.() : await week.load();
|
||||
if (!loaded) throw new Error('Reload Today before rescheduling another saved move.');
|
||||
if (loaded?.sync_pending) {
|
||||
throw new Error('Reconnect before rescheduling Today into Week Ahead.');
|
||||
}
|
||||
offline = offline || Boolean(loaded?.offline_snapshot);
|
||||
const estimate = Number(today.estimates?.[identity]);
|
||||
const estimateMinutes = Number.isFinite(estimate) && estimate > 0 ? estimate : null;
|
||||
const days = week.review().days.map(day => {
|
||||
const ids = (day.ids || []).filter(id => id !== identity);
|
||||
const planned = Number(day.planned_minutes) || 0;
|
||||
const existing = Number(day.estimates?.[identity]) || 0;
|
||||
return {
|
||||
...day,
|
||||
ids,
|
||||
planned_minutes: Math.max(0, planned - existing),
|
||||
load: `${Math.max(0, planned - existing)} / ${Number(day.capacity_minutes) || 0} min`,
|
||||
eligible: ids.length < 5,
|
||||
};
|
||||
});
|
||||
current = {
|
||||
identity,
|
||||
today_revision: today.revision,
|
||||
week_revision: loaded.revision,
|
||||
operation_id: operationId(),
|
||||
estimate_minutes: estimateMinutes,
|
||||
days,
|
||||
today,
|
||||
week: loaded,
|
||||
offline,
|
||||
};
|
||||
return {...current, days:days.map(day => ({...day, ids:[...day.ids]}))};
|
||||
}
|
||||
|
||||
async function confirm(planDate, estimateMinutes, {allowOverload = false} = {}) {
|
||||
if (!current) throw new Error('Open rescheduling before choosing a day.');
|
||||
const day = current.days.find(value => value.plan_date === planDate);
|
||||
if (!day) throw new Error('Choose one of the next seven days.');
|
||||
const estimate = Number(estimateMinutes);
|
||||
if (!Number.isFinite(estimate) || estimate < 5 || estimate > 1440) {
|
||||
throw new Error('Estimate must be between 5 and 1440 minutes.');
|
||||
}
|
||||
if (!day.eligible) throw new Error('That Week Ahead day already has five items.');
|
||||
const capacity = Number(day.capacity_minutes) || 0;
|
||||
if (capacity && day.planned_minutes + estimate > capacity && !allowOverload) {
|
||||
throw new Error("That move exceeds the day's capacity. Confirm overload before rescheduling.");
|
||||
}
|
||||
const body = {
|
||||
operation_id:current.operation_id,
|
||||
identity:current.identity,
|
||||
estimate_minutes:estimate,
|
||||
plan_date:planDate,
|
||||
today_revision:current.today_revision,
|
||||
week_revision:current.week_revision,
|
||||
allow_over_capacity:allowOverload,
|
||||
};
|
||||
const optimistic = optimisticSnapshots(planDate, estimate);
|
||||
const durable = Boolean(storageKey() && storage);
|
||||
const admitted = durable && persist({body, ...optimistic, queued_at:now()});
|
||||
if (durable && !admitted) {
|
||||
throw new Error('Could not save this move on this device. Nothing changed.');
|
||||
}
|
||||
if (admitted) {
|
||||
adoptToday(optimistic.today);
|
||||
week.adopt(optimistic.week);
|
||||
}
|
||||
if (current.offline) {
|
||||
current = null;
|
||||
return {...optimistic, sync_pending:true, pending_count:readQueue().length};
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = admitted ? await flush() : await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
||||
});
|
||||
} catch(error) {
|
||||
if (admitted && error?.status === 409) await restoreConflict(error);
|
||||
if (admitted && error?.status !== 401 && error?.status !== 403) {
|
||||
current = null;
|
||||
return {...optimistic, sync_pending:true};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
adoptToday(result.today);
|
||||
week.adopt(result.week);
|
||||
current = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
function cancel() { current = null; }
|
||||
return {open, confirm, cancel, state:() => current, pending, resume, flush};
|
||||
}
|
||||
|
||||
function mountTodayWeekReschedule({
|
||||
qs, document=globalThis.document, window=globalThis.window, week, api, getToday, adoptToday, currentTarget, closeActions,
|
||||
refresh, warm, continueToday, announce, storage=null, getLogin=()=>'', schedule=callback=>requestAnimationFrame(callback),
|
||||
}={}) {
|
||||
const dialog=qs('#today-week-reschedule'),daysRoot=qs('#today-week-reschedule-days');
|
||||
const estimate=qs('#today-week-reschedule-estimate'),status=qs('#today-week-reschedule-status');
|
||||
const confirm=qs('#confirm-today-week-reschedule'),launcher=qs('[data-work-session-reschedule-week]');
|
||||
let selectedDate=null,allowOverload=false;
|
||||
const controller=createTodayWeekReschedule({week,api,getToday,adoptToday,storage,getLogin});
|
||||
async function flushPending() {
|
||||
if(!controller.pending())return false;
|
||||
try {
|
||||
const result=await controller.flush();
|
||||
announce('Today-to-Week move synced to your account.');
|
||||
await refresh();warm();
|
||||
return result;
|
||||
} catch(error) {
|
||||
announce(`${error.message||'Sync unavailable.'} Move saved on this device · sync pending.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function resumePending() {
|
||||
const restored=controller.resume();
|
||||
if(!restored)return false;
|
||||
announce('Today-to-Week move saved on this device · sync pending.');
|
||||
flushPending();
|
||||
return restored;
|
||||
}
|
||||
window?.addEventListener('online', flushPending);
|
||||
document?.addEventListener('visibilitychange',()=>document.hidden?false:flushPending());
|
||||
let restoreAttempts=0;
|
||||
function restoreWhenOwned(){
|
||||
if(resumePending()||getLogin()||restoreAttempts++>=20)return;
|
||||
window?.setTimeout(restoreWhenOwned,250);
|
||||
}
|
||||
restoreWhenOwned();
|
||||
function close() {
|
||||
controller.cancel();selectedDate=null;allowOverload=false;
|
||||
if(dialog.open)dialog.close();schedule(()=>{
|
||||
qs('[data-mobile-today-more]').click();launcher.focus();
|
||||
});
|
||||
}
|
||||
function render(opened) {
|
||||
daysRoot.replaceChildren(...opened.days.map(day=>{
|
||||
const button=document.createElement('button');
|
||||
button.type='button';button.dataset.planDate=day.plan_date;button.setAttribute('role','radio');
|
||||
button.setAttribute('aria-checked','false');button.disabled=!day.eligible;
|
||||
const label=document.createElement('strong');label.textContent=day.label;
|
||||
const load=document.createElement('span');load.className='small';
|
||||
load.textContent=day.eligible?day.load:'5 items · full';button.append(label,load);
|
||||
button.addEventListener('click',()=>{
|
||||
selectedDate=day.plan_date;allowOverload=false;
|
||||
daysRoot.querySelectorAll('button').forEach(choice=>choice.setAttribute(
|
||||
'aria-checked',String(choice===button)
|
||||
));
|
||||
status.textContent='';confirm.disabled=false;confirm.textContent='Move to Week Ahead & continue';
|
||||
});
|
||||
return button;
|
||||
}));
|
||||
}
|
||||
launcher.addEventListener('click',async()=>{
|
||||
const target=currentTarget();
|
||||
if(!target){announce('Start or resume a checkpointed Today item before rescheduling.');return;}
|
||||
closeActions();status.textContent='Loading Week Ahead…';confirm.disabled=true;dialog.showModal();
|
||||
try{
|
||||
const opened=await controller.open(target.identity);render(opened);
|
||||
estimate.value=opened.estimate_minutes||'';
|
||||
status.textContent=opened.estimate_minutes?'Choose a future day.':'Add an estimate, then choose a future day.';
|
||||
schedule(()=>daysRoot.querySelector('button:not(:disabled)')?.focus());
|
||||
}catch(error){status.textContent=error.message;}
|
||||
});
|
||||
qs('#cancel-today-week-reschedule').addEventListener('click',close);
|
||||
dialog.addEventListener('cancel',event=>{event.preventDefault();close();});
|
||||
confirm.addEventListener('click',async()=>{
|
||||
if(!selectedDate)return;
|
||||
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';
|
||||
try{
|
||||
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
||||
dialog.close();
|
||||
if(result.sync_pending){
|
||||
announce('Moved locally. Saved on this device · sync pending.');warm();
|
||||
}else{
|
||||
await refresh();warm();announce('Moved to Week Ahead. Continuing Today.');
|
||||
}
|
||||
await continueToday();
|
||||
}catch(error){
|
||||
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
||||
status.textContent=error.message;
|
||||
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
|
||||
confirm.disabled=false;
|
||||
}
|
||||
});
|
||||
return {controller,close,flushPending,resumePending};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createTodayWeekReschedule;
|
||||
module.exports.mount = mountTodayWeekReschedule;
|
||||
}
|
||||
+42
-12
@@ -1,4 +1,4 @@
|
||||
function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
function createTodayWrapUp({ todayWork, tomorrowPlan, todaySync, limit = 5 }) {
|
||||
let items = [];
|
||||
const selected = new Set();
|
||||
|
||||
@@ -28,16 +28,39 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
const wake = laterWork.presetUntil('tomorrow');
|
||||
let scheduled = 0;
|
||||
for (const item of items) {
|
||||
const loaded = await tomorrowPlan.load();
|
||||
const currentIds = Array.isArray(loaded?.ids) ? [...loaded.ids] : [];
|
||||
const carried = items.filter(item => selected.has(todayWork.identity(item)) && todayWork.contains(item));
|
||||
if (!carried.length) {
|
||||
return {
|
||||
scheduled:0,
|
||||
left:items.filter(item => todayWork.contains(item)).length,
|
||||
plan_count:currentIds.length,
|
||||
plan_date:loaded?.plan_date,
|
||||
sync_pending:Boolean(loaded?.sync_pending),
|
||||
};
|
||||
}
|
||||
const combinedIds = [...currentIds];
|
||||
for (const item of carried) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (!combinedIds.includes(identity)) combinedIds.push(identity);
|
||||
}
|
||||
if (combinedIds.length > limit) {
|
||||
throw new Error(`Tomorrow can hold ${limit} items. Keep ${combinedIds.length - limit} in Today or edit Tomorrow first.`);
|
||||
}
|
||||
const estimates = {...(loaded?.estimates || {})};
|
||||
const capacityMinutes = loaded?.capacity_minutes ?? null;
|
||||
const plannedMinutes = combinedIds.reduce((total, identity) => total + (Number(estimates[identity]) || 0), 0);
|
||||
if (capacityMinutes !== null && plannedMinutes > capacityMinutes) {
|
||||
throw new Error('Tomorrow is over capacity. Edit the plan before finishing wrap-up.');
|
||||
}
|
||||
const staged = tomorrowPlan.stage({ids:combinedIds, capacity_minutes:capacityMinutes, estimates});
|
||||
if (!staged) throw new Error('Tomorrow could not be saved on this device. Your Today plan is unchanged.');
|
||||
let scheduled = 0;
|
||||
for (const item of carried) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (!selected.has(identity) || !todayWork.contains(item)) continue;
|
||||
const deferred = laterWork.defer(item, wake, {handoff:'today'});
|
||||
if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.');
|
||||
if (!todaySync.enqueue('remove', identity)) {
|
||||
laterWork.restore?.(item);
|
||||
throw new Error('Today sync could not be queued. Your Today plan is unchanged.');
|
||||
throw new Error('Today sync could not be queued. The item remains in Today.');
|
||||
}
|
||||
if (todayWork.contains(item) && !todayWork.remove(item)) {
|
||||
throw new Error('Today could not be updated. Retry wrap-up.');
|
||||
@@ -45,7 +68,13 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
scheduled += 1;
|
||||
}
|
||||
await todaySync.flush();
|
||||
return { scheduled, left:items.length - scheduled, wake_at:wake.toISOString() };
|
||||
return {
|
||||
scheduled,
|
||||
left:items.length - scheduled,
|
||||
plan_count:combinedIds.length,
|
||||
plan_date:staged.plan_date,
|
||||
sync_pending:Boolean(staged.sync_pending),
|
||||
};
|
||||
}
|
||||
|
||||
return { open, choose, snapshot, finish };
|
||||
@@ -69,7 +98,7 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
|
||||
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
|
||||
'> Tomorrow 09:00</label></div>';
|
||||
'> Carry to Tomorrow</label></div>';
|
||||
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
|
||||
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
|
||||
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
|
||||
@@ -97,7 +126,8 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
|
||||
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
|
||||
}));
|
||||
const result = await controller.finish();
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' carried to Tomorrow · ' + result.left +
|
||||
' left in Today' + (result.sync_pending ? ' · sync pending.' : '.');
|
||||
close();
|
||||
onComplete(result, actualMinutes, workedItems, tomorrowItems);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
function createTomorrowPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||
let plan={revision:0,ids:[],capacity_minutes:null,estimates:{}};
|
||||
let flushing=null;
|
||||
let lastConflict=null;
|
||||
const storagePrefix='stackchain.tomorrow-sync.v1.';
|
||||
const state=()=>({...plan,ids:[...plan.ids],estimates:{...plan.estimates}});
|
||||
function storageKey() {
|
||||
const login=String(getLogin?.()||'').trim().toLowerCase();
|
||||
return login?storagePrefix+encodeURIComponent(login):'';
|
||||
}
|
||||
function pending() {
|
||||
const key=storageKey();
|
||||
if(!key||!storage) return false;
|
||||
try {
|
||||
const value=JSON.parse(storage.getItem(key)||'null');
|
||||
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.ids)?
|
||||
{...value,ids:[...value.ids],estimates:{...(value.estimates||{})},sync_pending:true}:false;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
function adopt(value) {
|
||||
if (!Number.isInteger(value?.revision)||!Array.isArray(value?.ids)) return false;
|
||||
plan={revision:value.revision,ids:[...value.ids],capacity_minutes:value.capacity_minutes??null,
|
||||
estimates:{...(value.estimates||{})},...(value.plan_date?{plan_date:value.plan_date,timezone:value.timezone||null}:{})};
|
||||
return state();
|
||||
}
|
||||
function nextLocalDate() {
|
||||
const [y,m,d]=localDate().split('-').map(Number);
|
||||
return new Date(Date.UTC(y,m-1,d+1)).toISOString().slice(0,10);
|
||||
}
|
||||
async function load(){
|
||||
const queued=pending();
|
||||
return queued?(plan={...queued},state()):adopt(await fetchJson('api/v1/tomorrow'));
|
||||
}
|
||||
function stage(value) {
|
||||
const key=storageKey();
|
||||
if(!key||!storage) return false;
|
||||
const queued={base_revision:plan.revision,revision:plan.revision,ids:[...(value.ids||[])],
|
||||
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
|
||||
plan_date:nextLocalDate(),timezone:timeZone(),sync_pending:true};
|
||||
try { storage.setItem(key,JSON.stringify(queued)); }
|
||||
catch (_error) { return false; }
|
||||
lastConflict=null;
|
||||
plan=queued;
|
||||
return state();
|
||||
}
|
||||
function deliveryBody(value) {
|
||||
return {base_revision:value.base_revision,ids:[...value.ids],
|
||||
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
|
||||
plan_date:value.plan_date,timezone:value.timezone};
|
||||
}
|
||||
function flush() {
|
||||
if(flushing) return flushing;
|
||||
const queued=pending();
|
||||
const key=storageKey();
|
||||
if(!queued||!key) return Promise.resolve(false);
|
||||
const body=deliveryBody(queued);
|
||||
flushing=fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body)}).then(saved=>{
|
||||
const current=pending();
|
||||
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
|
||||
if(!pending()) adopt(saved);
|
||||
return saved;
|
||||
}).catch(async error=>{
|
||||
if(error?.status===409) {
|
||||
const remote=await fetchJson('api/v1/tomorrow');
|
||||
lastConflict={key,local:pending(),remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}};
|
||||
}
|
||||
throw error;
|
||||
}).finally(()=>{flushing=null;});
|
||||
return flushing;
|
||||
}
|
||||
async function keepLocal() {
|
||||
if(!lastConflict||lastConflict.key!==storageKey()) return false;
|
||||
const conflictKey=lastConflict.key;
|
||||
const local={...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}};
|
||||
const body=deliveryBody({...local,base_revision:lastConflict.remote.revision});
|
||||
try {
|
||||
const saved=await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body)});
|
||||
const current=pending();
|
||||
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
|
||||
if(!pending()) adopt(saved);
|
||||
lastConflict=null;
|
||||
return saved;
|
||||
} catch(error) {
|
||||
if(error?.status===409) {
|
||||
const remote=await fetchJson('api/v1/tomorrow');
|
||||
lastConflict={key:conflictKey,local,remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function useRemote() {
|
||||
if(!lastConflict||lastConflict.key!==storageKey()) return false;
|
||||
const remote=lastConflict.remote;
|
||||
const key=storageKey();
|
||||
if(key&&storage) storage.removeItem(key);
|
||||
const adopted=adopt(remote);
|
||||
lastConflict=null;
|
||||
return adopted;
|
||||
}
|
||||
async function save(value) {
|
||||
const body={base_revision:plan.revision,ids:[...(value.ids||[])],
|
||||
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
|
||||
plan_date:nextLocalDate(),timezone:timeZone()};
|
||||
return adopt(await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
|
||||
}
|
||||
async function promote(today_revision) {
|
||||
if (pending()||!plan.plan_date||plan.plan_date>localDate()||!plan.ids.length) return false;
|
||||
const promotion_id=`tomorrow-${plan.plan_date}-r${plan.revision}`;
|
||||
return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({promotion_id,tomorrow_revision:plan.revision,today_revision})});
|
||||
}
|
||||
function millisecondsUntilNextDay() {
|
||||
const now=Date.now();
|
||||
const zone=timeZone();
|
||||
let formatter;
|
||||
try {
|
||||
formatter=new Intl.DateTimeFormat('en-CA',{timeZone:zone,year:'numeric',month:'2-digit',day:'2-digit',
|
||||
hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'});
|
||||
} catch (_error) {
|
||||
const next=new Date(now);
|
||||
next.setHours(24,0,0,250);
|
||||
return Math.max(1,next.getTime()-now);
|
||||
}
|
||||
const zoned=value=>Object.fromEntries(formatter.formatToParts(new Date(value))
|
||||
.filter(part=>part.type!=='literal').map(part=>[part.type,Number(part.value)]));
|
||||
const current=zoned(now);
|
||||
const targetWall=Date.UTC(current.year,current.month-1,current.day+1);
|
||||
let candidate=targetWall;
|
||||
for(let attempt=0;attempt<2;attempt+=1){
|
||||
const parts=zoned(candidate);
|
||||
const offset=Date.UTC(parts.year,parts.month-1,parts.day,parts.hour,parts.minute,parts.second)-candidate;
|
||||
candidate=targetWall-offset;
|
||||
}
|
||||
return Math.max(1,candidate-now+250);
|
||||
}
|
||||
function summary(value=plan) {
|
||||
const ids=Array.isArray(value?.ids)?value.ids:[];
|
||||
const suffix=value?.sync_pending?' · sync pending':'';
|
||||
if(!ids.length) return 'Nothing planned'+suffix;
|
||||
const label=`${ids.length} planned`;
|
||||
const estimates=value.estimates||{};
|
||||
const estimated=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0);
|
||||
const capacity=Number(value.capacity_minutes)||0;
|
||||
return (estimated&&capacity?`${label} · ${estimated} of ${capacity} min`:label)+suffix;
|
||||
}
|
||||
function startLifecycle({windowObject,documentObject,check,setTimer=setTimeout,clearTimer=clearTimeout,
|
||||
nextDelay=millisecondsUntilNextDay}={}) {
|
||||
let flight=null;
|
||||
let timer=null;
|
||||
const schedule=()=>{
|
||||
if(timer!==null) clearTimer(timer);
|
||||
timer=setTimer(run,nextDelay());
|
||||
};
|
||||
function run(){
|
||||
if(!flight) flight=Promise.resolve().then(check).finally(()=>{flight=null;schedule();});
|
||||
return flight;
|
||||
}
|
||||
windowObject?.addEventListener?.('online',run);
|
||||
documentObject?.addEventListener?.('visibilitychange',()=>documentObject.hidden?false:run());
|
||||
run();
|
||||
return {run,stop(){if(timer!==null)clearTimer(timer);timer=null;}};
|
||||
}
|
||||
function conflict() {
|
||||
return lastConflict?.key===storageKey()?{local:{...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}},
|
||||
remote:{...lastConflict.remote,ids:[...lastConflict.remote.ids],estimates:{...lastConflict.remote.estimates}}}:null;
|
||||
}
|
||||
return {adopt,load,save,stage,pending,flush,conflict,keepLocal,useRemote,promote,state,summary,nextLocalDate,startLifecycle};
|
||||
}
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=createTomorrowPlan;
|
||||
@@ -0,0 +1,263 @@
|
||||
function createWeekCalendarImport() {
|
||||
const maxBytes=1024*1024;
|
||||
function clock(value) {
|
||||
const match=/^(\d{2}):(\d{2})$/.exec(String(value||''));
|
||||
if(!match)return null;
|
||||
const minutes=Number(match[1])*60+Number(match[2]);
|
||||
return Number(match[1])<24&&Number(match[2])<60?minutes:null;
|
||||
}
|
||||
function instant(value) {
|
||||
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value||'');
|
||||
if(!match)return null;
|
||||
const parts=match.slice(1,7).map(Number);
|
||||
const milliseconds=match[7]?Date.UTC(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]):
|
||||
new Date(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]).getTime();
|
||||
return Number.isFinite(milliseconds)?milliseconds:null;
|
||||
}
|
||||
function zonedInstant(value,timeZone) {
|
||||
if(!timeZone)return instant(value);
|
||||
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/.exec(value||'');
|
||||
if(!match)return null;
|
||||
try {
|
||||
const wanted=match.slice(1).map(Number),formatter=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',
|
||||
});
|
||||
let result=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5]);
|
||||
for(let attempt=0;attempt<2;attempt+=1) {
|
||||
const shown=Object.fromEntries(formatter.formatToParts(new Date(result)).map(part=>[part.type,part.value]));
|
||||
const represented=Date.UTC(Number(shown.year),Number(shown.month)-1,Number(shown.day),Number(shown.hour),Number(shown.minute),Number(shown.second));
|
||||
result+=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5])-represented;
|
||||
}
|
||||
return result;
|
||||
} catch(error) { return null; }
|
||||
}
|
||||
function supportedTimeZone(timeZone) {
|
||||
if(!timeZone)return true;
|
||||
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);return true;}
|
||||
catch(error) {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'});
|
||||
const parts=Object.fromEntries(formatter.formatToParts(date).map(part=>[part.type,part.value]));
|
||||
return {year:Number(parts.year),month:Number(parts.month),day:Number(parts.day),
|
||||
weekday:['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(parts.weekday),hour:Number(parts.hour),
|
||||
minute:Number(parts.minute),second:Number(parts.second)};
|
||||
}
|
||||
function addCalendarDays(value,days,timeZone) {
|
||||
if(!timeZone) {const date=new Date(value);date.setDate(date.getDate()+days);return date.getTime();}
|
||||
const parts=zoneParts(value,timeZone),day=new Date(Date.UTC(parts.year,parts.month-1,parts.day+days));
|
||||
const wall=[day.getUTCFullYear(),String(day.getUTCMonth()+1).padStart(2,'0'),String(day.getUTCDate()).padStart(2,'0')].join('')+
|
||||
'T'+[parts.hour,parts.minute,parts.second].map(part=>String(part).padStart(2,'0')).join('');
|
||||
return zonedInstant(wall,timeZone);
|
||||
}
|
||||
function dateInstant(value) {
|
||||
const match=/^(\d{4})(\d{2})(\d{2})$/.exec(value||'');
|
||||
return match?new Date(Number(match[1]),Number(match[2])-1,Number(match[3])).getTime():null;
|
||||
}
|
||||
function events(source) {
|
||||
if(typeof source!=='string'||!source.includes('BEGIN:VCALENDAR'))throw new Error('Choose a valid .ics calendar file.');
|
||||
if(new TextEncoder().encode(source).length>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
|
||||
const lines=source.replace(/\r\n[ \t]/g,'').split(/\r?\n/);
|
||||
const result=[];let current=null;
|
||||
lines.forEach(line=>{
|
||||
if(line==='BEGIN:VEVENT'){current={};return;}
|
||||
if(line==='END:VEVENT'){
|
||||
if((current?.unsupportedTimezone||(current?.start!=null&¤t?.end>current.start))&&
|
||||
current.status!=='CANCELLED'&¤t.transparency!=='TRANSPARENT')result.push(current);
|
||||
current=null;return;
|
||||
}
|
||||
if(!current)return;
|
||||
const separator=line.indexOf(':');if(separator<0)return;
|
||||
const property=line.slice(0,separator),parts=property.split(';'),name=parts[0],value=line.slice(separator+1);
|
||||
const parameters=Object.fromEntries(parts.slice(1).map(part=>part.split('=')));
|
||||
if(parameters.TZID&&!supportedTimeZone(parameters.TZID))current.unsupportedTimezone=true;
|
||||
const allDay=parameters.VALUE==='DATE',parsed=allDay?dateInstant(value):zonedInstant(value,parameters.TZID);
|
||||
if(name==='DTSTART'){current.start=parsed;current.timeZone=parameters.TZID||null;}
|
||||
if(name==='DTEND')current.end=parsed;
|
||||
if(name==='RRULE')current.rule=value;
|
||||
if(name==='RDATE')current.rdates=(current.rdates||[]).concat(value.split(',').map(item=>allDay?dateInstant(item):zonedInstant(item,parameters.TZID)).filter(value=>value!=null));
|
||||
if(name==='EXDATE')current.exdates=(current.exdates||[]).concat(value.split(',').map(item=>allDay?dateInstant(item):zonedInstant(item,parameters.TZID)).filter(value=>value!=null));
|
||||
if(name==='STATUS')current.status=value.toUpperCase();
|
||||
if(name==='TRANSP')current.transparency=value.toUpperCase();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
function recurrenceRule(value) {
|
||||
return Object.fromEntries(String(value||'').split(';').filter(Boolean).map(part=>part.split('=')));
|
||||
}
|
||||
function supportedRecurrence(value) {
|
||||
const rule=recurrenceRule(value),keys=Object.keys(rule);
|
||||
if(!['DAILY','WEEKLY'].includes(rule.FREQ))return false;
|
||||
const allowed=new Set(['FREQ','COUNT','INTERVAL','UNTIL',...(rule.FREQ==='WEEKLY'?['BYDAY']:[])]);
|
||||
if(keys.some(key=>!allowed.has(key)))return false;
|
||||
if(rule.COUNT&&(!/^\d+$/.test(rule.COUNT)||Number(rule.COUNT)<1))return false;
|
||||
if(rule.INTERVAL&&(!/^\d+$/.test(rule.INTERVAL)||Number(rule.INTERVAL)<1))return false;
|
||||
if(rule.COUNT&&rule.UNTIL)return false;
|
||||
if(rule.UNTIL&&instant(rule.UNTIL)==null&&dateInstant(rule.UNTIL)==null)return false;
|
||||
return !rule.BYDAY||rule.BYDAY.split(',').every(day=>/^(MO|TU|WE|TH|FR|SA|SU)$/.test(day));
|
||||
}
|
||||
function occurrences(event,rangeStart,rangeEnd) {
|
||||
const duration=event.end-event.start,excluded=new Set(event.exdates||[]);
|
||||
if(!event.rule) {
|
||||
return [event.start,...(event.rdates||[])].filter((start,index,all)=>start<rangeEnd&&!excluded.has(start)&&all.indexOf(start)===index)
|
||||
.map(start=>({start,end:start+duration}));
|
||||
}
|
||||
const rule=recurrenceRule(event.rule);
|
||||
if(!['DAILY','WEEKLY'].includes(rule.FREQ))return [event];
|
||||
const count=Math.max(1,Math.min(Number(rule.COUNT)||10000,10000));
|
||||
const result=[];
|
||||
const weekdays={SU:0,MO:1,TU:2,WE:3,TH:4,FR:5,SA:6};
|
||||
const selected=new Set((rule.BYDAY||'').split(',').map(day=>weekdays[day]).filter(day=>day!=null));
|
||||
if(rule.FREQ==='WEEKLY'&&!selected.size)selected.add(zoneParts(event.start,event.timeZone).weekday);
|
||||
const interval=Math.max(1,Number(rule.INTERVAL)||1),origin=zoneParts(event.start,event.timeZone);
|
||||
const until=rule.UNTIL?(rule.UNTIL.endsWith('Z')?instant(rule.UNTIL):
|
||||
(zonedInstant(rule.UNTIL,event.timeZone)??dateInstant(rule.UNTIL))):null;
|
||||
const originDay=Date.UTC(origin.year,origin.month-1,origin.day);
|
||||
let start=event.start,matched=0;
|
||||
if(rule.FREQ==='DAILY'&&start+duration<=rangeStart) {
|
||||
const target=zoneParts(rangeStart-duration,event.timeZone);
|
||||
const targetDay=Date.UTC(target.year,target.month-1,target.day);
|
||||
const elapsed=Math.max(0,Math.floor((targetDay-originDay)/(24*60*60*1000)));
|
||||
const jumps=Math.floor(elapsed/interval);
|
||||
if(jumps) {start=addCalendarDays(start,jumps*interval,event.timeZone);matched=jumps;}
|
||||
while(start+duration<=rangeStart&&matched<count) {start=addCalendarDays(start,interval,event.timeZone);matched+=1;}
|
||||
}
|
||||
if(rule.FREQ==='WEEKLY'&&start+duration<=rangeStart) {
|
||||
const target=zoneParts(rangeStart-duration,event.timeZone);
|
||||
const targetDay=Date.UTC(target.year,target.month-1,target.day);
|
||||
const elapsed=Math.max(0,Math.floor((targetDay-originDay)/(24*60*60*1000)));
|
||||
const cycleDays=7*interval,cycles=Math.floor(elapsed/cycleDays),offset=elapsed-cycles*cycleDays;
|
||||
matched=cycles*selected.size;
|
||||
start=addCalendarDays(event.start,cycles*cycleDays+(offset>=7?offset:0),event.timeZone);
|
||||
while(start+duration<=rangeStart&&matched<count) {
|
||||
const cursor=zoneParts(start,event.timeZone),cursorDay=Date.UTC(cursor.year,cursor.month-1,cursor.day);
|
||||
const weeks=Math.floor((cursorDay-originDay)/(7*24*60*60*1000));
|
||||
if(weeks%interval===0&&selected.has(cursor.weekday))matched+=1;
|
||||
start=addCalendarDays(start,1,event.timeZone);
|
||||
}
|
||||
}
|
||||
while(start<rangeEnd&&matched<count&&(until==null||start<=until)) {
|
||||
const cursor=zoneParts(start,event.timeZone),cursorDay=Date.UTC(cursor.year,cursor.month-1,cursor.day);
|
||||
const weeks=Math.floor((cursorDay-originDay)/(7*24*60*60*1000));
|
||||
const matches=rule.FREQ==='DAILY'||(weeks%interval===0&&selected.has(cursor.weekday));
|
||||
if(matches) {
|
||||
matched+=1;
|
||||
if(!excluded.has(start))result.push({start,end:start+duration});
|
||||
}
|
||||
start=addCalendarDays(start,rule.FREQ==='DAILY'?interval:1,event.timeZone);
|
||||
}
|
||||
(event.rdates||[]).forEach(extra=>{
|
||||
if(extra<rangeEnd&&!excluded.has(extra)&&!result.some(item=>item.start===extra))result.push({start:extra,end:extra+duration});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
function dayBoundary(date,minutes) {
|
||||
const [year,month,day]=date.split('-').map(Number);
|
||||
return new Date(year,month-1,day,Math.floor(minutes/60),minutes%60).getTime();
|
||||
}
|
||||
function localClock(value) {
|
||||
const date=new Date(value);
|
||||
return String(date.getHours()).padStart(2,'0')+':'+String(date.getMinutes()).padStart(2,'0');
|
||||
}
|
||||
function review(source,{dates,workdayStart='09:00',workdayEnd='17:00'}={}) {
|
||||
if(!Array.isArray(dates)||dates.length!==7)throw new Error('Week Ahead must contain seven dates.');
|
||||
const startMinute=clock(workdayStart),endMinute=clock(workdayEnd);
|
||||
if(startMinute==null||endMinute==null||endMinute<=startMinute)throw new Error('Working hours must end after they start.');
|
||||
const parsedEvents=events(source),rangeStart=dayBoundary(dates[0],0),rangeEnd=dayBoundary(dates[6],24*60);
|
||||
const unsupportedRecurrence=parsedEvents.filter(event=>event.rule&&!supportedRecurrence(event.rule)&&
|
||||
event.start<rangeEnd&&(event.end>rangeStart||event.rule)).length;
|
||||
const unsupportedTimezone=parsedEvents.filter(event=>event.unsupportedTimezone).length;
|
||||
const unsupported_count=unsupportedRecurrence+unsupportedTimezone;
|
||||
const calendarEvents=parsedEvents.filter(event=>!event.unsupportedTimezone).flatMap(event=>occurrences(event,rangeStart,rangeEnd));
|
||||
const days=dates.map(plan_date=>{
|
||||
const start=dayBoundary(plan_date,startMinute),end=dayBoundary(plan_date,endMinute);
|
||||
const ranges=calendarEvents.map(event=>[Math.max(start,event.start),Math.min(end,event.end)])
|
||||
.filter(range=>range[1]>range[0]).sort((left,right)=>left[0]-right[0]);
|
||||
const merged=[];
|
||||
ranges.forEach(range=>{
|
||||
const previous=merged[merged.length-1];
|
||||
if(previous&&range[0]<=previous[1])previous[1]=Math.max(previous[1],range[1]);
|
||||
else merged.push([...range]);
|
||||
});
|
||||
const busy_minutes=Math.round(merged.reduce((total,range)=>total+range[1]-range[0],0)/60000);
|
||||
const free_windows=[];let cursor=start;
|
||||
merged.forEach(range=>{
|
||||
if(range[0]>cursor)free_windows.push({start_time:localClock(cursor),end_time:localClock(range[0])});
|
||||
cursor=Math.max(cursor,range[1]);
|
||||
});
|
||||
if(cursor<end)free_windows.push({start_time:localClock(cursor),end_time:localClock(end)});
|
||||
return {plan_date,busy_minutes,capacity_minutes:endMinute-startMinute-busy_minutes,free_windows};
|
||||
});
|
||||
days.unsupported_count=unsupported_count;
|
||||
days.unsupported_timezone_count=unsupportedTimezone;
|
||||
return days;
|
||||
}
|
||||
function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
|
||||
let reviewed=null,activeAvailability=null;
|
||||
const root=()=>qs('#week-capacity-import');
|
||||
function clear() {
|
||||
reviewed=null;
|
||||
qs('#week-capacity-file').value='';
|
||||
qs('#week-capacity-review').hidden=true;
|
||||
qs('#week-capacity-days').innerHTML='';
|
||||
qs('#apply-week-capacities').disabled=false;
|
||||
}
|
||||
function open() {
|
||||
clear();root().hidden=false;qs('#week-capacity-status').textContent='';
|
||||
qs('#week-capacity-file').focus?.();return true;
|
||||
}
|
||||
function cancel() {clear();root().hidden=true;return true;}
|
||||
function reviewSource(source) {
|
||||
const display=controller.dates();
|
||||
reviewed=review(source,{dates:display.map(item=>item.date),workdayStart:qs('#week-capacity-start').value,
|
||||
workdayEnd:qs('#week-capacity-end').value});
|
||||
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('');
|
||||
qs('#week-capacity-review').hidden=false;
|
||||
const unsupported=reviewed.unsupported_count||0;
|
||||
qs('#apply-week-capacities').disabled=unsupported>0;
|
||||
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.';
|
||||
return reviewed.map(day=>({...day}));
|
||||
}
|
||||
async function apply() {
|
||||
if(!reviewed||reviewed.unsupported_count||!controller.stageCapacities(reviewed.map(day=>({
|
||||
plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
}))))return false;
|
||||
qs('#apply-week-capacities').disabled=true;
|
||||
try {await controller.flush();activeAvailability=reviewed.map(day=>({plan_date:day.plan_date,
|
||||
free_windows:day.free_windows.map(window=>({...window}))}));cancel();onApplied();return true;}
|
||||
finally {qs('#apply-week-capacities').disabled=false;}
|
||||
}
|
||||
qs('#week-capacity-file')?.addEventListener('change',async event=>{
|
||||
const file=event.currentTarget.files?.[0];if(!file)return;
|
||||
try {
|
||||
if(file.size>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
|
||||
reviewSource(await file.text());
|
||||
} catch(error) {clear();qs('#week-capacity-status').textContent=error.message||'Calendar could not be read.';}
|
||||
});
|
||||
qs('#cancel-week-capacity-import')?.addEventListener('click',cancel);
|
||||
qs('#apply-week-capacities')?.addEventListener('click',()=>apply().catch(error=>{
|
||||
qs('#week-capacity-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Capacities remain saved on this phone.';
|
||||
}));
|
||||
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};
|
||||
}
|
||||
function mount(controller,weekWorkflow,qs) {
|
||||
const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview});
|
||||
StackchainWeekCalendar.setAvailabilityProvider(workflow.availability);
|
||||
qs('#open-week-capacity-import').addEventListener('click',workflow.open);
|
||||
return workflow;
|
||||
}
|
||||
return {review,createWorkflow,mount};
|
||||
}
|
||||
const weekCalendarImport=createWeekCalendarImport();
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=weekCalendarImport;
|
||||
@@ -0,0 +1,130 @@
|
||||
(function(root){
|
||||
'use strict';
|
||||
let availabilityProvider=()=>null;
|
||||
function escapeText(value){
|
||||
return String(value||'').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/,/g,'\\,').replace(/;/g,'\\;');
|
||||
}
|
||||
function compact(value){return String(value||'').replace(/[-:]/g,'');}
|
||||
function addMinutes(value,minutes){
|
||||
const [hours,mins]=String(value).split(':').map(Number),total=hours*60+mins+Number(minutes);
|
||||
return String(Math.floor(total/60)%24).padStart(2,'0')+':'+String(total%60).padStart(2,'0');
|
||||
}
|
||||
function clockMinutes(value){const [hours,minutes]=String(value).split(':').map(Number);return hours*60+minutes;}
|
||||
function stableId(value){return String(value||'work').replace(/[^a-z0-9]+/gi,'-').replace(/^-|-$/g,'').toLowerCase();}
|
||||
function foldLine(line){
|
||||
const encoder=new TextEncoder(),chunks=[];let chunk='',bytes=0,limit=75;
|
||||
for(const character of String(line)){
|
||||
const width=encoder.encode(character).length;
|
||||
if(chunk&&bytes+width>limit){chunks.push(chunk);chunk=character;bytes=width;limit=74;}
|
||||
else {chunk+=character;bytes+=width;}
|
||||
}
|
||||
chunks.push(chunk);return chunks.join('\r\n ');
|
||||
}
|
||||
function buildWeekSchedule(plan,startTimes,getItem,selected,availability){
|
||||
const blocks=[],blockers=[],availableByDate=new Map((availability||[]).map(day=>[day.plan_date,day.free_windows||[]]));
|
||||
(plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{
|
||||
let cursor=startTimes?.[day.plan_date]||'09:00';
|
||||
(day.ids||[]).forEach(id=>{
|
||||
if(selected&& !selected.has(id))return;
|
||||
const minutes=Number(day.estimates?.[id]),item=getItem?.(id);
|
||||
if(!item||!Number.isFinite(minutes)||minutes<=0)return;
|
||||
const windows=availableByDate.get(day.plan_date);
|
||||
if(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;
|
||||
});
|
||||
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;
|
||||
});
|
||||
});
|
||||
return {blocks,blockers};
|
||||
}
|
||||
function buildWeekBlocks(plan,startTimes,getItem,selected,availability){
|
||||
return buildWeekSchedule(plan,startTimes,getItem,selected,availability).blocks;
|
||||
}
|
||||
function serializeWeekCalendar(blocks,{timezone='UTC',revision=0,generatedAt}={}){
|
||||
const stamp=generatedAt||new Date().toISOString().replace(/[-:]/g,'').replace(/\.\d{3}/,'');
|
||||
const lines=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Stackchain//Week Ahead//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','X-WR-CALNAME:Stackchain Week Ahead',`X-WR-TIMEZONE:${timezone}`,'BEGIN:VTIMEZONE',`TZID:${timezone}`,'END:VTIMEZONE'];
|
||||
(blocks||[]).forEach(block=>{
|
||||
const day=compact(block.plan_date),reference=block.repository+(block.number!=null?' #'+block.number:'');
|
||||
lines.push('BEGIN:VEVENT',`UID:week-${stableId(block.id)}@stackchain`,`SEQUENCE:${Math.max(0,Math.floor(Number(revision)||0))}`,`DTSTAMP:${stamp}`,
|
||||
`DTSTART;TZID=${timezone}:${day}T${compact(block.start_time)}00`,`DTEND;TZID=${timezone}:${day}T${compact(block.end_time)}00`,
|
||||
`SUMMARY:${escapeText(block.title||'Untitled work')}`,`DESCRIPTION:${escapeText(reference+' · Stackchain Week Ahead')}`,
|
||||
`URL:${String(block.url||'')}`,'TRANSP:OPAQUE','END:VEVENT');
|
||||
});
|
||||
lines.push('END:VCALENDAR');return lines.map(foldLine).join('\r\n')+'\r\n';
|
||||
}
|
||||
async function deliverWeekCalendar({text,filename,navigator,document,urlApi,FileCtor}){
|
||||
const file=new FileCtor([text],filename,{type:'text/calendar;charset=utf-8'});
|
||||
const payload={files:[file],title:'Stackchain Week Ahead',text:'Timed Week Ahead calendar blocks'};
|
||||
if(typeof navigator?.share==='function'&&typeof navigator?.canShare==='function'&&navigator.canShare(payload)){
|
||||
await navigator.share(payload);return 'shared';
|
||||
}
|
||||
const href=urlApi.createObjectURL(file);
|
||||
try{const anchor=document.createElement('a');anchor.href=href;anchor.download=filename;anchor.click();}
|
||||
finally{urlApi.revokeObjectURL(href);}
|
||||
return 'downloaded';
|
||||
}
|
||||
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,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;
|
||||
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 schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.());
|
||||
const blocks=()=>schedule().blocks;
|
||||
function update(){
|
||||
const current=schedule(),byId=new Map(current.blocks.map(block=>[block.id,block])),blockedById=new Map(current.blockers.map(blocker=>[blocker.id,blocker]));
|
||||
daysRoot.querySelectorAll('[data-week-calendar-preview]').forEach(node=>{
|
||||
const id=node.dataset.weekCalendarPreview,block=byId.get(id),blocker=blockedById.get(id);
|
||||
node.textContent=block?block.start_time+'–'+block.end_time+' · '+block.minutes+' min':
|
||||
(blocker?'Does not fit imported free time':'Excluded from calendar');
|
||||
});
|
||||
qs('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0;
|
||||
if(current.blockers.length){
|
||||
const blocker=current.blockers[0];
|
||||
qs('#week-calendar-status').textContent=blocker.title+' does not fit free time on '+blocker.plan_date+'. Adjust the start, estimate, or calendar import.';
|
||||
}else qs('#week-calendar-status').textContent=(getAvailability?.()?'Planning around imported busy time · ':'Manual timing · ')+
|
||||
current.blocks.length+' calendar block'+(current.blocks.length===1?'':'s')+' selected.';
|
||||
}
|
||||
function close({back=false}={}){
|
||||
handoff.hidden=true;
|
||||
if(back){review.hidden=false;qs('#confirm-week-plan').focus?.();}
|
||||
}
|
||||
function open(value){
|
||||
plan=value;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('');
|
||||
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('#share-week-calendar').addEventListener('click',async()=>{
|
||||
const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;
|
||||
button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…';
|
||||
const day=new Date().toISOString().slice(0,10);
|
||||
try{
|
||||
const text=serializeWeekCalendar(current,{timezone:plan.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone,revision:plan.revision});
|
||||
const result=await deliverWeekCalendar({text,filename:'stackchain-week-ahead-'+day+'.ics',navigator:navigatorObject,
|
||||
document:documentObject,urlApi,FileCtor});
|
||||
close();onDone?.(result);
|
||||
}catch(error){
|
||||
qs('#week-calendar-status').textContent=error?.name==='AbortError'?'Share cancelled. Your Week Ahead is still ready.':'Calendar export failed. Retry without leaving Week Ahead.';
|
||||
button.disabled=false;
|
||||
}
|
||||
});
|
||||
return {open,close,blocks};
|
||||
}
|
||||
function setAvailabilityProvider(value){availabilityProvider=typeof value==='function'?value:()=>null;}
|
||||
const api={buildWeekBlocks,buildWeekSchedule,deliverWeekCalendar,escapeText,foldLine,mountWeekCalendarHandoff,serializeWeekCalendar,setAvailabilityProvider};
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.StackchainWeekCalendar=api;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,16 +11,18 @@ async function loadWorkspace({
|
||||
window?.addEventListener('online', captureOnline);
|
||||
const status = document.querySelector('#my-work-action-status');
|
||||
const retryButton = document.querySelector('#retry-workspace');
|
||||
const url = document.querySelector(
|
||||
'meta[name="stackchain-feature-today-timer"]'
|
||||
)?.content || '';
|
||||
const urls = {'today-timer':url};
|
||||
const urls = Object.fromEntries(['today-timer', 'planning'].map(name => [name,
|
||||
document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || ''
|
||||
]));
|
||||
const originalUrls = {...urls};
|
||||
const loader = createLoader({document, urls});
|
||||
|
||||
let attempts = 0;
|
||||
const load = () => {
|
||||
if (attempts++) urls['today-timer'] = url + '?retry=' + attempts;
|
||||
return loader.load('today-timer');
|
||||
if (attempts++) Object.keys(urls).forEach(name => {
|
||||
urls[name] = originalUrls[name] + '?retry=' + attempts;
|
||||
});
|
||||
return Promise.all(Object.keys(urls).map(name => loader.load(name)));
|
||||
};
|
||||
const waitForRecovery = () => new Promise(resolve => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
|
||||
@@ -19,6 +19,7 @@ RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
|
||||
RUNTIME_FILES = (
|
||||
"README.md",
|
||||
"requirements.txt",
|
||||
"scripts/rotate_private_state.py",
|
||||
"scripts/rotate_unfiled_drafts.py",
|
||||
)
|
||||
EXCLUDED_PARTS = {"__pycache__", ".pytest_cache"}
|
||||
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rewrap shared private-state envelopes under the configured active key."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.private_state import connect_private_sqlite # noqa: E402
|
||||
from src.state_encryption import ( # noqa: E402
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Field:
|
||||
column: str
|
||||
binding: str
|
||||
alias: str | None = None
|
||||
plaintext_string: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Table:
|
||||
name: str
|
||||
context_columns: tuple[str, ...]
|
||||
fields: tuple[Field, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Store:
|
||||
name: str
|
||||
identity: str
|
||||
env: str
|
||||
filename: str
|
||||
tables: tuple[Table, ...]
|
||||
|
||||
|
||||
STORES = (
|
||||
Store("today", "today", "STACKCHAIN_TODAY_DB", "today.sqlite3", (
|
||||
Table("today_plans", ("login",), (Field("ids", "plan:{login}"),)),
|
||||
Table("tomorrow_plans", ("login",), (Field("payload", "tomorrow:{login}"),)),
|
||||
Table("tomorrow_promotions", ("login", "promotion_id"), (Field("result", "tomorrow-promotion:{login}:{promotion_id}"),)),
|
||||
Table("week_plans", ("login",), (Field("payload", "week:{login}"),)),
|
||||
Table("week_promotions", ("login", "promotion_id"), (Field("result", "week-promotion:{login}:{promotion_id}"),)),
|
||||
Table("week_reschedules", ("login", "operation_id"), (Field("result", "week-reschedule:{login}:{operation_id}"),)),
|
||||
Table("today_sessions", ("login",), (Field("device_id", "session:{login}"),)),
|
||||
Table("today_recaps", ("login",), (
|
||||
Field("session_id", "recap-id:{login}", "session_id", True),
|
||||
Field("items", "recap-items:{login}:{session_id}"),
|
||||
)),
|
||||
Table("today_time_logs", ("login",), (
|
||||
Field("session_id", "time-log-session:{login}", "session_id", True),
|
||||
Field("identity", "time-log-identity:{login}:{session_id}", "identity", True),
|
||||
Field("actual_minutes", "time-log-payload:{login}:{session_id}:{identity}"),
|
||||
)),
|
||||
)),
|
||||
Store("later", "later", "STACKCHAIN_LATER_DB", "later.sqlite3", (
|
||||
Table("later_plans", ("login",), (Field("records", "plan:{login}"),)),
|
||||
Table("later_item_revisions", ("login",), (Field("item_id", "item-revision:{login}", plaintext_string=True),)),
|
||||
)),
|
||||
Store("live-snapshot", "live-snapshot", "STACKCHAIN_LIVE_SNAPSHOT_DB", "live-snapshot.sqlite3", (
|
||||
Table("live_snapshot", ("generation",), (Field("value_json", "{generation}"),)),
|
||||
)),
|
||||
Store("available-issue-snapshot", "available-issue-snapshot", "STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB", "available-issue-snapshot.sqlite3", (
|
||||
Table("available_issue_snapshot", (), (Field("items_json", "singleton"),)),
|
||||
)),
|
||||
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
|
||||
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
|
||||
)),
|
||||
Store("security-events", "security-events", "STACKCHAIN_SECURITY_EVENT_DB", "security-events.sqlite3", (
|
||||
Table("security_events", ("id",), (Field("payload", "event:{id}"),)),
|
||||
)),
|
||||
Store("idempotency-ledger", "idempotency-ledger", "STACKCHAIN_IDEMPOTENCY_DB", "idempotency.sqlite3", (
|
||||
Table("idempotency_operations", ("key",), (
|
||||
Field("fingerprint", "{key}:fingerprint"),
|
||||
Field("response_json", "{key}:response"),
|
||||
)),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
def _tables(connection: sqlite3.Connection) -> set[str]:
|
||||
return {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def rotate_store(path: Path, spec: Store, config) -> dict[str, int]:
|
||||
counts = {"current": 0, "failed": 0, "migrated": 0, "total": 0}
|
||||
if not path.exists():
|
||||
return counts
|
||||
cipher = PrivateStateCipher(config, store=spec.identity)
|
||||
with connect_private_sqlite(path, timeout=1.0) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
available = _tables(connection)
|
||||
for table in spec.tables:
|
||||
if table.name not in available:
|
||||
continue
|
||||
columns = (*table.context_columns, *(field.column for field in table.fields))
|
||||
query = f"SELECT rowid AS _rotation_rowid, {', '.join(columns)} FROM {table.name}"
|
||||
for row in connection.execute(query).fetchall():
|
||||
context = {name: row[name] for name in table.context_columns}
|
||||
updates: dict[str, str] = {}
|
||||
row_failed = False
|
||||
row_counts = {"current": 0, "migrated": 0, "total": 0}
|
||||
for field in table.fields:
|
||||
payload = row[field.column]
|
||||
if payload is None:
|
||||
continue
|
||||
row_counts["total"] += 1
|
||||
try:
|
||||
binding = field.binding.format(**context)
|
||||
if isinstance(payload, str) and not payload.startswith(("v1:", "v2:")) and field.plaintext_string:
|
||||
value, stale = payload, True
|
||||
else:
|
||||
value, stale = cipher.open(str(payload), binding=binding)
|
||||
if field.alias:
|
||||
context[field.alias] = value
|
||||
if stale:
|
||||
updates[field.column] = cipher.seal(value, binding=binding)
|
||||
row_counts["migrated"] += 1
|
||||
else:
|
||||
row_counts["current"] += 1
|
||||
except (KeyError, PrivateStateEncryptionError, ValueError):
|
||||
row_failed = True
|
||||
break
|
||||
counts["total"] += row_counts["total"]
|
||||
if row_failed:
|
||||
counts["failed"] += 1
|
||||
continue
|
||||
if updates:
|
||||
assignments = ", ".join(f"{name} = ?" for name in updates)
|
||||
connection.execute(
|
||||
f"UPDATE {table.name} SET {assignments} WHERE rowid = ?",
|
||||
(*updates.values(), row["_rotation_rowid"]),
|
||||
)
|
||||
counts["migrated"] += row_counts["migrated"]
|
||||
counts["current"] += row_counts["current"]
|
||||
return counts
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
config = private_state_encryption_config()
|
||||
if isinstance(config, bytes):
|
||||
raise PrivateStateEncryptionError("rotation requires a keyring")
|
||||
state = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
|
||||
report = {
|
||||
spec.name: rotate_store(
|
||||
Path(os.getenv(spec.env, str(state / spec.filename))), spec, config
|
||||
)
|
||||
for spec in STORES
|
||||
}
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
print(json.dumps({"error": "Private-state rotation configuration is unavailable"}, sort_keys=True))
|
||||
return 2
|
||||
print(json.dumps(report, sort_keys=True))
|
||||
return 1 if any(item["failed"] for item in report.values()) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -12,7 +12,7 @@ from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_key,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class AvailableIssueSnapshotStore:
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="available-issue-snapshot",
|
||||
)
|
||||
with self._connect() as connection:
|
||||
|
||||
@@ -33,14 +33,19 @@ FEATURE_SOURCES = {
|
||||
"static/device-storage.js", "static/mobile-device-setup.js",
|
||||
),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"planning": (
|
||||
"static/plan-today.js", "static/plan-today-readiness.js",
|
||||
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
|
||||
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
||||
),
|
||||
"today-timer": (
|
||||
"static/conversation.js", "static/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-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.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/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
"static/search-batch-plan.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
),
|
||||
}
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, private_state_encryption_key
|
||||
from src.state_encryption import PrivateStateCipher, private_state_encryption_config
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -38,7 +38,7 @@ class IdempotencyLedger:
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="idempotency-ledger",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@ 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_key
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
class LaterStore:
|
||||
@@ -26,7 +26,7 @@ class LaterStore:
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="later",
|
||||
)
|
||||
self._initialize()
|
||||
@@ -124,7 +124,7 @@ class LaterStore:
|
||||
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
|
||||
(login,),
|
||||
):
|
||||
if stored_item_id.startswith("v1:"):
|
||||
if stored_item_id.startswith(("v1:", "v2:")):
|
||||
item_id, _legacy = self._cipher.open(
|
||||
stored_item_id, binding=f"item-revision:{login}"
|
||||
)
|
||||
@@ -141,7 +141,7 @@ class LaterStore:
|
||||
) -> dict[str, str]:
|
||||
migrated = dict(stored_ids)
|
||||
for item_id, stored_item_id in stored_ids.items():
|
||||
if stored_item_id.startswith("v1:"):
|
||||
if stored_item_id.startswith(("v1:", "v2:")):
|
||||
continue
|
||||
sealed_item_id = self._cipher.seal(
|
||||
item_id, binding=f"item-revision:{login}"
|
||||
|
||||
@@ -15,7 +15,7 @@ from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_key,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
SECTIONS = ("context", "events", "notifications")
|
||||
@@ -63,7 +63,7 @@ class LiveSnapshotStore:
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="live-snapshot",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
+402
-3
@@ -55,6 +55,7 @@ from src.passkey_store import PasskeyStore
|
||||
from src.push_notifications import (
|
||||
PushConfiguration,
|
||||
dispatch_deadline_reminders,
|
||||
dispatch_start_day_reminders,
|
||||
dispatch_unread_updates,
|
||||
send_web_push,
|
||||
)
|
||||
@@ -72,7 +73,10 @@ from src.unfiled_draft_store import (
|
||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||
from src.suggestion_engine import compute
|
||||
from src.later_store import LaterStore
|
||||
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore
|
||||
from src.today_store import (
|
||||
TodayPlanFull, TodayPromotionConflict, TodaySessionConflict, TodayStore,
|
||||
TomorrowPlanConflict, TomorrowPlanNotDue, WeekPlanConflict,
|
||||
)
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
from src.views import FRONTEND_BUILD, router as frontend_router
|
||||
|
||||
@@ -118,6 +122,14 @@ async def _push_channel_loop(dispatch, *, interval: float) -> None:
|
||||
next_tick += elapsed_intervals * interval
|
||||
|
||||
|
||||
async def _start_day_plan_snapshot() -> dict:
|
||||
user = await current_user()
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
return {}
|
||||
return await asyncio.to_thread(_today_store().get_start_day_plan, login)
|
||||
|
||||
|
||||
async def _push_poll_loop() -> None:
|
||||
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
|
||||
deadline_interval = max(
|
||||
@@ -160,11 +172,25 @@ async def _push_poll_loop() -> None:
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
|
||||
async def dispatch_start_day() -> None:
|
||||
await dispatch_start_day_reminders(
|
||||
_push_subscription_store,
|
||||
_push_configuration(),
|
||||
_start_day_plan_snapshot,
|
||||
session_statuses=dashboard_auth.managed_session_statuses,
|
||||
send_timeout_seconds=send_timeout,
|
||||
lease_seconds=lease_seconds,
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
|
||||
channel_tasks = (
|
||||
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
|
||||
asyncio.create_task(
|
||||
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
|
||||
),
|
||||
asyncio.create_task(
|
||||
_push_channel_loop(dispatch_start_day, interval=deadline_interval)
|
||||
),
|
||||
)
|
||||
try:
|
||||
done, _pending = await asyncio.wait(
|
||||
@@ -430,6 +456,23 @@ class DeadlineReminderPayload(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class StartDayReminderPayload(BaseModel):
|
||||
enabled: bool
|
||||
timezone: str = Field(min_length=1, max_length=64)
|
||||
reminder_hour: int = Field(default=9, ge=0, le=23)
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except ZoneInfoNotFoundError as error:
|
||||
raise ValueError("Valid IANA timezone required") from error
|
||||
return value
|
||||
|
||||
|
||||
StepUpAction = Literal[
|
||||
"merge_pull",
|
||||
"submit_pull_review",
|
||||
@@ -553,7 +596,7 @@ class NotificationReadBatch(BaseModel):
|
||||
|
||||
class TodayOperation(BaseModel):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
action: Literal["add", "remove", "move", "configure", "rollover"]
|
||||
action: Literal["add", "remove", "move", "configure", "rollover", "activate"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
direction: Literal["up", "down"] | None = None
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
@@ -562,6 +605,7 @@ class TodayOperation(BaseModel):
|
||||
plan_date: str | None = Field(default=None, min_length=10, max_length=10)
|
||||
timezone: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
ids: list[str] = Field(default_factory=list, max_length=5)
|
||||
activation_state: Literal["coaching", "complete"] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_action_fields(self):
|
||||
@@ -580,6 +624,11 @@ class TodayOperation(BaseModel):
|
||||
raise ValueError("rollover IDs must be unique")
|
||||
elif self.plan_date is not None or self.timezone is not None or self.ids:
|
||||
raise ValueError("date, timezone, and IDs are only valid for rollover")
|
||||
if self.action == "activate":
|
||||
if self.item_id != "first-task" or self.activation_state is None:
|
||||
raise ValueError("activate requires a first-task activation state")
|
||||
elif self.activation_state is not None:
|
||||
raise ValueError("activation state is only valid for activate")
|
||||
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
|
||||
for item_id, minutes in self.estimates.items()):
|
||||
raise ValueError("estimates must use bounded item IDs and minutes")
|
||||
@@ -591,6 +640,8 @@ class TodayOperation(BaseModel):
|
||||
data.pop("plan_date", None)
|
||||
data.pop("timezone", None)
|
||||
data.pop("ids", None)
|
||||
if self.action != "activate":
|
||||
data.pop("activation_state", None)
|
||||
return data
|
||||
|
||||
|
||||
@@ -650,6 +701,87 @@ class TodayOperationBatch(BaseModel):
|
||||
operations: list[TodayOperation] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
class TomorrowPlanUpdate(BaseModel):
|
||||
base_revision: int = Field(ge=0)
|
||||
ids: list[str] = Field(max_length=5)
|
||||
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
||||
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
|
||||
plan_date: str = Field(min_length=10, max_length=10)
|
||||
timezone: str = Field(min_length=1, max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_plan(self):
|
||||
if len(set(self.ids)) != len(self.ids):
|
||||
raise ValueError("Tomorrow IDs must be unique")
|
||||
if any(not item_id or len(item_id) > 500 for item_id in self.ids):
|
||||
raise ValueError("Tomorrow IDs must be bounded")
|
||||
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
|
||||
for item_id, minutes in self.estimates.items()):
|
||||
raise ValueError("estimates must use bounded item IDs and minutes")
|
||||
return self
|
||||
|
||||
|
||||
class TomorrowPromotion(BaseModel):
|
||||
promotion_id: str = Field(min_length=1, max_length=100)
|
||||
tomorrow_revision: int = Field(ge=0)
|
||||
today_revision: int = Field(ge=0)
|
||||
|
||||
|
||||
class 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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class WeekPromotion(BaseModel):
|
||||
promotion_id: str = Field(min_length=1, max_length=100)
|
||||
week_revision: int = Field(ge=0)
|
||||
plan_date: str = Field(min_length=10, max_length=10)
|
||||
today_revision: int = Field(ge=0)
|
||||
|
||||
|
||||
class WeekReschedule(BaseModel):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
identity: str = Field(min_length=1, max_length=500)
|
||||
estimate_minutes: int = Field(ge=5, le=1440)
|
||||
plan_date: str = Field(min_length=10, max_length=10)
|
||||
today_revision: int = Field(ge=0)
|
||||
week_revision: int = Field(ge=0)
|
||||
allow_over_capacity: bool = False
|
||||
|
||||
|
||||
class WeekItemPull(BaseModel):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
identity: str = Field(min_length=1, max_length=500)
|
||||
today_revision: int = Field(ge=0)
|
||||
week_revision: int = Field(ge=0)
|
||||
allow_over_capacity: bool = False
|
||||
|
||||
|
||||
class WeekReconciliation(WeekPromotion):
|
||||
ids: list[str] = Field(max_length=5)
|
||||
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
||||
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_selection(self):
|
||||
if len(set(self.ids)) != len(self.ids):
|
||||
raise ValueError("reconciliation IDs must be unique")
|
||||
if any(not item_id or len(item_id) > 500 for item_id in self.ids):
|
||||
raise ValueError("reconciliation IDs must be bounded")
|
||||
if any(item_id not in self.ids or minutes < 5 or minutes > 1440
|
||||
for item_id, minutes in self.estimates.items()):
|
||||
raise ValueError("estimates must match selected IDs and be bounded")
|
||||
return self
|
||||
|
||||
|
||||
class LaterOperationBatch(BaseModel):
|
||||
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
|
||||
|
||||
@@ -1354,7 +1486,7 @@ async def require_operator_session(request: Request, call_next):
|
||||
async def prevent_live_api_caching(request, call_next):
|
||||
response = await call_next(request)
|
||||
path = dashboard_auth.application_path(request)
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||
path.startswith("/api/v1/repos/")
|
||||
and path.endswith("/review")
|
||||
) or path.startswith("/api/v1/notifications") or (
|
||||
@@ -2161,6 +2293,9 @@ async def push_status(request: Request):
|
||||
preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.deadline_preferences, device_id, now=time.time()
|
||||
)
|
||||
start_day_preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.start_day_preferences, device_id
|
||||
)
|
||||
delivery_health = await asyncio.to_thread(
|
||||
_push_subscription_store.delivery_health, device_id
|
||||
)
|
||||
@@ -2173,6 +2308,9 @@ async def push_status(request: Request):
|
||||
"reminder_hour": preferences["reminder_hour"],
|
||||
"reminder_days": preferences["reminder_days"],
|
||||
"snoozed_until": preferences["snoozed_until"],
|
||||
"start_day_enabled": start_day_preferences["enabled"],
|
||||
"start_day_timezone": start_day_preferences["timezone"],
|
||||
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
|
||||
"delivery_health": delivery_health,
|
||||
}
|
||||
|
||||
@@ -2298,6 +2436,29 @@ async def update_deadline_reminders(payload: DeadlineReminderPayload, request: R
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/v1/push-subscription/start-day")
|
||||
async def update_start_day_reminders(payload: StartDayReminderPayload, request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
if payload.enabled and not await asyncio.to_thread(
|
||||
_push_subscription_store.is_subscribed, device_id
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="Enable device notifications first")
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.set_start_day_preferences,
|
||||
device_id,
|
||||
enabled=payload.enabled,
|
||||
timezone=payload.timezone,
|
||||
reminder_hour=payload.reminder_hour,
|
||||
)
|
||||
return {
|
||||
"start_day_enabled": payload.enabled,
|
||||
"start_day_timezone": payload.timezone,
|
||||
"start_day_reminder_hour": payload.reminder_hour,
|
||||
}
|
||||
|
||||
|
||||
@app.patch("/api/v1/push-subscription/deadlines/snooze")
|
||||
async def snooze_deadline_reminder(request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
@@ -2582,6 +2743,244 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/tomorrow")
|
||||
async def get_tomorrow_plan():
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(_today_store().get_tomorrow, login)
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Tomorrow synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/v1/tomorrow")
|
||||
async def replace_tomorrow_plan(payload: TomorrowPlanUpdate):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().replace_tomorrow, login, **payload.model_dump()
|
||||
)
|
||||
except TomorrowPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "tomorrow_changed", "snapshot": error.snapshot},
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Tomorrow synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/tomorrow/promote")
|
||||
async def promote_tomorrow_plan(payload: TomorrowPromotion):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().promote_tomorrow, login, **payload.model_dump()
|
||||
)
|
||||
except TomorrowPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "tomorrow_changed", "snapshot": error.snapshot},
|
||||
)
|
||||
except TodayPromotionConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "today_changed", "today": error.today},
|
||||
)
|
||||
except TomorrowPlanNotDue as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "tomorrow_not_due", "plan_date": error.plan_date},
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Tomorrow promotion is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/week")
|
||||
async def get_week_plan():
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(_today_store().get_week, login)
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Week Ahead synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/v1/week")
|
||||
async def replace_week_plan(payload: WeekPlanUpdate):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().replace_week, login,
|
||||
base_revision=payload.base_revision,
|
||||
days=[day.model_dump() for day in payload.days],
|
||||
timezone=payload.timezone,
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Week Ahead synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/week/promote")
|
||||
async def promote_week_plan(payload: WeekPromotion):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().promote_week, login, **payload.model_dump()
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
|
||||
)
|
||||
except TodayPromotionConflict as error:
|
||||
if error.week is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "week_today_in_progress", "today": error.today, "week": error.week},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "today_changed", "today": error.today}
|
||||
)
|
||||
except TomorrowPlanNotDue as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_not_due", "plan_date": error.plan_date}
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Week Ahead promotion is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/week/reschedule")
|
||||
async def reschedule_today_to_week(payload: WeekReschedule):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().reschedule_today_to_week, login, **payload.model_dump()
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_changed", "week": error.snapshot}
|
||||
)
|
||||
except TodayPromotionConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "today_changed", "today": error.today, "week": error.week},
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Week Ahead rescheduling is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/week/pull-item")
|
||||
async def pull_week_item_into_today(payload: WeekItemPull):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().pull_week_item, login, **payload.model_dump()
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_changed", "week": error.snapshot}
|
||||
)
|
||||
except TodayPromotionConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "today_changed", "today": error.today, "week": error.week},
|
||||
)
|
||||
except TodayPlanFull:
|
||||
raise HTTPException(status_code=409, detail={"code": "today_full"})
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Adding Week Ahead work to Today is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/week/start-early")
|
||||
async def start_week_day_early(payload: WeekPromotion):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().promote_week, login, **payload.model_dump(), allow_future=True
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
|
||||
)
|
||||
except TodayPromotionConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "week_today_in_progress", "today": error.today, "week": error.week},
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Starting Week Ahead early is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/week/reconcile")
|
||||
async def reconcile_week_plan(payload: WeekReconciliation):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().reconcile_week, login, **payload.model_dump()
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
|
||||
)
|
||||
except TodayPromotionConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "week_today_changed", "today": error.today, "week": error.week},
|
||||
)
|
||||
except TomorrowPlanNotDue as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_not_due", "plan_date": error.plan_date}
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Week Ahead reconciliation is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/today/session")
|
||||
async def get_today_session():
|
||||
login = await _confirmed_login()
|
||||
|
||||
@@ -356,6 +356,125 @@ async def dispatch_deadline_reminders(
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="deadline")
|
||||
|
||||
|
||||
async def dispatch_start_day_reminders(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
tomorrow: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
lease_seconds: float = 60.0,
|
||||
max_concurrency: int = 8,
|
||||
) -> int:
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="start-day",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
devices = await asyncio.to_thread(store.start_day_reminder_devices)
|
||||
if not devices:
|
||||
return 0
|
||||
current = now or datetime.now(timezone.utc)
|
||||
due_devices = []
|
||||
for device in devices:
|
||||
try:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
except ZoneInfoNotFoundError:
|
||||
continue
|
||||
if local_now.hour >= device.reminder_hour:
|
||||
due_devices.append((device, local_now.date().isoformat()))
|
||||
if not due_devices:
|
||||
return 0
|
||||
plan = await tomorrow()
|
||||
ids = plan.get("ids") if isinstance(plan, dict) else None
|
||||
plan_date = plan.get("plan_date") if isinstance(plan, dict) else None
|
||||
if not isinstance(ids, list) or not ids or not isinstance(plan_date, str):
|
||||
return 0
|
||||
due_devices = [
|
||||
(device, local_day) for device, local_day in due_devices
|
||||
if local_day >= plan_date and device.delivered_plan_date != plan_date
|
||||
]
|
||||
if not due_devices:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses(
|
||||
[device.session_id for device, _local_day in due_devices]
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
for device, _local_day in due_devices:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
due_devices = [
|
||||
pair for pair in due_devices
|
||||
if statuses.get(pair[0].session_id) == "active"
|
||||
]
|
||||
payload = json.dumps({
|
||||
"title": "Your planned day is ready",
|
||||
"body": "Open Stackchain to prepare Today.",
|
||||
"route": "#/my-work/start-day",
|
||||
"tag": f"stackchain-start-day-{plan_date}",
|
||||
"plan_date": plan_date,
|
||||
}, separators=(",", ":"))
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="start-day",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not still_owner:
|
||||
return 0
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload) if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
device.session_id,
|
||||
"start-day",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, device.session_id, "start-day"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_start_day_reminder_delivered, device.session_id, plan_date
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device, _local_day in due_devices),
|
||||
return_exceptions=True,
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="start-day")
|
||||
|
||||
|
||||
async def _dispatch_deadline_reminders_unlocked(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
|
||||
@@ -43,6 +43,15 @@ class DeadlineReminderDevice:
|
||||
snoozed_until: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StartDayReminderDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
timezone: str
|
||||
reminder_hour: int
|
||||
delivered_plan_date: str | None
|
||||
|
||||
|
||||
class DisabledPushSubscriptionStore:
|
||||
"""No-persistence store used when Web Push is not configured."""
|
||||
|
||||
@@ -66,6 +75,12 @@ class DisabledPushSubscriptionStore:
|
||||
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
||||
return []
|
||||
|
||||
def start_day_preferences(self, session_id: str) -> dict:
|
||||
return {"enabled": False, "timezone": "UTC", "reminder_hour": 9}
|
||||
|
||||
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
|
||||
return []
|
||||
|
||||
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
|
||||
return []
|
||||
|
||||
@@ -96,6 +111,12 @@ class DisabledPushSubscriptionStore:
|
||||
def mark_deadline_reminder_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_start_day_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def reconcile_unread(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
@@ -206,6 +227,15 @@ class PushSubscriptionStore:
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_start_day_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
delivered_plan_date TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
@@ -485,6 +515,60 @@ class PushSubscriptionStore:
|
||||
(local_day, session_id),
|
||||
)
|
||||
|
||||
def set_start_day_preferences(
|
||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_start_day_preferences(
|
||||
session_id, enabled, timezone, reminder_hour
|
||||
) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
timezone = excluded.timezone,
|
||||
reminder_hour = excluded.reminder_hour""",
|
||||
(session_id, int(enabled), timezone, reminder_hour),
|
||||
)
|
||||
|
||||
def start_day_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT enabled, timezone, reminder_hour
|
||||
FROM push_start_day_preferences WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"enabled": bool(row[0]) if row else False,
|
||||
"timezone": row[1] if row else "UTC",
|
||||
"reminder_hour": row[2] if row else 9,
|
||||
}
|
||||
|
||||
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
||||
p.reminder_hour, p.delivered_plan_date
|
||||
FROM push_subscriptions s
|
||||
JOIN push_start_day_preferences p ON p.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
return [
|
||||
StartDayReminderDevice(
|
||||
row[0], self._open_subscription(row[0], row[1]), row[2], row[3], row[4]
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def mark_start_day_reminder_delivered(
|
||||
self, session_id: str, plan_date: str
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_start_day_preferences SET delivered_plan_date = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(plan_date, session_id),
|
||||
)
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||
) -> list[PushDelivery]:
|
||||
|
||||
@@ -5,7 +5,7 @@ import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
@@ -33,7 +33,7 @@ class SavedSearchStore:
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="saved-searches",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
@@ -10,7 +10,7 @@ from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_key,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class SecurityEventStore:
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
try:
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="security-events",
|
||||
)
|
||||
except PrivateStateEncryptionError as exc:
|
||||
|
||||
+136
-18
@@ -6,6 +6,8 @@ import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
@@ -16,7 +18,7 @@ class PrivateStateEncryptionError(RuntimeError):
|
||||
|
||||
|
||||
def decode_private_state_encryption_key(encoded: str) -> bytes:
|
||||
"""Decode the independently injected 256-bit private-state key."""
|
||||
"""Decode an independently injected 256-bit private-state key."""
|
||||
try:
|
||||
key = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError) as error:
|
||||
@@ -31,38 +33,130 @@ def decode_private_state_encryption_key(encoded: str) -> bytes:
|
||||
|
||||
|
||||
def private_state_encryption_key() -> bytes:
|
||||
"""Load the legacy single key setting."""
|
||||
return decode_private_state_encryption_key(
|
||||
os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", "")
|
||||
)
|
||||
|
||||
|
||||
_KEY_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
|
||||
_MAX_KEYS = 4
|
||||
|
||||
|
||||
def decode_private_state_encryption_keyring(
|
||||
encoded: str, active_key_id: str
|
||||
) -> tuple[dict[str, bytes], str]:
|
||||
"""Decode a bounded key-ID-to-key JSON object and validate its active key."""
|
||||
try:
|
||||
raw = json.loads(encoded)
|
||||
except (TypeError, json.JSONDecodeError) as error:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption keyring is invalid"
|
||||
) from error
|
||||
if not isinstance(raw, dict) or not raw:
|
||||
raise PrivateStateEncryptionError("private state encryption keyring is invalid")
|
||||
if len(raw) > _MAX_KEYS:
|
||||
raise PrivateStateEncryptionError(
|
||||
f"private state encryption keyring supports at most {_MAX_KEYS} keys"
|
||||
)
|
||||
keys: dict[str, bytes] = {}
|
||||
for key_id, value in raw.items():
|
||||
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
|
||||
raise PrivateStateEncryptionError("private state encryption key ID is invalid")
|
||||
if not isinstance(value, str):
|
||||
raise PrivateStateEncryptionError("private state encryption keyring is invalid")
|
||||
keys[key_id] = decode_private_state_encryption_key(value)
|
||||
if active_key_id not in keys:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state active key is missing from the keyring"
|
||||
)
|
||||
return keys, active_key_id
|
||||
|
||||
|
||||
def private_state_encryption_config() -> bytes | tuple[dict[str, bytes], str]:
|
||||
"""Load a rotation keyring when configured, otherwise the legacy single key."""
|
||||
encoded = os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS", "")
|
||||
active = os.getenv("STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID", "")
|
||||
if encoded or active:
|
||||
return decode_private_state_encryption_keyring(encoded, active)
|
||||
return private_state_encryption_key()
|
||||
|
||||
|
||||
class PrivateStateCipher:
|
||||
"""Seal JSON values with store-specific authenticated context."""
|
||||
|
||||
def __init__(self, key: bytes, *, store: str):
|
||||
if not isinstance(key, bytes) or len(key) != 32:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption requires exactly 32 key bytes"
|
||||
)
|
||||
def __init__(
|
||||
self,
|
||||
key: bytes | Mapping[str, bytes] | tuple[dict[str, bytes], str],
|
||||
*,
|
||||
store: str,
|
||||
active_key_id: str | None = None,
|
||||
):
|
||||
if isinstance(key, tuple):
|
||||
key, active_key_id = key
|
||||
if isinstance(key, Mapping):
|
||||
if not key or len(key) > _MAX_KEYS or active_key_id not in key:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption keyring configuration is invalid"
|
||||
)
|
||||
ciphers: dict[str, AESGCM] = {}
|
||||
for key_id, raw_key in key.items():
|
||||
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption key ID is invalid"
|
||||
)
|
||||
if not isinstance(raw_key, bytes) or len(raw_key) != 32:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption requires exactly 32 key bytes"
|
||||
)
|
||||
ciphers[key_id] = AESGCM(raw_key)
|
||||
self._ciphers = ciphers
|
||||
self._active_key_id = active_key_id
|
||||
self._legacy_cipher = None
|
||||
else:
|
||||
if not isinstance(key, bytes) or len(key) != 32:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption requires exactly 32 key bytes"
|
||||
)
|
||||
self._ciphers = {}
|
||||
self._active_key_id = None
|
||||
self._legacy_cipher = AESGCM(key)
|
||||
if not store or "\0" in store:
|
||||
raise ValueError("private state store identity is invalid")
|
||||
self._cipher = AESGCM(key)
|
||||
self._store = store
|
||||
|
||||
def _aad(self, binding: str) -> bytes:
|
||||
return f"stackchain:private-state:v1\0{self._store}\0{binding}".encode()
|
||||
def _aad(self, binding: str, *, version: str = "v1", key_id: str = "") -> bytes:
|
||||
prefix = f"stackchain:private-state:{version}\0{self._store}\0{binding}"
|
||||
if version == "v2":
|
||||
prefix += f"\0{key_id}"
|
||||
return prefix.encode()
|
||||
|
||||
def seal(self, value: object, *, binding: str = "singleton") -> str:
|
||||
plaintext = json.dumps(value, separators=(",", ":")).encode()
|
||||
nonce = os.urandom(12)
|
||||
sealed = nonce + self._cipher.encrypt(nonce, plaintext, self._aad(binding))
|
||||
if self._active_key_id is not None:
|
||||
key_id = self._active_key_id
|
||||
sealed = nonce + self._ciphers[key_id].encrypt(
|
||||
nonce, plaintext, self._aad(binding, version="v2", key_id=key_id)
|
||||
)
|
||||
return f"v2:{key_id}:" + base64.urlsafe_b64encode(sealed).decode()
|
||||
sealed = nonce + self._legacy_cipher.encrypt(
|
||||
nonce, plaintext, self._aad(binding)
|
||||
)
|
||||
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
|
||||
|
||||
@staticmethod
|
||||
def _decode_sealed(payload: str) -> bytes:
|
||||
sealed = base64.b64decode(payload, altchars=b"-_", validate=True)
|
||||
if len(sealed) < 28:
|
||||
raise ValueError("encrypted payload is too short")
|
||||
return sealed
|
||||
|
||||
def open(self, payload: str, *, binding: str = "singleton") -> tuple[object, bool]:
|
||||
"""Return the decoded value and whether plaintext migration is required."""
|
||||
"""Return the decoded value and whether active-key rewrapping is required."""
|
||||
if not isinstance(payload, str):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
if not payload.startswith("v1:"):
|
||||
if not payload.startswith(("v1:", "v2:")):
|
||||
try:
|
||||
return json.loads(payload), True
|
||||
except (TypeError, json.JSONDecodeError) as error:
|
||||
@@ -70,13 +164,37 @@ class PrivateStateCipher:
|
||||
"private state could not be decrypted"
|
||||
) from error
|
||||
try:
|
||||
sealed = base64.b64decode(payload[3:], altchars=b"-_", validate=True)
|
||||
if len(sealed) < 28:
|
||||
raise ValueError("encrypted payload is too short")
|
||||
plaintext = self._cipher.decrypt(
|
||||
sealed[:12], sealed[12:], self._aad(binding)
|
||||
if payload.startswith("v2:"):
|
||||
_, key_id, encoded = payload.split(":", 2)
|
||||
cipher = self._ciphers.get(key_id)
|
||||
if cipher is None:
|
||||
raise ValueError("encrypted payload uses an unavailable key")
|
||||
sealed = self._decode_sealed(encoded)
|
||||
plaintext = cipher.decrypt(
|
||||
sealed[:12],
|
||||
sealed[12:],
|
||||
self._aad(binding, version="v2", key_id=key_id),
|
||||
)
|
||||
return json.loads(plaintext), key_id != self._active_key_id
|
||||
|
||||
sealed = self._decode_sealed(payload[3:])
|
||||
candidates = (
|
||||
[self._legacy_cipher]
|
||||
if self._legacy_cipher is not None
|
||||
else list(self._ciphers.values())
|
||||
)
|
||||
return json.loads(plaintext), False
|
||||
plaintext = None
|
||||
for cipher in candidates:
|
||||
try:
|
||||
plaintext = cipher.decrypt(
|
||||
sealed[:12], sealed[12:], self._aad(binding)
|
||||
)
|
||||
break
|
||||
except InvalidTag:
|
||||
continue
|
||||
if plaintext is None:
|
||||
raise InvalidTag
|
||||
return json.loads(plaintext), self._active_key_id is not None
|
||||
except (
|
||||
binascii.Error,
|
||||
InvalidTag,
|
||||
|
||||
+712
-11
@@ -3,11 +3,12 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
class TodayPlanFull(ValueError):
|
||||
@@ -22,6 +23,39 @@ class TodaySessionConflict(ValueError):
|
||||
self.session = session
|
||||
|
||||
|
||||
class TomorrowPlanConflict(ValueError):
|
||||
"""Raised when Tomorrow was edited from an obsolete revision."""
|
||||
|
||||
def __init__(self, snapshot: dict):
|
||||
super().__init__("Tomorrow plan changed on another device")
|
||||
self.snapshot = snapshot
|
||||
|
||||
|
||||
class TomorrowPlanNotDue(ValueError):
|
||||
"""Raised when Tomorrow is promoted before its saved local date."""
|
||||
|
||||
def __init__(self, plan_date: str):
|
||||
super().__init__("Tomorrow plan is not due")
|
||||
self.plan_date = plan_date
|
||||
|
||||
|
||||
class WeekPlanConflict(ValueError):
|
||||
"""Raised when Week Ahead was edited from an obsolete revision."""
|
||||
|
||||
def __init__(self, snapshot: dict):
|
||||
super().__init__("Week Ahead changed on another device")
|
||||
self.snapshot = snapshot
|
||||
|
||||
|
||||
class TodayPromotionConflict(ValueError):
|
||||
"""Raised when promotion cannot safely replace the current Today plan."""
|
||||
|
||||
def __init__(self, today: dict, week: dict | None = None):
|
||||
super().__init__("Today must be reviewed before a saved plan can be promoted")
|
||||
self.today = today
|
||||
self.week = week
|
||||
|
||||
|
||||
class TodayStore:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -43,14 +77,14 @@ class TodayStore:
|
||||
self.recap_limit = recap_limit
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="today",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
connection = connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 3:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 7:
|
||||
connection.close()
|
||||
return
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
@@ -137,7 +171,35 @@ class TodayStore:
|
||||
session_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_sessions)")}
|
||||
if "break_deadline_at" not in session_columns:
|
||||
connection.execute("ALTER TABLE today_sessions ADD COLUMN break_deadline_at INTEGER")
|
||||
connection.execute("PRAGMA user_version = 3")
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS tomorrow_plans ("
|
||||
"login TEXT PRIMARY KEY, revision INTEGER NOT NULL, payload TEXT NOT NULL)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS tomorrow_promotions ("
|
||||
"login TEXT NOT NULL, promotion_id TEXT NOT NULL, result TEXT NOT NULL, "
|
||||
"created_at REAL NOT NULL, PRIMARY KEY (login, promotion_id))"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS week_plans ("
|
||||
"login TEXT PRIMARY KEY, revision INTEGER NOT NULL, payload TEXT NOT NULL)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS week_promotions ("
|
||||
"login TEXT NOT NULL, promotion_id TEXT NOT NULL, result TEXT NOT NULL, "
|
||||
"created_at REAL NOT NULL, PRIMARY KEY (login, promotion_id))"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS week_reschedules ("
|
||||
"login TEXT NOT NULL, operation_id TEXT NOT NULL, result TEXT NOT NULL, "
|
||||
"created_at REAL NOT NULL, PRIMARY KEY (login, operation_id))"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS week_item_pulls ("
|
||||
"login TEXT NOT NULL, operation_id TEXT NOT NULL, result TEXT NOT NULL, "
|
||||
"created_at REAL NOT NULL, PRIMARY KEY (login, operation_id))"
|
||||
)
|
||||
connection.execute("PRAGMA user_version = 7")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
@@ -171,7 +233,7 @@ class TodayStore:
|
||||
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}, False
|
||||
if row[1].startswith("v1:"):
|
||||
if row[1].startswith(("v1:", "v2:")):
|
||||
payload, legacy = self._cipher.open(row[1], binding=f"plan:{login}")
|
||||
if not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
@@ -180,6 +242,7 @@ class TodayStore:
|
||||
capacity_minutes = payload.get("capacity_minutes")
|
||||
plan_date = payload.get("plan_date")
|
||||
timezone = payload.get("timezone")
|
||||
first_task_state = payload.get("first_task_state", "")
|
||||
else:
|
||||
legacy = True
|
||||
ids = json.loads(row[1])
|
||||
@@ -187,6 +250,7 @@ class TodayStore:
|
||||
capacity_minutes = row[2]
|
||||
plan_date = row[4] if len(row) > 4 else None
|
||||
timezone = row[5] if len(row) > 5 else None
|
||||
first_task_state = ""
|
||||
if not isinstance(ids, list) or not isinstance(estimates, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
snapshot = {
|
||||
@@ -198,6 +262,8 @@ class TodayStore:
|
||||
if plan_date:
|
||||
snapshot["plan_date"] = plan_date
|
||||
snapshot["timezone"] = timezone
|
||||
if first_task_state in {"coaching", "complete"}:
|
||||
snapshot["first_task_state"] = first_task_state
|
||||
return snapshot, legacy
|
||||
|
||||
def _sealed_plan(self, login: str, snapshot: dict) -> str:
|
||||
@@ -207,6 +273,7 @@ class TodayStore:
|
||||
"estimates": snapshot["estimates"],
|
||||
"plan_date": snapshot.get("plan_date"),
|
||||
"timezone": snapshot.get("timezone"),
|
||||
"first_task_state": snapshot.get("first_task_state", ""),
|
||||
}, binding=f"plan:{login}")
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
@@ -226,6 +293,625 @@ class TodayStore:
|
||||
)
|
||||
return snapshot
|
||||
|
||||
@staticmethod
|
||||
def _empty_tomorrow(revision: int = 0) -> dict:
|
||||
return {"revision": revision, "ids": [], "capacity_minutes": None, "estimates": {}}
|
||||
|
||||
def _tomorrow_snapshot(self, row, login: str) -> dict:
|
||||
if row is None:
|
||||
return self._empty_tomorrow()
|
||||
payload, _legacy = self._cipher.open(row[1], binding=f"tomorrow:{login}")
|
||||
if not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
snapshot = {"revision": int(row[0]), **payload}
|
||||
if not isinstance(snapshot.get("ids"), list) or not isinstance(snapshot.get("estimates"), dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return snapshot
|
||||
|
||||
def get_tomorrow(self, login: str) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, payload FROM tomorrow_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
return self._tomorrow_snapshot(row, login)
|
||||
|
||||
def _normalize_tomorrow(
|
||||
self, *, ids: list[str], capacity_minutes: int | None,
|
||||
estimates: dict[str, int], plan_date: str, timezone: str,
|
||||
) -> dict:
|
||||
try:
|
||||
if date.fromisoformat(plan_date or "").isoformat() != plan_date:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("plan_date must be an ISO calendar date") from None
|
||||
if not isinstance(timezone, str) or not timezone.strip() or len(timezone) > 100:
|
||||
raise ValueError("timezone is required and bounded")
|
||||
timezone = timezone.strip()
|
||||
try:
|
||||
ZoneInfo(timezone)
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
raise ValueError("timezone must be a valid IANA timezone") from None
|
||||
if not isinstance(ids, list) or len(ids) > self.limit or any(
|
||||
not isinstance(item_id, str) or not item_id or len(item_id) > 500 for item_id in ids
|
||||
):
|
||||
raise ValueError("Tomorrow IDs are invalid or exceed the plan limit")
|
||||
if len(set(ids)) != len(ids):
|
||||
raise ValueError("Tomorrow IDs must be unique")
|
||||
if capacity_minutes is not None and (
|
||||
not isinstance(capacity_minutes, int) or isinstance(capacity_minutes, bool)
|
||||
or capacity_minutes < 15 or capacity_minutes > 1440
|
||||
):
|
||||
raise ValueError("capacity_minutes must be between 15 and 1440")
|
||||
if not isinstance(estimates, dict):
|
||||
raise ValueError("estimates must be an object")
|
||||
normalized_estimates = {}
|
||||
for item_id, minutes in estimates.items():
|
||||
if item_id not in ids:
|
||||
continue
|
||||
if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
|
||||
raise ValueError("estimate minutes must be between 5 and 1440")
|
||||
normalized_estimates[item_id] = minutes
|
||||
return {
|
||||
"ids": list(ids), "capacity_minutes": capacity_minutes,
|
||||
"estimates": normalized_estimates, "plan_date": plan_date,
|
||||
"timezone": timezone.strip(),
|
||||
}
|
||||
|
||||
def replace_tomorrow(self, login: str, *, base_revision: int, **plan) -> 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_tomorrow(**plan)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, payload FROM tomorrow_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current = self._tomorrow_snapshot(row, login)
|
||||
if current["revision"] != base_revision:
|
||||
raise TomorrowPlanConflict(current)
|
||||
snapshot = {"revision": base_revision + 1, **normalized}
|
||||
sealed = self._cipher.seal(normalized, binding=f"tomorrow:{login}")
|
||||
connection.execute(
|
||||
"INSERT INTO tomorrow_plans(login, revision, payload) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, payload=excluded.payload",
|
||||
(login, snapshot["revision"], sealed),
|
||||
)
|
||||
return snapshot
|
||||
|
||||
def promote_tomorrow(
|
||||
self, login: str, *, promotion_id: str, tomorrow_revision: int, today_revision: int,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
if not isinstance(promotion_id, str) or not promotion_id.strip() or len(promotion_id) > 100:
|
||||
raise ValueError("promotion_id is required and bounded")
|
||||
promotion_id = promotion_id.strip()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
receipt = connection.execute(
|
||||
"SELECT result FROM tomorrow_promotions WHERE login = ? AND promotion_id = ?",
|
||||
(login, promotion_id),
|
||||
).fetchone()
|
||||
if receipt:
|
||||
result, _legacy = self._cipher.open(
|
||||
receipt[0], binding=f"tomorrow-promotion:{login}:{promotion_id}"
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return result
|
||||
tomorrow_row = connection.execute(
|
||||
"SELECT revision, payload FROM tomorrow_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
tomorrow = self._tomorrow_snapshot(tomorrow_row, login)
|
||||
if tomorrow["revision"] != tomorrow_revision or not tomorrow.get("plan_date"):
|
||||
raise TomorrowPlanConflict(tomorrow)
|
||||
local_date = datetime.fromtimestamp(
|
||||
self.clock(), ZoneInfo(tomorrow["timezone"])
|
||||
).date().isoformat()
|
||||
if local_date < tomorrow["plan_date"]:
|
||||
raise TomorrowPlanNotDue(tomorrow["plan_date"])
|
||||
today_row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
today, _legacy = self._snapshot(today_row, login)
|
||||
if today["revision"] != today_revision:
|
||||
raise TodayPromotionConflict(today)
|
||||
result = {"revision": today_revision + 1, **{
|
||||
key: tomorrow[key] for key in
|
||||
("ids", "capacity_minutes", "estimates", "plan_date", "timezone")
|
||||
}}
|
||||
sealed_today = self._sealed_plan(login, result)
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, NULL, '{}', NULL, NULL) ON CONFLICT(login) DO UPDATE SET "
|
||||
"revision=excluded.revision, ids=excluded.ids, capacity_minutes=NULL, estimates='{}', "
|
||||
"plan_date=NULL, timezone=NULL",
|
||||
(login, result["revision"], sealed_today),
|
||||
)
|
||||
empty = self._empty_tomorrow(tomorrow_revision + 1)
|
||||
sealed_empty = self._cipher.seal(
|
||||
{key: empty[key] for key in ("ids", "capacity_minutes", "estimates")},
|
||||
binding=f"tomorrow:{login}",
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE tomorrow_plans SET revision = ?, payload = ? WHERE login = ?",
|
||||
(empty["revision"], sealed_empty, login),
|
||||
)
|
||||
sealed_result = self._cipher.seal(
|
||||
result, binding=f"tomorrow-promotion:{login}:{promotion_id}"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO tomorrow_promotions(login, promotion_id, result, created_at) VALUES (?, ?, ?, ?)",
|
||||
(login, promotion_id, sealed_result, self.clock()),
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _empty_week(revision: int = 0) -> dict:
|
||||
return {"revision": revision, "timezone": None, "days": []}
|
||||
|
||||
def _week_snapshot(self, row, login: str) -> dict:
|
||||
if row is None:
|
||||
return self._empty_week()
|
||||
payload, _legacy = self._cipher.open(row[1], binding=f"week:{login}")
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("days"), list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {"revision": int(row[0]), **payload}
|
||||
|
||||
def get_start_day_plan(self, login: str) -> dict:
|
||||
"""Return the next private planned day without changing either plan."""
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
week_row = connection.execute(
|
||||
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
if week_row is not None:
|
||||
week = self._week_snapshot(week_row, login)
|
||||
local_date = datetime.fromtimestamp(
|
||||
self.clock(), ZoneInfo(week["timezone"])
|
||||
).date().isoformat()
|
||||
for day in week["days"]:
|
||||
if (
|
||||
isinstance(day, dict)
|
||||
and isinstance(day.get("ids"), list)
|
||||
and day["ids"]
|
||||
and day.get("plan_date", "") <= local_date
|
||||
):
|
||||
return {**day, "timezone": week.get("timezone")}
|
||||
tomorrow_row = connection.execute(
|
||||
"SELECT revision, payload FROM tomorrow_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
tomorrow = self._tomorrow_snapshot(tomorrow_row, login)
|
||||
return tomorrow if tomorrow.get("ids") and tomorrow.get("plan_date") else {}
|
||||
|
||||
def get_week(self, login: str) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
return self._week_snapshot(row, login)
|
||||
tomorrow_row = connection.execute(
|
||||
"SELECT revision, payload FROM tomorrow_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
tomorrow = self._tomorrow_snapshot(tomorrow_row, login)
|
||||
if not tomorrow.get("plan_date"):
|
||||
return self._empty_week()
|
||||
day = {key: tomorrow[key] for key in (
|
||||
"plan_date", "ids", "capacity_minutes", "estimates"
|
||||
)}
|
||||
payload = {"timezone": tomorrow["timezone"], "days": [day]}
|
||||
connection.execute(
|
||||
"INSERT INTO week_plans(login, revision, payload) VALUES (?, 1, ?)",
|
||||
(login, self._cipher.seal(payload, binding=f"week:{login}")),
|
||||
)
|
||||
empty = self._empty_tomorrow(tomorrow["revision"] + 1)
|
||||
connection.execute(
|
||||
"UPDATE tomorrow_plans SET revision = ?, payload = ? WHERE login = ?",
|
||||
(empty["revision"], self._cipher.seal(
|
||||
{key: empty[key] for key in ("ids", "capacity_minutes", "estimates")},
|
||||
binding=f"tomorrow:{login}",
|
||||
), login),
|
||||
)
|
||||
return {"revision": 1, **payload}
|
||||
|
||||
def _normalize_week(self, *, days: list[dict], timezone: str) -> 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:
|
||||
raise ValueError("timezone is required and bounded")
|
||||
timezone = timezone.strip()
|
||||
try:
|
||||
ZoneInfo(timezone)
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
raise ValueError("timezone must be a valid IANA timezone") from None
|
||||
normalized = []
|
||||
seen_dates = set()
|
||||
seen_ids = set()
|
||||
for day in days:
|
||||
if not isinstance(day, dict):
|
||||
raise ValueError("each Week Ahead date must be an object")
|
||||
plan_date = day.get("plan_date")
|
||||
try:
|
||||
if date.fromisoformat(plan_date or "").isoformat() != plan_date:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("plan_date must be an ISO calendar date") from None
|
||||
if plan_date in seen_dates:
|
||||
raise ValueError("Week Ahead dates must be unique")
|
||||
seen_dates.add(plan_date)
|
||||
plan = self._normalize_tomorrow(
|
||||
ids=day.get("ids", []), capacity_minutes=day.get("capacity_minutes"),
|
||||
estimates=day.get("estimates", {}), plan_date=plan_date, timezone=timezone,
|
||||
)
|
||||
duplicate_ids = seen_ids.intersection(plan["ids"])
|
||||
if duplicate_ids:
|
||||
raise ValueError("work must be assigned to only one Week Ahead date")
|
||||
seen_ids.update(plan["ids"])
|
||||
plan.pop("timezone")
|
||||
normalized.append(plan)
|
||||
normalized.sort(key=lambda item: item["plan_date"])
|
||||
return {"timezone": timezone, "days": normalized}
|
||||
|
||||
def replace_week(self, login: str, *, base_revision: int, days: list[dict], timezone: str) -> 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)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current = self._week_snapshot(row, login)
|
||||
if current["revision"] != base_revision:
|
||||
raise WeekPlanConflict(current)
|
||||
snapshot = {"revision": base_revision + 1, **normalized}
|
||||
connection.execute(
|
||||
"INSERT INTO week_plans(login, revision, payload) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, payload=excluded.payload",
|
||||
(login, snapshot["revision"], self._cipher.seal(normalized, binding=f"week:{login}")),
|
||||
)
|
||||
return snapshot
|
||||
|
||||
def reschedule_today_to_week(
|
||||
self, login: str, *, operation_id: str, identity: str, estimate_minutes: int,
|
||||
plan_date: str, today_revision: int, week_revision: int,
|
||||
allow_over_capacity: bool = False,
|
||||
) -> dict:
|
||||
"""Atomically move one current Today item to one Week Ahead date."""
|
||||
login = self._normalize_login(login)
|
||||
if not isinstance(operation_id, str) or not operation_id.strip() or len(operation_id) > 100:
|
||||
raise ValueError("operation_id is required and bounded")
|
||||
operation_id = operation_id.strip()
|
||||
if not isinstance(identity, str) or not identity or len(identity) > 500:
|
||||
raise ValueError("identity is required and bounded")
|
||||
if (
|
||||
not isinstance(estimate_minutes, int) or isinstance(estimate_minutes, bool)
|
||||
or estimate_minutes < 5 or estimate_minutes > 1440
|
||||
):
|
||||
raise ValueError("estimate_minutes must be between 5 and 1440")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
receipt = connection.execute(
|
||||
"SELECT result FROM week_reschedules WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if receipt:
|
||||
result, _legacy = self._cipher.open(
|
||||
receipt[0], binding=f"week-reschedule:{login}:{operation_id}"
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return result
|
||||
today_row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,),
|
||||
).fetchone()
|
||||
today, _legacy = self._snapshot(today_row, login)
|
||||
week_row = connection.execute(
|
||||
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
week = self._week_snapshot(week_row, login)
|
||||
if today["revision"] != today_revision or identity not in today["ids"]:
|
||||
raise TodayPromotionConflict(today, week)
|
||||
if week["revision"] != week_revision:
|
||||
raise WeekPlanConflict(week)
|
||||
|
||||
days = []
|
||||
for source in week["days"]:
|
||||
estimates = dict(source.get("estimates", {}))
|
||||
estimates.pop(identity, None)
|
||||
days.append({
|
||||
**source,
|
||||
"ids": [item_id for item_id in source.get("ids", []) if item_id != identity],
|
||||
"estimates": estimates,
|
||||
})
|
||||
destination = next((day for day in days if day["plan_date"] == plan_date), None)
|
||||
if destination is None:
|
||||
destination = {
|
||||
"plan_date": plan_date, "ids": [],
|
||||
"capacity_minutes": None, "estimates": {},
|
||||
}
|
||||
days.append(destination)
|
||||
destination["ids"].append(identity)
|
||||
destination["estimates"][identity] = estimate_minutes
|
||||
planned_minutes = sum(
|
||||
int(destination["estimates"].get(item_id, 0)) for item_id in destination["ids"]
|
||||
)
|
||||
if (
|
||||
destination.get("capacity_minutes") is not None
|
||||
and planned_minutes > destination["capacity_minutes"]
|
||||
and not allow_over_capacity
|
||||
):
|
||||
raise ValueError("explicit overload confirmation is required")
|
||||
normalized_week = self._normalize_week(days=days, timezone=week["timezone"])
|
||||
|
||||
today_ids = [item_id for item_id in today["ids"] if item_id != identity]
|
||||
today_estimates = {
|
||||
item_id: minutes for item_id, minutes in today["estimates"].items()
|
||||
if item_id != identity
|
||||
}
|
||||
today_result = {
|
||||
**today, "revision": today_revision + 1,
|
||||
"ids": today_ids, "estimates": today_estimates,
|
||||
}
|
||||
week_result = {"revision": week_revision + 1, **normalized_week}
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = NULL, "
|
||||
"estimates = '{}', plan_date = NULL, timezone = NULL WHERE login = ?",
|
||||
(today_result["revision"], self._sealed_plan(login, today_result), login),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE week_plans SET revision = ?, payload = ? WHERE login = ?",
|
||||
(week_result["revision"], self._cipher.seal(
|
||||
normalized_week, binding=f"week:{login}"
|
||||
), login),
|
||||
)
|
||||
result = {"today": today_result, "week": week_result}
|
||||
connection.execute(
|
||||
"INSERT INTO week_reschedules(login, operation_id, result, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(login, operation_id, self._cipher.seal(
|
||||
result, binding=f"week-reschedule:{login}:{operation_id}"
|
||||
), self.clock()),
|
||||
)
|
||||
return result
|
||||
|
||||
def pull_week_item(
|
||||
self, login: str, *, operation_id: str, identity: str,
|
||||
today_revision: int, week_revision: int, allow_over_capacity: bool = False,
|
||||
) -> dict:
|
||||
"""Atomically append one Week Ahead item to Today and remove it from Week."""
|
||||
login = self._normalize_login(login)
|
||||
if not isinstance(operation_id, str) or not operation_id.strip() or len(operation_id) > 100:
|
||||
raise ValueError("operation_id is required and bounded")
|
||||
if not isinstance(identity, str) or not identity or len(identity) > 500:
|
||||
raise ValueError("identity is required and bounded")
|
||||
operation_id = operation_id.strip()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
receipt = connection.execute(
|
||||
"SELECT result FROM week_item_pulls WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if receipt:
|
||||
result, _legacy = self._cipher.open(
|
||||
receipt[0], binding=f"week-item-pull:{login}:{operation_id}"
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return result
|
||||
today_row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,),
|
||||
).fetchone()
|
||||
today, _legacy = self._snapshot(today_row, login)
|
||||
week_row = connection.execute(
|
||||
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,),
|
||||
).fetchone()
|
||||
week = self._week_snapshot(week_row, login)
|
||||
if today["revision"] != today_revision:
|
||||
raise TodayPromotionConflict(today, week)
|
||||
if week["revision"] != week_revision:
|
||||
raise WeekPlanConflict(week)
|
||||
if identity in today["ids"]:
|
||||
raise ValueError("work is already in Today")
|
||||
if len(today["ids"]) >= self.limit:
|
||||
raise TodayPlanFull("Today plan is full")
|
||||
source = next((day for day in week["days"] if identity in day["ids"]), None)
|
||||
if source is None:
|
||||
raise WeekPlanConflict(week)
|
||||
estimate = source.get("estimates", {}).get(identity)
|
||||
planned_minutes = sum(
|
||||
int(today["estimates"].get(item_id, 0)) for item_id in today["ids"]
|
||||
) + (int(estimate) if estimate else 0)
|
||||
if (
|
||||
today.get("capacity_minutes") is not None
|
||||
and planned_minutes > today["capacity_minutes"]
|
||||
and not allow_over_capacity
|
||||
):
|
||||
raise ValueError("moving work over Today capacity requires explicit overload confirmation")
|
||||
today_result = {
|
||||
**today, "revision": today_revision + 1,
|
||||
"ids": [*today["ids"], identity],
|
||||
"estimates": {**today["estimates"], **({identity: estimate} if estimate else {})},
|
||||
}
|
||||
remaining_days = []
|
||||
for day in week["days"]:
|
||||
estimates = dict(day.get("estimates", {}))
|
||||
estimates.pop(identity, None)
|
||||
remaining_days.append({
|
||||
**day, "ids": [item_id for item_id in day["ids"] if item_id != identity],
|
||||
"estimates": estimates,
|
||||
})
|
||||
week_payload = {"timezone": week["timezone"], "days": remaining_days}
|
||||
week_result = {"revision": week_revision + 1, **week_payload}
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, NULL, '{}', NULL, NULL) ON CONFLICT(login) DO UPDATE SET "
|
||||
"revision=excluded.revision, ids=excluded.ids, capacity_minutes=NULL, estimates='{}', "
|
||||
"plan_date=NULL, timezone=NULL",
|
||||
(login, today_result["revision"], self._sealed_plan(login, today_result)),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE week_plans SET revision = ?, payload = ? WHERE login = ?",
|
||||
(week_result["revision"], self._cipher.seal(week_payload, binding=f"week:{login}"), login),
|
||||
)
|
||||
result = {"today": today_result, "week": week_result}
|
||||
connection.execute(
|
||||
"INSERT INTO week_item_pulls(login, operation_id, result, created_at) VALUES (?, ?, ?, ?)",
|
||||
(login, operation_id, self._cipher.seal(
|
||||
result, binding=f"week-item-pull:{login}:{operation_id}"
|
||||
), self.clock()),
|
||||
)
|
||||
return result
|
||||
|
||||
def promote_week(
|
||||
self, login: str, *, promotion_id: str, week_revision: int,
|
||||
plan_date: str, today_revision: int, allow_future: bool = False,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
if not isinstance(promotion_id, str) or not promotion_id.strip() or len(promotion_id) > 100:
|
||||
raise ValueError("promotion_id is required and bounded")
|
||||
promotion_id = promotion_id.strip()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
receipt = connection.execute(
|
||||
"SELECT result FROM week_promotions WHERE login = ? AND promotion_id = ?",
|
||||
(login, promotion_id),
|
||||
).fetchone()
|
||||
if receipt:
|
||||
result, _legacy = self._cipher.open(
|
||||
receipt[0], binding=f"week-promotion:{login}:{promotion_id}"
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return result
|
||||
row = connection.execute(
|
||||
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
week = self._week_snapshot(row, login)
|
||||
if week["revision"] != week_revision:
|
||||
raise WeekPlanConflict(week)
|
||||
day = next((item for item in week["days"] if item["plan_date"] == plan_date), None)
|
||||
if day is None:
|
||||
raise WeekPlanConflict(week)
|
||||
local_date = datetime.fromtimestamp(self.clock(), ZoneInfo(week["timezone"])).date().isoformat()
|
||||
if local_date < plan_date and not allow_future:
|
||||
raise TomorrowPlanNotDue(plan_date)
|
||||
today_row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
today, _legacy = self._snapshot(today_row, login)
|
||||
if today["revision"] != today_revision:
|
||||
raise TodayPromotionConflict(today)
|
||||
if today["ids"]:
|
||||
raise TodayPromotionConflict(today, week)
|
||||
result = {"revision": today_revision + 1, **day, "timezone": week["timezone"]}
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, NULL, '{}', NULL, NULL) ON CONFLICT(login) DO UPDATE SET "
|
||||
"revision=excluded.revision, ids=excluded.ids, capacity_minutes=NULL, estimates='{}', "
|
||||
"plan_date=NULL, timezone=NULL",
|
||||
(login, result["revision"], self._sealed_plan(login, result)),
|
||||
)
|
||||
remaining = {"timezone": week["timezone"], "days": [
|
||||
item for item in week["days"] if item["plan_date"] != plan_date
|
||||
]}
|
||||
connection.execute(
|
||||
"UPDATE week_plans SET revision = ?, payload = ? WHERE login = ?",
|
||||
(week_revision + 1, self._cipher.seal(remaining, binding=f"week:{login}"), login),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO week_promotions(login, promotion_id, result, created_at) VALUES (?, ?, ?, ?)",
|
||||
(login, promotion_id, self._cipher.seal(
|
||||
result, binding=f"week-promotion:{login}:{promotion_id}"
|
||||
), self.clock()),
|
||||
)
|
||||
return result
|
||||
|
||||
def reconcile_week(
|
||||
self, login: str, *, promotion_id: str, week_revision: int,
|
||||
plan_date: str, today_revision: int, ids: list[str],
|
||||
capacity_minutes: int | None, estimates: dict[str, int],
|
||||
) -> dict:
|
||||
"""Atomically replace Today from preserved Today/due-week work and consume that day."""
|
||||
login = self._normalize_login(login)
|
||||
if not isinstance(promotion_id, str) or not promotion_id.strip() or len(promotion_id) > 100:
|
||||
raise ValueError("promotion_id is required and bounded")
|
||||
promotion_id = promotion_id.strip()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
receipt = connection.execute(
|
||||
"SELECT result FROM week_promotions WHERE login = ? AND promotion_id = ?",
|
||||
(login, promotion_id),
|
||||
).fetchone()
|
||||
if receipt:
|
||||
result, _legacy = self._cipher.open(
|
||||
receipt[0], binding=f"week-promotion:{login}:{promotion_id}"
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return result
|
||||
row = connection.execute(
|
||||
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
week = self._week_snapshot(row, login)
|
||||
if week["revision"] != week_revision:
|
||||
raise WeekPlanConflict(week)
|
||||
day = next((item for item in week["days"] if item["plan_date"] == plan_date), None)
|
||||
if day is None:
|
||||
raise WeekPlanConflict(week)
|
||||
local_date = datetime.fromtimestamp(self.clock(), ZoneInfo(week["timezone"])).date().isoformat()
|
||||
if local_date < plan_date:
|
||||
raise TomorrowPlanNotDue(plan_date)
|
||||
today_row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
today, _legacy = self._snapshot(today_row, login)
|
||||
if today["revision"] != today_revision:
|
||||
raise TodayPromotionConflict(today, week)
|
||||
allowed = set(today["ids"]) | set(day["ids"])
|
||||
if any(item_id not in allowed for item_id in ids):
|
||||
raise ValueError("selected work must come from Today or the due Week Ahead day")
|
||||
normalized = self._normalize_tomorrow(
|
||||
ids=ids, capacity_minutes=capacity_minutes, estimates=estimates,
|
||||
plan_date=plan_date, timezone=week["timezone"],
|
||||
)
|
||||
today_result = {"revision": today_revision + 1, **normalized}
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, NULL, '{}', NULL, NULL) ON CONFLICT(login) DO UPDATE SET "
|
||||
"revision=excluded.revision, ids=excluded.ids, capacity_minutes=NULL, estimates='{}', "
|
||||
"plan_date=NULL, timezone=NULL",
|
||||
(login, today_result["revision"], self._sealed_plan(login, today_result)),
|
||||
)
|
||||
remaining_payload = {"timezone": week["timezone"], "days": [
|
||||
item for item in week["days"] if item["plan_date"] != plan_date
|
||||
]}
|
||||
week_result = {"revision": week_revision + 1, **remaining_payload}
|
||||
connection.execute(
|
||||
"UPDATE week_plans SET revision = ?, payload = ? WHERE login = ?",
|
||||
(week_result["revision"], self._cipher.seal(
|
||||
remaining_payload, binding=f"week:{login}"
|
||||
), login),
|
||||
)
|
||||
result = {"today": today_result, "week": week_result}
|
||||
connection.execute(
|
||||
"INSERT INTO week_promotions(login, promotion_id, result, created_at) VALUES (?, ?, ?, ?)",
|
||||
(login, promotion_id, self._cipher.seal(
|
||||
result, binding=f"week-promotion:{login}:{promotion_id}"
|
||||
), self.clock()),
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _empty_session() -> dict:
|
||||
return {
|
||||
@@ -236,7 +922,7 @@ class TodayStore:
|
||||
def _session_snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return self._empty_session(), False
|
||||
if row[1].startswith("v1:"):
|
||||
if row[1].startswith(("v1:", "v2:")):
|
||||
payload, legacy = self._cipher.open(row[1], binding=f"session:{login}")
|
||||
if not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
@@ -337,7 +1023,7 @@ class TodayStore:
|
||||
def _recap_snapshot(
|
||||
self, login: str, encrypted_session_id: str, created_at: float, serialized: str
|
||||
) -> tuple[dict, bool]:
|
||||
if encrypted_session_id.startswith("v1:"):
|
||||
if encrypted_session_id.startswith(("v1:", "v2:")):
|
||||
session_id, legacy_session = self._cipher.open(
|
||||
encrypted_session_id, binding=f"recap-id:{login}"
|
||||
)
|
||||
@@ -429,7 +1115,7 @@ class TodayStore:
|
||||
|
||||
def _time_log_snapshot(self, login: str, row) -> tuple[dict, bool]:
|
||||
encrypted_session, encrypted_identity, payload_value, status_value = row
|
||||
if encrypted_session.startswith("v1:"):
|
||||
if encrypted_session.startswith(("v1:", "v2:")):
|
||||
session_id = self._cipher.open(
|
||||
encrypted_session, binding=f"time-log-session:{login}"
|
||||
)[0]
|
||||
@@ -598,6 +1284,7 @@ class TodayStore:
|
||||
"ids": ids, "capacity_minutes": snapshot["capacity_minutes"],
|
||||
"estimates": estimates, "plan_date": snapshot.get("plan_date"),
|
||||
"timezone": snapshot.get("timezone"),
|
||||
"first_task_state": snapshot.get("first_task_state", ""),
|
||||
})
|
||||
if row is None:
|
||||
connection.execute(
|
||||
@@ -621,6 +1308,8 @@ class TodayStore:
|
||||
if snapshot.get("plan_date"):
|
||||
result["plan_date"] = snapshot["plan_date"]
|
||||
result["timezone"] = snapshot["timezone"]
|
||||
if snapshot.get("first_task_state"):
|
||||
result["first_task_state"] = snapshot["first_task_state"]
|
||||
return result
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
@@ -638,6 +1327,7 @@ class TodayStore:
|
||||
estimates = dict(snapshot["estimates"])
|
||||
plan_date = snapshot.get("plan_date")
|
||||
timezone = snapshot.get("timezone")
|
||||
first_task_state = snapshot.get("first_task_state", "")
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
@@ -650,7 +1340,7 @@ class TodayStore:
|
||||
direction = operation.get("direction")
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"add", "remove", "move", "configure", "rollover"}:
|
||||
if action not in {"add", "remove", "move", "configure", "rollover", "activate"}:
|
||||
raise ValueError("unsupported Today action")
|
||||
if action == "move" and direction not in {"up", "down"}:
|
||||
raise ValueError("move direction must be up or down")
|
||||
@@ -667,7 +1357,7 @@ class TodayStore:
|
||||
base_revision = snapshot["revision"]
|
||||
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")
|
||||
if base_revision < snapshot["revision"]:
|
||||
if action != "activate" and base_revision < snapshot["revision"]:
|
||||
self._record_operation(connection, login, operation_id)
|
||||
rejected.append({"operation_id": operation_id, "reason": "stale_intent"})
|
||||
continue
|
||||
@@ -714,6 +1404,14 @@ class TodayStore:
|
||||
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
|
||||
capacity_minutes = proposed_capacity
|
||||
estimates = normalized_estimates
|
||||
elif action == "activate":
|
||||
proposed_state = operation.get("activation_state")
|
||||
if item_id != "first-task" or proposed_state not in {"coaching", "complete"}:
|
||||
raise ValueError("first-task activation state is invalid")
|
||||
rank = {"": 0, "coaching": 1, "complete": 2}
|
||||
changed = rank[proposed_state] > rank[first_task_state]
|
||||
if changed:
|
||||
first_task_state = proposed_state
|
||||
else:
|
||||
proposed_date = operation.get("plan_date")
|
||||
proposed_timezone = operation.get("timezone")
|
||||
@@ -764,6 +1462,7 @@ class TodayStore:
|
||||
sealed = self._sealed_plan(login, {
|
||||
"ids": ids, "capacity_minutes": capacity_minutes, "estimates": estimates,
|
||||
"plan_date": plan_date, "timezone": timezone,
|
||||
"first_task_state": first_task_state,
|
||||
})
|
||||
if row is None:
|
||||
connection.execute(
|
||||
@@ -789,4 +1488,6 @@ class TodayStore:
|
||||
if plan_date:
|
||||
result["plan_date"] = plan_date
|
||||
result["timezone"] = timezone
|
||||
if first_task_state:
|
||||
result["first_task_state"] = first_task_state
|
||||
return result
|
||||
@@ -64,6 +64,49 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
||||
expect(page.locator("#mobile-first-task-status")).to_contain_text("Create a task now")
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_enabled()
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_focused()
|
||||
|
||||
page.locator("#close-mobile-first-task").click()
|
||||
context.set_offline(False)
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
document.querySelector('[data-mobile-today-hud]').hidden = false;
|
||||
localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching');
|
||||
const productionCoach = document.querySelector('[data-mobile-first-task-coach]');
|
||||
const isolatedCoach = productionCoach.cloneNode(true);
|
||||
productionCoach.replaceWith(isolatedCoach);
|
||||
window.firstTaskOutcomeProbe = createMobileFirstTask({
|
||||
getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true,
|
||||
coach: isolatedCoach,
|
||||
});
|
||||
return window.firstTaskOutcomeProbe.refresh();
|
||||
}"""
|
||||
)
|
||||
coach = page.locator("[data-mobile-first-task-coach]")
|
||||
expect(coach).to_be_visible()
|
||||
expect(coach).to_contain_text("Complete your first task")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True
|
||||
expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
|
||||
expect(coach).to_be_hidden()
|
||||
expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
fresh_context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
fresh_page = fresh_context.new_page()
|
||||
fresh_page.goto(origin + "/", wait_until="networkidle")
|
||||
fresh_page.locator('input[name="device_label"]').fill("Replacement release phone")
|
||||
fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
fresh_page.locator("#submit-sign-in").click()
|
||||
fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||
assert fresh_page.evaluate(
|
||||
"localStorage.getItem('stackchain.first-task.v1:timmy')"
|
||||
) == "complete"
|
||||
fresh_page.locator('[data-mobile-task="work"]').click()
|
||||
expect(fresh_page.locator("#mobile-first-task")).to_be_hidden()
|
||||
fresh_context.close()
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
@@ -180,6 +223,18 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator("#work-settings-toggle").click()
|
||||
tomorrow = page.locator("#plan-tomorrow")
|
||||
expect(tomorrow).to_be_visible()
|
||||
tomorrow_bounds = tomorrow.bounding_box()
|
||||
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
|
||||
tomorrow.click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
|
||||
expect(page.locator("#save-and-start-today")).to_be_hidden()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
|
||||
expect(delivery_queue).to_be_visible()
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
sync_api = pytest.importorskip("playwright.sync_api")
|
||||
|
||||
FRONTEND = Path(__file__).parents[2] / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width,height", [(320, 568), (390, 844)])
|
||||
def test_search_week_plan_sheet_fits_small_phones_with_touch_safe_controls(width, height):
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
with sync_api.sync_playwright() as playwright:
|
||||
try:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium unavailable: {error}")
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.locator("#search-week-plan-days").evaluate(
|
||||
"""node => node.innerHTML = Array.from({length:7}, (_, index) =>
|
||||
`<button type="button" data-search-week-date="2026-08-${21+index}" aria-pressed="${index===0}">
|
||||
<strong>Day ${index+1}</strong><small>${index*30} of 120 min · ${index} planned</small>
|
||||
</button>`).join('')"""
|
||||
)
|
||||
page.locator("#search-week-plan-sheet").evaluate("node => node.hidden = false")
|
||||
page.locator("#search-week-plan-estimate").fill("45")
|
||||
|
||||
expect = sync_api.expect
|
||||
expect(page.locator("#search-week-plan-sheet")).to_be_visible()
|
||||
expect(page.locator("#confirm-search-week-plan")).to_be_visible()
|
||||
for button in page.locator("#search-week-plan-days button").all():
|
||||
bounds = button.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.locator("#confirm-search-week-plan").bounding_box()["height"] >= 44
|
||||
assert page.locator("#cancel-search-week-plan").bounding_box()["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.locator(".search-week-plan-panel").evaluate(
|
||||
"node => node.scrollWidth <= node.clientWidth"
|
||||
)
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width,height", [(320, 568), (390, 844)])
|
||||
def test_search_week_batch_review_fits_small_phones_without_horizontal_overflow(width, height):
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
with sync_api.sync_playwright() as playwright:
|
||||
try:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium unavailable: {error}")
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.locator("#search-week-batch-list").evaluate(
|
||||
"""node => node.innerHTML = Array.from({length:4}, (_, index) =>
|
||||
`<div class="search-week-batch-row"><div><span class="small">stackchain/dashboard#${1196+index}</span>
|
||||
<strong>Substantial mobile planning issue ${index+1}</strong></div>
|
||||
<div class="search-week-batch-row-controls"><label>Day<select><option>Fri, Aug 21 · 2 planned</option></select></label>
|
||||
<label>Minutes<input type="number" value="45"></label></div></div>`).join('')"""
|
||||
)
|
||||
page.locator("#search-week-batch-review").evaluate("node => node.hidden = false")
|
||||
page.locator("#cmd-palette").evaluate("node => node.classList.add('open')")
|
||||
|
||||
expect = sync_api.expect
|
||||
expect(page.locator("#search-week-batch-review")).to_be_visible()
|
||||
expect(page.locator("#confirm-search-week-batch")).to_be_visible()
|
||||
for control in page.locator("#search-week-batch-review select, #search-week-batch-review input, #search-week-batch-review button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.locator("#search-week-batch-review").evaluate(
|
||||
"node => node.scrollWidth <= node.clientWidth"
|
||||
)
|
||||
browser.close()
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Today to Week Ahead journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reschedules_active_today_into_week_ahead(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with release_server(
|
||||
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
|
||||
) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(
|
||||
viewport={"width": width, "height": height}, timezone_id="UTC"
|
||||
)
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Reschedule release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
plan_date = (date.today() + timedelta(days=2)).isoformat()
|
||||
saved_week = page.evaluate(
|
||||
"""async ({planDate}) => {
|
||||
const response=await fetch('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({base_revision:0,timezone:'UTC',days:[{
|
||||
plan_date:planDate,ids:[],capacity_minutes:90,estimates:{}
|
||||
}]})});
|
||||
return {status:response.status,body:await response.json()};
|
||||
}""",
|
||||
{"planDate": plan_date},
|
||||
)
|
||||
assert saved_week["status"] == 200
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
page.locator("#plan-today-available").fill("90")
|
||||
page.locator("#plan-today-available").press("Tab")
|
||||
page.locator("#build-today-plan").click()
|
||||
missing = page.locator("[data-plan-missing-estimate]")
|
||||
for _ in range(2):
|
||||
missing.nth(0).fill("30")
|
||||
missing.nth(0).press("Tab")
|
||||
save_and_start = page.locator("#save-and-start-today")
|
||||
expect(save_and_start).to_be_enabled()
|
||||
save_and_start.click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden(timeout=10_000)
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
page.locator("#close-issue-sheet").click()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
launcher = page.locator("[data-work-session-reschedule-week]")
|
||||
page.locator("[data-mobile-today-more]").click()
|
||||
expect(launcher).to_be_visible()
|
||||
launcher_bounds = launcher.bounding_box()
|
||||
assert launcher_bounds and launcher_bounds["height"] >= 44
|
||||
launcher.click()
|
||||
dialog = page.locator("#today-week-reschedule")
|
||||
expect(dialog).to_be_visible()
|
||||
days = page.locator("#today-week-reschedule-days button")
|
||||
try:
|
||||
expect(days).to_have_count(7)
|
||||
except AssertionError as error:
|
||||
raise AssertionError({
|
||||
"status": page.locator("#today-week-reschedule-status").text_content(),
|
||||
"page_errors": page_errors,
|
||||
"today": page.evaluate("async()=>await (await fetch('api/v1/today')).json()"),
|
||||
"week": page.evaluate("async()=>await (await fetch('api/v1/week')).json()"),
|
||||
}) from error
|
||||
for control in [*days.all(), page.locator("#cancel-today-week-reschedule"), page.locator("#confirm-today-week-reschedule")]:
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("#cancel-today-week-reschedule").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
expect(launcher).to_be_focused()
|
||||
|
||||
launcher.click()
|
||||
destination = page.locator(
|
||||
f'#today-week-reschedule-days button[data-plan-date="{plan_date}"]'
|
||||
)
|
||||
destination.click()
|
||||
page.locator("#confirm-today-week-reschedule").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
|
||||
week = page.evaluate("async()=>await (await fetch('api/v1/week')).json()")
|
||||
today = page.evaluate("async()=>await (await fetch('api/v1/today')).json()")
|
||||
assert week["days"][0]["plan_date"] == plan_date
|
||||
assert len(week["days"][0]["ids"]) == 1
|
||||
assert len(today["ids"]) == 1
|
||||
assert not page_errors
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=2)
|
||||
@@ -56,17 +56,19 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
|
||||
{kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'},
|
||||
];
|
||||
const identity=item => `${item.kind}:${item.repository}:${item.number}:`;
|
||||
const today=items.map(identity); const later={}; const operations=[];
|
||||
const today=items.map(identity); const operations=[];
|
||||
let tomorrow={revision:4,ids:['issue:acme/mobile:42:'],capacity_minutes:120,
|
||||
estimates:{'issue:acme/mobile:42:':45},plan_date:'2026-08-18'};
|
||||
const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{
|
||||
const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true;
|
||||
}};
|
||||
const laterWork={presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:(item,wake)=>{
|
||||
later[identity(item)]=wake.toISOString(); return 'deferred';
|
||||
const tomorrowPlan={load:async()=>tomorrow,stage:value=>{
|
||||
tomorrow={...tomorrow,...value,sync_pending:true}; return tomorrow;
|
||||
}};
|
||||
const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)};
|
||||
const controller=createTodayWrapUp({todayWork,laterWork,todaySync});
|
||||
const controller=createTodayWrapUp({todayWork,tomorrowPlan,todaySync});
|
||||
const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
||||
window.__wrapTest={view,today,later,operations};
|
||||
window.__wrapTest={view,today,getTomorrow:()=>tomorrow,operations};
|
||||
view.open(items,{});
|
||||
}
|
||||
"""
|
||||
@@ -84,11 +86,18 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
|
||||
page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))")
|
||||
expect(sheet).to_be_hidden()
|
||||
result = page.evaluate(
|
||||
"({today:window.__wrapTest.today,later:window.__wrapTest.later,operations:window.__wrapTest.operations})"
|
||||
"({today:window.__wrapTest.today,tomorrow:window.__wrapTest.getTomorrow(),operations:window.__wrapTest.operations})"
|
||||
)
|
||||
assert result == {
|
||||
"today": ["issue:acme/mobile:42:"],
|
||||
"later": {"issue:acme/mobile:41:": "2026-08-17T09:00:00.000Z"},
|
||||
"tomorrow": {
|
||||
"revision": 4,
|
||||
"ids": ["issue:acme/mobile:42:", "issue:acme/mobile:41:"],
|
||||
"capacity_minutes": 120,
|
||||
"estimates": {"issue:acme/mobile:42:": 45},
|
||||
"plan_date": "2026-08-18",
|
||||
"sync_pending": True,
|
||||
},
|
||||
"operations": [["remove", "issue:acme/mobile:41:"]],
|
||||
}
|
||||
assert browser_errors == []
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Tomorrow conflict journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_resolves_cross_device_tomorrow_conflicts_on_mobile(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
put_bodies: list[dict] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
|
||||
def tomorrow_route(route):
|
||||
if route.request.method == "PUT":
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
put_bodies.append(body)
|
||||
if body.get("base_revision") != 12:
|
||||
route.fulfill(status=409, content_type="application/json", body='{"detail":"changed elsewhere"}')
|
||||
return
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"revision": 13, **body}),
|
||||
)
|
||||
return
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps(
|
||||
{
|
||||
"revision": 12,
|
||||
"ids": ["issue:acme/mobile:server:"],
|
||||
"capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:server:": 30},
|
||||
"plan_date": "2026-08-21",
|
||||
"timezone": "UTC",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
page.route("**/api/v1/tomorrow", tomorrow_route)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Tomorrow conflict release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
def stage_phone_plan():
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
localStorage.setItem('stackchain.tomorrow-sync.v1.timmy', JSON.stringify({
|
||||
base_revision:11, revision:11,
|
||||
ids:['issue:acme/mobile:phone:'], capacity_minutes:90,
|
||||
estimates:{'issue:acme/mobile:phone:':45},
|
||||
plan_date:'2026-08-21', timezone:'UTC', sync_pending:true,
|
||||
}));
|
||||
window.dispatchEvent(new Event('online'));
|
||||
}"""
|
||||
)
|
||||
expect(page.locator("#mobile-tomorrow-summary")).to_have_text(
|
||||
"Conflict · review required"
|
||||
)
|
||||
|
||||
def open_conflict():
|
||||
queue_sheet = page.locator("#mobile-queue-sheet")
|
||||
if not queue_sheet.evaluate("element => element.open"):
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="tomorrow"]').click()
|
||||
review = page.locator("#tomorrow-conflict-review")
|
||||
expect(review).to_be_visible()
|
||||
expect(page.locator("#plan-today-title")).to_have_text(
|
||||
"Resolve Tomorrow conflict"
|
||||
)
|
||||
expect(page.locator("#tomorrow-conflict-phone-plan")).to_contain_text(
|
||||
"issue:acme/mobile:phone:"
|
||||
)
|
||||
expect(page.locator("#tomorrow-conflict-phone-plan")).to_contain_text(
|
||||
"45 min"
|
||||
)
|
||||
expect(page.locator("#tomorrow-conflict-server-plan")).to_contain_text(
|
||||
"issue:acme/mobile:server:"
|
||||
)
|
||||
expect(page.locator("#tomorrow-conflict-server-plan")).to_contain_text(
|
||||
"30 min"
|
||||
)
|
||||
expect(page.locator("#keep-phone-tomorrow")).to_be_focused()
|
||||
for selector in ("#keep-phone-tomorrow", "#use-server-tomorrow"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
stage_phone_plan()
|
||||
open_conflict()
|
||||
initial_puts = len(put_bodies)
|
||||
page.locator("#use-server-tomorrow").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
expect(page.locator("#mobile-tomorrow-summary")).to_contain_text("1 planned")
|
||||
assert len(put_bodies) == initial_puts
|
||||
|
||||
stage_phone_plan()
|
||||
open_conflict()
|
||||
page.locator("#keep-phone-tomorrow").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
expect(page.locator("#mobile-tomorrow-summary")).to_contain_text("1 planned · 45 of 90 min")
|
||||
assert put_bodies[-1]["base_revision"] == 12
|
||||
assert put_bodies[-1]["ids"] == ["issue:acme/mobile:phone:"]
|
||||
assert page.evaluate(
|
||||
"localStorage.getItem('stackchain.tomorrow-sync.v1.timmy')"
|
||||
) is None
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -0,0 +1,490 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Week Ahead journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
saved: list[dict] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC")
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
pulled: list[dict] = []
|
||||
|
||||
page.route("**/api/v1/today", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 3, "ids": ["issue:acme/mobile:99:"],
|
||||
"capacity_minutes": 60, "estimates": {"issue:acme/mobile:99:": 30},
|
||||
}),
|
||||
))
|
||||
|
||||
def pull_item_route(route):
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
pulled.append(body)
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"today": {"revision": 4, "ids": ["issue:acme/mobile:99:", body["identity"]],
|
||||
"capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:99:": 30, body["identity"]: 45}},
|
||||
"week": {"revision": 99, "timezone": "UTC", "days": [{
|
||||
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
|
||||
"ids": [], "capacity_minutes": 60, "estimates": {},
|
||||
}]},
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week/pull-item", pull_item_route)
|
||||
|
||||
def week_route(route):
|
||||
if route.request.method == "PUT":
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
saved.append(body)
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": body["base_revision"] + 1,
|
||||
"timezone": body["timezone"], "days": body["days"],
|
||||
}))
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 0, "timezone": None, "days": [{
|
||||
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
|
||||
"ids": ["issue:acme/mobile:41:", "issue:acme/mobile:42:"], "capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:41:": 45, "issue:acme/mobile:42:": 45},
|
||||
}],
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week", week_route)
|
||||
page.route(
|
||||
"**/api/v1/repos/acme/mobile/issues/41/close",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"number":41,"state":"closed","updated_at":"2026-08-21T12:00:00Z"}',
|
||||
),
|
||||
)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Week Ahead release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
assert not page_errors, f"Week Ahead launch raised: {page_errors}"
|
||||
expect(page.locator("#plan-today-title")).to_have_text("Week Ahead")
|
||||
overview = page.locator("#week-review")
|
||||
expect(overview).to_be_visible()
|
||||
cards = page.locator("#week-review-days .week-review-day")
|
||||
expect(cards).to_have_count(7)
|
||||
expect(cards.first).to_contain_text("Next up")
|
||||
expect(cards.first).to_contain_text("Ship mobile capture")
|
||||
expect(page.locator("#week-review-status")).to_have_text(
|
||||
"Week Ahead overview · no changes made."
|
||||
)
|
||||
expect(page.locator("#confirm-week-plan")).to_be_hidden()
|
||||
edit_week = page.locator("#edit-week-plan")
|
||||
expect(edit_week).to_be_visible()
|
||||
remove_actions = cards.first.locator("[data-week-unplan]")
|
||||
expect(remove_actions).to_have_count(2)
|
||||
remove_bounds = remove_actions.nth(1).bounding_box()
|
||||
assert remove_bounds and remove_bounds["height"] >= 44
|
||||
remove_actions.nth(1).click()
|
||||
expect(cards.first).not_to_contain_text("Polish desktop filters")
|
||||
expect(page.locator("#week-unplan-receipt")).to_have_text(
|
||||
"Polish desktop filters removed from Week Ahead."
|
||||
)
|
||||
undo = page.locator("#undo-week-unplan")
|
||||
expect(undo).to_be_visible()
|
||||
undo_bounds = undo.bounding_box()
|
||||
assert undo_bounds and undo_bounds["height"] >= 44
|
||||
undo.click()
|
||||
expect(cards.first).to_contain_text("Polish desktop filters")
|
||||
expect(page.locator("#week-unplan-receipt")).to_be_hidden()
|
||||
assert saved[-2]["days"][0]["ids"] == ["issue:acme/mobile:41:"]
|
||||
assert saved[-1]["days"][0]["ids"] == [
|
||||
"issue:acme/mobile:41:", "issue:acme/mobile:42:"
|
||||
]
|
||||
writes_before_reflow = len(saved)
|
||||
reflow = page.locator("#open-week-reflow")
|
||||
expect(reflow).to_be_visible()
|
||||
bounds = reflow.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
reflow.click()
|
||||
preview = page.locator("#week-reflow-review")
|
||||
expect(preview).to_be_visible()
|
||||
expect(page.locator("#week-reflow-days")).to_contain_text("1 item · 45 of 60 min")
|
||||
expect(page.locator("#week-reflow-unscheduled")).to_have_text(
|
||||
"1 item cannot fit: Polish desktop filters. It will remain in My Work."
|
||||
)
|
||||
for control in (page.locator("#cancel-week-reflow"), page.locator("#apply-week-reflow")):
|
||||
control_bounds = control.bounding_box()
|
||||
assert control_bounds and control_bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#cancel-week-reflow").click()
|
||||
expect(preview).to_be_hidden()
|
||||
assert len(saved) == writes_before_reflow, "cancelling reflow must not write"
|
||||
reflow.click()
|
||||
page.locator("#apply-week-reflow").click()
|
||||
expect(page.locator("#week-review-status")).to_have_text("Week Ahead reflowed and saved.")
|
||||
expect(preview).to_be_hidden()
|
||||
assert len(saved) == writes_before_reflow + 1
|
||||
assert [day["ids"] for day in saved[-1]["days"]][:2] == [["issue:acme/mobile:41:"], []]
|
||||
planned_item = cards.first.locator("[data-week-open-item]")
|
||||
expect(planned_item).to_have_count(1)
|
||||
planned_bounds = planned_item.bounding_box()
|
||||
assert planned_bounds and planned_bounds["height"] >= 44
|
||||
planned_item.click()
|
||||
expect(page.locator("#issue-sheet")).to_have_class(re.compile(r"\bopen\b"))
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
expect(overview).to_be_visible()
|
||||
page.locator("#close-issue-sheet").click()
|
||||
expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b"))
|
||||
expect(overview).to_be_visible()
|
||||
expect(planned_item).to_be_focused()
|
||||
assert len(saved) == writes_before_reflow + 1, "opening and inspecting Week Ahead must not add a write"
|
||||
for control in (cards.first.locator("[data-week-edit-day]"), edit_week):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
edit_week.click()
|
||||
expect(overview).to_be_hidden()
|
||||
expect(page.locator("#plan-today-title")).to_have_text("Plan Week Ahead")
|
||||
dates = page.locator("#week-plan-dates button")
|
||||
expect(dates).to_have_count(7)
|
||||
expect(dates.first).to_have_attribute("aria-current", "date")
|
||||
for index in range(7):
|
||||
bounds = dates.nth(index).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
dates.nth(2).click()
|
||||
expect(dates.nth(2)).to_have_attribute("aria-current", "date")
|
||||
expect(page.locator("#week-plan-progress")).to_have_text("Day 3 of 7 · 1 planned")
|
||||
expect(page.locator("#save-today-plan")).to_have_text("Save & next")
|
||||
page.locator("#save-today-plan").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(dates.nth(3)).to_have_attribute("aria-current", "date")
|
||||
expect(page.locator("#week-plan-progress")).to_have_text("Day 4 of 7 · 1 planned")
|
||||
dates.nth(6).click()
|
||||
expect(page.locator("#save-today-plan")).to_have_text("Save week")
|
||||
page.locator("#save-today-plan").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
expect(page.locator("#week-review-days .week-review-day")).to_have_count(7)
|
||||
first_day = page.locator("#week-review-days .week-review-day").first
|
||||
expect(first_day).to_contain_text("Ship mobile capture")
|
||||
expect(first_day).to_contain_text("acme/mobile #41 · Issue · 45 min")
|
||||
expect(first_day.locator("code")).to_have_count(0)
|
||||
edit = first_day.locator("[data-week-edit-day]")
|
||||
bounds = edit.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
edit.click()
|
||||
expect(page.locator("#week-review")).to_be_hidden()
|
||||
expect(page.locator("#back-to-week-review")).to_be_visible()
|
||||
expect(page.locator("#save-today-plan")).to_have_text("Save & review")
|
||||
page.locator("#back-to-week-review").click()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
expect(first_day).to_contain_text("Ship mobile capture")
|
||||
|
||||
page.locator("#open-week-capacity-import").click()
|
||||
capacity_import = page.locator("#week-capacity-import")
|
||||
expect(capacity_import).to_be_visible()
|
||||
private_title = "Private customer planning"
|
||||
tomorrow = (date.today() + timedelta(days=1)).strftime("%Y%m%d")
|
||||
excluded = (date.today() + timedelta(days=2)).strftime("%Y%m%d")
|
||||
added = (date.today() + timedelta(days=4)).strftime("%Y%m%d")
|
||||
calendar = (
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
|
||||
f"SUMMARY:{private_title}\r\nDTSTART;TZID=America/New_York:{tomorrow}T050000\r\n"
|
||||
f"DTEND;TZID=America/New_York:{tomorrow}T060000\r\nRRULE:FREQ=DAILY;COUNT=3\r\n"
|
||||
f"EXDATE;TZID=America/New_York:{excluded}T050000\r\n"
|
||||
f"RDATE;TZID=America/New_York:{added}T050000\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
||||
)
|
||||
unsupported_title = "Private monthly board review"
|
||||
unsupported_calendar = (
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
|
||||
f"SUMMARY:{unsupported_title}\r\nDTSTART:{tomorrow}T100000\r\n"
|
||||
f"DTEND:{tomorrow}T110000\r\nRRULE:FREQ=MONTHLY;BYDAY=1FR\r\n"
|
||||
"END:VEVENT\r\nEND:VCALENDAR"
|
||||
)
|
||||
page.locator("#week-capacity-file").set_input_files({
|
||||
"name": "unsupported.ics", "mimeType": "text/calendar", "buffer": unsupported_calendar.encode()
|
||||
})
|
||||
expect(page.locator("#week-capacity-status")).to_contain_text(
|
||||
"1 recurring event could not be counted"
|
||||
)
|
||||
expect(page.locator("#apply-week-capacities")).to_be_disabled()
|
||||
expect(capacity_import).not_to_contain_text(unsupported_title)
|
||||
page.locator("#week-capacity-file").set_input_files({
|
||||
"name": "availability.ics", "mimeType": "text/calendar", "buffer": calendar.encode()
|
||||
})
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day")).to_have_count(7)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").first).to_contain_text(
|
||||
"420 min available"
|
||||
)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").nth(1)).to_contain_text(
|
||||
"480 min available"
|
||||
)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").nth(2)).to_contain_text(
|
||||
"420 min available"
|
||||
)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").nth(3)).to_contain_text(
|
||||
"420 min available"
|
||||
)
|
||||
expect(capacity_import).not_to_contain_text(private_title)
|
||||
for control in (
|
||||
page.locator("#cancel-week-capacity-import"),
|
||||
page.locator("#week-capacity-file"),
|
||||
page.locator("#week-capacity-start"),
|
||||
page.locator("#week-capacity-end"),
|
||||
page.locator("#apply-week-capacities"),
|
||||
):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
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")
|
||||
|
||||
page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled")
|
||||
confirm = page.locator("#confirm-week-plan")
|
||||
bounds = confirm.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
confirm.click()
|
||||
expect(page.locator("#week-calendar-handoff")).to_be_visible()
|
||||
expect(page.locator("#week-review")).to_be_hidden()
|
||||
start = page.locator("[data-week-calendar-start]").first
|
||||
expect(start).to_have_value("09:00")
|
||||
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:00–10:45 · 45 min")
|
||||
expect(page.locator("#week-calendar-status")).to_contain_text("Planning around imported busy time")
|
||||
for control in (start, page.locator("#back-to-week-review-from-calendar"), page.locator("#share-week-calendar")):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator("#back-to-week-review-from-calendar").click()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
confirm.click()
|
||||
with page.expect_download() as download_info:
|
||||
page.locator("#share-week-calendar").click()
|
||||
assert download_info.value.suggested_filename.startswith("stackchain-week-ahead-")
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
|
||||
assert saved and len(saved[-1]["days"]) == 7
|
||||
assert [day["plan_date"] for day in saved[-1]["days"]] == sorted(
|
||||
day["plan_date"] for day in saved[-1]["days"]
|
||||
)
|
||||
|
||||
before_retirement = len(saved)
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
expect(overview).to_be_visible()
|
||||
planned_item = cards.first.locator(
|
||||
'[data-week-open-item="issue:acme/mobile:41:"]'
|
||||
)
|
||||
planned_item.click()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
page.once("dialog", lambda dialog: dialog.accept())
|
||||
page.locator("#close-issue").click()
|
||||
expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b"))
|
||||
expect(cards.first).not_to_contain_text("Ship mobile capture")
|
||||
expect(cards.first).to_contain_text("Polish desktop filters")
|
||||
expect(cards.first).to_contain_text("45 of 60 min")
|
||||
expect(page.locator("#week-review-status")).to_have_text(
|
||||
"Ship mobile capture removed and Week Ahead saved."
|
||||
)
|
||||
expect(edit_week).to_be_focused()
|
||||
assert len(saved) == before_retirement + 1
|
||||
assert saved[-1]["days"][0]["ids"] == ["issue:acme/mobile:42:"]
|
||||
assert saved[-1]["days"][0]["capacity_minutes"] == 60
|
||||
pull = cards.first.locator('[data-week-pull-item="issue:acme/mobile:42:"]')
|
||||
expect(pull).to_have_text("Review Today · 75 of 60 min")
|
||||
pull_bounds = pull.bounding_box()
|
||||
assert pull_bounds and pull_bounds["height"] >= 44
|
||||
page.once("dialog", lambda dialog: dialog.dismiss())
|
||||
pull.click()
|
||||
expect(cards.first).to_contain_text("Polish desktop filters")
|
||||
assert pulled == [], "cancelling the overload review must not submit"
|
||||
expect(pull).to_have_text("Review Today · 75 of 60 min")
|
||||
page.once("dialog", lambda dialog: dialog.accept())
|
||||
pull.click()
|
||||
expect(cards.first).not_to_contain_text("Polish desktop filters")
|
||||
expect(page.locator("#week-review-status")).to_have_text(
|
||||
"Polish desktop filters added to Today."
|
||||
)
|
||||
assert pulled and pulled[-1]["identity"] == "issue:acme/mobile:42:"
|
||||
assert pulled[-1]["today_revision"] == 3
|
||||
assert pulled[-1]["allow_over_capacity"] is True
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reconciles_only_the_week_day_changed_on_both_devices(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
puts: list[dict] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
|
||||
def week_route(route):
|
||||
if route.request.method == "PUT":
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
puts.append(body)
|
||||
if len(puts) == 1:
|
||||
route.fulfill(status=409, content_type="application/json", body='{"message":"changed"}')
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 6, "timezone": body["timezone"], "days": body["days"],
|
||||
}))
|
||||
return
|
||||
if puts:
|
||||
changed_date = puts[0]["days"][0]["plan_date"]
|
||||
following_date = (date.fromisoformat(changed_date) + timedelta(days=1)).isoformat()
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 5, "timezone": puts[0]["timezone"], "days": [
|
||||
{"plan_date": changed_date, "ids": [], "capacity_minutes": 90, "estimates": {}},
|
||||
{"plan_date": following_date, "ids": [], "capacity_minutes": 30, "estimates": {}},
|
||||
],
|
||||
}))
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 4, "timezone": None, "days": [],
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week", week_route)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Week conflict release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
page.locator("#edit-week-plan").click()
|
||||
page.locator("#plan-today-available").fill("60")
|
||||
page.locator("#save-today-plan").click()
|
||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent.includes('Conflict')")
|
||||
|
||||
choices = page.locator("[data-week-conflict-choice]")
|
||||
expect(choices).to_have_count(2)
|
||||
for index in range(2):
|
||||
bounds = choices.nth(index).locator("xpath=..").bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator('[data-week-conflict-choice="phone"]').check()
|
||||
expect(page.locator("#save-merged-week")).to_be_enabled()
|
||||
page.locator("#save-merged-week").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
|
||||
assert len(puts) == 2
|
||||
assert [day["capacity_minutes"] for day in puts[-1]["days"]] == [60, 30]
|
||||
assert not page_errors, f"Week conflict recovery raised: {page_errors}"
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reads_the_confirmed_week_offline_then_retries_live(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
online = True
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC")
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
|
||||
def week_route(route):
|
||||
if not online:
|
||||
route.fulfill(status=503, content_type="application/json", body='{"message":"offline"}')
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 7, "timezone": "UTC", "days": [{
|
||||
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
|
||||
"ids": ["issue:acme/mobile:41:"], "capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:41:": 30},
|
||||
}],
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week", week_route)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Offline Week Ahead phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture")
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
online = False
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
notice = page.locator("#week-offline-snapshot")
|
||||
expect(notice).to_be_visible()
|
||||
expect(notice).to_contain_text("Offline snapshot")
|
||||
expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture")
|
||||
expect(page.locator("[data-week-edit-day]")).to_have_count(0)
|
||||
expect(page.locator("#edit-week-plan")).to_be_hidden()
|
||||
expect(page.locator("#open-week-capacity-import")).to_be_hidden()
|
||||
retry = page.locator("#retry-week-live")
|
||||
expect(retry).to_be_visible()
|
||||
bounds = retry.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
online = True
|
||||
retry.click()
|
||||
expect(notice).to_be_hidden()
|
||||
expect(page.locator("#edit-week-plan")).to_be_visible()
|
||||
expect(page.locator("[data-week-edit-day]")).to_have_count(7)
|
||||
assert not page_errors, f"offline Week Ahead recovery raised: {page_errors}"
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
@@ -48,7 +48,7 @@ def test_release_promotion_waits_for_tests_and_bundle():
|
||||
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
|
||||
|
||||
|
||||
def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
def test_release_promotion_waits_for_packaged_mobile_journeys():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
@@ -61,12 +61,23 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
assert (
|
||||
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
|
||||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||
"tests/e2e/test_mobile_search_week_plan.py "
|
||||
"tests/e2e/test_mobile_find_work_release.py "
|
||||
"tests/e2e/test_mobile_home_bootstrap_release.py "
|
||||
"tests/e2e/test_mobile_sign_out_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||
"tests/e2e/test_mobile_today_summary_release.py "
|
||||
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
|
||||
"tests/e2e/test_mobile_week_ahead_release.py "
|
||||
"tests/e2e/test_mobile_today_week_reschedule_release.py "
|
||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
||||
) in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
|
||||
|
||||
def test_browser_job_runs_packaged_today_week_reschedule_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
|
||||
assert "tests/e2e/test_mobile_today_week_reschedule_release.py" in browser
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert "stackchain-dashboard-shell-v130" in worker
|
||||
@@ -364,6 +364,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
@@ -391,6 +392,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
@@ -523,6 +525,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -547,6 +550,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -644,6 +648,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
@@ -50,7 +50,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
|
||||
assert set(first.feature_bundles) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
|
||||
"today-timer", "security-center",
|
||||
"today-timer", "security-center", "planning",
|
||||
}
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
@@ -71,6 +71,9 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createQueueToday" not in first.runtime_bytes
|
||||
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createPlanToday" in first.feature_bundles["planning"].runtime_bytes
|
||||
assert b"function createTomorrowPlan" in first.feature_bundles["planning"].runtime_bytes
|
||||
assert b"function createPlanToday" not in first.runtime_bytes
|
||||
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
|
||||
|
||||
@@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert "stackchain-dashboard-shell-v130" in worker
|
||||
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert "stackchain-dashboard-shell-v130" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "controller.recoverPermission('deadline')" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert "stackchain-dashboard-shell-v130" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -28,31 +28,39 @@ class Element {{
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_first_task_activation_is_account_bound_and_completes_only_when_today_is_active():
|
||||
def test_first_task_activation_is_account_bound_and_completes_only_after_a_today_outcome():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key => values.get(key) || null, setItem:(key,value) => values.set(key,value)};
|
||||
const sheet = new Element();
|
||||
const coach = new Element(); coach.hidden = true;
|
||||
const receipt = new Element(); receipt.hidden = true;
|
||||
let login = 'Timmy';
|
||||
let hasWork = false;
|
||||
let todayActive = false;
|
||||
let hideReceipt;
|
||||
const controller = createFirstTask({
|
||||
storage, getLogin:() => login, hasWork:() => hasWork, isTodayActive:() => todayActive,
|
||||
isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(),
|
||||
isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(), coach, receipt,
|
||||
findButton:new Element(), createButton:new Element(), setupButton:new Element(),
|
||||
closeButton:new Element(), status:new Element(), onFind() {}, onCreate() {}, onSetup() {},
|
||||
setTimer:callback => { hideReceipt = callback; return {unref() {}}; },
|
||||
});
|
||||
const before = [controller.required(), controller.open(), sheet.open];
|
||||
hasWork = true;
|
||||
const awaitingStart = controller.refresh();
|
||||
const stillRequired = controller.required();
|
||||
todayActive = true;
|
||||
const completed = controller.refresh();
|
||||
const timmyRequired = controller.required();
|
||||
const coaching = controller.refresh();
|
||||
const afterStart = {required:controller.required(), sheetOpen:sheet.open, coachHidden:coach.hidden, stored:values.get('stackchain.first-task.v1:timmy')};
|
||||
const completed = controller.completeOutcome();
|
||||
const afterOutcome = {required:controller.required(), coachHidden:coach.hidden, receiptHidden:receipt.hidden, stored:values.get('stackchain.first-task.v1:timmy')};
|
||||
hideReceipt();
|
||||
const receiptHiddenAfterTimeout = receipt.hidden;
|
||||
login = 'alexander'; hasWork = false; todayActive = false;
|
||||
const alexanderRequired = controller.required();
|
||||
process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, completed, sheetOpen:sheet.open, timmyRequired, alexanderRequired, values:[...values]}));
|
||||
process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, coaching, afterStart, completed, afterOutcome, receiptHiddenAfterTimeout, alexanderRequired}));
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -60,11 +68,22 @@ process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, compl
|
||||
"before": [True, True, True],
|
||||
"awaitingStart": "awaiting-start",
|
||||
"stillRequired": True,
|
||||
"completed": "completed",
|
||||
"sheetOpen": False,
|
||||
"timmyRequired": False,
|
||||
"coaching": "coaching",
|
||||
"afterStart": {
|
||||
"required": False,
|
||||
"sheetOpen": False,
|
||||
"coachHidden": False,
|
||||
"stored": "coaching",
|
||||
},
|
||||
"completed": True,
|
||||
"afterOutcome": {
|
||||
"required": False,
|
||||
"coachHidden": True,
|
||||
"receiptHidden": False,
|
||||
"stored": "complete",
|
||||
},
|
||||
"receiptHiddenAfterTimeout": True,
|
||||
"alexanderRequired": True,
|
||||
"values": [["stackchain.first-task.v1:timmy", "complete"]],
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +118,34 @@ process.stdout.write(JSON.stringify({
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_adopts_remote_completion_without_crossing_accounts():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map(); let login='timmy'; let todayActive=false;
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
const controller=createFirstTask({
|
||||
storage,getLogin:()=>login,hasWork:()=>false,isTodayActive:()=>todayActive,isOnline:()=>true,
|
||||
mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach:new Element(),receipt:new Element(),
|
||||
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),
|
||||
status:new Element(),
|
||||
});
|
||||
const adopted=controller.adoptRemote('complete');
|
||||
const timmy={required:controller.required(),stored:values.get('stackchain.first-task.v1:timmy')};
|
||||
login='alexander'; const alexanderRequired=controller.required();
|
||||
todayActive=true; controller.refresh(); controller.completeOutcome();
|
||||
process.stdout.write(JSON.stringify({adopted,timmy,alexanderRequired,
|
||||
alexander:values.get('stackchain.first-task.v1:alexander')}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"adopted": True,
|
||||
"timmy": {"required": False, "stored": "complete"},
|
||||
"alexanderRequired": True,
|
||||
"alexander": "complete",
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline():
|
||||
result = run_node(
|
||||
"""
|
||||
@@ -143,6 +190,34 @@ process.stdout.write(JSON.stringify({offline, calls, sheetOpen:sheet.open}));
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_exposes_completion_operation_before_showing_local_success():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map([['stackchain.first-task.v1:timmy', 'coaching']]);
|
||||
let dispatched = 0;
|
||||
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:new Element(),receipt:new Element(),
|
||||
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),status:new Element(),
|
||||
eventTarget:{dispatchEvent:()=>{dispatched += 1}}, setTimer:()=>({unref(){}}),
|
||||
});
|
||||
const before = globalThis.stackchainFirstTaskCompleting();
|
||||
const globalProbe = globalThis.stackchainFirstTaskCompleting();
|
||||
const completed = controller.completeOutcome();
|
||||
process.stdout.write(JSON.stringify({before,globalProbe,completed,stored:values.get('stackchain.first-task.v1:timmy'),dispatched}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"before": True,
|
||||
"globalProbe": True,
|
||||
"completed": True,
|
||||
"stored": "complete",
|
||||
"dispatched": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
||||
html = await dashboard()
|
||||
@@ -155,9 +230,14 @@ async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
||||
assert '<script src="static/mobile-first-task.js"></script>' in html
|
||||
assert "const mobileFirstTask = createMobileFirstTask({" in html
|
||||
assert "isTodayActive: () => workSession.checkpointed()" in html
|
||||
|
||||
assert "shouldActivate: () => mobileFirstTask.required()" in html
|
||||
assert "openActivation: () => mobileFirstTask.open()" in html
|
||||
assert "mobileFirstTask.refresh()" in html
|
||||
assert 'data-mobile-first-task-coach hidden' in html
|
||||
assert 'Complete your first task' in html
|
||||
assert 'id="mobile-first-task-receipt" role="status" aria-live="polite" hidden' in html
|
||||
assert "advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete'))" in html
|
||||
assert '.mobile-first-task { box-sizing:border-box; width:100%;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-first-task-actions button { min-height:48px;' in html
|
||||
@@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert "stackchain-dashboard-shell-v130" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v123" in service_worker
|
||||
assert "stackchain-dashboard-shell-v130" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -129,6 +129,15 @@ process.stdout.write(JSON.stringify(values));
|
||||
assert result == {"--mobile-today-clearance": "168px"}
|
||||
|
||||
|
||||
def test_visible_mobile_today_hud_reserves_measured_scroll_clearance_without_timer_class():
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert (
|
||||
'body:has(.mobile-today-hud:not([hidden]):not([data-overlay-hidden="true"])) '
|
||||
"{ padding-bottom:calc(var(--mobile-today-clearance, 166px)"
|
||||
) in css
|
||||
|
||||
|
||||
def test_mobile_command_bar_keeps_primary_actions_visible_and_secondary_actions_modal():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
@@ -3829,7 +3829,7 @@ async def test_checkpointed_detail_defer_completes_today_and_advertises_next_ite
|
||||
|
||||
assert "continueSession: item => completeTodayItem(item, {" in html
|
||||
assert "successMessage: 'Deferred to Later. Next Today item opened.'" in html
|
||||
assert "advance: () => runTodayTransition('complete')" in html
|
||||
assert "advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete'))" in html
|
||||
assert "button.textContent = active ? 'Later today & next' : 'Later today'" in html
|
||||
assert "button.textContent = active ? 'Tomorrow & next' : 'Tomorrow'" in html
|
||||
assert "button.textContent = active ? 'Choose date & time & next' : 'Choose date & time'" in html
|
||||
@@ -7788,7 +7788,7 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
|
||||
assert "window.confirm(confirmation)" in html
|
||||
assert "issueController.close(selectedIssue)" in html
|
||||
assert "lastMyWork.filter" in html
|
||||
assert "if (issueTrigger?.isConnected) issueTrigger.focus()" in html
|
||||
assert "setTimeout(()=>issueTrigger?.focus())" in html
|
||||
assert "e.key === 'Escape' && selectedIssue" in html
|
||||
|
||||
|
||||
|
||||
@@ -549,5 +549,5 @@ async def test_offline_today_issue_queues_closure_before_completing_and_advancin
|
||||
admission = handler.index("await closeOfflineIssue(closing)")
|
||||
sheet_close = handler.index("closeIssueSheet()")
|
||||
assert admission < sheet_close
|
||||
assert "Issue closure queued for reconnect." in handler
|
||||
assert "Issue close queued." in handler
|
||||
assert "The issue remains in Today; retry." in handler
|
||||
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -33,6 +33,7 @@ process.stdout.write(JSON.stringify(databases));
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ const clear = createPrivateDeviceDataPurger({{
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
|
||||
@@ -21,6 +21,12 @@ const deadlineControl = {
|
||||
const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callback) => state.deadlineHourChange = callback};
|
||||
const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback};
|
||||
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
|
||||
const startDayControl = {
|
||||
checked:false, disabled:false,
|
||||
addEventListener:(_name, callback) => state.startDayChange = callback,
|
||||
};
|
||||
const startDayHour = {value:'9', disabled:false};
|
||||
const startDayStatus = {set textContent(value) { state.startDayText = value; }, get textContent() { return state.startDayText; }};
|
||||
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||
const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
|
||||
const deadlineSnooze = {hidden:true};
|
||||
@@ -33,6 +39,7 @@ const registration = {pushManager:{
|
||||
}};
|
||||
const feature = createPushNotifications({
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
|
||||
onReviewDeadlines:() => { state.reviewed = true; },
|
||||
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }},
|
||||
@@ -111,6 +118,26 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 2 days."
|
||||
|
||||
|
||||
def test_start_day_opt_in_reuses_subscription_and_persists_local_hour():
|
||||
result = run_scenario("""
|
||||
state.current = existing;
|
||||
state.server = {available:true,subscribed:true,start_day_enabled:false,start_day_reminder_hour:9,public_key:'AQID'};
|
||||
await feature.init();
|
||||
startDayHour.value = '8';
|
||||
startDayControl.checked = true;
|
||||
await state.startDayChange();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["prompts"] == 0
|
||||
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/start-day", "PUT"]
|
||||
body = json.loads(result["requests"][-1][2])
|
||||
assert body["enabled"] is True
|
||||
assert body["reminder_hour"] == 8
|
||||
assert isinstance(body["timezone"], str) and body["timezone"]
|
||||
assert result["startDayText"] == "Start-day reminder enabled for 08:00 local time."
|
||||
|
||||
|
||||
def test_deadline_setup_subscribes_once_and_persists_selected_local_hour():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'};
|
||||
|
||||
@@ -398,6 +398,7 @@ async def test_managed_session_statuses_apply_configured_idle_deadline_in_one_ca
|
||||
@pytest.mark.anyio
|
||||
async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch):
|
||||
captured = {}
|
||||
hold_other_channels = asyncio.Event()
|
||||
|
||||
async def no_wait(_seconds):
|
||||
return None
|
||||
@@ -407,10 +408,15 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
|
||||
captured.update(kwargs)
|
||||
raise asyncio.CancelledError
|
||||
|
||||
async def hold_dispatch(*_args, **_kwargs):
|
||||
await hold_other_channels.wait()
|
||||
|
||||
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
|
||||
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "4")
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await main._push_poll_loop()
|
||||
@@ -431,8 +437,8 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
|
||||
async def no_wait(_seconds):
|
||||
nonlocal sleeps
|
||||
sleeps += 1
|
||||
if sleeps <= 2:
|
||||
if sleeps == 2:
|
||||
if sleeps <= 3:
|
||||
if sleeps == 3:
|
||||
first_tick.set()
|
||||
await first_tick.wait()
|
||||
else:
|
||||
@@ -445,14 +451,18 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
|
||||
async def dispatch_deadlines(*_args, **_kwargs):
|
||||
calls.append("deadline")
|
||||
|
||||
async def dispatch_start_day(*_args, **_kwargs):
|
||||
calls.append("start-day")
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert sorted(calls) == ["deadline", "unread"]
|
||||
assert sorted(calls) == ["deadline", "start-day", "unread"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -472,9 +482,13 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon
|
||||
deadline_started.set()
|
||||
await release_unread.wait()
|
||||
|
||||
async def dispatch_start_day(*_args, **_kwargs):
|
||||
await release_unread.wait()
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
||||
|
||||
poll = asyncio.create_task(main._push_poll_loop())
|
||||
await asyncio.wait_for(unread_started.wait(), timeout=0.5)
|
||||
@@ -499,7 +513,7 @@ async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
|
||||
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert sorted(intervals) == [30.0, 600.0]
|
||||
assert sorted(intervals) == [30.0, 600.0, 600.0]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -1261,6 +1275,9 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
"reminder_hour": 9,
|
||||
"reminder_days": 2,
|
||||
"snoozed_until": None,
|
||||
"start_day_enabled": False,
|
||||
"start_day_timezone": "UTC",
|
||||
"start_day_reminder_hour": 9,
|
||||
"delivery_health": {},
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
|
||||
@@ -20,6 +20,7 @@ def _fixture(root: Path) -> None:
|
||||
(root / "requirements.txt").write_text("fastapi==1.0\n")
|
||||
(root / "README.md").write_text("# Stackchain\n")
|
||||
(root / "scripts" / "rotate_unfiled_drafts.py").write_text("print('rotate')\n")
|
||||
(root / "scripts" / "rotate_private_state.py").write_text("print('rotate private')\n")
|
||||
|
||||
|
||||
def _commit_fixture(source: Path) -> str:
|
||||
@@ -87,6 +88,7 @@ def test_release_bundle_is_reproducible_from_declared_commit(tmp_path):
|
||||
"README.md",
|
||||
"frontend/index.html",
|
||||
"requirements.txt",
|
||||
"scripts/rotate_private_state.py",
|
||||
"scripts/rotate_unfiled_drafts.py",
|
||||
"src/main.py",
|
||||
]
|
||||
@@ -98,6 +100,7 @@ def test_release_bundle_is_reproducible_from_declared_commit(tmp_path):
|
||||
"frontend/index.html",
|
||||
"release-manifest.json",
|
||||
"requirements.txt",
|
||||
"scripts/rotate_private_state.py",
|
||||
"scripts/rotate_unfiled_drafts.py",
|
||||
"src/main.py",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from src.saved_search_store import SavedSearchStore
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "rotate_private_state.py"
|
||||
|
||||
|
||||
def encoded(value: bytes) -> str:
|
||||
return base64.b64encode(value * 32).decode()
|
||||
|
||||
|
||||
def run_rotation(state: Path, *, tamper: bool = False):
|
||||
env = {
|
||||
**os.environ,
|
||||
"STACKCHAIN_STATE_DIR": str(state),
|
||||
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS": json.dumps(
|
||||
{"old": encoded(b"o"), "next": encoded(b"n")}
|
||||
),
|
||||
"STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID": "next",
|
||||
}
|
||||
if tamper:
|
||||
with sqlite3.connect(state / "saved-searches.sqlite3") as connection:
|
||||
connection.execute(
|
||||
"UPDATE saved_searches SET views = 'v1:tampered' WHERE login = 'timmy'"
|
||||
)
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT)], env=env, text=True, capture_output=True
|
||||
)
|
||||
|
||||
|
||||
def test_rotation_command_rewraps_shared_store_rows_without_changing_metadata(tmp_path):
|
||||
state = tmp_path / "state"
|
||||
path = state / "saved-searches.sqlite3"
|
||||
store = SavedSearchStore(path, encryption_key=b"o" * 32)
|
||||
expected = store.replace(
|
||||
"timmy",
|
||||
0,
|
||||
[{"id": "mine", "name": "My private work", "query": "assignee:timmy"}],
|
||||
)
|
||||
|
||||
completed = run_rotation(state)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
report = json.loads(completed.stdout)
|
||||
assert report["saved-searches"] == {
|
||||
"current": 0,
|
||||
"failed": 0,
|
||||
"migrated": 1,
|
||||
"total": 1,
|
||||
}
|
||||
with sqlite3.connect(path) as connection:
|
||||
revision, payload = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = 'timmy'"
|
||||
).fetchone()
|
||||
assert revision == expected["revision"]
|
||||
assert payload.startswith("v2:next:")
|
||||
assert SavedSearchStore(
|
||||
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||
).get("timmy") == expected
|
||||
|
||||
|
||||
def test_rotation_command_fails_closed_and_never_prints_private_content(tmp_path):
|
||||
state = tmp_path / "state"
|
||||
secret = "private launch roadmap"
|
||||
store = SavedSearchStore(state / "saved-searches.sqlite3", encryption_key=b"o" * 32)
|
||||
store.replace("timmy", 0, [{"id": "secret", "name": secret, "query": "is:open"}])
|
||||
|
||||
completed = run_rotation(state, tamper=True)
|
||||
|
||||
assert completed.returncode == 1
|
||||
report = json.loads(completed.stdout)
|
||||
assert report["saved-searches"]["failed"] == 1
|
||||
assert secret not in completed.stdout
|
||||
assert "timmy" not in completed.stdout
|
||||
assert completed.stderr == ""
|
||||
@@ -83,6 +83,84 @@ process.stdout.write(JSON.stringify({{one,mixed:flow.releaseRepository()}}));
|
||||
assert run_node(script) == {"one": "stackchain/dashboard", "mixed": None}
|
||||
|
||||
|
||||
def test_week_batch_validates_every_row_before_claiming_and_flushes_one_staged_week():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const createWeekBatchPlan=createSearchBatchPlan.createWeekBatchPlan;
|
||||
const calls=[];
|
||||
const days=[
|
||||
{{date:'2026-08-21',label:'Fri, Aug 21'}},
|
||||
{{date:'2026-08-22',label:'Sat, Aug 22'}},
|
||||
];
|
||||
const state={{
|
||||
'2026-08-21':{{ids:['stackchain/dashboard#1'],capacity_minutes:90,estimates:{{'stackchain/dashboard#1':30}}}},
|
||||
'2026-08-22':{{ids:[],capacity_minutes:60,estimates:{{}}}},
|
||||
}};
|
||||
const week={{
|
||||
load:async()=>{{calls.push('load');}},dates:()=>days,
|
||||
day:date=>state[date],placement:id=>null,
|
||||
place:(id,date,estimate,options)=>{{calls.push(['place',id,date,estimate,options.move]);state[date].ids.push(id);state[date].estimates[id]=estimate;return true;}},
|
||||
flush:async()=>{{calls.push('flush');}},
|
||||
}};
|
||||
const items=[
|
||||
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:2,title:'Two'}},
|
||||
{{kind:'issue',state:'open',repository:'stackchain/api',number:3,title:'Three'}},
|
||||
];
|
||||
const flow=createWeekBatchPlan({{
|
||||
week,identity:item=>item.repository+'#'+item.number,
|
||||
prepare:async item=>{{calls.push(['claim',item.number]);return {{...item,assigned_to_me:true}};}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const invalid=await flow.run(items,{{
|
||||
'stackchain/dashboard#2':{{date:'2026-08-21',estimate:25}},
|
||||
'stackchain/api#3':{{date:'2026-08-22',estimate:0}},
|
||||
}});
|
||||
const before=calls.slice();
|
||||
const planned=await flow.run(items,{{
|
||||
'stackchain/dashboard#2':{{date:'2026-08-21',estimate:25}},
|
||||
'stackchain/api#3':{{date:'2026-08-22',estimate:20}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{invalid,before,planned,calls}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["invalid"]["status"] == "invalid"
|
||||
assert result["invalid"]["errors"] == [{"id": "stackchain/api#3", "reason": "estimate-required"}]
|
||||
assert result["before"] == ["load"]
|
||||
assert result["planned"]["status"] == "planned"
|
||||
assert result["planned"]["planned"] == 2
|
||||
assert result["planned"]["failed"] == []
|
||||
assert result["calls"].count("flush") == 1
|
||||
assert [call for call in result["calls"] if isinstance(call, list) and call[0] == "claim"] == [
|
||||
["claim", 2], ["claim", 3],
|
||||
]
|
||||
|
||||
|
||||
def test_week_batch_stages_details_only_for_successfully_placed_canonical_items():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const flow=createSearchBatchPlan.createWeekBatchPlan({{
|
||||
week:{{load:async()=>{{}},dates:()=>[{{date:'2026-08-21'}}],day:()=>({{ids:[],estimates:{{}}}}),
|
||||
placement:()=>null,place:id=>!id.endsWith('#2'),
|
||||
rememberPendingItem:(id,item)=>calls.push(['details',id,item.title]),flush:async()=>calls.push(['flush'])}},
|
||||
prepare:async item=>({{...item,title:'Canonical '+item.number}}),
|
||||
identity:item=>item.repository+'#'+item.number,
|
||||
}});
|
||||
const calls=[];const items=[
|
||||
{{repository:'r',number:1,title:'Search one'}},{{repository:'r',number:2,title:'Search two'}}];
|
||||
(async()=>{{const outcome=await flow.run(items,{{'r#1':{{date:'2026-08-21',estimate:20}},'r#2':{{date:'2026-08-21',estimate:30}}}});
|
||||
process.stdout.write(JSON.stringify({{outcome,calls}}));}})();
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["outcome"] == {
|
||||
"status": "partial", "planned": 1,
|
||||
"failed": [{"id": "r#2", "reason": "assigned-not-planned"}],
|
||||
}
|
||||
assert result["calls"] == [["details", "r#1", "Canonical 1"], ["flush"]]
|
||||
|
||||
|
||||
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
@@ -124,6 +202,29 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
||||
assert "BASE + 'static/search-batch-plan.js'" in worker
|
||||
|
||||
|
||||
def test_mobile_search_exposes_batch_week_review_with_per_issue_day_and_estimate_controls():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
controller = SEARCH_BATCH_PLAN.read_text()
|
||||
|
||||
assert 'id="week-selected-search-results"' in html
|
||||
assert 'id="search-week-batch-review" role="dialog" aria-modal="true"' in html
|
||||
assert 'id="search-week-batch-list"' in html
|
||||
assert 'id="search-week-batch-summary" aria-live="polite"' in html
|
||||
assert 'id="cancel-search-week-batch"' in html
|
||||
assert 'id="confirm-search-week-batch"' in html
|
||||
assert "createWeekBatchPlan" in controller
|
||||
assert "data-search-week-batch-date" in controller
|
||||
assert "data-search-week-batch-estimate" in controller
|
||||
assert "overload-confirmation-required" in controller
|
||||
assert "weekPlan, localStorage" in dashboard
|
||||
assert ".search-week-batch-review" in css
|
||||
assert "max-height:calc(100dvh - 48px)" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
|
||||
|
||||
def test_selected_search_issues_choose_one_future_time_before_durable_batch_defer():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
MODULE = FRONTEND / "search-week-plan.js"
|
||||
|
||||
|
||||
def run_planner(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createSearchWeekPlan = require({json.dumps(str(MODULE))});
|
||||
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_search_week_plan_previews_seven_days_and_requires_explicit_overload_confirmation():
|
||||
result = run_planner("""
|
||||
let claims=0,stages=0;
|
||||
const days=Array.from({length:7},(_,index)=>({date:`2026-08-${21+index}`,label:`Day ${index+1}`}));
|
||||
const week={load:async()=>({}),dates:()=>days,day:date=>date==='2026-08-21'
|
||||
? {plan_date:date,ids:['existing'],capacity_minutes:60,estimates:{existing:50}}
|
||||
: {plan_date:date,ids:[],capacity_minutes:90,estimates:{}},
|
||||
placement:()=>null,place:()=>{stages+=1;return true;},flush:async()=>({revision:2})};
|
||||
const planner=createSearchWeekPlan({week,claim:async item=>{claims+=1;return {...item,assigned_to_me:true,claimable:false};},
|
||||
accept:item=>item,identity:item=>`issue:${item.repository}:${item.number}:`});
|
||||
const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
|
||||
const preview=await planner.preview(detail);
|
||||
const invalid=await planner.plan(detail,{date:'2026-08-21',estimate:0});
|
||||
const overload=await planner.plan(detail,{date:'2026-08-21',estimate:30});
|
||||
console.log(JSON.stringify({preview,invalid,overload,claims,stages}));
|
||||
""")
|
||||
|
||||
assert len(result["preview"]["days"]) == 7
|
||||
assert result["preview"]["days"][0]["planned_minutes"] == 50
|
||||
assert result["preview"]["days"][0]["capacity_minutes"] == 60
|
||||
assert result["invalid"] == {"status": "estimate-required"}
|
||||
assert result["overload"]["status"] == "overload-confirmation-required"
|
||||
assert result["overload"]["planned_minutes"] == 80
|
||||
assert result["claims"] == 0
|
||||
assert result["stages"] == 0
|
||||
|
||||
|
||||
def test_search_week_plan_assigns_once_then_durably_places_canonical_issue():
|
||||
result = run_planner("""
|
||||
let release,claims=0,places=0,flushes=0;
|
||||
const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat, Aug 22'}],
|
||||
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),placement:()=>null,
|
||||
place:(id,date,estimate)=>{places+=1;return {id,date,estimate};},flush:async()=>{flushes+=1;return {revision:3};}};
|
||||
const planner=createSearchWeekPlan({week,
|
||||
claim:async item=>{claims+=1;await new Promise(resolve=>{release=resolve});return {...item,assigned_to_me:true,claimable:false,title:'Canonical'};},
|
||||
accept:item=>({...item,accepted:true}),identity:item=>`issue:${item.repository}:${item.number}:`});
|
||||
const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
|
||||
const first=planner.plan(detail,{date:'2026-08-22',estimate:45});
|
||||
const second=planner.plan(detail,{date:'2026-08-22',estimate:45});
|
||||
await Promise.resolve();release();
|
||||
const outcomes=await Promise.all([first,second]);
|
||||
console.log(JSON.stringify({outcomes,claims,places,flushes}));
|
||||
""")
|
||||
|
||||
assert result["claims"] == 1
|
||||
assert result["places"] == 1
|
||||
assert result["flushes"] == 1
|
||||
assert result["outcomes"][0] == result["outcomes"][1]
|
||||
assert result["outcomes"][0]["status"] == "planned"
|
||||
assert result["outcomes"][0]["item"]["accepted"] is True
|
||||
|
||||
|
||||
def test_search_week_plan_reports_existing_placement_and_partial_assignment_truthfully():
|
||||
result = run_planner("""
|
||||
const base={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
|
||||
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),flush:async()=>({})};
|
||||
const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
|
||||
const existing=createSearchWeekPlan({week:{...base,placement:()=>({date:'2026-08-23',estimate:30}),place:()=>true},
|
||||
claim:async item=>item,accept:item=>item,identity:()=> 'work'});
|
||||
const blocked=await existing.plan(detail,{date:'2026-08-22',estimate:45});
|
||||
const partial=createSearchWeekPlan({week:{...base,placement:()=>null,place:()=>false},
|
||||
claim:async item=>({...item,assigned_to_me:true,claimable:false}),accept:item=>item,identity:()=> 'work'});
|
||||
const failed=await partial.plan(detail,{date:'2026-08-22',estimate:45});
|
||||
console.log(JSON.stringify({blocked,failed}));
|
||||
""")
|
||||
|
||||
assert result["blocked"] == {"status": "already-planned", "date": "2026-08-23", "estimate": 30}
|
||||
assert result["failed"]["status"] == "assigned-not-planned"
|
||||
|
||||
|
||||
def test_search_week_plan_moves_without_duplicates_and_keeps_pending_sync_on_network_failure():
|
||||
result = run_planner("""
|
||||
const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
|
||||
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),
|
||||
placement:()=>({date:'2026-08-21',estimate:30}),place:()=>true,
|
||||
flush:async()=>{throw new Error('offline');}};
|
||||
const planner=createSearchWeekPlan({week,claim:async item=>item,accept:item=>item,identity:()=> 'work'});
|
||||
const outcome=await planner.plan({kind:'issue',state:'open',assigned_to_me:true},
|
||||
{date:'2026-08-22',estimate:45,move:true});
|
||||
console.log(JSON.stringify(outcome));
|
||||
""")
|
||||
|
||||
assert result == {"status": "planned-pending", "date": "2026-08-22", "estimate": 45}
|
||||
|
||||
|
||||
def test_search_week_plan_stages_canonical_details_before_flushing():
|
||||
result = run_planner("""
|
||||
const calls=[];
|
||||
const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
|
||||
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),placement:()=>null,
|
||||
place:(id)=>{calls.push(['place',id]);return true;},
|
||||
rememberPendingItem:(id,item)=>{calls.push(['details',id,item.title,item.repository,item.number]);return true;},
|
||||
flush:async()=>{calls.push(['flush']);return {revision:3};}};
|
||||
const planner=createSearchWeekPlan({week,claim:async item=>({...item,title:'Canonical title'}),
|
||||
accept:item=>({...item,accepted:true}),identity:item=>`issue:${item.repository}:${item.number}:`});
|
||||
const outcome=await planner.plan({kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true},
|
||||
{date:'2026-08-22',estimate:45});
|
||||
console.log(JSON.stringify({outcome,calls}));
|
||||
""")
|
||||
|
||||
assert result["outcome"]["status"] == "planned"
|
||||
assert result["calls"] == [
|
||||
["place", "issue:stackchain/dashboard:42:"],
|
||||
["details", "issue:stackchain/dashboard:42:", "Canonical title", "stackchain/dashboard", 42],
|
||||
["flush"],
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
def test_search_preview_exposes_accessible_plan_ahead_sheet_and_packaged_controller():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
build = build_frontend(FRONTEND)
|
||||
|
||||
assert 'id="plan-search-result"' in html
|
||||
assert 'id="search-week-plan-sheet" role="dialog" aria-modal="true"' in html
|
||||
assert 'aria-labelledby="search-week-plan-title"' in html
|
||||
assert 'id="search-week-plan-days"' in html
|
||||
assert 'id="search-week-plan-estimate"' in html
|
||||
assert 'id="confirm-search-week-plan"' in html
|
||||
assert 'id="cancel-search-week-plan"' in html
|
||||
assert "static/search-week-plan.js" in build.page_sources
|
||||
assert b"createSearchWeekPlan" in build.feature_bundles["planning"].runtime_bytes
|
||||
|
||||
|
||||
def test_search_plan_ahead_ui_connects_eligibility_preview_and_truthful_outcomes():
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
source = (FRONTEND / "search-week-plan.js").read_text()
|
||||
|
||||
assert "const searchWeekPlan = createSearchWeekPlan" in dashboard
|
||||
assert "createSearchWeekPlanUI" in dashboard
|
||||
assert "planButton.hidden = !searchWeekPlan.eligible(detail)" in dashboard
|
||||
assert "await planner.preview(detail)" in source
|
||||
assert "overload-confirmation-required" in source
|
||||
assert "assigned-not-planned" in source
|
||||
assert "planned-pending" in source
|
||||
assert "Already planned for" in source
|
||||
assert "planner.pending()" in source
|
||||
assert "searchWeekPlan:true" in source
|
||||
assert "if(!qs('#search-week-plan-sheet').hidden)" in source
|
||||
assert "window.history.back()" in source
|
||||
|
||||
|
||||
def test_search_plan_ahead_sheet_is_phone_safe_and_touch_sized():
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert ".search-week-plan-sheet" in css
|
||||
assert "padding-bottom:calc(18px + env(safe-area-inset-bottom))" in css
|
||||
assert ".search-week-plan-days button" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
assert "@media (max-width:359px)" in css
|
||||
+152
-22
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], 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: [], 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',
|
||||
@@ -66,12 +66,24 @@ const context = {{
|
||||
get: async id => state.sharedRecords[id] || null,
|
||||
delete: async id => {{ delete state.sharedRecords[id]; }},
|
||||
}},
|
||||
__STACKCHAIN_TODAY_ACTION_STORE: {{
|
||||
put: async (clientId, command) => {{
|
||||
if (state.failTodayCommandPut) throw new Error('quota');
|
||||
state.todayCommands[clientId] = command;
|
||||
}},
|
||||
claim: async (clientId, now) => {{
|
||||
const command = state.todayCommands[clientId] || null;
|
||||
delete state.todayCommands[clientId];
|
||||
return command && command.expiresAt > now ? command : null;
|
||||
}},
|
||||
purge: async () => {{ state.todayCommands = {{}}; }},
|
||||
}},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{
|
||||
claim: async () => {{ state.claimed = true; }},
|
||||
matchAll: async () => state.clientList || [],
|
||||
openWindow: async url => {{ state.opened.push(url); }},
|
||||
openWindow: async url => {{ state.opened.push(url); return state.openedClient || {{id:'opened-client',url}}; }},
|
||||
}},
|
||||
registration: {{
|
||||
showNotification: async (title, options) => state.notifications.push({{title,options}}),
|
||||
@@ -133,9 +145,9 @@ async function dispatchSync(tag) {{
|
||||
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchMessage(data, ports = []) {{
|
||||
async function dispatchMessage(data, ports = [], source = null) {{
|
||||
let pending;
|
||||
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
|
||||
listeners.message({{ data, ports, source, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null, url = null, actionToken = '') {{
|
||||
@@ -165,16 +177,31 @@ async function dispatchPush(payload) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_offline_activation_migration_rolls_the_shell_cache():
|
||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
|
||||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
|
||||
|
||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -183,7 +210,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
@@ -192,7 +219,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
@@ -200,14 +227,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -215,7 +242,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -223,7 +250,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
@@ -233,14 +260,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -249,21 +276,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -451,6 +478,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert result["state"]["clientMessages"] == [
|
||||
@@ -610,17 +638,81 @@ def test_today_lock_screen_action_updates_an_open_dashboard_without_navigation()
|
||||
assert result["opened"] == []
|
||||
|
||||
|
||||
def test_today_lock_screen_action_opens_a_validated_one_shot_route_when_closed():
|
||||
def test_today_lock_screen_action_opens_clean_route_and_privately_hands_off_once_when_closed():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session');
|
||||
state.openedClient={id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today'};
|
||||
await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session',null,'opaque-token-1234567890');
|
||||
const messages=[];
|
||||
const source={id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today',postMessage:message=>messages.push(message)};
|
||||
await dispatchMessage({type:'stackchain-claim-today-action'}, [], source);
|
||||
await dispatchMessage({type:'stackchain-claim-today-action'}, [], source);
|
||||
state.claimMessages=messages;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["opened"] == [
|
||||
"https://forge.example/dashboard/?today_timer_action=resume#/my-work/today"
|
||||
"https://forge.example/dashboard/#/my-work/today"
|
||||
]
|
||||
assert "today_timer_action" not in result["opened"][0]
|
||||
assert "today_action_token" not in result["opened"][0]
|
||||
assert result["claimMessages"] == [{
|
||||
"type": "stackchain-today-timer-action",
|
||||
"action": "resume",
|
||||
"actionToken": "opaque-token-1234567890",
|
||||
}]
|
||||
assert result["todayCommands"] == {}
|
||||
|
||||
|
||||
def test_today_lock_screen_private_handoff_rejects_wrong_client_and_expires_closed():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.openedClient={id:'intended-client',url:'https://forge.example/dashboard/#/my-work/today'};
|
||||
await dispatchNotificationClick('#/my-work/today','finish-today',null,'stackchain-today-session',null,'opaque-token-1234567890');
|
||||
const wrong=[];
|
||||
await dispatchMessage({type:'stackchain-claim-today-action'}, [], {id:'other-client',url:'https://forge.example/dashboard/#/my-work/today',postMessage:message=>wrong.push(message)});
|
||||
state.todayCommands['intended-client'].expiresAt=0;
|
||||
const expired=[];
|
||||
await dispatchMessage({type:'stackchain-claim-today-action'}, [], {id:'intended-client',url:'https://forge.example/dashboard/#/my-work/today',postMessage:message=>expired.push(message)});
|
||||
process.stdout.write(JSON.stringify({wrong,expired,commands:state.todayCommands}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"wrong": [], "expired": [], "commands": {}}
|
||||
|
||||
|
||||
def test_today_action_mailbox_persistence_failure_opens_clean_route_without_mutation():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.failTodayCommandPut=true;
|
||||
state.openedClient={id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today'};
|
||||
const outcome=await dispatchNotificationClick('#/my-work/today','finish-today',null,'stackchain-today-session',null,'opaque-token-1234567890')
|
||||
.then(()=> 'safe', error=>error.message);
|
||||
process.stdout.write(JSON.stringify({outcome,opened:state.opened,commands:state.todayCommands}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"outcome": "safe",
|
||||
"opened": ["https://forge.example/dashboard/#/my-work/today"],
|
||||
"commands": {},
|
||||
}
|
||||
|
||||
|
||||
def test_private_data_purge_removes_pending_today_action_handoffs():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.openedClient={id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today'};
|
||||
await dispatchNotificationClick('#/my-work/today','finish-today',null,'stackchain-today-session',null,'opaque-token-1234567890');
|
||||
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:()=>{}}]);
|
||||
const messages=[];
|
||||
await dispatchMessage({type:'stackchain-claim-today-action'}, [], {id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today',postMessage:message=>messages.push(message)});
|
||||
process.stdout.write(JSON.stringify({messages,commands:state.todayCommands}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"messages": [], "commands": {}}
|
||||
|
||||
|
||||
def test_finish_today_lock_screen_action_forwards_only_its_opaque_token():
|
||||
@@ -707,6 +799,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
]
|
||||
assert result["replies"] == [{"ok": True}]
|
||||
|
||||
@@ -824,6 +917,36 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
|
||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_start_day_push_is_private_and_prepare_action_opens_cached_launch_route():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchPush({
|
||||
title:'must-not-render', body:'private/repo#42',
|
||||
tag:'stackchain-start-day-2026-08-20', route:'#/my-work/start-day',
|
||||
plan_date:'2026-08-20',
|
||||
});
|
||||
await dispatchNotificationClick(
|
||||
'#/my-work/start-day','prepare-today',null,'stackchain-start-day-2026-08-20'
|
||||
);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [{
|
||||
"title": "Your planned day is ready",
|
||||
"options": {
|
||||
"body": "Open Stackchain to prepare Today.",
|
||||
"tag": "stackchain-start-day-2026-08-20",
|
||||
"actions": [{"action": "prepare-today", "title": "Prepare Today"}],
|
||||
"data": {"route": "#/my-work/start-day"},
|
||||
},
|
||||
}]
|
||||
assert result["opened"] == [
|
||||
"https://forge.example/dashboard/#/my-work/start-day"
|
||||
]
|
||||
assert "private/repo" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_copy():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -1137,7 +1260,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -1212,6 +1335,12 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/plan-today.js",
|
||||
"/dashboard/static/plan-today-readiness.js",
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/tomorrow-plan.js",
|
||||
"/dashboard/static/week-calendar.js",
|
||||
"/dashboard/static/week-calendar-import.js",
|
||||
"/dashboard/static/week-plan.js",
|
||||
"/dashboard/static/today-week-reschedule.js",
|
||||
"/dashboard/static/search-week-plan.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/today-rollover.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
@@ -1434,6 +1563,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src import main
|
||||
from src.push_notifications import PushConfiguration, dispatch_start_day_reminders
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_start_day_reminder_sends_one_private_prompt_for_due_plan(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_start_day_preferences(
|
||||
"device-a", enabled=True, timezone="America/New_York", reminder_hour=9
|
||||
)
|
||||
sent = []
|
||||
|
||||
async def tomorrow():
|
||||
return {
|
||||
"revision": 3,
|
||||
"ids": ["private/repo#42"],
|
||||
"plan_date": "2026-08-20",
|
||||
"timezone": "America/New_York",
|
||||
}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
now = datetime(2026, 8, 20, 13, 5, tzinfo=timezone.utc)
|
||||
|
||||
assert await dispatch_start_day_reminders(
|
||||
store, configuration, tomorrow, send, now=now
|
||||
) == 1
|
||||
assert await dispatch_start_day_reminders(
|
||||
store, configuration, tomorrow, send, now=now
|
||||
) == 0
|
||||
assert sent == [{
|
||||
"title": "Your planned day is ready",
|
||||
"body": "Open Stackchain to prepare Today.",
|
||||
"route": "#/my-work/start-day",
|
||||
"tag": "stackchain-start-day-2026-08-20",
|
||||
"plan_date": "2026-08-20",
|
||||
}]
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_start_day_reminders_bound_parallel_device_delivery(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for index in range(6):
|
||||
session_id = f"device-{index}"
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_start_day_preferences(
|
||||
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
|
||||
active = 0
|
||||
peak = 0
|
||||
first_wave = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def tomorrow():
|
||||
return {"ids": ["one"], "plan_date": "2026-08-20"}
|
||||
|
||||
async def send(_subscription, _payload):
|
||||
nonlocal active, peak
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
if active == 2:
|
||||
first_wave.set()
|
||||
await release.wait()
|
||||
active -= 1
|
||||
|
||||
dispatch = asyncio.create_task(dispatch_start_day_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
tomorrow,
|
||||
send,
|
||||
now=datetime(2026, 8, 20, 9, 0, tzinfo=timezone.utc),
|
||||
max_concurrency=2,
|
||||
))
|
||||
await asyncio.wait_for(first_wave.wait(), timeout=0.5)
|
||||
assert peak == 2
|
||||
release.set()
|
||||
assert await dispatch == 6
|
||||
assert peak == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_start_day_reminder_stops_queued_send_after_lease_loss(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("device-a", "device-b"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_start_day_preferences(
|
||||
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
|
||||
lease_checks = 0
|
||||
|
||||
def acquire(_owner, *, channel, now, lease_seconds):
|
||||
nonlocal lease_checks
|
||||
assert channel == "start-day"
|
||||
assert now > 0
|
||||
assert lease_seconds >= 15
|
||||
lease_checks += 1
|
||||
return lease_checks <= 2
|
||||
|
||||
monkeypatch.setattr(store, "acquire_dispatch_lease", acquire)
|
||||
sent = []
|
||||
|
||||
async def tomorrow():
|
||||
return {"ids": ["one"], "plan_date": "2026-08-20"}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
sent.append(subscription["endpoint"])
|
||||
|
||||
delivered = await dispatch_start_day_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
tomorrow,
|
||||
send,
|
||||
now=datetime(2026, 8, 20, 9, 0, tzinfo=timezone.utc),
|
||||
max_concurrency=1,
|
||||
)
|
||||
|
||||
assert delivered == 1
|
||||
assert lease_checks == 3
|
||||
assert len(sent) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_start_day_reminder_contains_one_device_persistence_failure(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("device-a", "device-b"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_start_day_preferences(
|
||||
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
|
||||
mark_succeeded = store.mark_delivery_succeeded
|
||||
|
||||
def fail_one_device(session_id, channel):
|
||||
if session_id == "device-a":
|
||||
raise RuntimeError("device checkpoint unavailable")
|
||||
return mark_succeeded(session_id, channel)
|
||||
|
||||
monkeypatch.setattr(store, "mark_delivery_succeeded", fail_one_device)
|
||||
sent = []
|
||||
|
||||
async def tomorrow():
|
||||
return {"ids": ["one"], "plan_date": "2026-08-20"}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
sent.append(subscription["endpoint"])
|
||||
|
||||
delivered = await dispatch_start_day_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
tomorrow,
|
||||
send,
|
||||
now=datetime(2026, 8, 20, 9, 0, tzinfo=timezone.utc),
|
||||
max_concurrency=2,
|
||||
)
|
||||
|
||||
assert delivered == 1
|
||||
assert len(sent) == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_poll_applies_configured_concurrency_to_start_day(
|
||||
monkeypatch
|
||||
):
|
||||
captured = {}
|
||||
hold = asyncio.Event()
|
||||
|
||||
async def no_wait(_seconds):
|
||||
return None
|
||||
|
||||
async def hold_dispatch(*_args, **_kwargs):
|
||||
await hold.wait()
|
||||
|
||||
async def capture_start_day(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert captured["max_concurrency"] == 3
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_start_day_reminder_waits_for_hour_and_nonempty_due_plan(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_start_day_preferences(
|
||||
"device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9
|
||||
)
|
||||
sent = []
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(payload)
|
||||
|
||||
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
before_hour = datetime(2026, 8, 20, 15, 59, tzinfo=timezone.utc)
|
||||
|
||||
async def due_plan():
|
||||
return {"ids": ["one"], "plan_date": "2026-08-20"}
|
||||
|
||||
assert await dispatch_start_day_reminders(
|
||||
store, configuration, due_plan, send, now=before_hour
|
||||
) == 0
|
||||
|
||||
async def empty_plan():
|
||||
return {"ids": [], "plan_date": "2026-08-20"}
|
||||
|
||||
assert await dispatch_start_day_reminders(
|
||||
store, configuration, empty_plan, send,
|
||||
now=datetime(2026, 8, 20, 16, 0, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert sent == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_can_enable_start_day_reminders(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
|
||||
async def management_id(_session):
|
||||
return "device-a"
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
payload = main.StartDayReminderPayload(
|
||||
enabled=True, timezone="America/New_York", reminder_hour=8
|
||||
)
|
||||
|
||||
assert await main.update_start_day_reminders(payload, request) == {
|
||||
"start_day_enabled": True,
|
||||
"start_day_timezone": "America/New_York",
|
||||
"start_day_reminder_hour": 8,
|
||||
}
|
||||
assert store.start_day_preferences("device-a") == {
|
||||
"enabled": True, "timezone": "America/New_York", "reminder_hour": 8,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_worker_snapshot_reads_the_confirmed_accounts_next_planned_day(monkeypatch):
|
||||
async def current_user():
|
||||
return {"login": "Timmy"}
|
||||
|
||||
class Store:
|
||||
def get_start_day_plan(self, login):
|
||||
assert login == "Timmy"
|
||||
return {"ids": ["one"], "plan_date": "2026-08-20"}
|
||||
|
||||
def get_tomorrow(self, _login):
|
||||
raise AssertionError("the worker must use Week Ahead discovery")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", current_user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: Store())
|
||||
|
||||
assert await main._start_day_plan_snapshot() == {
|
||||
"ids": ["one"], "plan_date": "2026-08-20",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_settings_and_launch_route_wire_start_day_into_existing_promotion():
|
||||
frontend = Path(__file__).parents[1] / "frontend"
|
||||
html = (frontend / "index.html").read_text()
|
||||
dashboard = (frontend / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="push-start-day"' in html
|
||||
assert 'id="push-start-day-hour"' in html
|
||||
assert 'id="device-setup-start-day"' in html
|
||||
assert "startDayControl:qs('#push-start-day')" in dashboard
|
||||
assert "startDayHour:qs('#push-start-day-hour')" in dashboard
|
||||
assert "window.location.hash !== '#/my-work/start-day'" in dashboard
|
||||
assert "weekFlow.promote(plan).finally(() =>" in dashboard
|
||||
assert "onRemotePlan: async plan =>" not in dashboard
|
||||
assert "openMobileStartDay()" in dashboard
|
||||
@@ -0,0 +1,119 @@
|
||||
import base64
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from src.today_store import TodayStore
|
||||
from src.state_encryption import (
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
decode_private_state_encryption_keyring,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
|
||||
def encoded(byte: bytes) -> str:
|
||||
return base64.b64encode(byte * 32).decode()
|
||||
|
||||
|
||||
def test_keyring_writes_active_versioned_envelope_and_reads_it_current():
|
||||
keys, active = decode_private_state_encryption_keyring(
|
||||
json.dumps({"old": encoded(b"o"), "next": encoded(b"n")}), "next"
|
||||
)
|
||||
|
||||
cipher = PrivateStateCipher(keys, active_key_id=active, store="today")
|
||||
payload = cipher.seal({"ids": [7]}, binding="timmy")
|
||||
|
||||
assert payload.startswith("v2:next:")
|
||||
assert cipher.open(payload, binding="timmy") == ({"ids": [7]}, False)
|
||||
|
||||
|
||||
def test_keyring_reads_legacy_v1_and_marks_it_for_rewrap():
|
||||
legacy = PrivateStateCipher(b"o" * 32, store="today")
|
||||
payload = legacy.seal({"ids": [7]}, binding="timmy")
|
||||
rotating = PrivateStateCipher(
|
||||
{"old": b"o" * 32, "next": b"n" * 32},
|
||||
active_key_id="next",
|
||||
store="today",
|
||||
)
|
||||
|
||||
assert rotating.open(payload, binding="timmy") == ({"ids": [7]}, True)
|
||||
|
||||
|
||||
def test_keyring_reads_inactive_v2_and_marks_it_for_rewrap():
|
||||
old = PrivateStateCipher(
|
||||
{"old": b"o" * 32, "next": b"n" * 32},
|
||||
active_key_id="old",
|
||||
store="today",
|
||||
)
|
||||
payload = old.seal({"ids": [7]}, binding="timmy")
|
||||
rotating = PrivateStateCipher(
|
||||
{"old": b"o" * 32, "next": b"n" * 32},
|
||||
active_key_id="next",
|
||||
store="today",
|
||||
)
|
||||
|
||||
assert rotating.open(payload, binding="timmy") == ({"ids": [7]}, True)
|
||||
|
||||
|
||||
def test_keyring_fails_closed_for_unknown_key_id():
|
||||
cipher = PrivateStateCipher(
|
||||
{"next": b"n" * 32}, active_key_id="next", store="today"
|
||||
)
|
||||
|
||||
with pytest.raises(PrivateStateEncryptionError, match="could not be decrypted"):
|
||||
cipher.open("v2:retired:AAAA", binding="timmy")
|
||||
|
||||
|
||||
def test_keyring_configuration_is_bounded_and_requires_active_key():
|
||||
with pytest.raises(PrivateStateEncryptionError, match="active key"):
|
||||
decode_private_state_encryption_keyring(
|
||||
json.dumps({"old": encoded(b"o")}), "missing"
|
||||
)
|
||||
with pytest.raises(PrivateStateEncryptionError, match="at most 4"):
|
||||
decode_private_state_encryption_keyring(
|
||||
json.dumps({str(i): encoded(bytes([65 + i])) for i in range(5)}), "0"
|
||||
)
|
||||
|
||||
|
||||
def test_environment_prefers_keyring_and_keeps_single_key_rollout(monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", encoded(b"l"))
|
||||
monkeypatch.delenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID", raising=False)
|
||||
assert private_state_encryption_config() == b"l" * 32
|
||||
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS",
|
||||
json.dumps({"legacy": encoded(b"l"), "next": encoded(b"n")}),
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID", "next")
|
||||
keys, active = private_state_encryption_config()
|
||||
assert keys == {"legacy": b"l" * 32, "next": b"n" * 32}
|
||||
assert active == "next"
|
||||
|
||||
|
||||
def test_today_store_reads_and_lazily_rewraps_v2_state_during_rotation(tmp_path, monkeypatch):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
legacy = TodayStore(path, encryption_key=b"l" * 32)
|
||||
legacy.apply("timmy", "seed", "add", "issue:stackchain/dashboard#1237")
|
||||
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS",
|
||||
json.dumps({"legacy": encoded(b"l"), "next": encoded(b"n")}),
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID", "next")
|
||||
rotating = TodayStore(path)
|
||||
|
||||
assert rotating.get("timmy")["ids"] == ["issue:stackchain/dashboard#1237"]
|
||||
assert rotating.apply(
|
||||
"timmy", "add-next", "add", "issue:stackchain/dashboard#1238"
|
||||
)["ids"] == [
|
||||
"issue:stackchain/dashboard#1237",
|
||||
"issue:stackchain/dashboard#1238",
|
||||
]
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT ids FROM today_plans WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v2:next:")
|
||||
@@ -185,6 +185,88 @@ process.stdout.write(JSON.stringify({{opened,cancelled,started,expired,final:{{c
|
||||
}
|
||||
|
||||
|
||||
def test_identity_ready_restore_renders_break_again_after_sync_recovery_settles():
|
||||
script = f"""
|
||||
const createBreak = require({json.dumps(str(BREAK))});
|
||||
global.createTodayBreak = createBreak;
|
||||
global.setInterval = () => 1;
|
||||
global.clearInterval = () => {{}};
|
||||
Date.now = () => 100000;
|
||||
const createTimer = require({json.dumps(str(TIMER))});
|
||||
const createSync = require({json.dumps(str(ROOT / "frontend" / "today-sync.js"))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
get length(){{return values.size;}},
|
||||
key:index=>Array.from(values.keys())[index]??null,
|
||||
getItem:key=>values.get(key)||null,
|
||||
setItem:(key,value)=>values.set(key,value),
|
||||
removeItem:key=>values.delete(key),
|
||||
}};
|
||||
let login = '';
|
||||
values.set('stackchain.today-timer.v1.timmy', JSON.stringify({{
|
||||
version:1, active_identity:'issue:r:42:',
|
||||
entries:{{'issue:r:42:':{{elapsed_ms:12000,started_at:null,running:false}}}},
|
||||
away_at:null,pending_interruption:null,attention_interruption:null,capture_interruption:null,
|
||||
search_interruption:null,detour_interruption:null,
|
||||
timed_break:{{identity:'issue:r:42:',deadline_at:400000}},
|
||||
}}));
|
||||
const status = {{hidden:false,textContent:''}}, resume = {{hidden:false,addEventListener(){{}}}};
|
||||
const opener = {{hidden:false,addEventListener(){{}}}};
|
||||
const elements = {{'#today-break-status':[status],'#resume-today-break':[resume],'[data-today-break-open]':[opener]}};
|
||||
const timer = createTimer({{storage,getLogin:()=>login,now:()=>100000}});
|
||||
const view = createTimer.createView({{
|
||||
timer,isActive:()=>true,queryAll:selector=>elements[selector]||[],
|
||||
formatEstimate:value=>String(value),getItem:()=>({{title:'Ship mobile capture'}}),
|
||||
}});
|
||||
login = 'timmy';
|
||||
values.set('stackchain.today-sync.v1.timmy.operation.broken', '{{not-json');
|
||||
const statuses = [];
|
||||
const sync = createSync({{
|
||||
storage,getLogin:()=>login,now:()=>100000,
|
||||
fetchJson:async()=>({{revision:1,ids:['issue:r:42:'],capacity_minutes:null,estimates:{{}}}}),
|
||||
onStatus:(state,detail)=>statuses.push([state,detail.discarded||0]),
|
||||
}});
|
||||
const recovery = sync.flush();
|
||||
const restoring = view.restore(recovery);
|
||||
const immediate = {{hidden:status.hidden,text:status.textContent,openerHidden:opener.hidden}};
|
||||
status.hidden = true; status.textContent = ''; opener.hidden = false;
|
||||
restoring.then(result => {{
|
||||
const settled = {{hidden:status.hidden,text:status.textContent,openerHidden:opener.hidden}};
|
||||
status.hidden = true; status.textContent = ''; opener.hidden = false;
|
||||
return view.restore(Promise.reject(new Error('coordinator unavailable'))).then(rejected =>
|
||||
process.stdout.write(JSON.stringify({{
|
||||
immediate, settled, rejectedSettled:{{hidden:status.hidden,text:status.textContent,openerHidden:opener.hidden}},
|
||||
result,rejected,statuses,
|
||||
}}))
|
||||
);
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"immediate": {
|
||||
"hidden": False,
|
||||
"text": "On break · resume in 5:00",
|
||||
"openerHidden": True,
|
||||
},
|
||||
"settled": {
|
||||
"hidden": False,
|
||||
"text": "On break · resume in 5:00",
|
||||
"openerHidden": True,
|
||||
},
|
||||
"rejectedSettled": {
|
||||
"hidden": False,
|
||||
"text": "On break · resume in 5:00",
|
||||
"openerHidden": True,
|
||||
},
|
||||
"result": True,
|
||||
"rejected": True,
|
||||
"statuses": [["recovered", 1]],
|
||||
}
|
||||
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
assert "timerView.restore(todaySync.flush());" in dashboard
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -74,21 +74,19 @@ def test_disabling_or_ending_session_removes_notification():
|
||||
]
|
||||
|
||||
|
||||
def test_valid_notification_action_is_consumed_once_and_removed_from_url():
|
||||
def test_cold_launch_requests_one_private_action_claim_without_url_capabilities():
|
||||
result = run_scenario(r"""
|
||||
values.set('stackchain.today-lock-screen.v1.timmy','1');
|
||||
locationRef.href='https://forge.example/dashboard/?today_timer_action=pause#/my-work/today';
|
||||
const consumed = await lockScreen.consumeLaunchAction();
|
||||
const second = await lockScreen.consumeLaunchAction();
|
||||
listeners['sw-message']({data:{type:'stackchain-today-timer-action',action:'resume'}});
|
||||
process.stdout.write(JSON.stringify({consumed,second,actions,href:locationRef.href}));
|
||||
process.stdout.write(JSON.stringify({consumed,actions,href:locationRef.href,messages}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"consumed": True,
|
||||
"second": False,
|
||||
"actions": [["pause", None], ["resume", None]],
|
||||
"actions": [["resume", None]],
|
||||
"href": "https://forge.example/dashboard/#/my-work/today",
|
||||
"messages": [{"type": "stackchain-claim-today-action"}],
|
||||
}
|
||||
|
||||
|
||||
@@ -133,20 +131,18 @@ def test_break_sync_carries_deadline_and_resume_is_bound_to_that_exact_break():
|
||||
assert "private/repo" not in json.dumps(result["messages"])
|
||||
|
||||
|
||||
def test_cold_launch_finish_action_is_removed_from_history_before_completion():
|
||||
def test_private_cold_launch_finish_action_is_bound_to_the_active_identity():
|
||||
result = run_scenario(r"""
|
||||
await lockScreen.enable();
|
||||
await lockScreen.sync({identity:'issue:private/repo:42:',running:true}, true);
|
||||
locationRef.href='https://forge.example/dashboard/?today_timer_action=complete&today_action_token=opaque-token-1234567890#/my-work/today';
|
||||
const consumed = await lockScreen.consumeLaunchAction();
|
||||
process.stdout.write(JSON.stringify({consumed,actions,href:locationRef.href}));
|
||||
await lockScreen.consumeLaunchAction();
|
||||
await listeners['sw-message']({data:{type:'stackchain-today-timer-action',action:'complete',actionToken:'opaque-token-1234567890'}});
|
||||
process.stdout.write(JSON.stringify({actions,href:locationRef.href,messages}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"consumed": True,
|
||||
"actions": [["complete", "issue:private/repo:42:"]],
|
||||
"href": "https://forge.example/dashboard/#/my-work/today",
|
||||
}
|
||||
assert result["actions"] == [["complete", "issue:private/repo:42:"]]
|
||||
assert result["href"] == "https://forge.example/dashboard/#/my-work/today"
|
||||
assert result["messages"][-1] == {"type": "stackchain-claim-today-action"}
|
||||
|
||||
|
||||
def test_permission_denial_is_truthful_and_does_not_persist_opt_in():
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v123';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v130';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -132,6 +132,18 @@ process.stdout.write(JSON.stringify({{date:rollover.localDate(),zone:rollover.ti
|
||||
assert run_node(script) == {"date": "2026-11-01", "zone": "America/New_York"}
|
||||
|
||||
|
||||
def test_invalid_runtime_timezone_falls_back_to_utc_without_breaking_planners():
|
||||
script = f"""
|
||||
const create = require({json.dumps(str(ROLLOVER))});
|
||||
const rollover = create({{
|
||||
now:()=>new Date('2026-08-20T23:30:00Z'),
|
||||
resolvedTimeZone:()=> 'Etc/Unknown'
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{date:rollover.localDate(),zone:rollover.timeZone()}}));
|
||||
"""
|
||||
assert run_node(script) == {"date": "2026-08-20", "zone": "UTC"}
|
||||
|
||||
|
||||
def test_rollover_is_one_durable_sync_operation_with_the_server_revision():
|
||||
script = f"""
|
||||
const createSync = require({json.dumps(str(SYNC))});
|
||||
|
||||
@@ -304,6 +304,36 @@ def test_initialized_today_reads_remain_available_during_a_planning_write(tmp_pa
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_first_task_completion_is_monotonic_durable_and_account_scoped(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
key = b"f" * 32
|
||||
store = TodayStore(path, encryption_key=key)
|
||||
store.apply("timmy", "plan-change", "add", "issue:r:1:")
|
||||
|
||||
completed = store.apply_batch("Timmy", [{
|
||||
"operation_id": "first-task-complete",
|
||||
"action": "activate",
|
||||
"item_id": "first-task",
|
||||
"activation_state": "complete",
|
||||
"base_revision": 0,
|
||||
}])
|
||||
replayed_coaching = store.apply_batch("timmy", [{
|
||||
"operation_id": "late-coaching",
|
||||
"action": "activate",
|
||||
"item_id": "first-task",
|
||||
"activation_state": "coaching",
|
||||
"base_revision": 0,
|
||||
}])
|
||||
|
||||
assert completed["first_task_state"] == "complete"
|
||||
assert replayed_coaching["first_task_state"] == "complete"
|
||||
assert TodayStore(path, encryption_key=key).get("timmy")["first_task_state"] == "complete"
|
||||
assert "first_task_state" not in store.get("alexander")
|
||||
retained = path.read_bytes()
|
||||
assert b'first_task_state' not in retained
|
||||
assert b'complete' not in retained
|
||||
|
||||
|
||||
def test_capacity_and_estimates_are_durable_account_scoped_and_follow_item_identity(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, limit=3)
|
||||
@@ -499,6 +529,24 @@ def test_today_api_model_accepts_bounded_capacity_configuration():
|
||||
}
|
||||
|
||||
|
||||
def test_today_api_model_accepts_only_bounded_first_task_activation():
|
||||
operation = main.TodayOperation(
|
||||
operation_id="activation", action="activate", item_id="first-task",
|
||||
activation_state="complete", base_revision=7,
|
||||
)
|
||||
|
||||
assert operation.model_dump() == {
|
||||
"operation_id": "activation", "action": "activate", "item_id": "first-task",
|
||||
"direction": None, "base_revision": 7, "capacity_minutes": None,
|
||||
"estimates": {}, "activation_state": "complete",
|
||||
}
|
||||
with pytest.raises(ValueError):
|
||||
main.TodayOperation(
|
||||
operation_id="bad", action="activate", item_id="another-flow",
|
||||
activation_state="complete",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_single_capacity_configuration_uses_atomic_batch_path(monkeypatch):
|
||||
async def user():
|
||||
|
||||
+153
-1
@@ -84,6 +84,53 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
}
|
||||
|
||||
|
||||
def test_activation_queued_during_an_empty_inflight_refresh_is_drained_before_it_settles():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
let releaseRefresh;
|
||||
const refreshStarted = new Promise(resolve => {{ globalThis.refreshStarted = resolve; }});
|
||||
const patches = [];
|
||||
const statuses = [];
|
||||
const sync = createTodaySync({{
|
||||
storage, getLogin: () => 'timmy', createOperationId: () => 'activation-1',
|
||||
fetchJson: async (_url, options={{}}) => {{
|
||||
if (!options.method) {{
|
||||
globalThis.refreshStarted();
|
||||
await new Promise(resolve => {{ releaseRefresh = resolve; }});
|
||||
return {{revision:0, ids:[]}};
|
||||
}}
|
||||
const operations = JSON.parse(options.body).operations;
|
||||
patches.push(...operations);
|
||||
return {{revision:1, ids:[], first_task_state:'complete',
|
||||
accepted_operation_ids:operations.map(item => item.operation_id)}};
|
||||
}},
|
||||
onRemoteIds: () => {{}}, onStatus: state => statuses.push(state),
|
||||
}});
|
||||
(async () => {{
|
||||
const refreshing = sync.flush();
|
||||
await refreshStarted;
|
||||
sync.enqueueActivation('complete');
|
||||
const sharedFlush = sync.flush();
|
||||
releaseRefresh();
|
||||
await sharedFlush;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
sharesInflight: refreshing === sharedFlush, patches, pending:sync.pending(), statuses,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert result["sharesInflight"] is True
|
||||
assert [operation["activation_state"] for operation in result["patches"]] == ["complete"]
|
||||
assert result["pending"] == []
|
||||
assert result["statuses"][-1] == "saved"
|
||||
|
||||
|
||||
def test_operations_queued_in_the_same_millisecond_preserve_enqueue_order():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
@@ -188,10 +235,115 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
}
|
||||
|
||||
|
||||
def test_today_batch_admission_leaves_no_partial_operations_when_storage_fails():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map(); let writes = 0; let sequence = 0;
|
||||
const storage = {{
|
||||
get length() {{ return values.size; }}, key: index => [...values.keys()][index] || null,
|
||||
getItem: key => values.get(key) || null,
|
||||
setItem: (key, value) => {{ writes += 1; throw new Error('quota'); }},
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const sync = createTodaySync({{
|
||||
storage, getLogin: () => 'timmy', createOperationId: () => 'batch-' + (++sequence),
|
||||
fetchJson: async () => {{ throw new Error('must not deliver'); }}, onStatus: () => {{}},
|
||||
}});
|
||||
const admitted = sync.enqueueBatch([
|
||||
{{action:'remove', item_id:'issue:r:1:'}},
|
||||
{{action:'activate', item_id:'first-task', activation_state:'complete'}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({{admitted, writes, pending:sync.pending(), keys:[...values.keys()]}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"admitted": False,
|
||||
"writes": 1,
|
||||
"pending": [],
|
||||
"keys": [],
|
||||
}
|
||||
|
||||
|
||||
def test_today_batch_delivers_remove_and_activation_together_then_clears_receipts():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map(); let sequence = 0; const patches = [];
|
||||
const storage = {{
|
||||
get length() {{ return values.size; }}, key: index => [...values.keys()][index] || null,
|
||||
getItem: key => values.get(key) || null, setItem: (key,value) => values.set(key,value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const sync = createTodaySync({{
|
||||
storage, getLogin: () => 'timmy', createOperationId: () => 'batch-' + (++sequence),
|
||||
fetchJson: async (_url, options={{}}) => {{
|
||||
const operations = JSON.parse(options.body).operations; patches.push(operations);
|
||||
return {{revision:3, ids:[], first_task_state:'complete',
|
||||
accepted_operation_ids:operations.map(item => item.operation_id)}};
|
||||
}}, onRemoteIds: () => {{}}, onStatus: () => {{}},
|
||||
}});
|
||||
const admitted = sync.enqueueBatch([
|
||||
{{action:'remove', item_id:'issue:r:1:'}},
|
||||
{{action:'activate', item_id:'first-task', activation_state:'complete'}},
|
||||
]);
|
||||
const before = sync.pending();
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{
|
||||
admitted, before, patches, after:sync.pending(), operationKeys:[...values.keys()].filter(key=>key.includes('.operation.')),
|
||||
}}));}})();
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["admitted"] is True
|
||||
assert [[item["action"] for item in batch] for batch in data["patches"]] == [["remove", "activate"]]
|
||||
assert data["before"] == data["patches"][0]
|
||||
assert data["after"] == []
|
||||
assert data["operationKeys"] == []
|
||||
|
||||
|
||||
def test_first_task_activation_uses_today_outbox_and_adopts_remote_completion():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map(); let sequence=0; const adopted=[]; const delivered=[]; const listeners={{}};
|
||||
const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync = createTodaySync({{
|
||||
storage, getLogin:()=> 'timmy', createOperationId:()=> 'activation-' + (++sequence),
|
||||
fetchJson:async(_url, options={{}})=>{{
|
||||
if (!options.method) return {{revision:4,ids:[],first_task_state:'complete'}};
|
||||
const operations=JSON.parse(options.body).operations; delivered.push(...operations);
|
||||
return {{revision:5,ids:[],first_task_state:'complete',accepted_operation_ids:operations.map(x=>x.operation_id)}};
|
||||
}},
|
||||
onRemotePlan:plan=>adopted.push(plan.first_task_state), onRemoteIds:()=>{{}}, onStatus:()=>{{}},
|
||||
}});
|
||||
sync.startLifecycle({{window:{{addEventListener:(name,callback)=>listeners[name]=callback}},document:{{addEventListener(){{}}}}}});
|
||||
listeners['stackchain:first-task-complete']();
|
||||
listeners['stackchain:first-task-complete']();
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{
|
||||
delivered,adopted,pending:sync.pending(),hydrated:values.get('stackchain.first-task.v1:timmy')
|
||||
}}));}})();
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert result == {
|
||||
"delivered": [{
|
||||
"operation_id": "activation-1", "action": "activate", "item_id": "first-task",
|
||||
"direction": None, "activation_state": "complete", "base_revision": 0,
|
||||
}],
|
||||
"adopted": ["complete"],
|
||||
"pending": [],
|
||||
"hydrated": "complete",
|
||||
}
|
||||
|
||||
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).parents[1] / "frontend" / "today-week-reschedule.js"
|
||||
INDEX = CONTROLLER.parent / "index.html"
|
||||
CSS = CONTROLLER.parent / "dashboard.css"
|
||||
DASHBOARD = CONTROLLER.parent / "dashboard.js"
|
||||
BUNDLE = CONTROLLER.parents[1] / "src" / "frontend_bundle.py"
|
||||
|
||||
|
||||
def run_controller(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createReschedule = require({json.dumps(str(CONTROLLER))});
|
||||
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_reschedule_controller_reviews_load_and_atomically_confirms_selected_day():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
const weekState={revision:7,offline_snapshot:false,days:[]};
|
||||
const review={days:[
|
||||
{plan_date:'2026-08-24',label:'Mon, Aug 24',ids:['one'],planned_minutes:30,capacity_minutes:60,overloaded:false},
|
||||
{plan_date:'2026-08-25',label:'Tue, Aug 25',ids:[],planned_minutes:0,capacity_minutes:90,overloaded:false}
|
||||
]};
|
||||
const week={load:async()=>weekState,review:()=>review,adopt:value=>{weekState.revision=value.revision;weekState.days=value.days;}};
|
||||
const api=async(url,options)=>{
|
||||
if(url==='api/v1/today')return {revision:4,ids:['active','other'],estimates:{active:45}};
|
||||
requests.push({url,body:JSON.parse(options.body)});return {
|
||||
today:{revision:5,ids:['other'],capacity_minutes:120,estimates:{other:20}},
|
||||
week:{revision:8,timezone:'UTC',days:[{plan_date:'2026-08-25',ids:['active'],capacity_minutes:90,estimates:{active:45}}]}
|
||||
};};
|
||||
let adoptedToday=null;
|
||||
const controller=createReschedule({week,api,getToday:()=>({revision:4,ids:['active','other'],estimates:{active:45}}),
|
||||
adoptToday:value=>{adoptedToday=value;},operationId:()=> 'move-active'});
|
||||
const opened=await controller.open('active');
|
||||
const confirmed=await controller.confirm('2026-08-25',45);
|
||||
console.log(JSON.stringify({opened,confirmed,requests,adoptedToday,weekState}));
|
||||
""")
|
||||
|
||||
assert result["opened"]["estimate_minutes"] == 45
|
||||
assert result["opened"]["days"][0]["load"] == "30 / 60 min"
|
||||
assert result["opened"]["days"][1]["eligible"] is True
|
||||
assert result["requests"] == [{
|
||||
"url": "api/v1/week/reschedule",
|
||||
"body": {
|
||||
"operation_id": "move-active",
|
||||
"identity": "active",
|
||||
"estimate_minutes": 45,
|
||||
"plan_date": "2026-08-25",
|
||||
"today_revision": 4,
|
||||
"week_revision": 7,
|
||||
"allow_over_capacity": False,
|
||||
},
|
||||
}]
|
||||
assert result["adoptedToday"]["ids"] == ["other"]
|
||||
assert result["weekState"]["revision"] == 8
|
||||
assert result["confirmed"]["today"]["revision"] == 5
|
||||
|
||||
|
||||
def test_reschedule_controller_blocks_offline_full_and_overloaded_days_without_mutation():
|
||||
result = run_controller("""
|
||||
let calls=0;
|
||||
const days=[
|
||||
{plan_date:'2026-08-24',label:'Mon',ids:['1','2','3','4','5'],planned_minutes:50,capacity_minutes:60},
|
||||
{plan_date:'2026-08-25',label:'Tue',ids:['1'],planned_minutes:50,capacity_minutes:60}
|
||||
];
|
||||
const week={load:async()=>({revision:2,offline_snapshot:false}),review:()=>({days}),adopt:()=>{}};
|
||||
const api=async url=>{if(url==='api/v1/today')return {revision:3,ids:['active'],estimates:{active:30}};calls++;};
|
||||
const controller=createReschedule({week,api,getToday:()=>({revision:3,ids:['active'],estimates:{active:30}})});
|
||||
const opened=await controller.open('active');
|
||||
let fullError='',overError='';
|
||||
try{await controller.confirm('2026-08-24',30);}catch(error){fullError=error.message;}
|
||||
try{await controller.confirm('2026-08-25',30);}catch(error){overError=error.message;}
|
||||
const offline=createReschedule({week:{load:async()=>({revision:2,offline_snapshot:true,days:[]}),review:()=>({days})},api,getToday:()=>({revision:3,ids:['active'],estimates:{active:30}})});
|
||||
let offlineOpened=null;try{offlineOpened=await offline.open('active');}catch(_error){}
|
||||
console.log(JSON.stringify({opened,fullError,overError,offlineOpened,calls}));
|
||||
""")
|
||||
|
||||
assert result["opened"]["days"][0]["eligible"] is False
|
||||
assert result["fullError"] == "That Week Ahead day already has five items."
|
||||
assert result["overError"] == "That move exceeds the day's capacity. Confirm overload before rescheduling."
|
||||
assert result["offlineOpened"]["offline"] is True
|
||||
assert result["calls"] == 0
|
||||
|
||||
|
||||
def test_reschedule_controller_persists_offline_move_before_optimistic_adoption():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const events=[];
|
||||
const storage={
|
||||
getItem:key=>values.get(key)||null,
|
||||
setItem:(key,value)=>{events.push('persist');values.set(key,value);},
|
||||
removeItem:key=>values.delete(key),
|
||||
};
|
||||
const today={revision:4,ids:['active','other'],capacity_minutes:120,estimates:{active:45,other:20}};
|
||||
const weekState={revision:7,timezone:'UTC',offline_snapshot:true,days:[
|
||||
{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}
|
||||
]};
|
||||
const week={load:async()=>weekState,review:()=>({days:[
|
||||
{plan_date:'2026-08-25',label:'Tue',ids:[],planned_minutes:0,capacity_minutes:90,estimates:{}}
|
||||
]}),adopt:value=>events.push(['week',value])};
|
||||
const api=async()=>{const error=new Error('offline');error.status=0;throw error;};
|
||||
const controller=createReschedule({week,api,getToday:()=>today,adoptToday:value=>events.push(['today',value]),
|
||||
storage,getLogin:()=> 'Timmy',operationId:()=> 'move-active',now:()=>123});
|
||||
const opened=await controller.open('active');
|
||||
const confirmed=await controller.confirm('2026-08-25',45);
|
||||
console.log(JSON.stringify({opened,confirmed,events,keys:[...values.keys()],record:JSON.parse([...values.values()][0])}));
|
||||
""")
|
||||
|
||||
assert result["opened"]["offline"] is True
|
||||
assert result["confirmed"]["sync_pending"] is True
|
||||
assert result["events"][0] == "persist"
|
||||
assert result["events"][1][0] == "today"
|
||||
assert result["events"][1][1]["ids"] == ["other"]
|
||||
assert result["events"][2][0] == "week"
|
||||
assert result["events"][2][1]["days"][0]["ids"] == ["active"]
|
||||
assert result["keys"] == ["stackchain.today-week-reschedule.v1.timmy"]
|
||||
assert result["record"]["version"] == 2
|
||||
assert result["record"]["records"][0]["body"]["operation_id"] == "move-active"
|
||||
assert result["record"]["records"][0]["queued_at"] == 123
|
||||
|
||||
|
||||
def test_reschedule_controller_restores_and_delivers_same_operation_after_reload():
|
||||
result = run_controller("""
|
||||
const key='stackchain.today-week-reschedule.v1.timmy';
|
||||
const record={queued_at:123,body:{operation_id:'move-active',identity:'active',estimate_minutes:45,
|
||||
plan_date:'2026-08-25',today_revision:4,week_revision:7,allow_over_capacity:false},
|
||||
today:{revision:4,ids:['other'],estimates:{other:20}},
|
||||
week:{revision:7,timezone:'UTC',days:[{plan_date:'2026-08-25',ids:['active'],capacity_minutes:90,estimates:{active:45}}]}};
|
||||
const values=new Map([[key,JSON.stringify(record)]]);const adopted=[];const requests=[];
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const api=async(url,options)=>{requests.push({url,body:JSON.parse(options.body)});return {
|
||||
today:{revision:5,ids:['other'],estimates:{other:20}},
|
||||
week:{revision:8,timezone:'UTC',days:[{plan_date:'2026-08-25',ids:['active'],capacity_minutes:90,estimates:{active:45}}]}};};
|
||||
const controller=createReschedule({week:{adopt:value=>adopted.push(['week',value])},api,
|
||||
getToday:()=>null,adoptToday:value=>adopted.push(['today',value]),storage,getLogin:()=> 'timmy'});
|
||||
const restored=controller.resume();
|
||||
const delivered=await controller.flush();
|
||||
console.log(JSON.stringify({restored,delivered,adopted,requests,remaining:values.size}));
|
||||
""")
|
||||
|
||||
assert result["restored"]["sync_pending"] is True
|
||||
assert result["adopted"][0][0] == "today"
|
||||
assert result["adopted"][1][0] == "week"
|
||||
assert result["requests"][0]["body"]["operation_id"] == "move-active"
|
||||
assert result["delivered"]["sync_pending"] is False
|
||||
assert result["remaining"] == 0
|
||||
|
||||
|
||||
def test_reschedule_controller_keeps_admitted_operation_when_connection_drops_on_confirm():
|
||||
result = run_controller("""
|
||||
const values=new Map();const events=[];
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>{events.push('persist');values.set(key,value);},removeItem:key=>values.delete(key)};
|
||||
const weekState={revision:7,timezone:'UTC',days:[{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}]};
|
||||
const week={load:async()=>weekState,review:()=>({days:[{...weekState.days[0],label:'Tue',planned_minutes:0}]}),
|
||||
adopt:value=>events.push(['week',value])};
|
||||
let calls=0;const api=async url=>{if(url==='api/v1/today')return {revision:4,ids:['active'],estimates:{active:45}};
|
||||
calls++;const error=new Error('connection dropped');error.status=0;throw error;};
|
||||
const controller=createReschedule({week,api,getToday:()=>null,adoptToday:value=>events.push(['today',value]),
|
||||
storage,getLogin:()=> 'timmy',operationId:()=> 'stable-operation'});
|
||||
await controller.open('active');
|
||||
const result=await controller.confirm('2026-08-25',45);
|
||||
console.log(JSON.stringify({result,events,calls,pending:controller.pending()}));
|
||||
""")
|
||||
|
||||
assert result["result"]["sync_pending"] is True
|
||||
assert result["events"][0] == "persist"
|
||||
assert result["calls"] == 1
|
||||
assert result["pending"]["body"]["operation_id"] == "stable-operation"
|
||||
|
||||
|
||||
def test_reschedule_controller_queues_two_offline_moves_without_replacing_the_first():
|
||||
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)};
|
||||
let today={revision:4,ids:['active','other'],capacity_minutes:120,estimates:{active:45,other:20}};
|
||||
let weekState={revision:7,timezone:'UTC',offline_snapshot:true,days:[
|
||||
{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}
|
||||
]};
|
||||
const confirmedWeek=JSON.parse(JSON.stringify(weekState));let loads=0;
|
||||
const week={load:async()=>{loads++;weekState=JSON.parse(JSON.stringify(confirmedWeek));return weekState;},state:()=>weekState,review:()=>({days:[
|
||||
{...weekState.days[0],label:'Tue',planned_minutes:Object.values(weekState.days[0].estimates).reduce((a,b)=>a+b,0)}
|
||||
]}),adopt:value=>{weekState={...value,offline_snapshot:true};}};
|
||||
const api=async()=>{const error=new Error('offline');error.status=0;throw error;};
|
||||
let n=0;
|
||||
const controller=createReschedule({week,api,getToday:()=>today,adoptToday:value=>{today=value;},
|
||||
storage,getLogin:()=> 'timmy',operationId:()=> `move-${++n}`,now:()=>100+n});
|
||||
await controller.open('active');
|
||||
await controller.confirm('2026-08-25',45);
|
||||
await controller.open('other');
|
||||
const confirmed=await controller.confirm('2026-08-25',20);
|
||||
const stored=JSON.parse([...values.values()][0]);
|
||||
console.log(JSON.stringify({confirmed,stored,today,weekState,loads}));
|
||||
""")
|
||||
|
||||
assert [entry["body"]["operation_id"] for entry in result["stored"]["records"]] == [
|
||||
"move-1",
|
||||
"move-2",
|
||||
]
|
||||
assert result["confirmed"]["pending_count"] == 2
|
||||
assert result["today"]["ids"] == []
|
||||
assert result["weekState"]["days"][0]["ids"] == ["active", "other"]
|
||||
assert result["loads"] == 1
|
||||
|
||||
|
||||
def test_reschedule_controller_drains_fifo_and_rebases_each_next_move():
|
||||
result = run_controller("""
|
||||
const key='stackchain.today-week-reschedule.v1.timmy';
|
||||
const make=(id,identity,today,week)=>({queued_at:1,body:{operation_id:id,identity,estimate_minutes:20,
|
||||
plan_date:'2026-08-25',today_revision:today,week_revision:week,allow_over_capacity:false},
|
||||
today:{revision:today,ids:[]},week:{revision:week,days:[]}});
|
||||
const values=new Map([[key,JSON.stringify({version:2,records:[make('move-1','one',4,7),make('move-2','two',4,7)]})]]);
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const requests=[];let active=0,maxActive=0;
|
||||
const api=async(_url,options)=>{active++;maxActive=Math.max(maxActive,active);
|
||||
const body=JSON.parse(options.body);requests.push(body);await new Promise(resolve=>setTimeout(resolve,1));active--;
|
||||
const step=requests.length;return {today:{revision:4+step,ids:[]},week:{revision:7+step,days:[]}};};
|
||||
const controller=createReschedule({week:{adopt:()=>{}},api,adoptToday:()=>{},storage,getLogin:()=> 'timmy'});
|
||||
const delivered=await controller.flush();
|
||||
console.log(JSON.stringify({requests,maxActive,delivered,remaining:values.size}));
|
||||
""")
|
||||
|
||||
assert [request["operation_id"] for request in result["requests"]] == ["move-1", "move-2"]
|
||||
assert result["requests"][1]["today_revision"] == 5
|
||||
assert result["requests"][1]["week_revision"] == 8
|
||||
assert result["maxActive"] == 1
|
||||
assert result["delivered"]["pending_count"] == 0
|
||||
assert result["remaining"] == 0
|
||||
|
||||
|
||||
def test_reschedule_controller_restores_server_truth_and_keeps_intent_on_conflict():
|
||||
result = run_controller("""
|
||||
const key='stackchain.today-week-reschedule.v1.timmy';
|
||||
const record={queued_at:123,body:{operation_id:'move-active'},today:{revision:4,ids:[]},week:{revision:7,days:[]}};
|
||||
const values=new Map([[key,JSON.stringify(record)]]);const adopted=[];
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const api=async url=>{
|
||||
if(url==='api/v1/week/reschedule'){const error=new Error('conflict');error.status=409;throw error;}
|
||||
if(url==='api/v1/today')return {revision:9,ids:['active'],estimates:{active:45}};
|
||||
return {revision:12,timezone:'UTC',days:[]};
|
||||
};
|
||||
const controller=createReschedule({week:{adopt:value=>adopted.push(['week',value])},api,
|
||||
adoptToday:value=>adopted.push(['today',value]),storage,getLogin:()=> 'timmy'});
|
||||
let message='';try{await controller.flush();}catch(error){message=error.message;}
|
||||
console.log(JSON.stringify({message,adopted,pending:controller.pending(),remaining:values.size}));
|
||||
""")
|
||||
|
||||
assert result["message"] == "Plans changed on another device. Review and retry this saved move."
|
||||
assert result["adopted"][0][1]["revision"] == 9
|
||||
assert result["adopted"][1][1]["revision"] == 12
|
||||
assert result["pending"]["body"]["operation_id"] == "move-active"
|
||||
assert result["remaining"] == 1
|
||||
|
||||
|
||||
def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_release_bundle():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
bundle = BUNDLE.read_text()
|
||||
|
||||
assert 'data-work-session-reschedule-week' in index
|
||||
assert 'id="today-week-reschedule"' in index
|
||||
assert 'id="today-week-reschedule-days"' in index
|
||||
assert 'id="today-week-reschedule-estimate"' in index
|
||||
assert 'id="confirm-today-week-reschedule"' in index
|
||||
assert 'id="cancel-today-week-reschedule"' in index
|
||||
assert '.today-week-reschedule-days button { min-height:44px;' in css
|
||||
assert '.today-week-reschedule-panel { width:min(100%,560px);' in css
|
||||
assert 'x:currentTodayProgressTarget' in dashboard
|
||||
assert 'restoreWhenOwned()' in CONTROLLER.read_text()
|
||||
assert "runTodayTransition('next')" in dashboard
|
||||
assert 'reschedule:()=>({storage,getLogin})' in (CONTROLLER.parent / "week-plan.js").read_text()
|
||||
assert "addEventListener('online', flushPending)" in CONTROLLER.read_text()
|
||||
assert '"static/today-week-reschedule.js"' in bundle
|
||||
@@ -319,6 +319,43 @@ process.stdout.write(JSON.stringify({{completed, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_done_for_today_restores_plan_and_keeps_session_when_durable_batch_admission_fails():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const todayWork = createTodayWork({{storage, getLogin:()=> 'timmy'}});
|
||||
const item = {{kind:'issue', repository:'r', number:1}};
|
||||
todayWork.replace(['issue:r:1:', 'issue:r:2:']);
|
||||
const calls = []; let offered = false;
|
||||
globalThis.stackchainFirstTaskCompleting = () => true;
|
||||
const complete = createTodayCompletion({{
|
||||
todayWork,
|
||||
todaySync: {{enqueueBatch: operations => {{calls.push(['batch', operations]); return false;}},
|
||||
flush: () => calls.push(['flush'])}},
|
||||
refresh: () => calls.push(['refresh']), warm: () => calls.push(['warm']),
|
||||
workSession: {{complete: () => calls.push(['advance'])}},
|
||||
announce: message => calls.push(['announce', message]), onOffer: () => {{offered = true;}},
|
||||
}});
|
||||
const completed = complete(item);
|
||||
process.stdout.write(JSON.stringify({{completed, ids:todayWork.read(), calls, offered}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"completed": False,
|
||||
"ids": ["issue:r:1:", "issue:r:2:"],
|
||||
"calls": [
|
||||
["batch", [
|
||||
{"action": "remove", "item_id": "issue:r:1:"},
|
||||
{"action": "activate", "item_id": "first-task", "activation_state": "complete"},
|
||||
]],
|
||||
["announce", "Device storage is full. Free space and try again."],
|
||||
],
|
||||
"offered": False,
|
||||
}
|
||||
|
||||
|
||||
def test_today_completion_reports_context_specific_success_and_partial_failure():
|
||||
script = f"""
|
||||
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
||||
|
||||
+105
-19
@@ -13,7 +13,7 @@ def run_node(script: str):
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_wrap_up_schedules_selected_work_for_tomorrow_and_leaves_other_work_in_order():
|
||||
def test_wrap_up_appends_selected_work_to_the_ordered_tomorrow_plan_before_removing_it_from_today():
|
||||
script = f"""
|
||||
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||
const items = [
|
||||
@@ -23,36 +23,117 @@ const items = [
|
||||
];
|
||||
const ids = items.map(item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':');
|
||||
const today = [...ids];
|
||||
const later = {{}};
|
||||
const todayOps = [];
|
||||
const laterOps = [];
|
||||
const events = [];
|
||||
let tomorrow = {{revision:7, ids:[ids[0]], capacity_minutes:120, estimates:{{[ids[0]]:45}}, plan_date:'2026-08-21'}};
|
||||
const todayWork = {{
|
||||
identity:item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':',
|
||||
read:() => [...today],
|
||||
contains:item => today.includes((item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'),
|
||||
remove:item => {{
|
||||
const id=(item.kind || 'work') + ':' + item.repository + ':' + item.number + ':';
|
||||
const index=today.indexOf(id); if(index < 0) return false; today.splice(index,1); return true;
|
||||
const index=today.indexOf(id); if(index < 0) return false; events.push('remove:'+id); today.splice(index,1); return true;
|
||||
}},
|
||||
}};
|
||||
const laterWork = {{
|
||||
identity:todayWork.identity,
|
||||
presetUntil:preset => new Date('2026-08-17T09:00:00-04:00'),
|
||||
defer:(item, until, options) => {{ later[todayWork.identity(item)] = until.toISOString(); laterOps.push([todayWork.identity(item), options]); return 'deferred'; }},
|
||||
const tomorrowPlan = {{
|
||||
load:async()=>tomorrow,
|
||||
state:()=>({{...tomorrow,ids:[...tomorrow.ids],estimates:{{...tomorrow.estimates}}}}),
|
||||
stage:value=>{{events.push('stage');tomorrow={{...tomorrow,...value,plan_date:'2026-08-21',sync_pending:true}};return tomorrow;}},
|
||||
}};
|
||||
const todaySync = {{enqueue:(action,id) => {{todayOps.push([action,id]); return true;}}, flush:()=>Promise.resolve(true)}};
|
||||
const wrap = createWrapUp({{todayWork,laterWork,todaySync}});
|
||||
const wrap = createWrapUp({{todayWork,tomorrowPlan,todaySync}});
|
||||
wrap.open(items);
|
||||
wrap.choose(ids[1], true);
|
||||
const result = await wrap.finish();
|
||||
process.stdout.write(JSON.stringify({{result,today,later,todayOps,laterOps}}));
|
||||
process.stdout.write(JSON.stringify({{result,today,tomorrow,todayOps,events}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output["result"] == {"scheduled": 1, "left": 2, "wake_at": "2026-08-17T13:00:00.000Z"}
|
||||
assert output["result"] == {
|
||||
"scheduled": 1,
|
||||
"left": 2,
|
||||
"plan_count": 2,
|
||||
"plan_date": "2026-08-21",
|
||||
"sync_pending": True,
|
||||
}
|
||||
assert output["today"] == ["issue:stackchain/dashboard:1:", "pull:stackchain/dashboard:3:"]
|
||||
assert output["later"] == {"issue:stackchain/dashboard:2:": "2026-08-17T13:00:00.000Z"}
|
||||
assert output["tomorrow"]["ids"] == [
|
||||
"issue:stackchain/dashboard:1:",
|
||||
"issue:stackchain/dashboard:2:",
|
||||
]
|
||||
assert output["tomorrow"]["capacity_minutes"] == 120
|
||||
assert output["tomorrow"]["estimates"] == {"issue:stackchain/dashboard:1:": 45}
|
||||
assert output["todayOps"] == [["remove", "issue:stackchain/dashboard:2:"]]
|
||||
assert output["laterOps"] == [["issue:stackchain/dashboard:2:", {"handoff": "today"}]]
|
||||
assert output["events"] == ["stage", "remove:issue:stackchain/dashboard:2:"]
|
||||
|
||||
|
||||
def test_wrap_up_keeps_today_unchanged_when_the_combined_tomorrow_plan_exceeds_five_items():
|
||||
script = f"""
|
||||
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Sixth'}};
|
||||
const id='issue:acme/mobile:41:'; let present=true; let staged=0; let enqueued=0;
|
||||
const todayWork={{identity:()=>id,read:()=>[id],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
||||
const tomorrowPlan={{
|
||||
load:async()=>({{revision:3,ids:['a','b','c','d','e'],capacity_minutes:null,estimates:{{}},plan_date:'2026-08-21'}}),
|
||||
stage:()=>{{staged+=1;return {{}};}},
|
||||
}};
|
||||
const todaySync={{enqueue:()=>{{enqueued+=1;return true;}},flush:async()=>true}};
|
||||
const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true);
|
||||
let error=''; try {{ await wrap.finish(); }} catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{error,present,staged,enqueued}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output == {
|
||||
"error": "Tomorrow can hold 5 items. Keep 1 in Today or edit Tomorrow first.",
|
||||
"present": True,
|
||||
"staged": 0,
|
||||
"enqueued": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_wrap_up_keeps_today_unchanged_when_tomorrow_is_over_capacity():
|
||||
script = f"""
|
||||
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Carry'}};
|
||||
const id='issue:acme/mobile:41:'; let present=true; let staged=0;
|
||||
const todayWork={{identity:()=>id,read:()=>[id],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
||||
const tomorrowPlan={{
|
||||
load:async()=>({{revision:3,ids:['a','b'],capacity_minutes:60,estimates:{{a:40,b:30}},plan_date:'2026-08-21'}}),
|
||||
stage:()=>{{staged+=1;return {{}};}},
|
||||
}};
|
||||
const todaySync={{enqueue:()=>true,flush:async()=>true}};
|
||||
const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true);
|
||||
let error=''; try {{ await wrap.finish(); }} catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{error,present,staged}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output == {
|
||||
"error": "Tomorrow is over capacity. Edit the plan before finishing wrap-up.",
|
||||
"present": True,
|
||||
"staged": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_wrap_up_keeps_today_unchanged_when_tomorrow_cannot_be_saved_on_the_phone():
|
||||
script = f"""
|
||||
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Carry'}};
|
||||
const id='issue:acme/mobile:41:'; let present=true; let enqueued=0;
|
||||
const todayWork={{identity:()=>id,read:()=>[id],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
||||
const tomorrowPlan={{
|
||||
load:async()=>({{revision:3,ids:[],capacity_minutes:null,estimates:{{}},plan_date:'2026-08-21'}}),
|
||||
stage:()=>false,
|
||||
}};
|
||||
const todaySync={{enqueue:()=>{{enqueued+=1;return true;}},flush:async()=>true}};
|
||||
const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true);
|
||||
let error=''; try {{ await wrap.finish(); }} catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{error,present,enqueued}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output == {
|
||||
"error": "Tomorrow could not be saved on this device. Your Today plan is unchanged.",
|
||||
"present": True,
|
||||
"enqueued": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_save():
|
||||
@@ -65,9 +146,13 @@ def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_sav
|
||||
|
||||
assert 'id="today-wrap-up-sheet" role="dialog"' in html
|
||||
assert 'id="today-wrap-up-items"' in html
|
||||
assert 'Carry selected work into the ordered Tomorrow plan.' in html
|
||||
assert 'Tomorrow 09:00' not in html
|
||||
assert 'id="finish-today-wrap-up"' in html
|
||||
assert 'static/today-wrap-up.js' in main.FRONTEND_BUILD.page_sources
|
||||
assert 'setupTodayWrapUp({' in dashboard
|
||||
assert 'todayWork, tomorrowPlan, todaySync' in dashboard
|
||||
assert 'syncPendingTomorrow();' in dashboard
|
||||
assert 'todayWrapUpView.open' in dashboard
|
||||
assert 'openWrapUp(handoff.actual_minutes, workedItems)' in recap
|
||||
assert '.today-wrap-up-actions button { min-height:44px;' in css
|
||||
@@ -75,19 +160,20 @@ def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_sav
|
||||
assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
|
||||
|
||||
|
||||
def test_wrap_up_repeat_confirmation_does_not_repeat_later_or_today_operations():
|
||||
def test_wrap_up_repeat_confirmation_does_not_repeat_tomorrow_or_today_operations():
|
||||
script = f"""
|
||||
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Ship'}};
|
||||
const id='issue:acme/mobile:41:';
|
||||
let present=true; let deferred=0; let removed=0;
|
||||
let present=true; let staged=0; let removed=0;
|
||||
let tomorrow={{revision:1,ids:[],capacity_minutes:null,estimates:{{}},plan_date:'2026-08-21'}};
|
||||
const todayWork={{identity:()=>id,read:()=>present?[id]:[],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
||||
const laterWork={{presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:()=>{{deferred+=1;return 'deferred';}}}};
|
||||
const tomorrowPlan={{load:async()=>tomorrow,stage:value=>{{staged+=1;tomorrow={{...tomorrow,...value,sync_pending:true}};return tomorrow;}}}};
|
||||
const todaySync={{enqueue:()=>{{removed+=1;return true;}},flush:()=>Promise.resolve(true)}};
|
||||
const wrap=createWrapUp({{todayWork,laterWork,todaySync}});
|
||||
const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}});
|
||||
wrap.open([item]); wrap.choose(id,true);
|
||||
await wrap.finish(); await wrap.finish();
|
||||
process.stdout.write(JSON.stringify({{deferred,removed,present}}));
|
||||
process.stdout.write(JSON.stringify({{staged,removed,present}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output == {"deferred": 1, "removed": 1, "present": False}
|
||||
assert output == {"staged": 1, "removed": 1, "present": False}
|
||||
@@ -0,0 +1,173 @@
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.today_store import TodayPromotionConflict, TodayStore, TomorrowPlanConflict
|
||||
|
||||
|
||||
def test_tomorrow_plan_is_encrypted_scoped_and_promotes_atomically_exactly_once(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
key = b"n" * 32
|
||||
store = TodayStore(
|
||||
path,
|
||||
encryption_key=key,
|
||||
clock=lambda: datetime(2026, 8, 21, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
store.apply("timmy", "today-1", "add", "issue:r:1:")
|
||||
|
||||
tomorrow = store.replace_tomorrow(
|
||||
"Timmy", base_revision=0,
|
||||
ids=["issue:secret/repo:3:", "issue:secret/repo:2:"],
|
||||
capacity_minutes=150,
|
||||
estimates={"issue:secret/repo:3:": 60, "issue:secret/repo:2:": 45},
|
||||
plan_date="2026-08-20", timezone="America/Los_Angeles",
|
||||
)
|
||||
|
||||
assert tomorrow == {
|
||||
"revision": 1,
|
||||
"ids": ["issue:secret/repo:3:", "issue:secret/repo:2:"],
|
||||
"capacity_minutes": 150,
|
||||
"estimates": {"issue:secret/repo:3:": 60, "issue:secret/repo:2:": 45},
|
||||
"plan_date": "2026-08-20",
|
||||
"timezone": "America/Los_Angeles",
|
||||
}
|
||||
assert store.get("timmy")["ids"] == ["issue:r:1:"]
|
||||
assert store.get_tomorrow("alexander")["ids"] == []
|
||||
retained = path.read_bytes()
|
||||
assert b"issue:secret/repo" not in retained
|
||||
assert b"America/Los_Angeles" not in retained
|
||||
|
||||
promoted = store.promote_tomorrow(
|
||||
"timmy", promotion_id="rollover-2026-08-20",
|
||||
tomorrow_revision=1, today_revision=1,
|
||||
)
|
||||
replay = store.promote_tomorrow(
|
||||
"timmy", promotion_id="rollover-2026-08-20",
|
||||
tomorrow_revision=1, today_revision=1,
|
||||
)
|
||||
|
||||
assert replay == promoted
|
||||
assert promoted["ids"] == tomorrow["ids"]
|
||||
assert promoted["capacity_minutes"] == 150
|
||||
assert promoted["plan_date"] == "2026-08-20"
|
||||
assert store.get_tomorrow("timmy") == {
|
||||
"revision": 2, "ids": [], "capacity_minutes": None, "estimates": {}
|
||||
}
|
||||
|
||||
|
||||
def test_tomorrow_edit_and_promotion_reject_stale_revisions_without_touching_today(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3",
|
||||
encryption_key=b"s" * 32,
|
||||
clock=lambda: datetime(2026, 8, 21, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
original_today = store.apply("timmy", "today", "add", "issue:r:1:")
|
||||
first = store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=["issue:r:2:"], capacity_minutes=60,
|
||||
estimates={"issue:r:2:": 30}, plan_date="2026-08-20", timezone="UTC",
|
||||
)
|
||||
|
||||
with pytest.raises(TomorrowPlanConflict) as stale_edit:
|
||||
store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=["issue:r:9:"], capacity_minutes=None,
|
||||
estimates={}, plan_date="2026-08-20", timezone="UTC",
|
||||
)
|
||||
assert stale_edit.value.snapshot == first
|
||||
|
||||
store.apply("timmy", "today-changed", "add", "issue:r:3:")
|
||||
with pytest.raises(TodayPromotionConflict) as stale_today:
|
||||
store.promote_tomorrow(
|
||||
"timmy", promotion_id="rollover", tomorrow_revision=1,
|
||||
today_revision=original_today["revision"],
|
||||
)
|
||||
assert stale_today.value.today["ids"] == ["issue:r:1:", "issue:r:3:"]
|
||||
assert store.get_tomorrow("timmy") == first
|
||||
|
||||
|
||||
def test_tomorrow_promotion_rejects_the_day_before_in_the_saved_timezone(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3",
|
||||
clock=lambda: datetime(2026, 8, 20, 6, 59, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.apply("timmy", "today", "add", "issue:r:1:")
|
||||
tomorrow = store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=["issue:r:2:"], capacity_minutes=60,
|
||||
estimates={}, plan_date="2026-08-20", timezone="America/Los_Angeles",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Tomorrow plan is not due"):
|
||||
store.promote_tomorrow(
|
||||
"timmy", promotion_id="tomorrow-2026-08-20-r1",
|
||||
tomorrow_revision=tomorrow["revision"], today_revision=today["revision"],
|
||||
)
|
||||
|
||||
assert store.get("timmy") == today
|
||||
assert store.get_tomorrow("timmy") == tomorrow
|
||||
|
||||
|
||||
def test_tomorrow_validation_is_bounded_and_date_specific(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
with pytest.raises(ValueError, match="ISO calendar date"):
|
||||
store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=[], capacity_minutes=None, estimates={},
|
||||
plan_date="tomorrow", timezone="UTC",
|
||||
)
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=["same", "same"], capacity_minutes=None,
|
||||
estimates={}, plan_date="2026-08-20", timezone="UTC",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tomorrow_api_round_trip_and_conflict_contract(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"a" * 32)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
payload = main.TomorrowPlanUpdate(
|
||||
base_revision=0, ids=["issue:r:2:"], capacity_minutes=90,
|
||||
estimates={"issue:r:2:": 45}, plan_date="2026-08-20", timezone="UTC",
|
||||
)
|
||||
saved = await main.replace_tomorrow_plan(payload)
|
||||
assert await main.get_tomorrow_plan() == saved
|
||||
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.replace_tomorrow_plan(payload)
|
||||
assert raised.value.status_code == 409
|
||||
assert raised.value.detail == {"code": "tomorrow_changed", "snapshot": saved}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tomorrow_api_reports_an_early_promotion_without_mutation(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3",
|
||||
clock=lambda: datetime(2026, 8, 20, 6, 59, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
today = store.apply("timmy", "today", "add", "issue:r:1:")
|
||||
tomorrow = store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=["issue:r:2:"], capacity_minutes=60,
|
||||
estimates={}, plan_date="2026-08-20", timezone="America/Los_Angeles",
|
||||
)
|
||||
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.promote_tomorrow_plan(main.TomorrowPromotion(
|
||||
promotion_id="tomorrow-2026-08-20-r1",
|
||||
tomorrow_revision=tomorrow["revision"], today_revision=today["revision"],
|
||||
))
|
||||
|
||||
assert raised.value.status_code == 409
|
||||
assert raised.value.detail == {
|
||||
"code": "tomorrow_not_due", "plan_date": "2026-08-20"
|
||||
}
|
||||
assert store.get("timmy") == today
|
||||
assert store.get_tomorrow("timmy") == tomorrow
|
||||
@@ -0,0 +1,423 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
CONTROLLER = FRONTEND / "tomorrow-plan.js"
|
||||
INDEX = FRONTEND / "index.html"
|
||||
CSS = FRONTEND / "dashboard.css"
|
||||
|
||||
|
||||
def run_controller(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createTomorrowPlan = require({json.dumps(str(CONTROLLER))});
|
||||
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_tomorrow_planner_restores_an_account_bound_plan_before_network_delivery():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const storage={
|
||||
getItem:key=>values.has(key)?values.get(key):null,
|
||||
setItem:(key,value)=>values.set(key,value),
|
||||
removeItem:key=>values.delete(key),
|
||||
};
|
||||
let login='Timmy';
|
||||
let requests=0;
|
||||
const options={storage,getLogin:()=>login,fetchJson:async()=>{requests+=1;throw new Error('offline');},
|
||||
localDate:()=> '2026-08-19',timeZone:()=> 'America/New_York'};
|
||||
const first=createTomorrowPlan(options);
|
||||
first.adopt({revision:7,ids:[],capacity_minutes:null,estimates:{}});
|
||||
const queued=first.stage({ids:['issue:r:3:','issue:r:2:'],capacity_minutes:120,
|
||||
estimates:{'issue:r:3:':45,'issue:r:2:':30}});
|
||||
const restored=await createTomorrowPlan(options).load();
|
||||
login='alexander';
|
||||
let otherError='';
|
||||
try { await createTomorrowPlan(options).load(); } catch(error) { otherError=error.message; }
|
||||
console.log(JSON.stringify({queued,restored,requests,otherError,keys:[...values.keys()]}));
|
||||
""")
|
||||
|
||||
assert result["queued"]["sync_pending"] is True
|
||||
assert result["restored"] == result["queued"]
|
||||
assert result["requests"] == 1
|
||||
assert result["otherError"] == "offline"
|
||||
assert result["keys"] == ["stackchain.tomorrow-sync.v1.timmy"]
|
||||
|
||||
|
||||
def test_tomorrow_planner_flushes_once_and_removes_pending_only_after_server_receipt():
|
||||
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)};
|
||||
let resolveRequest;
|
||||
let requests=0;
|
||||
const fetchJson=async (_url,options={})=>{
|
||||
requests+=1;
|
||||
const body=JSON.parse(options.body);
|
||||
await new Promise(resolve=>{resolveRequest=()=>resolve({revision:9,...body});});
|
||||
return {revision:9,...body};
|
||||
};
|
||||
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson,
|
||||
localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
|
||||
planner.adopt({revision:8,ids:[],capacity_minutes:null,estimates:{}});
|
||||
planner.stage({ids:['issue:r:4:'],capacity_minutes:60,estimates:{'issue:r:4:':30}});
|
||||
const first=planner.flush();
|
||||
const second=planner.flush();
|
||||
await Promise.resolve();
|
||||
const pendingDuring=planner.pending();
|
||||
resolveRequest();
|
||||
const [saved,reused]=await Promise.all([first,second]);
|
||||
console.log(JSON.stringify({requests,pendingDuring,saved,reused,pendingAfter:planner.pending(),keys:[...values.keys()]}));
|
||||
""")
|
||||
|
||||
assert result["requests"] == 1
|
||||
assert result["pendingDuring"]["ids"] == ["issue:r:4:"]
|
||||
assert result["saved"]["revision"] == 9
|
||||
assert result["reused"]["revision"] == 9
|
||||
assert result["pendingAfter"] is False
|
||||
assert result["keys"] == []
|
||||
|
||||
|
||||
def test_tomorrow_planner_preserves_local_and_server_plans_on_revision_conflict():
|
||||
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 requests=[];
|
||||
const fetchJson=async (_url,options={})=>{
|
||||
requests.push(options.method||'GET');
|
||||
if(options.method==='PUT'){const error=new Error('changed elsewhere');error.status=409;throw error;}
|
||||
return {revision:12,ids:['issue:r:server:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'};
|
||||
};
|
||||
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson,
|
||||
localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
|
||||
planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}});
|
||||
planner.stage({ids:['issue:r:phone:'],capacity_minutes:90,estimates:{}});
|
||||
let message='';
|
||||
try { await planner.flush(); } catch(error) { message=error.message; }
|
||||
console.log(JSON.stringify({requests,message,pending:planner.pending(),conflict:planner.conflict()}));
|
||||
""")
|
||||
|
||||
assert result["requests"] == ["PUT", "GET"]
|
||||
assert result["message"] == "changed elsewhere"
|
||||
assert result["pending"]["ids"] == ["issue:r:phone:"]
|
||||
assert result["conflict"]["local"]["ids"] == ["issue:r:phone:"]
|
||||
assert result["conflict"]["remote"]["ids"] == ["issue:r:server:"]
|
||||
assert result["conflict"]["remote"]["revision"] == 12
|
||||
|
||||
|
||||
def test_tomorrow_planner_keeps_phone_plan_by_rebasing_once_onto_conflict_revision():
|
||||
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 requests=[];
|
||||
let puts=0;
|
||||
const fetchJson=async (_url,options={})=>{
|
||||
requests.push({method:options.method||'GET',body:options.body?JSON.parse(options.body):null});
|
||||
if(options.method==='PUT'&&puts++===0){const error=new Error('changed elsewhere');error.status=409;throw error;}
|
||||
if(options.method==='PUT') return {revision:13,...JSON.parse(options.body)};
|
||||
return {revision:12,ids:['issue:r:server:'],capacity_minutes:60,estimates:{'issue:r:server:':30},plan_date:'2026-08-20',timezone:'UTC'};
|
||||
};
|
||||
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson,
|
||||
localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
|
||||
planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}});
|
||||
planner.stage({ids:['issue:r:phone:'],capacity_minutes:90,estimates:{'issue:r:phone:':45}});
|
||||
try { await planner.flush(); } catch(_error) {}
|
||||
const saved=await planner.keepLocal();
|
||||
console.log(JSON.stringify({saved,requests,pending:planner.pending(),conflict:planner.conflict(),state:planner.state()}));
|
||||
""")
|
||||
|
||||
assert [request["method"] for request in result["requests"]] == ["PUT", "GET", "PUT"]
|
||||
assert result["requests"][2]["body"]["base_revision"] == 12
|
||||
assert result["requests"][2]["body"]["ids"] == ["issue:r:phone:"]
|
||||
assert result["saved"]["revision"] == 13
|
||||
assert result["pending"] is False
|
||||
assert result["conflict"] is None
|
||||
assert result["state"]["ids"] == ["issue:r:phone:"]
|
||||
|
||||
|
||||
def test_tomorrow_planner_uses_server_plan_without_a_second_write():
|
||||
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 requests=[];
|
||||
const fetchJson=async (_url,options={})=>{
|
||||
requests.push(options.method||'GET');
|
||||
if(options.method==='PUT'){const error=new Error('changed elsewhere');error.status=409;throw error;}
|
||||
return {revision:12,ids:['issue:r:server:'],capacity_minutes:60,estimates:{'issue:r:server:':30},plan_date:'2026-08-20',timezone:'UTC'};
|
||||
};
|
||||
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson,
|
||||
localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
|
||||
planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}});
|
||||
planner.stage({ids:['issue:r:phone:'],capacity_minutes:90,estimates:{'issue:r:phone:':45}});
|
||||
try { await planner.flush(); } catch(_error) {}
|
||||
const adopted=planner.useRemote();
|
||||
console.log(JSON.stringify({adopted,requests,pending:planner.pending(),conflict:planner.conflict(),state:planner.state()}));
|
||||
""")
|
||||
|
||||
assert result["requests"] == ["PUT", "GET"]
|
||||
assert result["adopted"]["revision"] == 12
|
||||
assert result["adopted"]["ids"] == ["issue:r:server:"]
|
||||
assert result["pending"] is False
|
||||
assert result["conflict"] is None
|
||||
assert result["state"] == result["adopted"]
|
||||
|
||||
|
||||
def test_tomorrow_conflict_cannot_be_read_or_resolved_after_account_switch():
|
||||
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)};
|
||||
let login='timmy';
|
||||
const fetchJson=async (_url,options={})=>{
|
||||
if(options.method==='PUT'){const error=new Error('changed elsewhere');error.status=409;throw error;}
|
||||
return {revision:12,ids:['issue:private:timmy:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'};
|
||||
};
|
||||
const planner=createTomorrowPlan({storage,getLogin:()=>login,fetchJson,
|
||||
localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
|
||||
planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}});
|
||||
planner.stage({ids:['issue:private:phone:'],capacity_minutes:90,estimates:{}});
|
||||
try { await planner.flush(); } catch(_error) {}
|
||||
const before=planner.conflict();
|
||||
login='alexander';
|
||||
const after=planner.conflict();
|
||||
const keep=await planner.keepLocal();
|
||||
const use=planner.useRemote();
|
||||
login='timmy';
|
||||
const restored=planner.conflict();
|
||||
console.log(JSON.stringify({before,after,keep,use,restored}));
|
||||
""")
|
||||
|
||||
assert result["before"]["remote"]["ids"] == ["issue:private:timmy:"]
|
||||
assert result["after"] is None
|
||||
assert result["keep"] is False
|
||||
assert result["use"] is False
|
||||
assert result["restored"]["local"]["ids"] == ["issue:private:phone:"]
|
||||
|
||||
|
||||
def test_tomorrow_planner_loads_and_saves_independently_from_today():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
const fetchJson=async (url, options={})=>{
|
||||
requests.push({url,method:options.method||'GET',body:options.body ? JSON.parse(options.body) : null});
|
||||
if ((options.method||'GET') === 'GET') return {
|
||||
revision:2, ids:['issue:r:2:'], capacity_minutes:90,
|
||||
estimates:{'issue:r:2:':45}, plan_date:'2026-08-20', timezone:'UTC'
|
||||
};
|
||||
return {revision:3,...JSON.parse(options.body)};
|
||||
};
|
||||
const planner=createTomorrowPlan({fetchJson, localDate:()=> '2026-08-19', timeZone:()=> 'UTC'});
|
||||
const loaded=await planner.load();
|
||||
const saved=await planner.save({ids:['issue:r:3:'],capacity_minutes:120,estimates:{'issue:r:3:':60}});
|
||||
console.log(JSON.stringify({loaded,saved,requests}));
|
||||
""")
|
||||
|
||||
assert result["loaded"]["ids"] == ["issue:r:2:"]
|
||||
assert result["saved"]["plan_date"] == "2026-08-20"
|
||||
assert result["requests"] == [
|
||||
{"url": "api/v1/tomorrow", "method": "GET", "body": None},
|
||||
{
|
||||
"url": "api/v1/tomorrow",
|
||||
"method": "PUT",
|
||||
"body": {
|
||||
"base_revision": 2,
|
||||
"ids": ["issue:r:3:"],
|
||||
"capacity_minutes": 120,
|
||||
"estimates": {"issue:r:3:": 60},
|
||||
"plan_date": "2026-08-20",
|
||||
"timezone": "UTC",
|
||||
},
|
||||
},
|
||||
]
|
||||
assert all(request["url"] != "api/v1/today" for request in result["requests"])
|
||||
|
||||
|
||||
def test_pending_tomorrow_plan_is_not_promoted_before_account_sync():
|
||||
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 requests=[];
|
||||
let date='2026-08-19';
|
||||
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson:async(url)=>{requests.push(url);return {};},
|
||||
localDate:()=> date,timeZone:()=> 'UTC'});
|
||||
planner.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
|
||||
planner.stage({ids:['issue:r:2:'],capacity_minutes:60,estimates:{}});
|
||||
date='2026-08-22';
|
||||
const promoted=await planner.promote(7);
|
||||
console.log(JSON.stringify({promoted,requests,pending:planner.pending()}));
|
||||
""")
|
||||
|
||||
assert result["promoted"] is False
|
||||
assert result["requests"] == []
|
||||
assert result["pending"]["ids"] == ["issue:r:2:"]
|
||||
|
||||
|
||||
def test_tomorrow_planner_promotes_overdue_plans_with_a_stable_retry_identity():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
const fetchJson=async (url, options={})=>{
|
||||
requests.push({url,body:options.body ? JSON.parse(options.body) : null});
|
||||
return {ids:['issue:r:2:'],revision:8};
|
||||
};
|
||||
const before=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
|
||||
before.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
|
||||
const early=await before.promote(7);
|
||||
const overdue=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-22',timeZone:()=> 'UTC'});
|
||||
overdue.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
|
||||
const promoted=await overdue.promote(7);
|
||||
const retried=await overdue.promote(7);
|
||||
console.log(JSON.stringify({early,promoted,retried,requests}));
|
||||
""")
|
||||
|
||||
assert result["early"] is False
|
||||
assert result["promoted"]["revision"] == 8
|
||||
assert result["retried"]["revision"] == 8
|
||||
assert result["requests"] == [
|
||||
{
|
||||
"url": "api/v1/tomorrow/promote",
|
||||
"body": {
|
||||
"promotion_id": "tomorrow-2026-08-20-r3",
|
||||
"tomorrow_revision": 3,
|
||||
"today_revision": 7,
|
||||
},
|
||||
},
|
||||
{
|
||||
"url": "api/v1/tomorrow/promote",
|
||||
"body": {
|
||||
"promotion_id": "tomorrow-2026-08-20-r3",
|
||||
"tomorrow_revision": 3,
|
||||
"today_revision": 7,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_tomorrow_rollover_checks_startup_reconnect_foreground_and_next_midnight_single_flight():
|
||||
result = run_controller("""
|
||||
const listeners={};
|
||||
const timers=[];
|
||||
const cleared=[];
|
||||
let checks=0;
|
||||
const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
const windowObject={addEventListener:(name,fn)=>listeners['window:'+name]=fn};
|
||||
const documentObject={hidden:false,addEventListener:(name,fn)=>listeners['document:'+name]=fn};
|
||||
const lifecycle=planner.startLifecycle({
|
||||
windowObject,documentObject,check:async()=>{checks+=1;await Promise.resolve();return true;},
|
||||
setTimer:(fn,delay)=>{timers.push({fn,delay});return timers.length;},
|
||||
clearTimer:id=>cleared.push(id),nextDelay:()=>1234,
|
||||
});
|
||||
await lifecycle.run();
|
||||
const reconnect=listeners['window:online']();
|
||||
const foreground=listeners['document:visibilitychange']();
|
||||
await Promise.all([reconnect,foreground]);
|
||||
await timers[timers.length-1].fn();
|
||||
console.log(JSON.stringify({checks,delays:timers.map(timer=>timer.delay),cleared}));
|
||||
""")
|
||||
|
||||
assert result["checks"] == 3
|
||||
assert result["delays"] == [1234, 1234, 1234]
|
||||
assert result["cleared"] == [1, 2]
|
||||
|
||||
|
||||
def test_tomorrow_rollover_falls_back_when_the_reported_timezone_is_invalid():
|
||||
result = run_controller("""
|
||||
let scheduled=null;
|
||||
const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'Etc/Unknown'});
|
||||
const lifecycle=planner.startLifecycle({
|
||||
check:async()=>false,setTimer:(_fn,delay)=>{scheduled=delay;return 1;},clearTimer:()=>{},
|
||||
});
|
||||
await lifecycle.run();
|
||||
console.log(JSON.stringify({scheduled:Number.isFinite(scheduled)&&scheduled>0}));
|
||||
""")
|
||||
|
||||
assert result == {"scheduled": True}
|
||||
|
||||
|
||||
def test_tomorrow_planner_summarizes_empty_and_capacity_aware_plans_for_mobile_queues():
|
||||
result = run_controller("""
|
||||
const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
const empty=planner.summary({ids:[],capacity_minutes:null,estimates:{}});
|
||||
const planned=planner.summary({
|
||||
ids:['issue:r:1:','issue:r:2:','issue:r:3:'], capacity_minutes:120,
|
||||
estimates:{'issue:r:1:':30,'issue:r:2:':45,'issue:r:3:':30}
|
||||
});
|
||||
const unestimated=planner.summary({ids:['issue:r:4:'],capacity_minutes:null,estimates:{}});
|
||||
const pending=planner.summary({ids:['issue:r:5:'],capacity_minutes:60,estimates:{'issue:r:5:':30},sync_pending:true});
|
||||
console.log(JSON.stringify({empty,planned,unestimated,pending}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"empty": "Nothing planned",
|
||||
"planned": "3 planned · 105 of 120 min",
|
||||
"unestimated": "1 planned",
|
||||
"pending": "1 planned · 30 of 60 min · sync pending",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_tomorrow_conflict_review_exposes_two_touch_safe_resolution_paths():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="tomorrow-conflict-review"' in index
|
||||
assert 'id="tomorrow-conflict-phone-plan"' in index
|
||||
assert 'id="tomorrow-conflict-server-plan"' in index
|
||||
assert 'id="keep-phone-tomorrow"' in index
|
||||
assert 'id="use-server-tomorrow"' in index
|
||||
assert "Conflict · review required" in dashboard
|
||||
assert "if (tomorrowPlan.conflict())" in dashboard
|
||||
assert "tomorrowPlan.keepLocal()" in dashboard
|
||||
assert "tomorrowPlan.useRemote()" in dashboard
|
||||
assert ".tomorrow-conflict-actions button { min-height:44px;" in css
|
||||
|
||||
|
||||
def test_mobile_tomorrow_save_is_admitted_before_background_delivery():
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert "storage:localStorage" in dashboard
|
||||
assert "getLogin:() => planningOwnerLogin" in dashboard
|
||||
assert "const staged = tomorrowPlan.stage(normalized);" in dashboard
|
||||
assert "if (!staged) {" in dashboard
|
||||
assert "Free browser storage and retry." in dashboard
|
||||
assert "renderTomorrowQueueSummary(staged);" in dashboard
|
||||
assert "Tomorrow saved on this phone · sync pending." in dashboard
|
||||
assert "tomorrowPlan.flush().then" in dashboard
|
||||
assert "syncPendingTomorrow()" in dashboard
|
||||
assert dashboard.count("syncPendingTomorrow();") >= 2
|
||||
assert "Another device changed Tomorrow" in dashboard
|
||||
|
||||
|
||||
def test_mobile_queues_expose_tomorrow_and_keep_duplicate_header_actions_hidden():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert 'data-mobile-queue="tomorrow"' in index
|
||||
assert 'id="mobile-tomorrow-summary"' in index
|
||||
assert '>Nothing planned<' in index
|
||||
assert "name === 'tomorrow' ? openTomorrowPlanner" in dashboard
|
||||
assert "tomorrowPlan.summary()" in dashboard
|
||||
assert "renderTomorrowQueueSummary(saved)" in dashboard
|
||||
assert "planTodayTrigger?.dataset.mobileQueue === 'tomorrow'" in dashboard
|
||||
assert "qs('#mobile-queue-sheet').showModal()" in dashboard
|
||||
assert ".my-work-actions { display:none; }\n .my-work-actions { display:flex; }" not in css
|
||||
assert ".my-work-actions { display:none; }" in css
|
||||
|
||||
|
||||
def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_planner():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert '<button class="plan-tomorrow" id="plan-tomorrow" type="button">Plan Tomorrow</button>' in index
|
||||
assert ".plan-tomorrow { min-height:44px;" in css
|
||||
assert '<script src="static/tomorrow-plan.js"></script>' in index
|
||||
assert "const tomorrowPlan = createTomorrowPlan" in dashboard
|
||||
assert "qs('#plan-tomorrow').addEventListener('click'" in dashboard
|
||||
assert "planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)" in dashboard
|
||||
assert "tomorrowPlan.load()" in dashboard
|
||||
assert "weekFlow.promote(plan)" in dashboard
|
||||
assert "tomorrowPlan.startLifecycle" in dashboard
|
||||
@@ -0,0 +1,161 @@
|
||||
import json
|
||||
import subprocess
|
||||
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"
|
||||
BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
|
||||
|
||||
|
||||
def run_node(source: str):
|
||||
completed = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_week_calendar_builds_ordered_local_time_blocks_and_serializes_opaque_events():
|
||||
plan = {
|
||||
"timezone": "America/New_York",
|
||||
"revision": 7,
|
||||
"days": [{
|
||||
"plan_date": "2026-08-21",
|
||||
"ids": ["issue:stackchain/dashboard:12:", "pull:stackchain/api:7:"],
|
||||
"capacity_minutes": 120,
|
||||
"estimates": {
|
||||
"issue:stackchain/dashboard:12:": 45,
|
||||
"pull:stackchain/api:7:": 30,
|
||||
},
|
||||
}],
|
||||
}
|
||||
items = {
|
||||
"issue:stackchain/dashboard:12:": {
|
||||
"id": 401,
|
||||
"title": "Plan, ship; verify",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 12,
|
||||
"url": "https://forge.alexanderwhitestone.com/git/stackchain/stackchain-dashboard/issues/12",
|
||||
},
|
||||
"pull:stackchain/api:7:": {
|
||||
"title": "API release",
|
||||
"repository": "stackchain/api",
|
||||
"number": 7,
|
||||
"url": "https://forge.alexanderwhitestone.com/git/stackchain/api/pulls/7",
|
||||
},
|
||||
}
|
||||
source = f"""
|
||||
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
|
||||
const plan={json.dumps(plan)}, items={json.dumps(items)};
|
||||
const blocks=calendar.buildWeekBlocks(plan, {{'2026-08-21':'09:00'}}, id=>items[id]);
|
||||
const text=calendar.serializeWeekCalendar(blocks, {{timezone:plan.timezone,revision:plan.revision,generatedAt:'20260820T120000Z'}});
|
||||
console.log(JSON.stringify({{blocks,text}}));
|
||||
"""
|
||||
|
||||
result = run_node(source)
|
||||
assert [(block["start_time"], block["end_time"]) for block in result["blocks"]] == [
|
||||
("09:00", "09:45"), ("09:45", "10:15")
|
||||
]
|
||||
text = result["text"]
|
||||
assert "TZID:America/New_York" in text
|
||||
assert "DTSTART;TZID=America/New_York:20260821T090000" in text
|
||||
assert "DTEND;TZID=America/New_York:20260821T094500" in text
|
||||
assert "DTSTART;TZID=America/New_York:20260821T094500" in text
|
||||
assert "DTEND;TZID=America/New_York:20260821T101500" in text
|
||||
assert "TRANSP:OPAQUE" in text
|
||||
assert "UID:week-issue-stackchain-dashboard-12@stackchain" in text
|
||||
assert "SEQUENCE:7" in text
|
||||
assert "SUMMARY:Plan\\, ship\\; verify" in text
|
||||
unfolded = text.replace("\r\n ", "")
|
||||
assert "URL:https://forge.alexanderwhitestone.com/git/stackchain/stackchain-dashboard/issues/12" in unfolded
|
||||
|
||||
|
||||
def test_week_calendar_reexports_update_stable_distinct_work_events():
|
||||
source = f"""
|
||||
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
|
||||
const issue={{id:'issue:stackchain/dashboard:12:',title:'Ship it',repository:'stackchain/dashboard',number:12,
|
||||
plan_date:'2026-08-21',start_time:'09:00',end_time:'09:30'}};
|
||||
const moved={{...issue,plan_date:'2026-08-22',start_time:'14:00',end_time:'14:30'}};
|
||||
const pull={{...moved,id:'pull:stackchain/dashboard:12:'}};
|
||||
const first=calendar.serializeWeekCalendar([issue],{{timezone:'UTC',revision:7,generatedAt:'20260820T120000Z'}});
|
||||
const second=calendar.serializeWeekCalendar([moved,pull],{{timezone:'UTC',revision:8,generatedAt:'20260821T120000Z'}});
|
||||
const events=text=>text.split('BEGIN:VEVENT').slice(1).map(value=>({{
|
||||
uid:value.match(/UID:([^\\r]+)/)[1],sequence:Number(value.match(/SEQUENCE:(\\d+)/)[1])
|
||||
}}));
|
||||
console.log(JSON.stringify({{first:events(first),second:events(second)}}));
|
||||
"""
|
||||
|
||||
result = run_node(source)
|
||||
assert result["first"] == [{
|
||||
"uid": "week-issue-stackchain-dashboard-12@stackchain",
|
||||
"sequence": 7,
|
||||
}]
|
||||
assert result["second"] == [
|
||||
{"uid": "week-issue-stackchain-dashboard-12@stackchain", "sequence": 8},
|
||||
{"uid": "week-pull-stackchain-dashboard-12@stackchain", "sequence": 8},
|
||||
]
|
||||
|
||||
|
||||
def test_week_calendar_places_whole_tasks_in_imported_free_windows_and_reports_unfitted_work():
|
||||
source = f"""
|
||||
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
|
||||
const plan={{days:[{{plan_date:'2026-08-21',ids:['one','two','three'],estimates:{{one:45,two:90,three:400}}}}]}};
|
||||
const items={{one:{{title:'First',repository:'r',number:1}},two:{{title:'Second',repository:'r',number:2}},
|
||||
three:{{title:'Too large',repository:'r',number:3}}}};
|
||||
const availability=[{{plan_date:'2026-08-21',free_windows:[{{start_time:'09:00',end_time:'10:00'}},{{start_time:'11:00',end_time:'17:00'}}]}}];
|
||||
const result=calendar.buildWeekSchedule(plan,{{'2026-08-21':'09:00'}},id=>items[id],null,availability);
|
||||
console.log(JSON.stringify(result));
|
||||
"""
|
||||
|
||||
result = run_node(source)
|
||||
assert [(block["id"], block["start_time"], block["end_time"]) for block in result["blocks"]] == [
|
||||
("one", "09:00", "09:45"),
|
||||
("two", "11:00", "12:30"),
|
||||
]
|
||||
assert result["blockers"] == [{
|
||||
"id": "three", "title": "Too large", "plan_date": "2026-08-21", "minutes": 400
|
||||
}]
|
||||
|
||||
|
||||
def test_week_calendar_excludes_unselected_items_and_downloads_when_native_share_is_unavailable():
|
||||
source = f"""
|
||||
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
|
||||
const plan={{timezone:'UTC',revision:2,days:[{{plan_date:'2026-08-21',ids:['one','two'],capacity_minutes:60,estimates:{{one:20,two:25}}}}]}};
|
||||
const items={{one:{{title:'Private',repository:'r',number:1,url:'https://forge/r/1'}},two:{{title:'Public',repository:'r',number:2,url:'https://forge/r/2'}}}};
|
||||
const blocks=calendar.buildWeekBlocks(plan,{{'2026-08-21':'13:00'}},id=>items[id],new Set(['two']));
|
||||
class FakeFile {{constructor(parts,name,options){{this.parts=parts;this.name=name;this.type=options.type;}}}}
|
||||
const clicks=[],revoked=[];
|
||||
(async()=>{{
|
||||
const result=await calendar.deliverWeekCalendar({{text:'calendar',filename:'week.ics',navigator:{{}},FileCtor:FakeFile,
|
||||
document:{{createElement:()=>({{click(){{clicks.push([this.download,this.href]);}}}})}},
|
||||
urlApi:{{createObjectURL:()=> 'blob:week',revokeObjectURL:value=>revoked.push(value)}}}});
|
||||
console.log(JSON.stringify({{blocks,result,clicks,revoked}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
result = run_node(source)
|
||||
assert [block["id"] for block in result["blocks"]] == ["two"]
|
||||
assert result["blocks"][0]["start_time"] == "13:00"
|
||||
assert result["result"] == "downloaded"
|
||||
assert result["clicks"] == [["week.ics", "blob:week"]]
|
||||
assert result["revoked"] == ["blob:week"]
|
||||
|
||||
|
||||
def test_week_calendar_handoff_is_wired_into_confirmation_with_mobile_privacy_controls():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
bundle = BUNDLE.read_text()
|
||||
|
||||
assert '<script src="static/week-calendar.js"></script>' in index
|
||||
assert 'id="week-calendar-handoff"' in index
|
||||
assert 'id="week-calendar-days"' in index
|
||||
assert 'id="share-week-calendar"' in index
|
||||
assert 'id="back-to-week-review-from-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 "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
|
||||
@@ -0,0 +1,324 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "week-calendar-import.js"
|
||||
FRONTEND = MODULE.parent
|
||||
|
||||
|
||||
def run_import(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const calendarImport = require({json.dumps(str(MODULE))});
|
||||
(async() => {{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}})
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_calendar_import_unions_busy_time_within_working_hours_across_local_days():
|
||||
result = run_import("""
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:one\r\nSUMMARY:Private planning\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:two\r\nSUMMARY:Secret customer call\r\nATTENDEE:mailto:private@example.com\r\nDTSTART:20260821T103000\r\nDTEND:20260821T120000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:outside\r\nDTSTART:20260821T070000\r\nDTEND:20260821T080000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:overnight\r\nLOCATION:Private office\r\nDTSTART:20260821T163000\r\nDTEND:20260822T100000\r\nEND:VEVENT\r\nEND: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({days,serialized:JSON.stringify(days)}));
|
||||
""")
|
||||
|
||||
assert result["days"][0] == {
|
||||
"plan_date": "2026-08-21", "busy_minutes": 150, "capacity_minutes": 330,
|
||||
"free_windows": [
|
||||
{"start_time": "09:00", "end_time": "10:00"},
|
||||
{"start_time": "12:00", "end_time": "16:30"},
|
||||
],
|
||||
}
|
||||
assert result["days"][1] == {
|
||||
"plan_date": "2026-08-22", "busy_minutes": 60, "capacity_minutes": 420,
|
||||
"free_windows": [{"start_time": "10:00", "end_time": "17:00"}],
|
||||
}
|
||||
assert [day["capacity_minutes"] for day in result["days"][2:]] == [480] * 5
|
||||
for private_value in ("Private planning", "Secret customer call", "private@example.com", "Private office"):
|
||||
assert private_value not in result["serialized"]
|
||||
|
||||
|
||||
def test_calendar_import_converts_iana_tzid_into_the_device_local_workday():
|
||||
result = run_import("""
|
||||
process.env.TZ='America/Los_Angeles';
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20260821T120000\r\nDTEND;TZID=America/New_York:20260821T130000\r\nEND:VEVENT\r\nEND: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({first:days[0]}));
|
||||
""")
|
||||
|
||||
assert result["first"]["busy_minutes"] == 60
|
||||
assert result["first"]["free_windows"] == [
|
||||
{"start_time": "10:00", "end_time": "17:00"},
|
||||
]
|
||||
|
||||
|
||||
def test_calendar_import_fails_closed_for_unknown_tzid_without_exposing_event_details():
|
||||
result = run_import("""
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nSUMMARY:Private acquisition\r\nATTENDEE:mailto:secret@example.com\r\nDTSTART;TZID=Private/Boardroom:20260821T120000\r\nDTEND;TZID=Private/Boardroom:20260821T130000\r\nEND:VEVENT\r\nEND: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});
|
||||
console.log(JSON.stringify({unsupported:days.unsupported_count,busy:days.map(day=>day.busy_minutes),serialized:JSON.stringify(days)}));
|
||||
""")
|
||||
|
||||
assert result["unsupported"] == 1
|
||||
assert result["busy"] == [0] * 7
|
||||
assert "Private acquisition" not in result["serialized"]
|
||||
assert "secret@example.com" not in result["serialized"]
|
||||
|
||||
|
||||
def test_calendar_import_applies_tzid_to_rdate_and_exdate_values():
|
||||
result = run_import("""
|
||||
process.env.TZ='America/Los_Angeles';
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20260821T120000\r\nDTEND;TZID=America/New_York:20260821T130000\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEXDATE;TZID=America/New_York:20260822T120000\r\nRDATE;TZID=America/New_York:20260824T120000\r\nEND:VEVENT\r\nEND: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});
|
||||
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes),fourth:days[3].free_windows}));
|
||||
""")
|
||||
|
||||
assert result["busy"] == [60, 0, 60, 60, 0, 0, 0]
|
||||
assert result["fourth"] == [{"start_time": "10:00", "end_time": "17:00"}]
|
||||
|
||||
|
||||
def test_calendar_import_keeps_zoned_daily_recurrence_at_wall_time_across_dst():
|
||||
result = run_import("""
|
||||
process.env.TZ='UTC';
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20260307T090000\r\nDTEND;TZID=America/New_York:20260307T100000\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
||||
const dates=['2026-03-07','2026-03-08','2026-03-09','2026-03-10','2026-03-11','2026-03-12','2026-03-13'];
|
||||
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
||||
console.log(JSON.stringify({windows:days.slice(0,3).map(day=>day.free_windows)}));
|
||||
""")
|
||||
|
||||
assert result["windows"] == [
|
||||
[{"start_time": "09:00", "end_time": "14:00"}, {"start_time": "15:00", "end_time": "17:00"}],
|
||||
[{"start_time": "09:00", "end_time": "13:00"}, {"start_time": "14:00", "end_time": "17:00"}],
|
||||
[{"start_time": "09:00", "end_time": "13:00"}, {"start_time": "14:00", "end_time": "17:00"}],
|
||||
]
|
||||
|
||||
|
||||
def test_calendar_import_interprets_floating_until_in_the_event_timezone():
|
||||
result = run_import("""
|
||||
process.env.TZ='UTC';
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20260307T090000\r\nDTEND;TZID=America/New_York:20260307T100000\r\nRRULE:FREQ=DAILY;UNTIL=20260308T090000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
||||
const dates=['2026-03-07','2026-03-08','2026-03-09','2026-03-10','2026-03-11','2026-03-12','2026-03-13'];
|
||||
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, 60, 0, 0, 0, 0, 0]
|
||||
|
||||
|
||||
def test_calendar_import_fast_forwards_historical_daily_series_to_seven_day_range():
|
||||
result = run_import("""
|
||||
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)}));
|
||||
""")
|
||||
|
||||
assert result["busy"] == [60] * 7
|
||||
assert result["elapsed"] < 1000
|
||||
|
||||
|
||||
def test_calendar_import_fast_forwards_historical_weekly_series_to_seven_day_range():
|
||||
result = run_import("""
|
||||
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)}));
|
||||
""")
|
||||
|
||||
assert result["busy"] == [0, 0, 0, 60, 0, 60, 0]
|
||||
assert result["elapsed"] < 1000
|
||||
|
||||
|
||||
def test_calendar_import_expands_bounded_daily_recurrence_and_honors_exdate():
|
||||
result = run_import("""
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:standup\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEXDATE:20260823T100000\r\nEND:VEVENT\r\nEND: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({busy:days.map(day=>day.busy_minutes)}));
|
||||
""")
|
||||
|
||||
assert result["busy"] == [60, 60, 0, 60, 60, 0, 0]
|
||||
|
||||
|
||||
def test_calendar_import_adds_rdates_and_excludes_matching_occurrences():
|
||||
result = run_import("""
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRDATE:20260823T100000,20260825T100000\r\nEXDATE:20260823T100000\r\nEND:VEVENT\r\nEND: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});
|
||||
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
||||
""")
|
||||
|
||||
assert result["busy"] == [60, 0, 0, 0, 60, 0, 0]
|
||||
|
||||
|
||||
def test_calendar_import_stops_recurrence_at_until_boundary():
|
||||
result = run_import("""
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=DAILY;UNTIL=20260823T100000\r\nEND:VEVENT\r\nEND: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});
|
||||
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
||||
""")
|
||||
|
||||
assert result["busy"] == [60, 60, 60, 0, 0, 0, 0]
|
||||
|
||||
|
||||
def test_calendar_import_expands_weekly_recurrence_on_selected_weekdays():
|
||||
result = run_import("""
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=5\r\nEND:VEVENT\r\nEND: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});
|
||||
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
||||
""")
|
||||
|
||||
assert result["busy"] == [60, 0, 0, 60, 0, 60, 0]
|
||||
|
||||
|
||||
def test_calendar_import_treats_all_day_events_as_busy_for_working_hours():
|
||||
result = run_import("""
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;VALUE=DATE:20260822\r\nDTEND;VALUE=DATE:20260823\r\nEND:VEVENT\r\nEND: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({busy:days.map(day=>day.busy_minutes)}));
|
||||
""")
|
||||
|
||||
assert result["busy"] == [0, 480, 0, 0, 0, 0, 0]
|
||||
|
||||
|
||||
def test_calendar_import_ignores_cancelled_and_transparent_events():
|
||||
result = run_import("""
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nSTATUS:CANCELLED\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nTRANSP:TRANSPARENT\r\nDTSTART:20260822T100000\r\nDTEND:20260822T110000\r\nEND:VEVENT\r\nEND: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});
|
||||
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
||||
""")
|
||||
|
||||
assert result["busy"] == [0] * 7
|
||||
|
||||
|
||||
def test_calendar_import_rejects_invalid_and_oversized_files_without_returning_capacity():
|
||||
result = run_import("""
|
||||
const errors=[];
|
||||
for(const source of ['not a calendar','BEGIN:VCALENDAR\\r\\n'+('X'.repeat(1024*1024))+'\\r\\nEND:VCALENDAR']) {
|
||||
try { calendarImport.review(source,{dates:['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27']}); }
|
||||
catch(error) { errors.push(error.message); }
|
||||
}
|
||||
console.log(JSON.stringify({errors}));
|
||||
""")
|
||||
|
||||
assert result["errors"] == [
|
||||
"Choose a valid .ics calendar file.",
|
||||
"Calendar files must be 1 MB or smaller.",
|
||||
]
|
||||
|
||||
|
||||
def test_calendar_import_blocks_apply_when_recurrence_cannot_be_counted_truthfully():
|
||||
result = run_import("""
|
||||
const elements=new Map();
|
||||
const element=(value='')=>({value,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','#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';
|
||||
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=0;
|
||||
const controller={dates:()=>dates.map(date=>({date,label:date})),stageCapacities:()=>{staged+=1;return true;},flush:async()=>{}};
|
||||
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)});
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nSUMMARY:Private board review\r\nATTENDEE:mailto:secret@example.com\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=MONTHLY;BYDAY=1FR\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
||||
workflow.review(source);const applied=await workflow.apply();
|
||||
console.log(JSON.stringify({disabled:elements.get('#apply-week-capacities').disabled,status:elements.get('#week-capacity-status').textContent,
|
||||
markup:elements.get('#week-capacity-days').innerHTML,applied,staged}));
|
||||
""")
|
||||
|
||||
assert result["disabled"] is True
|
||||
assert result["applied"] is False
|
||||
assert result["staged"] == 0
|
||||
assert "1 recurring event could not be counted" in result["status"]
|
||||
assert "Apply is unavailable" in result["status"]
|
||||
assert "Private board review" not in json.dumps(result)
|
||||
assert "secret@example.com" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_calendar_import_marks_unsupported_daily_rule_parts_instead_of_ignoring_them():
|
||||
result = run_import("""
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=DAILY;BYHOUR=10\r\nEND:VEVENT\r\nEND: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});
|
||||
console.log(JSON.stringify({unsupported:days.unsupported_count}));
|
||||
""")
|
||||
|
||||
assert result["unsupported"] == 1
|
||||
|
||||
|
||||
def test_calendar_import_workflow_reviews_then_applies_once_without_persisting_calendar_text():
|
||||
result = run_import("""
|
||||
const elements=new Map();
|
||||
const element=(value='')=>({value,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
|
||||
addEventListener(name,listener){this.listeners[name]=listener;},focus(){this.focused=true;}});
|
||||
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','#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';
|
||||
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=[],flushes=0,appliedCalls=0;
|
||||
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),
|
||||
stageCapacities:value=>{staged.push(value);return {sync_pending:true};},flush:async()=>{flushes+=1;}};
|
||||
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector),onApplied:()=>{appliedCalls+=1;}});
|
||||
workflow.open();
|
||||
const source=`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Do not retain me\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
||||
const reviewed=workflow.review(source);
|
||||
await workflow.apply();
|
||||
console.log(JSON.stringify({reviewed,staged,flushes,appliedCalls,rootHidden:elements.get('#week-capacity-import').hidden,
|
||||
status:elements.get('#week-capacity-status').textContent,markup:elements.get('#week-capacity-days').innerHTML,
|
||||
fileValue:elements.get('#week-capacity-file').value,workflowState:workflow.state(),availability:workflow.availability()}));
|
||||
""")
|
||||
|
||||
assert result["reviewed"][0]["capacity_minutes"] == 420
|
||||
assert len(result["staged"]) == 1
|
||||
assert len(result["staged"][0]) == 7
|
||||
assert result["flushes"] == 1
|
||||
assert result["appliedCalls"] == 1
|
||||
assert result["rootHidden"] is True
|
||||
assert result["fileValue"] == ""
|
||||
assert result["workflowState"] is None
|
||||
assert result["availability"][0] == {
|
||||
"plan_date": "2026-08-21",
|
||||
"free_windows": [
|
||||
{"start_time": "09:00", "end_time": "10:00"},
|
||||
{"start_time": "11:00", "end_time": "17:00"},
|
||||
],
|
||||
}
|
||||
assert "free_windows" not in json.dumps(result["staged"])
|
||||
assert "Do not retain me" 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 '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="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')
|
||||
week_plan = (FRONTEND / "week-plan.js").read_text()
|
||||
assert "weekCalendarImport.mount" in week_plan
|
||||
assert "weekCalendarImport.mount" not in dashboard
|
||||
assert "open-week-capacity-import" in MODULE.read_text()
|
||||
assert ".week-capacity-import" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
@@ -0,0 +1,638 @@
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.today_store import TodayPromotionConflict, TodayStore, WeekPlanConflict
|
||||
|
||||
|
||||
def sample_days():
|
||||
return [
|
||||
{
|
||||
"plan_date": "2026-08-21",
|
||||
"ids": ["issue:secret/repo:3:", "issue:secret/repo:2:"],
|
||||
"capacity_minutes": 150,
|
||||
"estimates": {"issue:secret/repo:3:": 60, "issue:secret/repo:2:": 45},
|
||||
},
|
||||
{
|
||||
"plan_date": "2026-08-23",
|
||||
"ids": ["issue:secret/repo:5:"],
|
||||
"capacity_minutes": 90,
|
||||
"estimates": {"issue:secret/repo:5:": 30},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_week_plan_is_encrypted_account_scoped_revisioned_and_preserves_other_dates(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, encryption_key=b"w" * 32)
|
||||
|
||||
saved = store.replace_week(
|
||||
"Timmy", base_revision=0, days=sample_days(), timezone="America/Los_Angeles"
|
||||
)
|
||||
|
||||
assert saved == {
|
||||
"revision": 1,
|
||||
"timezone": "America/Los_Angeles",
|
||||
"days": sample_days(),
|
||||
}
|
||||
assert store.get_week("timmy") == saved
|
||||
assert store.get_week("alexander") == {"revision": 0, "timezone": None, "days": []}
|
||||
retained = path.read_bytes()
|
||||
assert b"issue:secret/repo" not in retained
|
||||
assert b"America/Los_Angeles" not in retained
|
||||
|
||||
changed = [dict(sample_days()[0], capacity_minutes=180), sample_days()[1]]
|
||||
updated = store.replace_week("timmy", base_revision=1, days=changed, timezone="America/Los_Angeles")
|
||||
assert updated["days"][1] == saved["days"][1]
|
||||
with pytest.raises(WeekPlanConflict) as conflict:
|
||||
store.replace_week("timmy", base_revision=1, days=[], timezone="UTC")
|
||||
assert conflict.value.snapshot == updated
|
||||
|
||||
|
||||
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,
|
||||
clock=lambda: datetime(2026, 8, 21, 13, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
days = [
|
||||
{
|
||||
"plan_date": "2026-08-20", "ids": [],
|
||||
"capacity_minutes": 60, "estimates": {},
|
||||
},
|
||||
{
|
||||
"plan_date": "2026-08-21", "ids": ["issue:private/repo:7:"],
|
||||
"capacity_minutes": 90,
|
||||
"estimates": {"issue:private/repo:7:": 45},
|
||||
},
|
||||
{
|
||||
"plan_date": "2026-08-22", "ids": ["issue:private/repo:8:"],
|
||||
"capacity_minutes": 120,
|
||||
"estimates": {"issue:private/repo:8:": 30},
|
||||
},
|
||||
]
|
||||
week = store.replace_week(
|
||||
"timmy", base_revision=0, days=days, timezone="America/New_York"
|
||||
)
|
||||
|
||||
assert store.get_start_day_plan("Timmy") == {
|
||||
**days[1], "timezone": "America/New_York",
|
||||
}
|
||||
assert store.get_week("timmy") == week
|
||||
|
||||
|
||||
def test_start_day_plan_falls_back_to_tomorrow_without_migrating_it(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"r" * 32)
|
||||
tomorrow = store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=["issue:private/repo:9:"],
|
||||
capacity_minutes=75, estimates={"issue:private/repo:9:": 45},
|
||||
plan_date="2026-08-21", timezone="UTC",
|
||||
)
|
||||
|
||||
assert store.get_start_day_plan("timmy") == tomorrow
|
||||
assert store.get_tomorrow("timmy") == tomorrow
|
||||
with store._connect() as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM week_plans WHERE login = ?", ("timmy",)
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_start_day_plan_uses_due_tomorrow_fallback_before_future_week_day(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3", encryption_key=b"r" * 32,
|
||||
clock=lambda: datetime(2026, 8, 20, 13, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
future = [{
|
||||
"plan_date": "2026-08-21", "ids": ["issue:private/repo:10:"],
|
||||
"capacity_minutes": 90, "estimates": {"issue:private/repo:10:": 45},
|
||||
}]
|
||||
store.replace_week("timmy", base_revision=0, days=future, timezone="UTC")
|
||||
tomorrow = store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=["issue:private/repo:9:"],
|
||||
capacity_minutes=75, estimates={"issue:private/repo:9:": 45},
|
||||
plan_date="2026-08-20", timezone="UTC",
|
||||
)
|
||||
|
||||
assert store.get_start_day_plan("timmy") == tomorrow
|
||||
|
||||
|
||||
def test_week_plan_rejects_the_same_work_on_multiple_dates_without_advancing_revision(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"w" * 32)
|
||||
original = store.replace_week(
|
||||
"timmy", base_revision=0, days=sample_days(), timezone="UTC"
|
||||
)
|
||||
duplicated = [
|
||||
sample_days()[0],
|
||||
{
|
||||
"plan_date": "2026-08-22",
|
||||
"ids": ["issue:secret/repo:3:"],
|
||||
"capacity_minutes": 60,
|
||||
"estimates": {"issue:secret/repo:3:": 30},
|
||||
},
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="work must be assigned to only one Week Ahead date"):
|
||||
store.replace_week(
|
||||
"timmy", base_revision=original["revision"], days=duplicated, timezone="UTC"
|
||||
)
|
||||
|
||||
assert store.get_week("timmy") == original
|
||||
|
||||
|
||||
def test_week_promotion_moves_only_due_date_to_today_exactly_once(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3",
|
||||
encryption_key=b"p" * 32,
|
||||
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.get("timmy")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
|
||||
promoted = store.promote_week(
|
||||
"timmy", promotion_id="week-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
)
|
||||
replay = store.promote_week(
|
||||
"timmy", promotion_id="week-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
)
|
||||
|
||||
assert replay == promoted
|
||||
assert promoted["ids"] == sample_days()[0]["ids"]
|
||||
remaining = store.get_week("timmy")
|
||||
assert remaining["revision"] == 2
|
||||
assert remaining["days"] == [sample_days()[1]]
|
||||
|
||||
|
||||
def test_week_early_start_moves_a_future_day_to_empty_today_exactly_once(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3",
|
||||
encryption_key=b"e" * 32,
|
||||
clock=lambda: datetime(2026, 8, 20, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.get("timmy")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
|
||||
promoted = store.promote_week(
|
||||
"timmy", promotion_id="early-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"], allow_future=True,
|
||||
)
|
||||
replay = store.promote_week(
|
||||
"timmy", promotion_id="early-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"], allow_future=True,
|
||||
)
|
||||
|
||||
assert replay == promoted
|
||||
assert promoted["ids"] == sample_days()[0]["ids"]
|
||||
assert promoted["plan_date"] == "2026-08-21"
|
||||
assert store.get_week("timmy")["days"] == [sample_days()[1]]
|
||||
|
||||
|
||||
def test_pull_week_item_atomically_appends_to_active_today_and_replays_exactly_once(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"i" * 32)
|
||||
active_id = "issue:stackchain/dashboard:1:"
|
||||
pulled_id = "issue:secret/repo:3:"
|
||||
today = store.apply("timmy", "seed-active", "add", active_id)
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
|
||||
result = store.pull_week_item(
|
||||
"timmy", operation_id="pull-secret-3", identity=pulled_id,
|
||||
today_revision=today["revision"], week_revision=week["revision"],
|
||||
)
|
||||
replay = store.pull_week_item(
|
||||
"timmy", operation_id="pull-secret-3", identity=pulled_id,
|
||||
today_revision=today["revision"], week_revision=week["revision"],
|
||||
)
|
||||
|
||||
assert replay == result
|
||||
assert result["today"]["ids"] == [active_id, pulled_id]
|
||||
assert result["today"]["estimates"] == {pulled_id: 60}
|
||||
assert result["week"]["days"][0]["ids"] == ["issue:secret/repo:2:"]
|
||||
assert result["week"]["days"][0]["estimates"] == {"issue:secret/repo:2:": 45}
|
||||
assert result["week"]["days"][1] == sample_days()[1]
|
||||
assert store.get("timmy") == result["today"]
|
||||
assert store.get_week("timmy") == result["week"]
|
||||
|
||||
|
||||
def test_pull_week_item_rejects_full_or_changed_today_without_partial_write(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"f" * 32)
|
||||
today = store.get("timmy")
|
||||
for index in range(5):
|
||||
today = store.apply("timmy", f"seed-{index}", "add", f"issue:r:{index + 1}:")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
|
||||
with pytest.raises(main.TodayPlanFull):
|
||||
store.pull_week_item(
|
||||
"timmy", operation_id="full", identity="issue:secret/repo:3:",
|
||||
today_revision=today["revision"], week_revision=week["revision"],
|
||||
)
|
||||
with pytest.raises(TodayPromotionConflict):
|
||||
store.pull_week_item(
|
||||
"timmy", operation_id="stale", identity="issue:secret/repo:3:",
|
||||
today_revision=today["revision"] - 1, week_revision=week["revision"],
|
||||
)
|
||||
|
||||
assert store.get("timmy") == today
|
||||
assert store.get_week("timmy") == week
|
||||
|
||||
|
||||
def test_pull_week_item_requires_explicit_today_capacity_overload_confirmation(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3", encryption_key=b"c" * 32,
|
||||
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
active_id = "issue:stackchain/dashboard:1:"
|
||||
pulled_id = "issue:stackchain/dashboard:2:"
|
||||
seed = store.replace_week(
|
||||
"timmy", base_revision=0, timezone="UTC", days=[{
|
||||
"plan_date": "2026-08-22", "ids": [active_id],
|
||||
"capacity_minutes": 60, "estimates": {active_id: 45},
|
||||
}],
|
||||
)
|
||||
today = store.promote_week(
|
||||
"timmy", promotion_id="seed-today", week_revision=seed["revision"],
|
||||
plan_date="2026-08-22", today_revision=0,
|
||||
)
|
||||
week = store.replace_week(
|
||||
"timmy", base_revision=2, timezone="UTC", days=[{
|
||||
"plan_date": "2026-08-23", "ids": [pulled_id],
|
||||
"capacity_minutes": 60, "estimates": {pulled_id: 30},
|
||||
}],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="explicit overload confirmation"):
|
||||
store.pull_week_item(
|
||||
"timmy", operation_id="unconfirmed", identity=pulled_id,
|
||||
today_revision=today["revision"], week_revision=week["revision"],
|
||||
)
|
||||
assert store.get("timmy") == today
|
||||
assert store.get_week("timmy") == week
|
||||
|
||||
moved = store.pull_week_item(
|
||||
"timmy", operation_id="confirmed", identity=pulled_id,
|
||||
today_revision=today["revision"], week_revision=week["revision"],
|
||||
allow_over_capacity=True,
|
||||
)
|
||||
assert moved["today"]["capacity_minutes"] == 60
|
||||
assert moved["today"]["estimates"] == {active_id: 45, pulled_id: 30}
|
||||
|
||||
|
||||
def test_normal_week_promotion_still_rejects_a_future_day_without_writes(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3",
|
||||
encryption_key=b"e" * 32,
|
||||
clock=lambda: datetime(2026, 8, 20, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.get("timmy")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
|
||||
with pytest.raises(main.TomorrowPlanNotDue):
|
||||
store.promote_week(
|
||||
"timmy", promotion_id="normal-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
)
|
||||
|
||||
assert store.get("timmy") == today
|
||||
assert store.get_week("timmy") == week
|
||||
|
||||
|
||||
def test_week_promotion_preserves_nonempty_today_and_the_due_week(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3",
|
||||
encryption_key=b"p" * 32,
|
||||
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.apply("timmy", "active", "add", "issue:r:active:")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
|
||||
with pytest.raises(TodayPromotionConflict) as blocked:
|
||||
store.promote_week(
|
||||
"timmy", promotion_id="week-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
)
|
||||
|
||||
assert blocked.value.today == today
|
||||
assert blocked.value.week == week
|
||||
assert store.get("timmy") == today
|
||||
assert store.get_week("timmy") == week
|
||||
|
||||
|
||||
def test_week_reconciliation_atomically_combines_today_and_consumes_only_due_day(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3", encryption_key=b"c" * 32,
|
||||
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.apply("timmy", "active", "add", "issue:r:active:")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
|
||||
result = store.reconcile_week(
|
||||
"timmy", promotion_id="reconcile-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
ids=["issue:r:active:", "issue:secret/repo:3:"], capacity_minutes=120,
|
||||
estimates={"issue:r:active:": 30, "issue:secret/repo:3:": 60},
|
||||
)
|
||||
replay = store.reconcile_week(
|
||||
"timmy", promotion_id="reconcile-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
ids=["issue:r:active:", "issue:secret/repo:3:"], capacity_minutes=120,
|
||||
estimates={"issue:r:active:": 30, "issue:secret/repo:3:": 60},
|
||||
)
|
||||
|
||||
assert replay == result
|
||||
assert result["today"]["ids"] == ["issue:r:active:", "issue:secret/repo:3:"]
|
||||
assert result["week"]["days"] == [sample_days()[1]]
|
||||
assert store.get("timmy") == result["today"]
|
||||
assert store.get_week("timmy") == result["week"]
|
||||
|
||||
|
||||
def test_week_reconciliation_rejects_work_outside_preserved_plans_without_partial_write(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3", encryption_key=b"c" * 32,
|
||||
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.apply("timmy", "active", "add", "issue:r:active:")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
|
||||
with pytest.raises(ValueError, match="selected work must come from Today or the due Week Ahead day"):
|
||||
store.reconcile_week(
|
||||
"timmy", promotion_id="bad", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
ids=["issue:other/repo:99:"], capacity_minutes=60,
|
||||
estimates={"issue:other/repo:99:": 30},
|
||||
)
|
||||
|
||||
assert store.get("timmy") == today
|
||||
assert store.get_week("timmy") == week
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_week_promotion_api_returns_both_preserved_plans_for_review(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3",
|
||||
encryption_key=b"p" * 32,
|
||||
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.apply("timmy", "active", "add", "issue:r:active:")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
|
||||
with pytest.raises(main.HTTPException) as blocked:
|
||||
await main.promote_week_plan(main.WeekPromotion(
|
||||
promotion_id="week-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
))
|
||||
|
||||
assert blocked.value.status_code == 409
|
||||
assert blocked.value.detail == {
|
||||
"code": "week_today_in_progress", "today": today, "week": week,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_week_early_start_api_promotes_a_future_day(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3", encryption_key=b"a" * 32,
|
||||
clock=lambda: datetime(2026, 8, 20, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.get("timmy")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
|
||||
result = await main.start_week_day_early(main.WeekPromotion(
|
||||
promotion_id="early-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
))
|
||||
|
||||
assert result["ids"] == sample_days()[0]["ids"]
|
||||
assert store.get_week("timmy")["days"] == [sample_days()[1]]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_week_item_api_returns_both_atomic_plans(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"u" * 32)
|
||||
today = store.apply("timmy", "seed", "add", "issue:r:active:")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
|
||||
result = await main.pull_week_item_into_today(main.WeekItemPull(
|
||||
operation_id="pull-api", identity="issue:secret/repo:3:",
|
||||
week_revision=week["revision"], today_revision=today["revision"],
|
||||
))
|
||||
|
||||
assert result["today"]["ids"] == ["issue:r:active:", "issue:secret/repo:3:"]
|
||||
assert result["week"]["days"][0]["ids"] == ["issue:secret/repo:2:"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_week_reconciliation_api_returns_atomic_today_and_remaining_week(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3", encryption_key=b"a" * 32,
|
||||
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.apply("timmy", "active", "add", "issue:r:active:")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
|
||||
result = await main.reconcile_week_plan(main.WeekReconciliation(
|
||||
promotion_id="reconcile-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
ids=["issue:r:active:", "issue:secret/repo:3:"], capacity_minutes=120,
|
||||
estimates={"issue:r:active:": 30, "issue:secret/repo:3:": 60},
|
||||
))
|
||||
|
||||
assert result["today"]["ids"] == ["issue:r:active:", "issue:secret/repo:3:"]
|
||||
assert result["week"]["days"] == [sample_days()[1]]
|
||||
|
||||
|
||||
def test_existing_tomorrow_plan_migrates_into_week_without_losing_planning_data(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"m" * 32)
|
||||
tomorrow = store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=["issue:r:9:"], capacity_minutes=75,
|
||||
estimates={"issue:r:9:": 45}, plan_date="2026-08-21", timezone="UTC",
|
||||
)
|
||||
|
||||
week = store.get_week("timmy")
|
||||
|
||||
assert week == {
|
||||
"revision": 1, "timezone": "UTC",
|
||||
"days": [{key: tomorrow[key] for key in ("plan_date", "ids", "capacity_minutes", "estimates")}],
|
||||
}
|
||||
assert store.get_tomorrow("timmy") == {
|
||||
"revision": 2, "ids": [], "capacity_minutes": None, "estimates": {}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_week_api_round_trip_and_conflict_contract(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"a" * 32)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
payload = main.WeekPlanUpdate(base_revision=0, days=sample_days(), timezone="UTC")
|
||||
|
||||
saved = await main.replace_week_plan(payload)
|
||||
assert await main.get_week_plan() == saved
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.replace_week_plan(payload)
|
||||
assert raised.value.status_code == 409
|
||||
assert raised.value.detail == {"code": "week_changed", "snapshot": saved}
|
||||
|
||||
|
||||
def test_reschedule_today_item_atomically_moves_it_into_selected_week_day(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"s" * 32)
|
||||
active_id = "issue:stackchain/dashboard:1228:"
|
||||
other_id = "issue:stackchain/dashboard:1229:"
|
||||
today = store.apply("timmy", "add-active", "add", active_id)
|
||||
today = store.apply("timmy", "add-other", "add", other_id)
|
||||
week = store.replace_week(
|
||||
"timmy",
|
||||
base_revision=0,
|
||||
timezone="UTC",
|
||||
days=[{
|
||||
"plan_date": "2026-08-24",
|
||||
"ids": [active_id],
|
||||
"capacity_minutes": 120,
|
||||
"estimates": {active_id: 30},
|
||||
}, {
|
||||
"plan_date": "2026-08-25",
|
||||
"ids": [],
|
||||
"capacity_minutes": 90,
|
||||
"estimates": {},
|
||||
}],
|
||||
)
|
||||
|
||||
result = store.reschedule_today_to_week(
|
||||
"timmy",
|
||||
operation_id="reschedule-active-2026-08-25",
|
||||
identity=active_id,
|
||||
estimate_minutes=45,
|
||||
plan_date="2026-08-25",
|
||||
today_revision=today["revision"],
|
||||
week_revision=week["revision"],
|
||||
)
|
||||
|
||||
assert result["today"]["ids"] == [other_id]
|
||||
assert result["week"]["days"] == [{
|
||||
"plan_date": "2026-08-24", "ids": [], "capacity_minutes": 120, "estimates": {},
|
||||
}, {
|
||||
"plan_date": "2026-08-25", "ids": [active_id], "capacity_minutes": 90,
|
||||
"estimates": {active_id: 45},
|
||||
}]
|
||||
assert store.get("timmy") == result["today"]
|
||||
assert store.get_week("timmy") == result["week"]
|
||||
|
||||
|
||||
def test_reschedule_today_item_is_idempotent_and_rejects_stale_plans_without_partial_write(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"i" * 32)
|
||||
identity = "issue:stackchain/dashboard:1228:"
|
||||
today = store.apply("timmy", "add-active", "add", identity)
|
||||
week = store.replace_week(
|
||||
"timmy", base_revision=0, timezone="UTC",
|
||||
days=[{
|
||||
"plan_date": "2026-08-25", "ids": [],
|
||||
"capacity_minutes": 60, "estimates": {},
|
||||
}],
|
||||
)
|
||||
arguments = {
|
||||
"operation_id": "same-operation", "identity": identity,
|
||||
"estimate_minutes": 30, "plan_date": "2026-08-25",
|
||||
"today_revision": today["revision"], "week_revision": week["revision"],
|
||||
}
|
||||
|
||||
moved = store.reschedule_today_to_week("timmy", **arguments)
|
||||
assert store.reschedule_today_to_week("timmy", **arguments) == moved
|
||||
|
||||
another = store.apply("timmy", "add-another", "add", "issue:stackchain/dashboard:1230:")
|
||||
with pytest.raises(TodayPromotionConflict) as conflict:
|
||||
store.reschedule_today_to_week(
|
||||
"timmy", **{**arguments, "operation_id": "stale-operation", "identity": another["ids"][-1]}
|
||||
)
|
||||
assert conflict.value.today == another
|
||||
assert store.get_week("timmy") == moved["week"]
|
||||
|
||||
|
||||
def test_reschedule_today_item_requires_explicit_capacity_overload_confirmation(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"o" * 32)
|
||||
identity = "issue:stackchain/dashboard:1228:"
|
||||
today = store.apply("timmy", "add-active", "add", identity)
|
||||
week = store.replace_week(
|
||||
"timmy", base_revision=0, timezone="UTC",
|
||||
days=[{
|
||||
"plan_date": "2026-08-25", "ids": ["issue:stackchain/dashboard:1:"],
|
||||
"capacity_minutes": 60, "estimates": {"issue:stackchain/dashboard:1:": 45},
|
||||
}],
|
||||
)
|
||||
arguments = {
|
||||
"identity": identity, "estimate_minutes": 30, "plan_date": "2026-08-25",
|
||||
"today_revision": today["revision"], "week_revision": week["revision"],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="explicit overload confirmation"):
|
||||
store.reschedule_today_to_week(
|
||||
"timmy", operation_id="unconfirmed", allow_over_capacity=False, **arguments
|
||||
)
|
||||
assert store.get("timmy") == today
|
||||
assert store.get_week("timmy") == week
|
||||
|
||||
moved = store.reschedule_today_to_week(
|
||||
"timmy", operation_id="confirmed", allow_over_capacity=True, **arguments
|
||||
)
|
||||
assert moved["week"]["days"][0]["ids"][-1] == identity
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_week_reschedule_api_returns_atomic_snapshots_and_conflict_recovery(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"a" * 32)
|
||||
identity = "issue:stackchain/dashboard:1228:"
|
||||
today = store.apply("timmy", "add-active", "add", identity)
|
||||
week = store.replace_week(
|
||||
"timmy", base_revision=0, timezone="UTC",
|
||||
days=[{
|
||||
"plan_date": "2026-08-25", "ids": [],
|
||||
"capacity_minutes": 60, "estimates": {},
|
||||
}],
|
||||
)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
payload = main.WeekReschedule(
|
||||
operation_id="move-active", identity=identity, estimate_minutes=30,
|
||||
plan_date="2026-08-25", today_revision=today["revision"],
|
||||
week_revision=week["revision"],
|
||||
)
|
||||
|
||||
moved = await main.reschedule_today_to_week(payload)
|
||||
assert moved["today"]["ids"] == []
|
||||
assert moved["week"]["days"][0]["ids"] == [identity]
|
||||
|
||||
stale = main.WeekReschedule(
|
||||
operation_id="stale", identity="issue:stackchain/dashboard:1229:", estimate_minutes=30,
|
||||
plan_date="2026-08-25", today_revision=today["revision"], week_revision=week["revision"],
|
||||
)
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.reschedule_today_to_week(stale)
|
||||
assert raised.value.status_code == 409
|
||||
assert raised.value.detail["code"] == "today_changed"
|
||||
assert raised.value.detail["today"] == moved["today"]
|
||||
assert raised.value.detail["week"] == moved["week"]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -25,16 +25,23 @@ const status={textContent:''};
|
||||
const document={
|
||||
querySelector(selector) {
|
||||
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
|
||||
if (selector === 'meta[name="stackchain-feature-planning"]') return {content:'feature-planning-def.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
let requested='';
|
||||
const createLoader=options=>({load:async name=>{requested=name + ':' + options.urls[name];}});
|
||||
const requested=[];
|
||||
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
|
||||
await loadWorkspace({document,createLoader});
|
||||
console.log(JSON.stringify({requested,status:status.textContent}));
|
||||
""")
|
||||
assert result == {"requested": "today-timer:feature-workspace-abc.js", "status": ""}
|
||||
assert result == {
|
||||
"requested": [
|
||||
"today-timer:feature-workspace-abc.js",
|
||||
"planning:feature-planning-def.js",
|
||||
],
|
||||
"status": "",
|
||||
}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_recovers_one_transient_failure_in_place():
|
||||
@@ -51,7 +58,7 @@ const schedule=callback=>{callback();};
|
||||
await loadWorkspace({document,createLoader,schedule});
|
||||
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
|
||||
""")
|
||||
assert result == {"attempts": 2, "status": "", "retryHidden": True}
|
||||
assert result == {"attempts": 4, "status": "", "retryHidden": True}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_offers_single_flight_manual_retry_without_reloading():
|
||||
@@ -65,7 +72,7 @@ const document={querySelector(selector) {
|
||||
return null;
|
||||
}};
|
||||
const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}};
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}});
|
||||
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent};
|
||||
@@ -74,7 +81,7 @@ await Promise.all([first,second,loading]);
|
||||
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
|
||||
""")
|
||||
assert result == {
|
||||
"attempts": 3,
|
||||
"attempts": 6,
|
||||
"reloads": 0,
|
||||
"offered": {
|
||||
"hidden": False,
|
||||
@@ -109,7 +116,7 @@ const window={
|
||||
addEventListener(name,callback) { listeners[name]=callback; },
|
||||
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
||||
};
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}});
|
||||
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const waiting=Boolean(listeners.online);
|
||||
@@ -118,7 +125,7 @@ await loading;
|
||||
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"attempts": 3,
|
||||
"attempts": 6,
|
||||
"waiting": True,
|
||||
"reloads": 0,
|
||||
"status": "",
|
||||
|
||||
Reference in new issue
Block a user