Compare commits
296
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b448fef23 | ||
|
|
881653eef1 | ||
|
|
d8476c6f7b | ||
|
|
6eb2709477 | ||
|
|
05405bf2e8 | ||
|
|
b18a50fec5 | ||
|
|
be9de6596f | ||
|
|
71631c6731 | ||
|
|
548b95ddb6 | ||
|
|
e0d8ab9f10 | ||
|
|
a629b5a93e | ||
|
|
ce9301f145 | ||
|
|
d0e8378a02 | ||
|
|
424ab2bb6d | ||
|
|
5fb4da30a4 | ||
|
|
f78da3dfc9 | ||
|
|
14b3fb4048 | ||
|
|
d27a481d96 | ||
|
|
24eb4d1e79 | ||
|
|
3c35aa31c1 | ||
|
|
363a97aa5e | ||
|
|
306a20c37f | ||
|
|
e06bd8435d | ||
|
|
76f89df609 | ||
|
|
f0a966d3a3 | ||
|
|
9eac3bb958 | ||
|
|
fc93620118 | ||
|
|
15739f7370 | ||
|
|
1ae8533702 | ||
|
|
ea3204c78c | ||
|
|
6e72f59fcf | ||
|
|
24cadf88f8 | ||
|
|
4b645d8460 | ||
|
|
a4498fdbbc | ||
|
|
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 | ||
|
|
ebb7a78cab | ||
|
|
b4a5401b22 | ||
|
|
870edda123 | ||
|
|
156d4b0ffc | ||
|
|
304e4a8a24 | ||
|
|
8731682090 | ||
|
|
348f43f1bc | ||
|
|
161563d6f5 | ||
|
|
931c82361b | ||
|
|
61b332e814 | ||
|
|
4b3e53f4d0 | ||
|
|
f3c9e89920 | ||
|
|
1248647aa4 | ||
|
|
b3832d107f | ||
|
|
a0da8e9fb2 | ||
|
|
8794200c2a | ||
|
|
c2526969b1 | ||
|
|
3b4f6b39a5 | ||
|
|
a9cf88b3ff | ||
|
|
3d4be0a913 | ||
|
|
9252807364 | ||
|
|
db85bfa5f9 | ||
|
|
3822ec4536 | ||
|
|
b278367e3b | ||
|
|
13e73bd342 | ||
|
|
1047f3ecdc | ||
|
|
f09c922c46 | ||
|
|
be9ce8ede6 | ||
|
|
7f2681c1c6 | ||
|
|
91fdd958c2 | ||
|
|
b70a2fb607 | ||
|
|
950d52d147 | ||
|
|
d054cd23ed | ||
|
|
f4d8d64b9d | ||
|
|
9763bb410b | ||
|
|
db3ca83f29 | ||
|
|
c219f130c8 | ||
|
|
dc4f457b21 | ||
|
|
9a471b7903 | ||
|
|
67d3242f37 | ||
|
|
fb08bc5ce5 | ||
|
|
9d9b28af8e | ||
|
|
8ad02933c9 | ||
|
|
d5dc3f27db | ||
|
|
625d937f64 | ||
|
|
2bd20130b6 | ||
|
|
fdf6ae3ddd | ||
|
|
fc9ef1f8ae | ||
|
|
8cd1d1f684 | ||
|
|
e79c457774 | ||
|
|
d3f53b5a38 | ||
|
|
19a1184b1c | ||
|
|
ad40cb5fb9 | ||
|
|
a38934b099 | ||
|
|
3cf33869d6 | ||
|
|
41195f4c75 | ||
|
|
6db0802461 | ||
|
|
087300f9c3 | ||
|
|
abc736d32e | ||
|
|
b3a359760c | ||
|
|
5f86d48e6f | ||
|
|
1c17fcf337 | ||
|
|
1c6f5c685f | ||
|
|
473f592615 | ||
|
|
aba4b55ca0 | ||
|
|
4a53a95b2d | ||
|
|
a6f34fbfde | ||
|
|
472371d5a8 | ||
|
|
d7b8cd5e71 | ||
|
|
58cee5ea16 | ||
|
|
38efd4eb7c | ||
|
|
d4910b4398 | ||
|
|
ff34416c3c | ||
|
|
f8c2541d30 | ||
|
|
d9749e3a6f | ||
|
|
b77be4f5b0 | ||
|
|
fb7bfe0a5d | ||
|
|
79f6cecab4 | ||
|
|
0472e88d91 | ||
|
|
f710b2ce1d | ||
|
|
027ded1f8f | ||
|
|
3c8e2e2720 | ||
|
|
1bd87fc33e | ||
|
|
4009a4a4c1 | ||
|
|
339baab7ee | ||
|
|
a6c769901d | ||
|
|
a65aed5862 | ||
|
|
8b4bd480cc | ||
|
|
9c46455c35 | ||
|
|
47626338ba | ||
|
|
e290293bf1 | ||
|
|
688eadf17a | ||
|
|
daf34d7cdd | ||
|
|
1410a66a36 | ||
|
|
157d485685 | ||
|
|
4f07402936 | ||
|
|
9a4a1711fe | ||
|
|
7502d6f44d | ||
|
|
bed9bb4270 | ||
|
|
406d75287d | ||
|
|
8c604e0b04 | ||
|
|
ed15392097 | ||
|
|
af86034339 | ||
|
|
e8817056f7 | ||
|
|
a757bfe587 | ||
|
|
44aedfd623 | ||
|
|
58299311d0 | ||
|
|
89c25369fc | ||
|
|
be10dba4bd | ||
|
|
c655cf5861 | ||
|
|
2082fbd5eb | ||
|
|
55156f45c9 | ||
|
|
5bf0f97df8 | ||
|
|
ce2f04ab6d | ||
|
|
f7b2a96206 | ||
|
|
a6550f8b1f | ||
|
|
66968d90c0 | ||
|
|
442d0a157d | ||
|
|
4a85f02eae | ||
|
|
dfd33aee1c | ||
|
|
c5b466f281 | ||
|
|
1d242bab75 | ||
|
|
40284a7e30 | ||
|
|
ea08f566ff | ||
|
|
ca671f0d00 | ||
|
|
6e677f8857 | ||
|
|
e47e6329cc | ||
|
|
11395b655a | ||
|
|
ca520d2eb6 | ||
|
|
25dda21811 | ||
|
|
8a57ea73fb | ||
|
|
6729359ba2 | ||
|
|
f687389046 | ||
|
|
a7f4f9714f | ||
|
|
7ba238fb53 | ||
|
|
b845f8ba95 | ||
|
|
996309f52f | ||
|
|
8d12a0384b |
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_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
(cd dist && sha256sum -c ./*.sha256)
|
||||
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"
|
||||
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .
|
||||
|
||||
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"CI-tested release candidate for commit %s. Verify downloads with the attached SHA-256 checksum.","draft":true,"prerelease":true}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
|
||||
|
||||
@@ -40,10 +40,10 @@ identity and posts one ordered Markdown evidence comment. After a partial failur
|
||||
the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA
|
||||
Share Target accepts the same bounded multi-image bundle through sign-in continuation.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
issue-comment API. In issue, pull-request, and unread-update conversations, typing
|
||||
at least two characters after `@` offers repository-scoped teammate suggestions;
|
||||
touch or keyboard selection inserts the login without leaving the draft. Mention
|
||||
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
||||
issue-comment API. In issue, pull-request, unread-update, and active Today progress
|
||||
conversations, typing at least two characters after `@` offers repository-scoped
|
||||
teammate suggestions; touch or keyboard selection inserts the login without leaving
|
||||
the draft. Mention lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
||||
creation and assignment permission. A fresh capture never silently targets the first repository:
|
||||
the operator must explicitly choose one, either from the paginated browser or through the bounded
|
||||
authenticated repository search. Search results include only visible repository identities, stale
|
||||
@@ -75,6 +75,23 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
|
||||
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
||||
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
||||
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
||||
Eligible open issue previews also expose **Plan ahead**: choose one of the next seven local dates, review
|
||||
that day's current load and capacity, and enter a required estimate without leaving Search. Existing Week
|
||||
Ahead placement is moved only after an explicit day change, never duplicated; full days are blocked and
|
||||
over-capacity admission requires a second confirmation. The action claims unassigned work only after these
|
||||
checks, stages the canonical issue through the account-bound Week Ahead outbox, and preserves the Search
|
||||
preview through cancel or browser Back. Offline saves report **sync pending**; if assignment succeeds but
|
||||
local planning fails, the dashboard reports **Assigned, not planned** and leaves the issue recoverable in My Work.
|
||||
The live mobile Week Ahead overview also offers **Remove from week** for active planned work. It removes only the
|
||||
private planning placement—not assignment, issue state, or Gitea content—and immediately recalculates the day's
|
||||
load. A 10-second **Undo** receipt restores the exact day, list position, and estimate. Both transitions use the
|
||||
account-bound Week Ahead outbox, remain **sync pending** after delivery failure, and stay unavailable in a read-only
|
||||
offline snapshot.
|
||||
Search selection mode extends that flow across several open issues with **Plan Week Ahead**. One phone-safe
|
||||
review assigns each issue a future day and estimate, validates five-item limits and daily capacity before any
|
||||
claim, and requires a second confirmation for overload. Confirmed rows are staged into one canonical Week
|
||||
Ahead update and flushed together; partial assignment or offline sync is reported without discarding the
|
||||
selection, so claimed work remains recoverable from My Work.
|
||||
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
|
||||
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
|
||||
can queue photo-only, text-only, or mixed replies without leaving their Search pass, including while offline.
|
||||
@@ -117,8 +134,17 @@ time. An over-capacity plan requires a second explicit save, legacy plans migrat
|
||||
and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
|
||||
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
|
||||
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
|
||||
as unknown rather than unblocked. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
as unknown rather than unblocked. Completing any Today item now exposes a 10-second, touch-safe
|
||||
**Undo** receipt. Undo restores the item's original order and estimate, queues the inverse cross-device
|
||||
plan changes, and leaves the already-advanced work session on its current item; expiry, capacity, or a
|
||||
concurrently changed plan is reported without overwriting newer work. Rescheduling Today work into
|
||||
Week Ahead uses a bounded account-scoped FIFO on the device: multiple offline moves survive reload,
|
||||
deliver oldest-first with stable operation IDs, and rebase each later move on the confirmed plan revisions
|
||||
without concurrent requests. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. Cross-device **Continue here** handoff transfers the bounded per-item timing ledger—not only the active item—so the receiving phone keeps the complete recap and optional time-log durations; legacy single-item sessions migrate automatically. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
After wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it.
|
||||
|
||||
A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. When work is blocked, **Post blocker & move on** requires a future return time, admits the comment before any planning change, defers the item to Later, and continues the existing Today session. An admitted planning transition remains checkpointed for retry, so a storage or Today-removal failure cannot post the blocker twice. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
|
||||
After a reload or installed-app
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open
|
||||
assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps
|
||||
@@ -143,7 +169,46 @@ failure preserves both the reply draft and checkpoint. Finishing or choosing **E
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
||||
offline operations. **Plan Tomorrow** remains independent from active Today work and durably admits the exact
|
||||
ordered plan, capacity, estimates, next local date, timezone, and server revision to account-bound browser
|
||||
storage before closing on a phone. The Queues summary marks it **sync pending** while offline; reconnect,
|
||||
foreground, and midnight lifecycle checks share one delivery flight. A successful account receipt removes
|
||||
the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for
|
||||
review. Unsynced Tomorrow work is never promoted into Today.
|
||||
**Week Ahead** opens as a read-first seven-day mobile overview, so operators can inspect the next planned day,
|
||||
work titles and references, load versus capacity, overloads, and pending sync without staging a change. When Today
|
||||
is empty and no work session is active, **Start this day early** confirms the next planned date, item count, minutes,
|
||||
and capacity before atomically moving only that day into Today; offline, pending, stale, or non-empty plans remain
|
||||
unchanged. **Edit day** enters one date and returns to the refreshed overview; **Edit week** starts the continuous
|
||||
planning pass. Closing an issue from its Week Ahead detail retires that identity and estimate from every future
|
||||
week day in one durable update while preserving sibling order and daily capacity. The open overview recalculates
|
||||
immediately; an unavailable save remains visibly **sync pending** and retries through the existing lifecycle.
|
||||
**Plan Week Ahead** continues through seven local dates and now finishes on a mobile review step instead of
|
||||
closing after the seventh save. The review shows planned minutes against each day’s capacity, marks overloads,
|
||||
and flags work assigned to more than one date. Operators can move an item to another date without copying it;
|
||||
the estimate follows the item and the server rejects duplicate cross-day assignments without advancing the
|
||||
week revision. When capacity changes create overload, **Reflow remaining week** previews a deterministic,
|
||||
order-preserving redistribution across the seven visible dates. No day exceeds its capacity or five-item limit;
|
||||
work that cannot fit is named and remains in My Work, while missing estimates block apply. Cancel performs no
|
||||
write, and apply stages one durable whole-week transition through the existing conflict-safe sync path.
|
||||
Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
|
||||
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
|
||||
are not stranded behind an earlier request. From the same review, **Refresh from calendar** reads a local
|
||||
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and compares every current
|
||||
capacity with refreshed availability and planned load. If refreshed availability creates an overload, the review
|
||||
previews the deterministic destination of moved work and reports anything that cannot fit. One explicit
|
||||
**Apply capacities & reflow** action stages the complete seven-day plan once—there is no transient overloaded
|
||||
save—and preserves consented free windows alongside the redistributed work. A refresh that already fits keeps
|
||||
the direct one-write capacity path. IANA `TZID` values from Google and Outlook calendars are converted
|
||||
to the device's local workday, including daylight-saving transitions. Daily and weekly recurrence (`COUNT`,
|
||||
`UNTIL`, `INTERVAL`, and weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated with work bounded
|
||||
to the seven review dates; cancelled and transparent events do not consume capacity. If a time zone is unknown,
|
||||
or a recurring event that could affect the week uses another frequency or unsupported rule part, the review
|
||||
reports only an affected-event count and disables apply rather than understating busy time. Export a simpler
|
||||
seven-day calendar to proceed. Raw calendar data and event
|
||||
metadata are never persisted, rendered in diagnostics, or sent; only the reviewed capacity-minute totals use
|
||||
the existing encrypted, account-bound Week Ahead sync.
|
||||
Planning edits can remain offline for up to 30 days. After that, the
|
||||
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
||||
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||
receipts older than the same 30-day window; client base revisions keep a pruned replay from
|
||||
@@ -185,8 +250,26 @@ cannot alter a newer crash-recovery claim. Device purge cancels an active drain
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. All private SQLite stores enforce a
|
||||
filesystem boundary independently of the service umask: the database directory is repaired to
|
||||
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
|
||||
paths are rejected before access. This protects state from unrelated local accounts, but it is not
|
||||
encryption at rest; secure host access, encrypted volumes, and private backups are still required.
|
||||
paths are rejected before access. Worker-shared live and Find Work snapshots add AES-256-GCM
|
||||
envelopes authenticated to their store identity (and live generation), so copied databases do not
|
||||
expose issue bodies, titles, notification metadata, or repository context. The authored-action
|
||||
idempotency ledger encrypts both request fingerprints and confirmed upstream responses, authenticating
|
||||
each envelope to its operation key and field purpose so rows and fields cannot be substituted.
|
||||
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
||||
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
||||
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
||||
likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
|
||||
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
|
||||
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
|
||||
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
|
||||
startup without changing order or acknowledgement state. Missing, wrong, or modified key material
|
||||
returns no saved-view or receipt content. Other private stores
|
||||
are not encrypted at the application layer. Web
|
||||
Push subscriptions use a third, independent
|
||||
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
|
||||
single-device enrollment without retaining capability URLs. Existing plaintext subscriptions
|
||||
migrate atomically at startup without resetting delivery checkpoints or reminder schedules. Secure
|
||||
host access, encrypted volumes, and private backups are still required.
|
||||
Set `STACKCHAIN_STATE_DIR` to a
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||
@@ -252,6 +335,44 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
|
||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
|
||||
# planning, Saved Search state, completed Filed review receipts, and the Security activity journal. Keep this key independent
|
||||
# from the Draft key and inject the
|
||||
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
|
||||
# it. Missing, malformed, wrong-key, or modified state fails closed without
|
||||
# returning content. Legacy Today/Later, Saved Search, and Security activity rows
|
||||
# migrate atomically on first use without changing logical revisions or journal IDs.
|
||||
# The single-key setting remains the compatible first deployment and writes v1 envelopes.
|
||||
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||
# After all workers run keyring-capable code, configure at most four named keys and
|
||||
# select the active write key. Keep the old key present during the rolling deployment.
|
||||
# Key IDs use 1-32 letters, digits, underscores, or hyphens.
|
||||
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS='{"legacy":"<old-base64-key>","2026-08":"<new-base64-key>"}'
|
||||
export STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID='2026-08'
|
||||
# New writes use authenticated v2 envelopes carrying the active key ID. Rewrap every
|
||||
# shared store with the restart-safe command; output contains store-level counts only.
|
||||
# Run it again until every store reports failed=0, migrated=0, and current=total,
|
||||
# then remove the old key from every worker. Retain all keys and investigate if it exits 1.
|
||||
python3 scripts/rotate_private_state.py
|
||||
# Required for cross-device unfiled Draft sync. The single-key setting remains
|
||||
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
|
||||
# Inject the base64 encoding of exactly 32 random bytes from a secret manager.
|
||||
export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||
# After every worker runs keyring-capable code, replace the single-key setting with
|
||||
# a bounded JSON object (at most eight keys) and name one active write key. During
|
||||
# the first rotation, preserve the original single key under the reserved `legacy`
|
||||
# ID so existing v1 envelopes remain readable. Key IDs use 1-32 letters, digits,
|
||||
# underscores, or hyphens. Never commit either setting.
|
||||
export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS='{"legacy":"<old-base64-key>","2026-08":"<new-base64-key>"}'
|
||||
export STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID='2026-08'
|
||||
# New writes use authenticated v2 envelopes carrying the active key ID. Reads
|
||||
# atomically rewrap plaintext, v1, and inactive-key rows without advancing their
|
||||
# logical revision. Run the content-free, restart-safe migration until it exits 0:
|
||||
python3 scripts/rotate_unfiled_drafts.py
|
||||
# A result such as {"current":42,"failed":0,"migrated":0,"total":42} proves the
|
||||
# old key has no remaining row dependencies. Only then remove `legacy`/old keys and
|
||||
# restart. A nonzero exit reports unreadable row counts but never account or Draft
|
||||
# content. Roll back only to a keyring-capable build and retain every configured key.
|
||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
||||
@@ -267,6 +388,11 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
|
||||
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
||||
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
||||
# Required whenever all three VAPID settings enable Web Push. Keep this key
|
||||
# independent from the snapshot and Draft keys. Startup authenticates every retained
|
||||
# subscription and fails closed for a missing, malformed, wrong, or modified key.
|
||||
# Back up the key separately: losing it makes existing device enrollments unrecoverable.
|
||||
export STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
|
||||
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
|
||||
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
|
||||
@@ -285,6 +411,29 @@ export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqli
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
`GITEA_URL` is also the trust boundary for every Gitea resource link returned
|
||||
to the browser. Configure the externally reachable scheme, host, and subpath
|
||||
(for example `https://forge.example.com/git`); cross-origin, downgraded, and
|
||||
same-host links outside that subpath are discarded.
|
||||
|
||||
For a systemd deployment, keep those values in a root-readable environment file
|
||||
(`chmod 600`), reference it with `EnvironmentFile=`, and keep secrets out of the unit
|
||||
command line and repository. Liveness alone does not prove that operators can use the
|
||||
service: `/healthz` intentionally remains healthy when authentication is missing. After
|
||||
each restart or proxy change, run the complete public-subpath smoke journey with the
|
||||
operator secret supplied only through the process environment:
|
||||
|
||||
```bash
|
||||
STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>' \
|
||||
python3 scripts/verify_deployment.py \
|
||||
https://forge.example.com/dashboard/
|
||||
```
|
||||
|
||||
The verifier requires readiness, rendered sign-in, a manifest whose `scope` and
|
||||
`start_url` remain inside the supplied subpath, and an authenticated mobile Home with
|
||||
its New-issue entry. It emits only a small JSON result and never includes the operator
|
||||
secret in success or failure output.
|
||||
|
||||
Token and passkey sign-in failures are scoped to a hashed canonical client address and
|
||||
persisted across workers and restarts. Public passkey option issuance has a separate
|
||||
fixed-window admission budget in the same ledger, and live challenges are bounded per
|
||||
@@ -317,8 +466,13 @@ place, their live sessions remain valid, and their idle clock starts at migratio
|
||||
The same sheet includes **Security activity**, a reverse-chronological journal of
|
||||
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
|
||||
revocations, issue closures, and pull-request merges. The separate SQLite journal
|
||||
retains at most 10,000 events for 90 days and stores only bounded device labels and
|
||||
action targets. It never stores access tokens, cookies, session/CSRF values,
|
||||
retains at most 10,000 events for 90 days. Its bounded event kind, authentication
|
||||
method, device label, and action target are sealed at rest with the private-state
|
||||
encryption key and authenticated against the immutable event ID. Legacy plaintext
|
||||
journals migrate atomically without changing IDs, timestamps, order, or pending
|
||||
operations. Missing or wrong key material and modified ciphertext fail closed with
|
||||
no partial activity response. The journal never stores access tokens, cookies,
|
||||
session/CSRF values,
|
||||
credential IDs, public keys, challenges, attestation data, raw network addresses,
|
||||
request bodies, or comment content. Keep its database on the same class of persistent,
|
||||
writable storage as the session registry. Before passkey credential creation, session
|
||||
@@ -386,6 +540,11 @@ is assembled in source order into one content-addressed JavaScript response. Das
|
||||
HTML and the offline worker reference that exact fingerprint, while the runtime receives
|
||||
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
|
||||
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
|
||||
Device Setup also checks whether the browser has granted persistent storage without prompting.
|
||||
Choose **Protect offline work** to request protection from automatic storage-pressure eviction;
|
||||
Stackchain reports **Protected**, **Best effort**, **Denied**, or an unavailable/retryable state
|
||||
truthfully. Offline work continues when protection is unavailable or denied, but the browser may
|
||||
remove best-effort data, so persistence does not replace backups or device security.
|
||||
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
||||
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
|
||||
removing unrelated forge preferences. Rotate either dashboard secret by replacing
|
||||
|
||||
@@ -23,3 +23,20 @@
|
||||
- API contracts + versioning
|
||||
- independent deployability
|
||||
- observability: metrics, traces, structured logs
|
||||
|
||||
# Disk capacity incident
|
||||
|
||||
Root filesystem usage at or above 85% is an operations incident.
|
||||
|
||||
1. Capture the initial state with `df -h / /var/lib/gitea`.
|
||||
2. Inspect directory sizes and active processes before cleanup. Reclaim only
|
||||
disposable caches and abandoned temporary environments. Responders must not
|
||||
delete Gitea data, repositories, databases, secrets, or active release
|
||||
artifacts.
|
||||
3. Re-run `df -h / /var/lib/gitea`; usage must be below 85%.
|
||||
4. Confirm the release path with `systemctl is-active gitea act_runner`, then run
|
||||
the repository test suite.
|
||||
|
||||
Record the before and after usage in the incident ticket. If safe cleanup cannot
|
||||
restore capacity below 85%, keep the incident open and escalate storage
|
||||
expansion rather than removing durable data.
|
||||
@@ -0,0 +1,180 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\r?\n/g, '\\n')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/;/g, '\\;');
|
||||
}
|
||||
|
||||
function calendarDay(value) {
|
||||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
return match ? match.slice(1).join('') : '';
|
||||
}
|
||||
|
||||
function nextDay(day) {
|
||||
const date = new Date(Date.UTC(
|
||||
Number(day.slice(0, 4)), Number(day.slice(4, 6)) - 1, Number(day.slice(6, 8)) + 1
|
||||
));
|
||||
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
}
|
||||
|
||||
function uid(item) {
|
||||
const repository = String(item.repository || '').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
|
||||
return `issue-${Number(item.number)}@${repository}`;
|
||||
}
|
||||
|
||||
function foldLine(line) {
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [];
|
||||
let chunk = '';
|
||||
let bytes = 0;
|
||||
let limit = 75;
|
||||
for (const character of String(line)) {
|
||||
const width = encoder.encode(character).length;
|
||||
if (chunk && bytes + width > limit) {
|
||||
chunks.push(chunk);
|
||||
chunk = character;
|
||||
bytes = width;
|
||||
limit = 74;
|
||||
} else {
|
||||
chunk += character;
|
||||
bytes += width;
|
||||
}
|
||||
}
|
||||
chunks.push(chunk);
|
||||
return chunks.join('\r\n ');
|
||||
}
|
||||
|
||||
function serializeAgendaCalendar(items, { generatedOn } = {}) {
|
||||
const stampDay = String(generatedOn || new Date().toISOString().slice(0, 10).replace(/-/g, ''));
|
||||
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Stackchain//Agenda Snapshot//EN',
|
||||
'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:Stackchain Agenda'];
|
||||
(items || []).forEach(item => {
|
||||
const day = calendarDay(item.due_date);
|
||||
if (!day || !Number.isInteger(Number(item.number)) || !item.repository) return;
|
||||
lines.push(
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${uid(item)}`,
|
||||
`DTSTAMP:${stampDay}T000000Z`,
|
||||
`DTSTART;VALUE=DATE:${day}`,
|
||||
`DTEND;VALUE=DATE:${nextDay(day)}`,
|
||||
`SUMMARY:${escapeText(item.title)}`,
|
||||
`DESCRIPTION:${escapeText(`${item.repository}#${item.number} · Stackchain Agenda snapshot`)}`,
|
||||
`URL:${String(item.url || '')}`,
|
||||
'TRANSP:TRANSPARENT',
|
||||
'END:VEVENT',
|
||||
);
|
||||
});
|
||||
lines.push('END:VCALENDAR');
|
||||
return lines.map(foldLine).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
async function deliverCalendarSnapshot({
|
||||
text,
|
||||
filename,
|
||||
navigator,
|
||||
document,
|
||||
urlApi,
|
||||
FileCtor,
|
||||
}) {
|
||||
const file = new FileCtor([text], filename, { type: 'text/calendar;charset=utf-8' });
|
||||
const sharePayload = { files: [file], title: 'Stackchain Agenda', text: 'Agenda calendar snapshot' };
|
||||
if (typeof navigator?.share === 'function' && typeof navigator?.canShare === 'function' &&
|
||||
navigator.canShare(sharePayload)) {
|
||||
await navigator.share(sharePayload);
|
||||
return 'shared';
|
||||
}
|
||||
const href = urlApi.createObjectURL(file);
|
||||
try {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = href;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
} finally {
|
||||
urlApi.revokeObjectURL(href);
|
||||
}
|
||||
return 'downloaded';
|
||||
}
|
||||
|
||||
function mountAgendaCalendarExport({
|
||||
qs,
|
||||
getItems,
|
||||
escapeHtml,
|
||||
onDone,
|
||||
windowObject = root,
|
||||
navigatorObject = root.navigator,
|
||||
documentObject = root.document,
|
||||
urlApi = root.URL,
|
||||
FileCtor = root.File,
|
||||
}) {
|
||||
const sheet = qs('#agenda-export-sheet');
|
||||
let items = [];
|
||||
let scrollY = 0;
|
||||
let trigger = null;
|
||||
const selectedItems = () => Array.from(qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]'))
|
||||
.filter(checkbox => checkbox.checked)
|
||||
.map(checkbox => items[Number(checkbox.value)])
|
||||
.filter(Boolean);
|
||||
const updateSelection = () => {
|
||||
const selected = selectedItems();
|
||||
qs('#share-agenda-export').disabled = selected.length === 0;
|
||||
qs('#agenda-export-status').textContent = selected.length + ' of ' + items.length +
|
||||
(items.length === 1 ? ' deadline selected.' : ' deadlines selected.');
|
||||
};
|
||||
const close = () => {
|
||||
sheet.close();
|
||||
windowObject.scrollTo({ top:scrollY, behavior:'instant' });
|
||||
trigger?.focus();
|
||||
};
|
||||
qs('#open-agenda-export').addEventListener('click', event => {
|
||||
items = getItems();
|
||||
scrollY = windowObject.scrollY;
|
||||
trigger = event.currentTarget;
|
||||
qs('#agenda-export-items').innerHTML = items.map((item, index) =>
|
||||
'<label class="agenda-export-item"><input type="checkbox" value="' + index + '" checked> ' +
|
||||
'<span><strong>' + escapeHtml(item.title) + '</strong><small>' +
|
||||
escapeHtml(item.repository + '#' + item.number + ' · ' + item.due_date.slice(0, 10)) +
|
||||
'</small></span></label>'
|
||||
).join('');
|
||||
qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]').forEach(checkbox =>
|
||||
checkbox.addEventListener('change', updateSelection)
|
||||
);
|
||||
updateSelection();
|
||||
sheet.showModal();
|
||||
qs('#cancel-agenda-export').focus();
|
||||
});
|
||||
qs('#cancel-agenda-export').addEventListener('click', close);
|
||||
sheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
close();
|
||||
});
|
||||
qs('#share-agenda-export').addEventListener('click', async () => {
|
||||
const selected = selectedItems();
|
||||
if (!selected.length) return;
|
||||
const button = qs('#share-agenda-export');
|
||||
button.disabled = true;
|
||||
qs('#agenda-export-status').textContent = 'Preparing calendar snapshot…';
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const text = serializeAgendaCalendar(selected, { generatedOn:day.replace(/-/g, '') });
|
||||
const result = await deliverCalendarSnapshot({
|
||||
text, filename:'stackchain-agenda-' + day + '.ics',
|
||||
navigator:navigatorObject, document:documentObject, urlApi, FileCtor,
|
||||
});
|
||||
onDone(result);
|
||||
close();
|
||||
} catch (error) {
|
||||
qs('#agenda-export-status').textContent = error?.name === 'AbortError' ?
|
||||
'Share cancelled. Nothing was exported.' : 'Calendar export failed. Retry without leaving Agenda.';
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const api = { calendarDay, deliverCalendarSnapshot, escapeText, foldLine, mountAgendaCalendarExport, serializeAgendaCalendar };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.StackchainAgendaCalendar = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -55,6 +55,20 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
});
|
||||
}
|
||||
|
||||
function operationFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
kind:String(message.kind || ''), repository:String(message.repository || ''),
|
||||
number:Number(message.number || 0), notificationId:Number(message.notificationId || 0),
|
||||
body:String(message.body || ''), targetKind:String(message.targetKind || ''),
|
||||
decision:String(message.decision || 'comment'), expectedHeadSha:String(message.expectedHeadSha || ''),
|
||||
comments:Array.isArray(message.comments) ? message.comments : [],
|
||||
blockerRepository:String(message.blockerRepository || ''), blockerNumber:Number(message.blockerNumber || 0),
|
||||
present:message.present === true, title:String(message.title || ''),
|
||||
expectedUpdatedAt:String(message.expectedUpdatedAt || ''),
|
||||
attachments:messageAttachments(message).map(attachmentMetadata),
|
||||
});
|
||||
}
|
||||
|
||||
function clearConfirmedReviewState(item) {
|
||||
if (item.kind !== 'pull-review') return;
|
||||
try {
|
||||
@@ -93,7 +107,12 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
const items = read();
|
||||
const requestedOperationId = String(message.operationId || '').slice(0, 128);
|
||||
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
|
||||
if (existing) return { ...existing };
|
||||
if (existing) {
|
||||
if (operationFingerprint(existing) !== operationFingerprint(message)) {
|
||||
throw new Error('This operation ID is already bound to a different queued action.');
|
||||
}
|
||||
return { ...existing };
|
||||
}
|
||||
if (message.kind === 'pull-review') {
|
||||
const queuedReview = items.find(item => item.kind === 'pull-review' &&
|
||||
item.repository === String(message.repository || '') &&
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
function createConversationActionHydrator({ load, activate }) {
|
||||
let actions = null;
|
||||
let pending = null;
|
||||
const wiredRoots = new WeakSet();
|
||||
const wired = new WeakSet();
|
||||
|
||||
function ensure() {
|
||||
if (actions) return Promise.resolve(actions);
|
||||
if (!pending) {
|
||||
pending = load().then(() => {
|
||||
actions = activate();
|
||||
return actions;
|
||||
}).catch(error => {
|
||||
pending = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
async function ensure() {
|
||||
if (actions) return actions;
|
||||
if (!pending) pending = load().then(() => actions = activate()).catch(error => {
|
||||
pending = null;
|
||||
throw error;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
function show({ root, state, paint, wire, retry }) {
|
||||
if (actions) {
|
||||
retry.hidden = true;
|
||||
if (!wiredRoots.has(root)) {
|
||||
if (!wired.has(root)) {
|
||||
wire(actions);
|
||||
wiredRoots.add(root);
|
||||
wired.add(root);
|
||||
}
|
||||
paint(state, actions);
|
||||
return Promise.resolve(true);
|
||||
@@ -31,9 +26,9 @@ function createConversationActionHydrator({ load, activate }) {
|
||||
paint(state, null);
|
||||
retry.hidden = true;
|
||||
return ensure().then(controller => {
|
||||
if (!wiredRoots.has(root)) {
|
||||
if (!wired.has(root)) {
|
||||
wire(controller);
|
||||
wiredRoots.add(root);
|
||||
wired.add(root);
|
||||
}
|
||||
paint(state, controller);
|
||||
return true;
|
||||
@@ -43,7 +38,7 @@ function createConversationActionHydrator({ load, activate }) {
|
||||
});
|
||||
}
|
||||
|
||||
return { show, ready: () => Boolean(actions) };
|
||||
return {show,get:ensure};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationActionHydrator;
|
||||
@@ -35,12 +35,11 @@
|
||||
await current.pending;
|
||||
const attachments = await store.load(target);
|
||||
if (generation !== current.generation) return false;
|
||||
current.controller.clear();
|
||||
if (attachments?.length) {
|
||||
current.restoring = true;
|
||||
try { current.controller.restore(attachments); }
|
||||
finally { current.restoring = false; }
|
||||
}
|
||||
current.restoring = true;
|
||||
try {
|
||||
current.controller.clear();
|
||||
if (attachments?.length) current.controller.restore(attachments);
|
||||
} finally { current.restoring = false; }
|
||||
return Boolean(attachments?.length);
|
||||
}
|
||||
|
||||
@@ -51,9 +50,13 @@
|
||||
try { await checkpoint(kind); }
|
||||
catch (_error) { return false; }
|
||||
if (generation === current.generation) {
|
||||
current.controller.clear();
|
||||
current.target = null;
|
||||
current.generation += 1;
|
||||
current.restoring = true;
|
||||
try { current.controller.clear(); }
|
||||
finally {
|
||||
current.restoring = false;
|
||||
current.target = null;
|
||||
current.generation += 1;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -64,9 +67,13 @@
|
||||
const target = { ...current.target };
|
||||
await current.pending;
|
||||
await store.remove(target);
|
||||
current.controller.clear();
|
||||
current.target = null;
|
||||
current.generation += 1;
|
||||
current.restoring = true;
|
||||
try { current.controller.clear(); }
|
||||
finally {
|
||||
current.restoring = false;
|
||||
current.target = null;
|
||||
current.generation += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,9 +73,10 @@
|
||||
}
|
||||
|
||||
return function createConversationReplyDraftStore({
|
||||
indexedDB = globalThis.indexedDB, transaction, getOwnerLogin = () => '',
|
||||
indexedDB = globalThis.indexedDB, transaction, getOwnerLogin = () => '', scope = 'conversation',
|
||||
} = {}) {
|
||||
const transact = transaction || createTransaction(indexedDB);
|
||||
const draftScope = String(scope || 'conversation').trim().slice(0, 64) || 'conversation';
|
||||
|
||||
function identity(target) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
@@ -83,26 +84,30 @@
|
||||
const normalized = normalizedTarget(target);
|
||||
const targetKey = normalized.kind === 'update' ? normalized.notificationId :
|
||||
normalized.repository + ':' + normalized.number;
|
||||
const identityParts = draftScope === 'conversation' ? [ownerLogin, normalized.kind, targetKey] :
|
||||
[ownerLogin, draftScope, normalized.kind, targetKey];
|
||||
return {
|
||||
ownerLogin, target:normalized,
|
||||
id:[ownerLogin, normalized.kind, targetKey].map(value => encodeURIComponent(String(value))).join(':'),
|
||||
ownerLogin, scope:draftScope, target:normalized,
|
||||
id:identityParts.map(value => encodeURIComponent(String(value))).join(':'),
|
||||
};
|
||||
}
|
||||
|
||||
async function save(target, values) {
|
||||
if (!transact) throw new Error('Photo draft storage needs IndexedDB. Your current photos are still here.');
|
||||
const { id, ownerLogin, target:normalized } = identity(target);
|
||||
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
|
||||
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
|
||||
if (!list.length) { await transact('delete', id); return null; }
|
||||
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
|
||||
await transact('put', id, { id, version:1, ownerLogin, scope:recordScope, ...normalized, attachments:list });
|
||||
return list;
|
||||
}
|
||||
|
||||
async function load(target) {
|
||||
if (!transact) return null;
|
||||
const { id, ownerLogin, target:normalized } = identity(target);
|
||||
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
|
||||
const record = await transact('get', id);
|
||||
const same = record?.version === 1 && record.ownerLogin === ownerLogin &&
|
||||
const scopeMatches = recordScope === 'conversation' ? (!record?.scope || record.scope === recordScope) :
|
||||
record?.scope === recordScope;
|
||||
const same = record?.version === 1 && record.ownerLogin === ownerLogin && scopeMatches &&
|
||||
record.kind === normalized.kind && (normalized.kind === 'update' ?
|
||||
Number(record.notificationId) === normalized.notificationId :
|
||||
record.repository === normalized.repository && Number(record.number) === normalized.number);
|
||||
|
||||
@@ -8,6 +8,59 @@ function newIssueOperationId() {
|
||||
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
|
||||
}
|
||||
|
||||
function createIssueModalLifecycle({ root, background = [], document, requestClose }) {
|
||||
let launcher = null;
|
||||
let active = false;
|
||||
let backgroundState = [];
|
||||
const focusableSelector = [
|
||||
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
|
||||
'select:not([disabled])', 'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
const focusable = () => Array.from(root.querySelectorAll(focusableSelector)).filter(element =>
|
||||
!element.hidden && !element.disabled && element.getClientRects().length > 0
|
||||
);
|
||||
function keydown(event) {
|
||||
if (!active) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = focusable();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
root.addEventListener('keydown', keydown);
|
||||
return {
|
||||
open(trigger = null) {
|
||||
launcher = trigger?.isConnected ? trigger : document.activeElement;
|
||||
backgroundState = background.map(element => [element, element.inert]);
|
||||
backgroundState.forEach(([element]) => { element.inert = true; });
|
||||
active = true;
|
||||
(root.querySelector('#cancel-new-issue') || focusable()[0])?.focus();
|
||||
},
|
||||
close({ restore = true } = {}) {
|
||||
active = false;
|
||||
backgroundState.forEach(([element, inert]) => { element.inert = inert; });
|
||||
backgroundState = [];
|
||||
const panel = root.querySelector('.create-issue-panel');
|
||||
if (panel) panel.scrollTop = 0;
|
||||
if (restore && launcher?.isConnected) launcher.focus();
|
||||
launcher = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSharedContent(value = {}) {
|
||||
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
|
||||
const text = String(value.text || '').trim().slice(0, 9500);
|
||||
@@ -840,5 +893,6 @@ createIssueCapture.buildRelatedDraft = buildRelatedDraft;
|
||||
createIssueCapture.relatedChecklistDraft = relatedChecklistDraft;
|
||||
createIssueCapture.linkChecklistTask = linkChecklistTask;
|
||||
createIssueCapture.createChecklistPromotion = createChecklistPromotion;
|
||||
createIssueCapture.createModalLifecycle = createIssueModalLifecycle;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
+360
-11
@@ -6,14 +6,36 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
|
||||
.sign-out-review-sheet { position:fixed; inset:0; z-index:112; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.sign-out-review-sheet[hidden] { display:none; }
|
||||
.sign-out-review-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.sign-out-review-panel h2 { margin:.25rem 0; }
|
||||
.sign-out-review-warning { color:#fbbf24; }
|
||||
.sign-out-review-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.sign-out-review-actions button { min-height:44px; width:100%; }
|
||||
#confirm-sign-out { border-color:#b45309; }
|
||||
@media (max-width:359px) { .sign-out-review-actions { grid-template-columns:1fr; } }
|
||||
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.live-data-status-sheet[hidden] { display:none; }
|
||||
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
|
||||
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
|
||||
.live-data-status-today-paused { padding:10px 12px; border:1px solid #4ade80; border-radius:10px; color:#bbf7d0; background:#10291e; }
|
||||
.issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.issue-filing-receipt[hidden] { display:none; }
|
||||
.release-receipt-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
|
||||
.release-receipt-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.release-receipt-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.release-receipt-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.release-receipt-launcher { width:100%; min-height:44px; border-color:#4ade80; }
|
||||
.release-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.release-receipt-panel button, .release-receipt-panel .button-link { box-sizing:border-box; display:flex; align-items:center; justify-content:center; min-width:0; min-height:44px; width:100%; text-align:center; }
|
||||
.release-watchlist { display:grid; gap:8px; margin-top:14px; }
|
||||
.release-watchlist-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:10px; border:1px solid #334155; border-radius:10px; }
|
||||
.release-watchlist-item > div { display:grid; min-width:0; gap:3px; overflow-wrap:anywhere; }
|
||||
.release-watchlist-item > button { width:auto; min-width:88px; }
|
||||
@media (max-width:359px) { .release-receipt-actions { grid-template-columns:1fr; } }
|
||||
.issue-filing-receipt-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; }
|
||||
.issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
@@ -28,9 +50,25 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
#open-insights { display:none; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
.notification-undo[hidden] { display:none; }
|
||||
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
|
||||
.search-week-plan-sheet { position:fixed; inset:0; z-index:118; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.search-week-plan-sheet[hidden] { display:none; }
|
||||
.search-week-plan-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.search-week-plan-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.search-week-plan-panel h2 { margin:.2rem 0; }
|
||||
.search-week-plan-panel fieldset { min-width:0; margin:12px 0; padding:0; border:0; }
|
||||
.search-week-plan-days { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.search-week-plan-days button { display:grid; min-width:0; min-height:44px; text-align:left; overflow-wrap:anywhere; }
|
||||
.search-week-plan-days button[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 1px #60a5fa inset; }
|
||||
.search-week-plan-days small { color:#9fb3c8; }
|
||||
.search-week-plan-estimate { display:grid; gap:6px; }
|
||||
.search-week-plan-estimate input, #confirm-search-week-plan, #cancel-search-week-plan { box-sizing:border-box; min-height:44px; }
|
||||
#confirm-search-week-plan { width:100%; }
|
||||
@media (max-width:359px) { .search-week-plan-days { grid-template-columns:1fr; } }
|
||||
.notification-undo { position:fixed; }
|
||||
.notification-undo button { min-height:44px; }
|
||||
.notification-undo, .today-completion-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
.notification-undo[hidden], .today-completion-undo[hidden] { display:none; }
|
||||
.notification-undo button, .today-completion-undo button { min-height:44px; min-width:64px; flex:none; }
|
||||
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.draft-capacity-sheet[hidden] { display:none; }
|
||||
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
@@ -135,6 +173,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette.open { display: block; }
|
||||
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.cmd-palette-header-actions { display:flex; gap:8px; }
|
||||
.search-today-interruption { display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.search-today-interruption[hidden] { display:none; }
|
||||
.search-today-interruption button { min-height:44px; flex:0 0 auto; }
|
||||
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
|
||||
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
|
||||
.cmd-search-scope label { font-size:12px; color:#cbd5e1; }
|
||||
@@ -171,6 +212,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-batch-estimate-review input { min-height:44px; width:76px; }
|
||||
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-batch-estimate-actions button { min-height:44px; }
|
||||
.search-week-batch-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; overflow-x:hidden; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.search-week-batch-review[hidden] { display:none; }
|
||||
.search-week-batch-review > div:first-child, #search-week-batch-list { display:grid; gap:10px; }
|
||||
.search-week-batch-row { display:grid; gap:8px; padding:10px 0; border-bottom:1px solid #2a496e; overflow-wrap:anywhere; }
|
||||
.search-week-batch-row-controls { display:grid; grid-template-columns:minmax(0,1fr) minmax(96px,.45fr); gap:8px; }
|
||||
.search-week-batch-row select, .search-week-batch-row input, .search-week-batch-actions button { min-height:44px; width:100%; }
|
||||
.search-week-batch-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.search-release-review[hidden] { display:none; }
|
||||
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
|
||||
@@ -193,6 +241,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.offline-status[hidden] { display:none; }
|
||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||
.app-badge-setting { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.app-badge-control { min-height:44px; }
|
||||
.push-update-control { min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
@@ -214,6 +264,179 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||
.plan-today-header button { min-width:44px; min-height:44px; }
|
||||
.week-plan-progress { margin:8px 0 4px; color:#bfdbfe; font-weight:700; }
|
||||
.week-plan-dates { display:flex; gap:8px; margin:8px 0 12px; padding:2px 0 8px; overflow-x:auto; overscroll-behavior-inline:contain; }
|
||||
.week-plan-dates[hidden] { display:none; }
|
||||
.week-plan-dates button { min-height:44px; min-width:86px; flex:0 0 auto; padding:6px 10px; }
|
||||
.week-plan-dates button[aria-current="date"] { color:#bfdbfe; background:#17365a; border-color:#60a5fa; }
|
||||
.today-week-reschedule { box-sizing:border-box; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:inherit; }
|
||||
.today-week-reschedule::backdrop { background:rgba(5,12,21,.82); }
|
||||
.today-week-reschedule-panel { width:min(100%,560px); box-sizing:border-box; min-height:100%; margin-left:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow-x:hidden; }
|
||||
.today-week-reschedule-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-week-reschedule-panel h2, .today-week-reschedule-panel header p { margin-top:0; }
|
||||
.today-week-reschedule-panel input { box-sizing:border-box; width:100%; min-height:44px; margin:6px 0 10px; font-size:16px; }
|
||||
.today-week-reschedule-days { display:grid; gap:8px; margin:14px 0; }
|
||||
.today-week-reschedule-days button { min-height:44px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; text-align:left; overflow-wrap:anywhere; }
|
||||
.today-week-reschedule-days button[aria-checked="true"] { border-color:#60a5fa; background:#17365a; color:#eff6ff; }
|
||||
.today-week-reschedule-days button:disabled { opacity:.58; }
|
||||
#cancel-today-week-reschedule { min-height:44px; }
|
||||
#confirm-today-week-reschedule { position:sticky; bottom:0; width:100%; min-height:48px; }
|
||||
#today-week-reschedule-status { min-height:1.4em; color:#fde68a; }
|
||||
.back-to-week-review { width:100%; min-height:44px; margin:4px 0 12px; }
|
||||
.tomorrow-conflict-review { margin-top:14px; }
|
||||
.tomorrow-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||||
.tomorrow-conflict-plans > section { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.tomorrow-conflict-plans h4 { margin:0 0 8px; }
|
||||
.tomorrow-conflict-plan-summary { margin:0 0 8px; color:#bfdbfe; }
|
||||
.tomorrow-conflict-plan-list { margin:0; padding-left:22px; overflow-wrap:anywhere; }
|
||||
.tomorrow-conflict-plan-list li + li { margin-top:6px; }
|
||||
.tomorrow-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
||||
.tomorrow-conflict-actions button { min-height:44px; flex:1 1 0; }
|
||||
.tomorrow-conflict-status { min-height:1.4em; margin-top:8px; }
|
||||
.plan-today-sheet.tomorrow-conflict-mode .mobile-plan-today-nav,
|
||||
.plan-today-sheet.tomorrow-conflict-mode #plan-today-fit,
|
||||
.plan-today-sheet.tomorrow-conflict-mode #plan-today-selected,
|
||||
.plan-today-sheet.tomorrow-conflict-mode #plan-today-available-work,
|
||||
.plan-today-sheet.tomorrow-conflict-mode .plan-today-actions { display:none; }
|
||||
@media (max-width:480px) {
|
||||
.tomorrow-conflict-plans { grid-template-columns:1fr; }
|
||||
.tomorrow-conflict-actions { flex-direction:column; }
|
||||
}
|
||||
.week-conflict-review { margin-top:14px; }
|
||||
.week-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||||
.week-conflict-plans > section { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-conflict-plans h4 { margin:0 0 8px; }
|
||||
.week-conflict-day { margin:0 0 10px; padding-bottom:10px; border-bottom:1px solid #31577f; }
|
||||
.week-conflict-day:last-child { margin-bottom:0; padding-bottom:0; border-bottom:0; }
|
||||
.week-conflict-day strong { display:block; color:#bfdbfe; }
|
||||
.week-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
||||
.week-conflict-actions button { min-height:44px; flex:1 1 0; }
|
||||
.week-conflict-days { display:grid; gap:12px; }
|
||||
.week-conflict-date { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-conflict-date h4 { margin:0 0 8px; color:#bfdbfe; }
|
||||
.week-conflict-choice { min-height:44px; display:flex; align-items:center; gap:10px; padding:8px; border-radius:8px; }
|
||||
.week-conflict-choice:has(input:checked) { background:#17365a; outline:1px solid #60a5fa; }
|
||||
.week-conflict-choice input { min-width:20px; min-height:20px; }
|
||||
.week-conflict-choice span { display:grid; min-width:0; }
|
||||
.week-conflict-choice small { color:#a9bdd3; }
|
||||
#save-merged-week { width:100%; min-height:44px; margin-top:14px; }
|
||||
.week-conflict-status { min-height:1.4em; margin-top:8px; }
|
||||
.plan-today-sheet.week-conflict-mode .week-plan-dates,
|
||||
.plan-today-sheet.week-conflict-mode .mobile-plan-today-nav,
|
||||
.plan-today-sheet.week-conflict-mode #plan-today-fit,
|
||||
.plan-today-sheet.week-conflict-mode #plan-today-selected,
|
||||
.plan-today-sheet.week-conflict-mode #plan-today-available-work,
|
||||
.plan-today-sheet.week-conflict-mode .plan-today-actions { display:none; }
|
||||
@media (max-width:480px) {
|
||||
.week-conflict-plans { grid-template-columns:1fr; }
|
||||
.week-conflict-actions { flex-direction:column; }
|
||||
}
|
||||
.week-pull-conflict-review { min-width:0; margin-top:14px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||
.week-pull-conflict-item { margin-bottom:6px; color:#eff6ff; font-weight:700; }
|
||||
.week-pull-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
||||
.week-pull-conflict-actions button { min-height:44px; flex:1 1 0; }
|
||||
.week-pull-conflict-status { min-height:1.4em; margin-top:8px; }
|
||||
@media (max-width:480px) { .week-pull-conflict-actions { flex-direction:column; } }
|
||||
.week-review { margin-top:14px; }
|
||||
.week-review h2 { margin-bottom:6px; }
|
||||
.week-review-days { display:grid; gap:12px; }
|
||||
.week-review-day { padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-review-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
|
||||
.week-review-day.is-next-up { border-color:#60a5fa; box-shadow:0 0 0 1px #60a5fa inset; }
|
||||
.week-next-up { display:inline-block; margin-left:6px; padding:2px 7px; border-radius:999px; background:#1d4f7a; color:#dbeafe; font-size:11px; vertical-align:middle; }
|
||||
.week-review-day header { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||||
.week-review-day header button { flex:0 0 auto; min-height:44px; }
|
||||
.week-review-day h3, .week-review-day p { margin:0 0 8px; }
|
||||
.week-review-load { color:#bfdbfe; font-weight:700; }
|
||||
.week-review-day ul { display:grid; gap:10px; margin:0; padding:0; list-style:none; }
|
||||
.week-review-day li { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; padding-top:8px; border-top:1px solid #31577f; overflow-wrap:anywhere; }
|
||||
.week-review-item-copy { display:grid; min-width:0; gap:3px; }
|
||||
.week-review-item-open { display:block; width:100%; min-width:0; min-height:44px; padding:8px; border:0; border-radius:8px; background:transparent; color:inherit; text-align:left; }
|
||||
.week-review-item-open:hover { background:#173453; }
|
||||
.week-review-item-open:focus-visible { outline:3px solid #93c5fd; outline-offset:2px; }
|
||||
.week-review-unplan { width:100%; min-height:44px; border-color:#6b87a6; background:transparent; color:#d7e5f5; }
|
||||
.week-review-pull { grid-column:1/-1; width:100%; min-height:44px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
|
||||
.week-review-unplan { grid-column:1/-1; }
|
||||
.week-unplan-receipt { margin:12px 0 6px; padding:10px 12px; border:1px solid #60a5fa; border-radius:10px; background:#112d4d; color:#dbeafe; }
|
||||
#undo-week-unplan { width:100%; min-height:44px; margin-bottom:8px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
|
||||
.week-start-early { display:block; width:100%; min-height:44px; margin-top:12px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
|
||||
.week-start-early:focus-visible { outline:3px solid #bfdbfe; outline-offset:2px; }
|
||||
.week-review-item-copy strong, .week-review-item-copy small { overflow-wrap:anywhere; }
|
||||
.week-review-item-copy small { color:#a9bdd3; }
|
||||
.week-review-move { display:flex; align-items:end; gap:8px; }
|
||||
.week-review-day label { display:grid; gap:4px; font-size:12px; }
|
||||
.week-review-day select, .week-review-day button { min-height:44px; }
|
||||
.week-review-day button { min-height:44px; }
|
||||
.week-review-duplicates { margin:12px 0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; }
|
||||
.week-review-status { min-height:1.4em; margin:10px 0; }
|
||||
.week-offline-snapshot { margin:10px 0 8px; padding:10px 12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; color:#fde68a; overflow-wrap:anywhere; }
|
||||
#retry-week-live { min-height:44px; width:100%; margin-bottom:8px; border-color:#f59e0b; }
|
||||
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
#edit-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
#export-saved-week-calendar { width:100%; min-height:48px; margin:0 0 8px; }
|
||||
#open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; }
|
||||
#open-week-reflow { width:100%; min-height:48px; margin:0 0 12px; border-color:#60a5fa; }
|
||||
.week-reflow-review { box-sizing:border-box; width:100%; margin:0 0 14px; padding:14px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; overflow-x:hidden; }
|
||||
.week-reflow-review h3 { margin:.25rem 0; }
|
||||
.week-reflow-days { display:grid; gap:6px; margin:12px 0; }
|
||||
.week-reflow-day { display:flex; justify-content:space-between; gap:10px; min-width:0; padding:8px; border-radius:8px; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.week-reflow-unscheduled { min-height:1.4em; color:#fde68a; overflow-wrap:anywhere; }
|
||||
.week-reflow-actions { display:grid; grid-template-columns:1fr 2fr; gap:8px; margin-top:12px; }
|
||||
.week-reflow-actions button { min-width:0; min-height:48px; }
|
||||
.week-capacity-import { position:fixed; z-index:121; inset:0; box-sizing:border-box; width:100%; max-width:560px; margin-inline:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow:auto; overflow-x:hidden; }
|
||||
.week-capacity-import[hidden] { display:none; }
|
||||
.week-capacity-import > header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.week-capacity-import h2 { margin:.2rem 0; }
|
||||
.week-capacity-import > label, .week-capacity-hours label { display:grid; min-width:0; gap:6px; margin:12px 0; }
|
||||
.week-capacity-import input, .week-capacity-import button { box-sizing:border-box; min-width:0; min-height:44px; max-width:100%; }
|
||||
.week-capacity-hours { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
||||
.week-capacity-days { display:grid; gap:8px; margin:10px 0 14px; }
|
||||
.week-capacity-day { display:grid; min-width:0; gap:3px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.week-capacity-day span { color:#bfdbfe; font-weight:700; }
|
||||
.week-capacity-day small { color:#a9bdd3; }
|
||||
.week-capacity-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
|
||||
.week-capacity-day .week-capacity-over { color:#fde68a; font-weight:700; }
|
||||
.week-free-times-consent { display:flex; align-items:flex-start; gap:10px; min-width:0; margin:12px 0; padding:10px; border:1px solid #31577f; border-radius:10px; }
|
||||
.week-free-times-consent input { flex:0 0 44px; width:44px; margin:0; }
|
||||
.week-free-times-consent span { display:grid; min-width:0; gap:4px; overflow-wrap:anywhere; }
|
||||
.week-free-times-consent small { color:#a9bdd3; }
|
||||
#apply-week-capacities { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
.week-availability-editor { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
#open-week-availability { min-height:44px; }
|
||||
.week-availability-editor h3 { margin-top:0; }
|
||||
.week-availability-grid { display:grid; grid-template-columns:1fr; gap:8px; }
|
||||
.week-availability-grid label { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,120px); gap:10px; align-items:center; }
|
||||
.week-availability-grid input { min-height:44px; min-width:0; box-sizing:border-box; }
|
||||
.week-availability-actions { display:grid; grid-template-columns:1fr; gap:8px; margin-top:12px; }
|
||||
.week-availability-actions button { min-height:44px; }
|
||||
@media (min-width:600px) { .week-availability-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .week-availability-actions { grid-template-columns:repeat(3,minmax(0,1fr)); } }
|
||||
@media (max-width:359px) { .week-capacity-hours { grid-template-columns:1fr; } }
|
||||
.week-calendar-handoff h2 { margin-bottom:6px; }
|
||||
.week-calendar-days { display:grid; gap:12px; margin:14px 0; }
|
||||
.week-calendar-day { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-calendar-day header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.week-calendar-day h3 { margin:0; }
|
||||
.week-calendar-day input[type="time"] { min-height:44px; font-size:16px; }
|
||||
.week-calendar-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; min-width:0; padding:8px 0; overflow-wrap:anywhere; }
|
||||
.week-calendar-choice { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; }
|
||||
.week-calendar-choice input { width:22px; height:22px; }
|
||||
.week-calendar-choice span { display:grid; gap:3px; }
|
||||
.week-calendar-time { display:grid; gap:2px; font-size:12px; font-weight:700; }
|
||||
.week-calendar-time input { box-sizing:border-box; width:7.5rem; min-height:44px; font-size:16px; }
|
||||
.week-calendar-item small { color:#a9bdd3; }
|
||||
.week-calendar-status { min-height:1.4em; margin:10px 0; }
|
||||
.week-calendar-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#0b1526; }
|
||||
.week-calendar-actions button { min-width:0; min-height:48px; }
|
||||
@media(max-width:359px) { .week-calendar-item, .week-calendar-actions { grid-template-columns:1fr; } .week-calendar-time input { width:100%; } }
|
||||
.plan-today-sheet.week-review-mode .week-plan-dates,
|
||||
.plan-today-sheet.week-review-mode .mobile-plan-today-nav,
|
||||
.plan-today-sheet.week-review-mode #plan-today-fit,
|
||||
.plan-today-sheet.week-review-mode #plan-today-selected,
|
||||
.plan-today-sheet.week-review-mode #plan-today-available-work,
|
||||
.plan-today-sheet.week-review-mode .plan-today-actions { display:none; }
|
||||
@media (max-width:480px) {
|
||||
.week-review-day li { grid-template-columns:1fr; }
|
||||
}
|
||||
.mobile-plan-today-nav { position:sticky; top:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px; margin:0 -18px 10px; padding:4px 18px; background:rgba(11,21,38,.98); border-block:1px solid #2a496e; }
|
||||
.mobile-plan-today-nav button { min-width:0; min-height:44px; padding:4px; border-color:transparent; font-size:12px; }
|
||||
.mobile-plan-today-nav button[aria-current="location"] { color:#bfdbfe; background:#17365a; border-color:#31577f; }
|
||||
@@ -252,6 +475,59 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.today-interruption-panel h2 { margin:.25rem 0; overflow-wrap:anywhere; }
|
||||
.today-interruption-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.today-interruption-actions button { min-height:44px; width:100%; }
|
||||
.today-break-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
|
||||
.today-break-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-break-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(620px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-break-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-break-panel h2, .today-break-panel p { margin-top:0; }
|
||||
.today-break-panel header button, .today-break-actions button, .today-break-custom button { min-height:44px; }
|
||||
.today-break-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:16px 0; }
|
||||
.today-break-actions button { min-width:0; width:100%; }
|
||||
.today-break-custom { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; }
|
||||
.today-break-custom label, .today-break-custom p { grid-column:1 / -1; }
|
||||
.today-break-custom input { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.today-break-status { position:fixed; z-index:46; left:12px; right:12px; bottom:calc(224px + env(safe-area-inset-bottom)); box-sizing:border-box; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#102641; }
|
||||
.today-break-status:has(> span[hidden]) { display:none; }
|
||||
.today-break-status button { min-height:44px; min-width:112px; }
|
||||
@media(max-width:359px) { .today-break-actions { grid-template-columns:1fr; } }
|
||||
.today-progress-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
|
||||
.today-progress-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-progress-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(620px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-progress-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-progress-panel h2, .today-progress-panel p { margin-top:0; }
|
||||
.today-progress-panel header button, .today-progress-actions button { min-height:44px; }
|
||||
.today-progress-panel textarea { box-sizing:border-box; width:100%; min-height:120px; resize:vertical; }
|
||||
.today-progress-activity { display:grid; gap:8px; min-width:0; margin:0 0 14px; padding:12px; border:1px solid #294767; border-radius:12px; background:#0d1b2d; }
|
||||
.today-progress-activity-heading { display:flex; justify-content:space-between; gap:8px; }
|
||||
.today-progress-activity-list { display:grid; gap:8px; max-height:32dvh; overflow:auto; overflow-x:hidden; overflow-wrap:anywhere; }
|
||||
.today-progress-activity-list:empty::before { content:'No recent messages yet.'; color:#9ca3af; font-size:.875rem; }
|
||||
.today-progress-activity-item { min-width:0; padding:8px; border-radius:8px; background:#101f34; }
|
||||
.today-progress-activity-item .markdown-content { overflow-wrap:anywhere; }
|
||||
.today-progress-activity-actions { display:flex; gap:8px; }
|
||||
.today-progress-activity-actions button { min-height:44px; }
|
||||
.today-progress-activity-status { margin:0; }
|
||||
.today-progress-evidence { display:grid; gap:8px; min-width:0; margin-top:12px; }
|
||||
.today-progress-evidence .issue-attachment-preview { width:100%; box-sizing:border-box; }
|
||||
.today-progress-evidence .issue-evidence-note textarea { min-height:72px; }
|
||||
.today-progress-blocker { display:grid; gap:8px; margin-top:14px; padding:12px; border:1px solid #875f2a; border-radius:12px; background:#21180d; }
|
||||
.today-progress-blocker input, .today-progress-blocker button { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.today-blocker-presets { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
|
||||
.today-blocker-presets button { min-width:0; padding-inline:6px; }
|
||||
.today-progress-blocker p { margin:0; }
|
||||
.today-progress-blocker .today-progress-blocker-recovery { padding:8px; border-radius:8px; background:#3a270d; color:#fde68a; font-weight:700; }
|
||||
.today-progress-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; }
|
||||
.today-progress-actions button { width:100%; }
|
||||
.today-progress-panel .voice-conversation { margin-top:10px; }
|
||||
.today-progress-panel .voice-conversation-controls button,
|
||||
.today-progress-panel .voice-conversation-review-actions button { min-height:44px; }
|
||||
.today-progress-panel.blocker-mode .today-progress-activity,
|
||||
.today-progress-panel.blocker-mode .voice-conversation,
|
||||
.today-progress-panel.blocker-mode .today-progress-evidence,
|
||||
.today-progress-panel.blocker-mode .today-progress-update-help,
|
||||
.today-progress-panel.blocker-mode .today-progress-actions { display:none; }
|
||||
.today-progress-panel.blocker-mode textarea { min-height:112px; }
|
||||
@media(max-width:359px) { .today-blocker-presets { grid-template-columns:1fr; } }
|
||||
@media(max-width:359px) { .today-progress-actions { grid-template-columns:1fr; } }
|
||||
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-recap-sheet[hidden] { display:none; }
|
||||
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
@@ -284,6 +560,27 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.today-wrap-up-item input { width:24px; min-height:24px; }
|
||||
.today-wrap-up-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-wrap-up-actions button { min-height:44px; width:100%; }
|
||||
.today-summary-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-summary-sheet[hidden] { display:none; }
|
||||
.today-summary-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-summary-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-summary-header h2 { margin:.2rem 0; }
|
||||
.today-summary-header button { min-height:44px; min-width:44px; }
|
||||
.today-summary-items { display:grid; gap:8px; }
|
||||
.today-summary-item, .today-summary-option { display:flex; align-items:center; gap:10px; min-height:44px; padding:10px; border:1px solid #2a496e; border-radius:10px; background:#10233d; overflow-wrap:anywhere; }
|
||||
.today-summary-item input, .today-summary-option input { width:24px; min-height:24px; flex:0 0 auto; }
|
||||
.today-summary-item span { display:block; min-width:0; }
|
||||
.today-summary-note { display:grid; gap:6px; margin:14px 0; font-weight:700; }
|
||||
.today-summary-note textarea { box-sizing:border-box; width:100%; min-height:88px; resize:vertical; }
|
||||
.today-summary-preview { min-height:72px; white-space:pre-wrap; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#07101e; color:#e8f1ff; }
|
||||
.today-summary-destination { margin-top:14px; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#0d1c31; }
|
||||
.today-summary-destination h3 { margin-top:0; }
|
||||
.today-summary-destination-controls { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.today-summary-destination-controls input, .today-summary-destination-controls button { box-sizing:border-box; min-width:0; min-height:44px; }
|
||||
.today-summary-target { min-height:1.4em; margin-top:8px; }
|
||||
.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-summary-actions button { min-height:44px; width:100%; }
|
||||
@media (max-width:420px) { .today-summary-destination-controls, .today-summary-actions { grid-template-columns:1fr; } }
|
||||
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
|
||||
@@ -355,17 +652,36 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.draft-filing-session[hidden] { display:none; }
|
||||
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.deadline-snooze { box-sizing:border-box; min-width:0; margin:10px 0; padding:12px; border:1px solid #a16207; border-radius:12px; background:#2a1d08; display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.deadline-snooze p { margin:4px 0 0; overflow-wrap:anywhere; }
|
||||
.deadline-snooze button { min-height:44px; flex:0 0 auto; }
|
||||
.agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; }
|
||||
.agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.agenda-replan-launch p { margin:4px 0 0; }
|
||||
.agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; }
|
||||
.agenda-replan-actions label { grid-column:1/-1; }
|
||||
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
|
||||
.agenda-export { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.agenda-export p { margin:4px 0 0; }
|
||||
.agenda-export button { min-height:44px; flex:0 0 auto; }
|
||||
.agenda-export-sheet { box-sizing:border-box; width:min(560px,100%); max-width:none; max-height:none; height:100dvh; margin:0 0 0 auto; padding:0; border:0; color:var(--text); background:#0b1526; }
|
||||
.agenda-export-sheet::backdrop { background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
|
||||
.agenda-export-panel { box-sizing:border-box; min-height:100%; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow:auto; overflow-x:hidden; }
|
||||
.agenda-export-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.agenda-export-panel h2, .agenda-export-panel header p { margin:0; }
|
||||
#agenda-export-items { min-width:0; display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
.agenda-export-item { min-width:0; min-height:44px; display:flex; align-items:center; gap:10px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; }
|
||||
.agenda-export-item input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.agenda-export-item span, .agenda-export-item strong, .agenda-export-item small { min-width:0; display:block; overflow-wrap:anywhere; }
|
||||
#cancel-agenda-export, #share-agenda-export { min-height:44px; }
|
||||
#share-agenda-export { position:sticky; bottom:0; width:100%; margin-top:auto; }
|
||||
@media(max-width:430px) { .agenda-export { align-items:stretch; flex-direction:column; } .agenda-export button { width:100%; } .agenda-export-panel { padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); } }
|
||||
.protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.protect-today p { margin:4px 0; }
|
||||
.protect-today button { min-height:44px; flex:0 0 auto; }
|
||||
@media(max-width:360px) { .agenda-replan-launch { align-items:stretch; flex-direction:column; } .agenda-replan-actions { grid-template-columns:1fr; } .agenda-replan-actions label { grid-column:auto; } }
|
||||
@media(max-width:430px) { .protect-today { align-items:stretch; flex-direction:column; } .protect-today button { width:100%; } }
|
||||
@media(max-width:430px) { .deadline-snooze { align-items:stretch; flex-direction:column; } .deadline-snooze button { width:100%; } }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
@@ -510,6 +826,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.task-overlay-open .issue-sheet.open, .task-overlay-open .pull-sheet.open { z-index:76; }
|
||||
.issue-sheet.open { display:flex; }
|
||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
@@ -648,6 +965,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.plan-tomorrow { min-height:44px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.resume-today-session, .end-today-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
@@ -786,6 +1104,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.today-capture-interruption { display:grid; gap:2px; padding:10px 12px; border:1px solid #3f6f9f; border-radius:10px; background:#10243b; }
|
||||
.today-capture-interruption[hidden] { display:none; }
|
||||
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
|
||||
.mobile-create-issue-nav { display:none; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
@@ -926,6 +1246,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
|
||||
.mobile-today-hud { display:none; }
|
||||
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
|
||||
.mobile-today-actions { box-sizing:border-box; width:min(520px,100%); max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-today-actions::backdrop { background:rgba(3,9,18,.76); }
|
||||
.mobile-today-actions-panel { box-sizing:border-box; display:grid; gap:12px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-today-actions-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.mobile-today-actions-panel h2 { margin:0; }
|
||||
.mobile-today-navigation, .mobile-today-secondary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.mobile-today-actions-panel button { min-width:0; min-height:44px; }
|
||||
.mobile-today-secondary-actions [data-mobile-today-end] { grid-column:1 / -1; border-color:#b45309; }
|
||||
.today-session-handoff { position:fixed; left:12px; right:12px; bottom:calc(68px + env(safe-area-inset-bottom)); z-index:45; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:12px; border:1px solid #60a5fa; border-radius:12px; background:rgba(16,38,65,.98); box-shadow:0 8px 28px rgba(0,0,0,.35); }
|
||||
.today-session-handoff[hidden] { display:none; }
|
||||
.today-session-handoff button { min-height:44px; }
|
||||
@media(max-width:420px) { .today-session-handoff { grid-template-columns:1fr; } .today-session-handoff button { width:100%; } }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[hidden] { display:none; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
@@ -933,9 +1265,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-task-deadline { padding:1px 5px; border-radius:999px; color:#fef3c7; background:#92400e; font-size:10px; line-height:14px; }
|
||||
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
|
||||
.attention-interruption button { min-height:44px; flex:0 0 auto; }
|
||||
.today-detour-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
|
||||
.today-detour-interruption button { min-height:44px; flex:0 0 auto; }
|
||||
@media (max-width: 600px) {
|
||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||
body.mobile-today-active { padding-bottom:calc(166px + env(safe-area-inset-bottom)); }
|
||||
body.mobile-today-active { padding-bottom:calc(var(--mobile-today-clearance, 166px) + env(safe-area-inset-bottom)); }
|
||||
body:has(.mobile-today-hud:not([hidden]):not([data-overlay-hidden="true"])) { padding-bottom:calc(var(--mobile-today-clearance, 166px) + env(safe-area-inset-bottom)); }
|
||||
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
|
||||
.app-brand .muted, #clock { display:none; }
|
||||
.app-live-status { margin-left:auto; }
|
||||
@@ -1033,6 +1368,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
|
||||
.mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-first-task::backdrop { background:rgba(3,9,18,.78); }
|
||||
.mobile-first-task-panel { display:grid; gap:14px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-first-task-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.mobile-first-task-panel h2, .mobile-first-task-panel p { margin:0; }
|
||||
.mobile-first-task-actions { display:grid; gap:10px; }
|
||||
.mobile-first-task-actions button { min-height:48px; width:100%; }
|
||||
.mobile-first-task-actions button:disabled { opacity:.55; }
|
||||
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
@@ -1060,13 +1404,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-update-outcome { margin-top:12px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
|
||||
.mobile-update-outcome p { margin:0 0 10px; }
|
||||
.mobile-update-outcome button { width:100%; min-height:44px; }
|
||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
||||
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
||||
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mobile-today-hud [data-mobile-today-complete] { grid-area:complete; }
|
||||
.mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; }
|
||||
.mobile-today-hud button { min-height:44px; max-width:100%; }
|
||||
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
|
||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:4px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
||||
.mobile-first-task-coach { grid-column:1 / -1; display:grid; gap:2px; padding:8px 10px; border-left:3px solid #60a5fa; border-radius:6px; background:#172f4d; }
|
||||
.mobile-first-task-coach[hidden] { display:none; }
|
||||
.mobile-first-task-receipt { position:fixed; left:12px; right:12px; bottom:calc(var(--mobile-today-clearance, 166px) + 8px + env(safe-area-inset-bottom)); z-index:46; padding:12px; border:1px solid #34d399; border-radius:10px; background:#0d3b35; font-weight:700; }
|
||||
.mobile-first-task-receipt[hidden] { display:none; }
|
||||
.mobile-today-summary { grid-column:1 / 4; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
||||
.mobile-today-hud [data-work-session-progress] { grid-column:4 / 7; align-self:center; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mobile-today-hud [data-mobile-today-complete] { grid-column:1 / 3; }
|
||||
.mobile-today-hud [data-mobile-today-toggle] { grid-column:3 / 5; }
|
||||
.mobile-today-hud [data-mobile-today-more] { grid-column:5 / 7; }
|
||||
.mobile-today-hud button { min-width:0; min-height:44px; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-inline:6px; }
|
||||
.today-completion-undo { bottom:calc(var(--mobile-today-clearance, 166px) + 8px + env(safe-area-inset-bottom)); }
|
||||
}
|
||||
@media (min-width:701px) { .update-gesture-status { display:none; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
+669
-138
File diff suppressed because it is too large.
Load diff
@@ -21,6 +21,7 @@
|
||||
let privateRecordCount = 0;
|
||||
let inventoryUnavailable = false;
|
||||
let fullClearArmed = false;
|
||||
let persistence = { state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable' };
|
||||
const ownedCaches = async () => (await options.caches?.keys?.() || [])
|
||||
.filter(name => name.startsWith('stackchain-dashboard-'));
|
||||
|
||||
@@ -37,10 +38,48 @@
|
||||
return Math.round((Number(value) || 0) / (1024 * 1024));
|
||||
}
|
||||
|
||||
async function inspectPersistence() {
|
||||
if (typeof options.storageManager?.persisted !== 'function' || typeof options.storageManager?.persist !== 'function') {
|
||||
persistence = {state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable'};
|
||||
return persistence;
|
||||
}
|
||||
try {
|
||||
const protectedStorage = await options.storageManager.persisted();
|
||||
persistence = protectedStorage
|
||||
? {state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.', label:'Protected'}
|
||||
: {state:'incomplete', detail:'Offline work uses best-effort browser storage and may be removed under storage pressure.', label:'Best effort'};
|
||||
} catch (_error) {
|
||||
persistence = {state:'incomplete', detail:'Storage protection could not be checked. Retry to protect offline work.', label:'Check failed'};
|
||||
}
|
||||
return persistence;
|
||||
}
|
||||
|
||||
function persistenceReadiness() {
|
||||
return {state:persistence.state, detail:persistence.detail};
|
||||
}
|
||||
|
||||
async function requestPersistence() {
|
||||
if (typeof options.storageManager?.persist !== 'function') return persistenceReadiness();
|
||||
let granted = false;
|
||||
try { granted = await options.storageManager.persist(); }
|
||||
catch (_error) {
|
||||
persistence = {state:'incomplete', detail:'Storage protection request failed. Offline work still uses best-effort storage; retry when ready.', label:'Request failed'};
|
||||
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Request failed ');
|
||||
return persistenceReadiness();
|
||||
}
|
||||
await refresh();
|
||||
if (!granted && persistence.state !== 'complete') {
|
||||
persistence = {state:'incomplete', detail:'The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.', label:'Denied'};
|
||||
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Denied ');
|
||||
}
|
||||
return persistenceReadiness();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let estimate = null;
|
||||
try { estimate = await options.storageManager?.estimate?.(); }
|
||||
catch (_error) { /* Storage estimates are optional. */ }
|
||||
await inspectPersistence();
|
||||
options.summary.textContent = estimate?.quota
|
||||
? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.`
|
||||
: 'Browser storage usage is unavailable.';
|
||||
@@ -52,7 +91,7 @@
|
||||
privateRecordCount = inventory.recordCount;
|
||||
inventoryUnavailable = inventory.unavailable;
|
||||
const cacheCount = (await ownedCaches()).length;
|
||||
options.detail.textContent = `${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
|
||||
options.detail.textContent = `Storage protection: ${persistence.label} · ${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
|
||||
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
|
||||
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
|
||||
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
|
||||
@@ -84,7 +123,7 @@
|
||||
privateItemCount = 0;
|
||||
fullClearArmed = false;
|
||||
options.clearAllButton.textContent = 'Clear all private data';
|
||||
options.detail.textContent = '0 private browser items · 0 private work records · 0 cached app copies';
|
||||
options.detail.textContent = `Storage protection: ${persistence.label} · 0 private browser items · 0 private work records · 0 cached app copies`;
|
||||
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
|
||||
} catch (error) {
|
||||
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
|
||||
@@ -99,5 +138,5 @@
|
||||
return refresh();
|
||||
}
|
||||
|
||||
return { refresh, start };
|
||||
return { refresh, start, persistenceReadiness, requestPersistence };
|
||||
});
|
||||
+512
-8
@@ -27,16 +27,29 @@
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
<div id="sign-out-review-sheet" class="sign-out-review-sheet" hidden>
|
||||
<section class="sign-out-review-panel" role="dialog" aria-modal="true" aria-labelledby="sign-out-review-heading">
|
||||
<h2 id="sign-out-review-heading">Review sign out</h2>
|
||||
<p id="sign-out-review-summary" class="small" role="status" aria-live="polite">Checking private work…</p>
|
||||
<p id="sign-out-review-warning" class="sign-out-review-warning"></p>
|
||||
<div class="sign-out-review-actions">
|
||||
<button id="cancel-sign-out" type="button">Cancel</button>
|
||||
<button id="confirm-sign-out" type="button">Sign out</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
|
||||
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
|
||||
<div class="live-data-status-header">
|
||||
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
|
||||
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
|
||||
</div>
|
||||
<p id="live-data-status-today-paused" class="live-data-status-today-paused" role="status" hidden>Today paused while you check live data.</p>
|
||||
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
|
||||
<p id="live-data-status-retry" class="small muted"></p>
|
||||
<div class="live-data-status-actions">
|
||||
<button id="refresh-live-data" type="button">Refresh now</button>
|
||||
<button id="return-from-live-data-status" type="button">Return to Today</button>
|
||||
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -51,6 +64,10 @@
|
||||
<div><h2>Security Center</h2><p class="small muted">Investigate activity, remove untrusted access, and verify the result.</p></div>
|
||||
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||
</div>
|
||||
<aside class="today-detour-interruption" id="security-center-today-detour" data-today-detour role="status" aria-live="polite" hidden>
|
||||
<strong data-today-detour-label>Today paused</strong>
|
||||
<button id="return-from-security-center" data-return-from-detour type="button">Return to Today</button>
|
||||
</aside>
|
||||
<nav class="security-section-nav" aria-label="Security Center sections">
|
||||
<button id="security-section-activity" type="button">Activity</button>
|
||||
<button id="security-section-devices" type="button">Devices</button>
|
||||
@@ -95,6 +112,10 @@
|
||||
</div>
|
||||
<button id="close-device-setup" type="button" aria-label="Close device setup">Close</button>
|
||||
</div>
|
||||
<aside class="today-detour-interruption" id="device-setup-today-detour" data-today-detour role="status" aria-live="polite" hidden>
|
||||
<strong data-today-detour-label>Today paused</strong>
|
||||
<button id="return-from-device-setup" data-return-from-detour type="button">Return to Today</button>
|
||||
</aside>
|
||||
<ol class="device-setup-list">
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Install Stackchain</strong><p class="small" id="device-setup-install-status" role="status"></p></div>
|
||||
@@ -104,10 +125,18 @@
|
||||
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Protect offline work</strong><p class="small" id="device-setup-protection-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-protection" type="button">Protect</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step" id="device-setup-app-badge-step">
|
||||
<div><strong>Show unread Updates on the app icon</strong><p class="small" id="device-setup-app-badge-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-app-badge" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
|
||||
<div class="device-setup-deadline-controls">
|
||||
@@ -118,6 +147,14 @@
|
||||
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Start my planned day</strong><p class="small" id="device-setup-start-day-status" role="status">Get one private reminder when Tomorrow is ready.</p></div>
|
||||
<div class="device-setup-deadline-controls">
|
||||
<label for="device-setup-start-day-hour">Local hour</label>
|
||||
<select id="device-setup-start-day-hour" aria-label="Start-day reminder local hour"></select>
|
||||
<button class="device-setup-action" id="device-setup-start-day" type="button">Enable</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<section class="device-storage" aria-labelledby="device-storage-heading">
|
||||
<div>
|
||||
@@ -167,6 +204,7 @@
|
||||
<button class="work-filter" data-work-filter="later" aria-pressed="false">Later <span data-work-count="later">0</span></button>
|
||||
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
|
||||
</div>
|
||||
<button class="plan-tomorrow" id="plan-tomorrow" type="button">Plan Tomorrow</button>
|
||||
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
|
||||
<select class="work-milestone-filter" id="work-milestone-filter">
|
||||
<option value="all">All milestones</option>
|
||||
@@ -176,12 +214,22 @@
|
||||
<div class="offline-work-controls">
|
||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<span class="app-badge-setting" id="app-badge-setting">
|
||||
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show unread Updates on the app icon</label>
|
||||
<span class="small" id="app-badge-status" role="status" aria-live="polite"></span>
|
||||
</span>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
|
||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-start-day"><input id="push-start-day" type="checkbox" /> Remind me when my planned day is ready</label>
|
||||
<label for="push-start-day-hour">Start-day hour <select id="push-start-day-hour" aria-label="Start-day reminder local hour"></select></label>
|
||||
<span class="small" id="push-start-day-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="today-lock-screen"><input id="today-lock-screen" type="checkbox" /> Show active Today controls on my lock screen</label>
|
||||
<span class="small" id="today-lock-screen-status" role="status" aria-live="polite"></span>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||
@@ -205,6 +253,10 @@
|
||||
<span><strong>Today paused</strong> while handling Attention</span>
|
||||
<button id="return-to-today" type="button">Return to Today</button>
|
||||
</aside>
|
||||
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
|
||||
<strong data-today-detour-label>Today paused</strong>
|
||||
<button data-return-from-detour type="button">Return to Today</button>
|
||||
</aside>
|
||||
<section class="device-readiness-card" id="device-readiness-card" aria-labelledby="device-readiness-heading" hidden>
|
||||
<div>
|
||||
<strong id="device-readiness-heading">Make this phone work-ready</strong>
|
||||
@@ -246,6 +298,13 @@
|
||||
<button id="empty-work-create" type="button">Create an issue</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="deadline-snooze" id="deadline-snooze" aria-labelledby="deadline-snooze-heading" hidden>
|
||||
<div>
|
||||
<strong id="deadline-snooze-heading">Deadline reminders paused</strong>
|
||||
<p class="small" id="deadline-snooze-status" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<button id="review-snoozed-deadlines" type="button">Review now</button>
|
||||
</section>
|
||||
<section class="agenda-replan" id="agenda-replan" aria-labelledby="agenda-replan-heading" hidden>
|
||||
<div class="agenda-replan-launch">
|
||||
<div><strong id="agenda-replan-heading">Overdue deadlines</strong><p class="small muted">Make the Agenda credible without leaving the queue.</p></div>
|
||||
@@ -263,6 +322,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="agenda-export" id="agenda-export" aria-labelledby="agenda-export-heading" hidden>
|
||||
<div>
|
||||
<strong id="agenda-export-heading">Take deadlines with you</strong>
|
||||
<p class="small muted" id="agenda-export-empty">Review this Agenda before sharing a calendar snapshot.</p>
|
||||
</div>
|
||||
<button id="open-agenda-export" type="button">Export to calendar</button>
|
||||
</section>
|
||||
<section class="protect-today" id="protect-today-panel" aria-labelledby="protect-today-heading" hidden>
|
||||
<div>
|
||||
<strong id="protect-today-heading">Deadlines need a plan</strong>
|
||||
@@ -281,8 +347,11 @@
|
||||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||
<button class="retry-work-route" id="retry-workspace" type="button" hidden>Retry workspace</button>
|
||||
<button class="release-receipt-launcher" id="release-receipt-launcher" type="button" hidden>Merged · tracking release</button>
|
||||
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
|
||||
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
||||
<div class="small" id="today-session-sync-status" role="status" aria-live="polite" hidden></div>
|
||||
<div class="small" id="later-sync-status" aria-live="polite">Later is saved on this device.</div>
|
||||
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
|
||||
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
||||
@@ -297,11 +366,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<dialog class="release-receipt-sheet" id="release-receipt-sheet" aria-labelledby="release-receipt-title">
|
||||
<section class="release-receipt-panel">
|
||||
<header><div><p class="small muted">Exact merge evidence</p><h2 id="release-receipt-title">Release watchlist</h2></div><button id="close-release-receipt" type="button">Close</button></header>
|
||||
<p id="release-receipt-status" role="status" aria-live="polite">Checking the exact merge commit…</p>
|
||||
<p class="small" id="release-receipt-checks"></p>
|
||||
<a class="button-link" id="release-receipt-link" href="" hidden>Open release</a>
|
||||
<div class="release-watchlist" id="release-watchlist" aria-label="Tracked merges"></div>
|
||||
<div class="release-receipt-actions"><button id="refresh-release-receipt" type="button">Refresh all</button><button id="dismiss-release-receipt" type="button">Clear all</button></div>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog class="agenda-export-sheet" id="agenda-export-sheet" aria-labelledby="agenda-export-title">
|
||||
<section class="agenda-export-panel">
|
||||
<header>
|
||||
<div><p class="small muted">Calendar handoff</p><h2 id="agenda-export-title">Review Agenda export</h2></div>
|
||||
<button id="cancel-agenda-export" type="button">Cancel</button>
|
||||
</header>
|
||||
<p>This exports a snapshot, not ongoing synchronization. Only checked deadline details leave Stackchain.</p>
|
||||
<fieldset id="agenda-export-items" aria-describedby="agenda-export-privacy"></fieldset>
|
||||
<p class="small muted" id="agenda-export-privacy">Your issue titles, repositories, dates, and forge links are included in the calendar file.</p>
|
||||
<p class="small" id="agenda-export-status" role="status" aria-live="polite"></p>
|
||||
<button id="share-agenda-export" type="button">Share calendar file</button>
|
||||
</section>
|
||||
</dialog>
|
||||
<section class="insights-shell" id="insights-sheet" aria-labelledby="insights-heading">
|
||||
<div class="insights-header">
|
||||
<div><h2 id="insights-heading">Insights</h2><p class="small muted">Context, activity, live signals, and workspace tools.</p></div>
|
||||
<button id="close-insights" type="button">Close</button>
|
||||
</div>
|
||||
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
|
||||
<strong data-today-detour-label>Today paused</strong>
|
||||
<button data-return-from-detour type="button">Return to Today</button>
|
||||
</aside>
|
||||
<aside class="sidebar">
|
||||
<details class="panel stack" data-panel-key="context" open>
|
||||
<summary><h2>Context & view</h2></summary>
|
||||
@@ -372,6 +468,143 @@
|
||||
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
|
||||
<button id="cancel-plan-today" type="button">Cancel</button>
|
||||
</div>
|
||||
<p class="week-plan-progress" id="week-plan-progress" aria-live="polite" hidden></p>
|
||||
<nav class="week-plan-dates" id="week-plan-dates" aria-label="Week Ahead dates" hidden></nav>
|
||||
<button class="back-to-week-review" id="back-to-week-review" type="button" hidden>Back to week review</button>
|
||||
<section class="tomorrow-conflict-review" id="tomorrow-conflict-review" aria-labelledby="tomorrow-conflict-title" hidden>
|
||||
<div class="small">Cross-device change</div>
|
||||
<h3 id="tomorrow-conflict-title">Choose which Tomorrow plan to keep</h3>
|
||||
<p class="small muted">Both plans are preserved until one choice succeeds. Today will not change.</p>
|
||||
<div class="tomorrow-conflict-plans">
|
||||
<section aria-labelledby="tomorrow-conflict-phone-title">
|
||||
<h4 id="tomorrow-conflict-phone-title">This phone</h4>
|
||||
<div id="tomorrow-conflict-phone-plan"></div>
|
||||
</section>
|
||||
<section aria-labelledby="tomorrow-conflict-server-title">
|
||||
<h4 id="tomorrow-conflict-server-title">Saved on your account</h4>
|
||||
<div id="tomorrow-conflict-server-plan"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="tomorrow-conflict-actions">
|
||||
<button id="keep-phone-tomorrow" type="button">Keep this phone’s plan</button>
|
||||
<button id="use-server-tomorrow" type="button">Use saved account plan</button>
|
||||
</div>
|
||||
<div class="small tomorrow-conflict-status" id="tomorrow-conflict-status" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="week-conflict-review" id="week-conflict-review" aria-labelledby="week-conflict-title" hidden>
|
||||
<div class="small">Cross-device change</div>
|
||||
<h3 id="week-conflict-title">Resolve Week Ahead changes</h3>
|
||||
<p class="small muted">Changes on different days are combined automatically. Choose only where both devices changed the same day.</p>
|
||||
<div class="week-conflict-days" id="week-conflict-days" hidden></div>
|
||||
<button id="save-merged-week" type="button" hidden disabled>Save combined week</button>
|
||||
<div class="week-conflict-plans" id="week-conflict-legacy-plans">
|
||||
<section aria-labelledby="week-conflict-phone-title">
|
||||
<h4 id="week-conflict-phone-title">This phone</h4>
|
||||
<div id="week-conflict-phone-plan"></div>
|
||||
</section>
|
||||
<section aria-labelledby="week-conflict-server-title">
|
||||
<h4 id="week-conflict-server-title">Saved on your account</h4>
|
||||
<div id="week-conflict-server-plan"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="week-conflict-actions" id="week-conflict-legacy-actions">
|
||||
<button id="keep-phone-week" type="button">Keep this phone’s week</button>
|
||||
<button id="use-server-week" type="button">Use saved account week</button>
|
||||
</div>
|
||||
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="week-pull-conflict-review" id="week-pull-conflict-review" aria-labelledby="week-pull-conflict-title" tabindex="-1" hidden>
|
||||
<div class="small">Saved transfer needs review</div>
|
||||
<h3 id="week-pull-conflict-title">Resolve Today transfer</h3>
|
||||
<p id="week-pull-conflict-item" class="week-pull-conflict-item"></p>
|
||||
<p id="week-pull-conflict-context" class="small muted"></p>
|
||||
<div class="week-pull-conflict-actions">
|
||||
<button id="rebase-week-pull" type="button">Add using current plans</button>
|
||||
<button id="keep-week-pull" type="button">Keep in Week Ahead</button>
|
||||
</div>
|
||||
<div class="small week-pull-conflict-status" id="week-pull-conflict-status" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden>
|
||||
<div class="small">Seven-day overview</div>
|
||||
<h2 id="week-review-title">Week Ahead</h2>
|
||||
<p class="small muted">See what is next and check each day’s load. Edit only when you are ready.</p>
|
||||
<p class="week-offline-snapshot" id="week-offline-snapshot" role="status" aria-live="polite" hidden></p>
|
||||
<button id="retry-week-live" type="button" hidden>Retry live Week Ahead</button>
|
||||
<button id="open-week-capacity-import" type="button">Refresh from calendar</button>
|
||||
<button id="open-week-availability" type="button">Set weekly availability</button>
|
||||
<section class="week-availability-editor" id="week-availability-editor" aria-labelledby="week-availability-title" hidden>
|
||||
<h3 id="week-availability-title">Weekly availability</h3>
|
||||
<p class="small muted">New Week Ahead dates use these defaults. Capacity set on a specific date remains an override.</p>
|
||||
<div class="week-availability-grid">
|
||||
<label>Monday <input data-weekday-capacity="0" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Tuesday <input data-weekday-capacity="1" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Wednesday <input data-weekday-capacity="2" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Thursday <input data-weekday-capacity="3" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Friday <input data-weekday-capacity="4" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Saturday <input data-weekday-capacity="5" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Sunday <input data-weekday-capacity="6" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
</div>
|
||||
<div class="week-availability-actions">
|
||||
<button id="cancel-week-availability" type="button">Cancel</button>
|
||||
<button id="disable-week-availability" type="button">Disable defaults</button>
|
||||
<button id="save-week-availability" type="button">Save weekly availability</button>
|
||||
</div>
|
||||
<div id="week-availability-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<button id="open-week-reflow" type="button" hidden>Reflow remaining week</button>
|
||||
<section class="week-reflow-review" id="week-reflow-review" aria-labelledby="week-reflow-title" hidden>
|
||||
<div class="small">Capacity-safe preview</div>
|
||||
<h3 id="week-reflow-title">Reflow remaining week?</h3>
|
||||
<p class="small muted">Work keeps its current order. Anything that cannot fit stays unscheduled in My Work.</p>
|
||||
<div class="week-reflow-days" id="week-reflow-days"></div>
|
||||
<div class="week-reflow-unscheduled" id="week-reflow-unscheduled" role="status"></div>
|
||||
<div class="week-reflow-actions">
|
||||
<button id="cancel-week-reflow" type="button">Cancel</button>
|
||||
<button id="apply-week-reflow" type="button">Apply reflow</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
|
||||
<div class="week-review-days" id="week-review-days"></div>
|
||||
<div class="week-unplan-receipt" id="week-unplan-receipt" role="status" aria-live="polite" hidden></div>
|
||||
<button id="undo-week-unplan" type="button" hidden>Undo</button>
|
||||
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
|
||||
<button id="export-saved-week-calendar" type="button" hidden>Add saved week to calendar</button>
|
||||
<button id="edit-week-plan" type="button" hidden>Edit week</button>
|
||||
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
|
||||
</section>
|
||||
<section class="week-capacity-import" id="week-capacity-import" aria-labelledby="week-capacity-title" hidden>
|
||||
<header><div><div class="small">Private calendar refresh</div><h2 id="week-capacity-title">Refresh Week Ahead availability</h2></div>
|
||||
<button id="cancel-week-capacity-import" type="button">Cancel</button></header>
|
||||
<p class="small muted">Compare refreshed availability with planned load before anything changes. Your calendar is read only on this device; event titles, people, locations, and the file are never saved or uploaded.</p>
|
||||
<label>Calendar file
|
||||
<input id="week-capacity-file" type="file" accept=".ics,text/calendar" />
|
||||
</label>
|
||||
<div class="week-capacity-hours">
|
||||
<label>Workday starts <input id="week-capacity-start" type="time" value="09:00" required /></label>
|
||||
<label>Workday ends <input id="week-capacity-end" type="time" value="17:00" required /></label>
|
||||
</div>
|
||||
<div id="week-capacity-status" class="small" role="status" aria-live="assertive"></div>
|
||||
<section id="week-capacity-review" aria-labelledby="week-capacity-review-title" hidden>
|
||||
<h3 id="week-capacity-review-title">Review available time</h3>
|
||||
<div id="week-capacity-days" class="week-capacity-days"></div>
|
||||
<label class="week-free-times-consent"><input id="keep-week-free-times" type="checkbox" />
|
||||
<span><strong>Keep these free times with Week Ahead</strong><small>Only seven days of start/end times are encrypted and synced. Calendar events and the file stay on this device.</small></span>
|
||||
</label>
|
||||
<button id="apply-week-capacities" type="button">Apply seven capacities</button>
|
||||
</section>
|
||||
</section>
|
||||
<section class="week-calendar-handoff" id="week-calendar-handoff" aria-labelledby="week-calendar-title" hidden>
|
||||
<div class="small">Calendar handoff</div>
|
||||
<h2 id="week-calendar-title">Add Week Ahead to calendar</h2>
|
||||
<p class="small muted">Titles, references, dates, and times are included in a local calendar file; nothing is uploaded. Imported busy time is used only until this page reloads unless you consent to keep free times with the encrypted Week Ahead plan.</p>
|
||||
<div class="week-calendar-days" id="week-calendar-days"></div>
|
||||
<div class="small week-calendar-status" id="week-calendar-status" role="status" aria-live="assertive"></div>
|
||||
<div class="week-calendar-actions">
|
||||
<button id="back-to-week-review-from-calendar" type="button">Back to review</button>
|
||||
<button id="save-week-calendar-times" type="button">Save exact times</button>
|
||||
<button id="share-week-calendar" type="button">Share calendar blocks</button>
|
||||
</div>
|
||||
</section>
|
||||
<nav class="mobile-plan-today-nav" aria-label="Plan Today sections">
|
||||
<button type="button" data-plan-today-section="fit" aria-current="location">Fit</button>
|
||||
<button type="button" data-plan-today-section="today">Today</button>
|
||||
@@ -459,7 +692,7 @@
|
||||
<div><div class="small">End your day intentionally</div><h2 id="today-wrap-up-title">Wrap up Today</h2></div>
|
||||
<button id="close-today-wrap-up" type="button">Not now</button>
|
||||
</div>
|
||||
<p class="small muted">Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.</p>
|
||||
<p class="small muted">Carry selected work into the ordered Tomorrow plan. Unselected work stays in Today.</p>
|
||||
<div id="today-wrap-up-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-wrap-up-items" class="today-wrap-up-items"></div>
|
||||
<div class="today-wrap-up-actions">
|
||||
@@ -468,6 +701,40 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-summary-sheet" id="today-summary-sheet" role="dialog" aria-modal="true" aria-labelledby="today-summary-title" hidden>
|
||||
<section class="today-summary-panel">
|
||||
<div class="today-summary-header">
|
||||
<div><div class="small">Ready to communicate</div><h2 id="today-summary-title">Share day summary</h2></div>
|
||||
<button id="close-today-summary" type="button">Not now</button>
|
||||
</div>
|
||||
<p class="small muted">Review exactly what will leave this device. Time stays private unless you include it.</p>
|
||||
<div id="today-summary-status" class="small" role="status" aria-live="polite"></div>
|
||||
<h3>Worked on</h3>
|
||||
<div id="today-summary-worked" class="today-summary-items"></div>
|
||||
<h3>Tomorrow</h3>
|
||||
<div id="today-summary-tomorrow" class="today-summary-items"></div>
|
||||
<label class="today-summary-option"><input id="today-summary-include-actuals" type="checkbox"> Include actual time</label>
|
||||
<label class="today-summary-note">Optional note<textarea id="today-summary-note" maxlength="1000" rows="3"></textarea></label>
|
||||
<h3>Preview</h3>
|
||||
<pre id="today-summary-preview" class="today-summary-preview"></pre>
|
||||
<section class="today-summary-destination" aria-labelledby="today-summary-destination-title">
|
||||
<h3 id="today-summary-destination-title">Post to Gitea</h3>
|
||||
<p class="small muted">Enter the exact issue or pull request that should receive this reviewed comment.</p>
|
||||
<label for="today-summary-destination">Destination</label>
|
||||
<div class="today-summary-destination-controls">
|
||||
<input id="today-summary-destination" type="text" inputmode="text" autocomplete="off" maxlength="260" placeholder="owner/repo#42">
|
||||
<button id="validate-today-summary-destination" type="button">Review destination</button>
|
||||
</div>
|
||||
<div id="today-summary-target" class="today-summary-target small" aria-live="polite"></div>
|
||||
</section>
|
||||
<div class="today-summary-actions">
|
||||
<button id="post-today-summary" type="button" disabled>Post to Gitea</button>
|
||||
<button id="share-today-summary" type="button">Share summary</button>
|
||||
<button id="discard-today-summary" type="button">Discard draft</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
|
||||
<section class="today-readiness-panel">
|
||||
<div class="today-readiness-header">
|
||||
@@ -518,6 +785,10 @@
|
||||
<button id="close-command-palette" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="search-today-interruption" data-search-today-interruption role="status" aria-live="polite" hidden>
|
||||
<strong data-search-today-label>Today paused</strong>
|
||||
<button data-return-from-search type="button">Return to Today</button>
|
||||
</aside>
|
||||
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
||||
<fieldset class="cmd-search-scope">
|
||||
<legend>Filter search results</legend>
|
||||
@@ -558,6 +829,7 @@
|
||||
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
|
||||
<button id="cancel-search-selection" type="button">Cancel</button>
|
||||
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
|
||||
<button id="week-selected-search-results" type="button" disabled>Plan Week Ahead</button>
|
||||
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
|
||||
<button id="queue-selected-search-results" type="button" disabled>Assign & add to Today</button>
|
||||
</div>
|
||||
@@ -571,6 +843,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>
|
||||
@@ -595,6 +877,10 @@
|
||||
</div>
|
||||
<button id="close-search-preview" type="button">Back to search</button>
|
||||
</div>
|
||||
<aside class="search-today-interruption" data-search-today-interruption role="status" aria-live="polite" hidden>
|
||||
<strong data-search-today-label>Today paused</strong>
|
||||
<button data-return-from-search type="button">Return to Today</button>
|
||||
</aside>
|
||||
<nav class="mobile-search-preview-nav" aria-label="Search preview sections">
|
||||
<button type="button" data-search-preview-section="overview">Overview</button>
|
||||
<button type="button" data-search-preview-section="conversation">Conversation</button>
|
||||
@@ -669,6 +955,7 @@
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="defer-search-result" type="button" hidden>Assign & 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>
|
||||
@@ -678,6 +965,23 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="search-week-plan-sheet" id="search-week-plan-sheet" role="dialog" aria-modal="true"
|
||||
aria-labelledby="search-week-plan-title" hidden>
|
||||
<div class="search-week-plan-panel">
|
||||
<header>
|
||||
<div><div class="small">Week Ahead</div><h2 id="search-week-plan-title">Plan this issue</h2></div>
|
||||
<button id="cancel-search-week-plan" type="button">Cancel</button>
|
||||
</header>
|
||||
<p id="search-week-plan-copy" class="muted"></p>
|
||||
<fieldset><legend>Choose a day</legend><div class="search-week-plan-days" id="search-week-plan-days"></div></fieldset>
|
||||
<label class="search-week-plan-estimate" for="search-week-plan-estimate">Estimate in minutes
|
||||
<input id="search-week-plan-estimate" type="number" inputmode="numeric" min="1" max="1440" step="5" required />
|
||||
</label>
|
||||
<p id="search-week-plan-status" class="small" aria-live="polite"></p>
|
||||
<button id="confirm-search-week-plan" type="button">Plan</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="whiteboard-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-actions">
|
||||
@@ -898,6 +1202,10 @@
|
||||
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
|
||||
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
|
||||
</div>
|
||||
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
|
||||
<strong data-today-detour-label>Today paused</strong>
|
||||
<button data-return-from-detour type="button">Return to Today</button>
|
||||
</aside>
|
||||
<p class="small">Choose available issues, review the batch, then fit it into Today.</p>
|
||||
<nav class="mobile-find-work-nav" aria-label="Find Work steps">
|
||||
<button type="button" data-find-work-stage="discover">Discover</button>
|
||||
@@ -961,6 +1269,10 @@
|
||||
<h3 id="create-issue-heading">Capture work</h3>
|
||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||
</div>
|
||||
<aside class="today-capture-interruption" id="today-capture-interruption" role="status" hidden>
|
||||
<strong id="today-capture-interruption-label">Today paused</strong>
|
||||
<span class="small">Capture this thought, then return without counting interruption time.</span>
|
||||
</aside>
|
||||
<nav class="mobile-create-issue-nav" aria-label="New issue sections">
|
||||
<button type="button" data-create-issue-section="describe">Describe</button>
|
||||
<button type="button" data-create-issue-section="evidence">Evidence</button>
|
||||
@@ -1305,6 +1617,11 @@
|
||||
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
|
||||
</div>
|
||||
|
||||
<div class="today-completion-undo" id="today-completion-undo" role="status" aria-live="assertive" hidden>
|
||||
<span>Removed from Today. Undo?</span>
|
||||
<button id="undo-today-completion" type="button" aria-label="Undo removing the completed item from Today">Undo</button>
|
||||
</div>
|
||||
|
||||
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
|
||||
<section class="pull-sheet-panel">
|
||||
<div class="pull-sheet-header">
|
||||
@@ -1515,16 +1832,168 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
|
||||
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
|
||||
<button data-mobile-today-complete type="button" hidden>Done & next</button>
|
||||
<div class="small" data-work-session-progress aria-live="polite"></div>
|
||||
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
|
||||
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
|
||||
<section class="today-session-handoff" id="today-session-handoff" aria-live="polite" hidden>
|
||||
<div>
|
||||
<strong>Today is active on another device</strong>
|
||||
<div class="small" id="today-session-handoff-summary"></div>
|
||||
</div>
|
||||
<button id="continue-today-session" type="button">Continue here</button>
|
||||
</section>
|
||||
|
||||
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
|
||||
<div class="mobile-first-task-coach small" data-mobile-first-task-coach hidden>
|
||||
<strong>Complete your first task</strong>
|
||||
<span>Open it, then use Done & 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>
|
||||
<nav class="mobile-today-navigation" aria-label="Choose active Today item">
|
||||
<button type="button" data-work-session-previous>Previous item</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
<div class="mobile-today-secondary-actions">
|
||||
<button data-mobile-today-update type="button" hidden>Add update</button>
|
||||
<button class="mobile-today-blocked" data-mobile-today-blocked type="button" hidden>Report blocker</button>
|
||||
<button data-today-break-open type="button">Take break</button>
|
||||
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
|
||||
<button data-work-session-reschedule-week type="button">Reschedule to Week Ahead</button>
|
||||
<button data-mobile-today-end type="button">End session</button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog class="today-week-reschedule" id="today-week-reschedule" aria-labelledby="today-week-reschedule-title">
|
||||
<section class="today-week-reschedule-panel">
|
||||
<header>
|
||||
<div><p class="small muted">Active Today item</p><h2 id="today-week-reschedule-title">Reschedule to Week Ahead</h2></div>
|
||||
<button id="cancel-today-week-reschedule" type="button">Cancel</button>
|
||||
</header>
|
||||
<p class="small">Choose a future day after reviewing its current planned load. Today will continue with the next ready item.</p>
|
||||
<div class="today-week-reschedule-days" id="today-week-reschedule-days" role="radiogroup" aria-label="Week Ahead destination"></div>
|
||||
<label for="today-week-reschedule-estimate">Estimate in minutes</label>
|
||||
<input id="today-week-reschedule-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" required />
|
||||
<p id="today-week-reschedule-status" class="small" role="status" aria-live="assertive"></p>
|
||||
<button id="confirm-today-week-reschedule" type="button" disabled>Move to Week Ahead & continue</button>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog class="today-progress-sheet" id="today-progress-sheet" aria-labelledby="today-progress-title">
|
||||
<section class="today-progress-panel">
|
||||
<header><div><p class="small muted">Active Today item</p><h2 id="today-progress-title">Add progress update</h2></div><button id="cancel-today-progress" type="button">Cancel</button></header>
|
||||
<p id="today-progress-target" class="small"></p>
|
||||
<section class="today-progress-activity" id="today-progress-activity" aria-labelledby="today-progress-activity-title">
|
||||
<div class="today-progress-activity-heading">
|
||||
<strong id="today-progress-activity-title">Recent activity</strong>
|
||||
<span id="today-progress-activity-count" class="small muted"></span>
|
||||
</div>
|
||||
<div id="today-progress-activity-list" class="today-progress-activity-list" aria-live="polite"></div>
|
||||
<p id="today-progress-activity-status" class="small" role="status" aria-live="polite"></p>
|
||||
<div class="today-progress-activity-actions">
|
||||
<button id="retry-today-progress-activity" type="button" hidden>Retry activity</button>
|
||||
<button id="load-older-today-progress-activity" type="button" hidden>Load older</button>
|
||||
</div>
|
||||
</section>
|
||||
<label id="today-progress-body-label" for="today-progress-body">Update</label>
|
||||
<textarea id="today-progress-body" rows="5" maxlength="2000" placeholder="What changed, what you learned, or what comes next"></textarea>
|
||||
<div class="mention-options" id="today-progress-body-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="today-progress-body-mention-status" aria-live="polite"></div>
|
||||
<section class="voice-conversation" id="voice-today-progress" data-draft-label="progress update" aria-label="Dictate Today progress update" hidden>
|
||||
<div class="voice-conversation-controls">
|
||||
<button id="start-voice-today-progress" type="button">Dictate update</button>
|
||||
<button id="stop-voice-today-progress" type="button" hidden>Stop listening</button>
|
||||
</div>
|
||||
<div id="voice-today-progress-status" class="small" aria-live="polite"></div>
|
||||
<div class="voice-conversation-review" id="voice-today-progress-review" hidden>
|
||||
<label for="voice-today-progress-transcript">Review transcript</label>
|
||||
<textarea id="voice-today-progress-transcript" maxlength="2000"></textarea>
|
||||
<div class="voice-conversation-review-actions">
|
||||
<button id="append-voice-today-progress" type="button">Use transcript</button>
|
||||
<button id="replace-with-voice-today-progress" type="button" hidden>Replace update</button>
|
||||
<button id="discard-voice-today-progress" type="button">Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="today-progress-evidence" aria-labelledby="today-progress-evidence-label">
|
||||
<strong id="today-progress-evidence-label">Photo evidence <span class="small">Optional · Up to 5</span></strong>
|
||||
<div class="issue-attachment-controls conversation-photo-actions">
|
||||
<label class="issue-attachment-trigger" for="take-today-progress-photo">Take photo</label>
|
||||
<input class="visually-hidden" id="take-today-progress-photo" type="file" accept="image/*" capture="environment" />
|
||||
<label class="issue-attachment-trigger" for="today-progress-attachment">Choose existing</label>
|
||||
<input class="visually-hidden" id="today-progress-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview conversation-evidence-review" id="today-progress-attachment-preview" hidden>
|
||||
<img id="today-progress-attachment-image" alt="Selected progress photo preview" />
|
||||
<span class="small" id="today-progress-attachment-meta"></span>
|
||||
<div class="issue-evidence-tray" id="today-progress-attachment-tray" role="toolbar" aria-label="Progress photos" hidden></div>
|
||||
<label class="issue-evidence-note" for="today-progress-attachment-note">
|
||||
<span id="today-progress-attachment-note-label">Photo caption (optional)</span>
|
||||
<textarea id="today-progress-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
|
||||
</label>
|
||||
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
|
||||
<button id="move-today-progress-attachment-earlier" type="button">Move earlier</button>
|
||||
<button id="edit-today-progress-attachment" type="button">Edit photo</button>
|
||||
<button id="remove-today-progress-attachment" type="button">Remove selected</button>
|
||||
<button id="move-today-progress-attachment-later" type="button">Move later</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<p class="small muted today-progress-update-help">Save privately on this device, or post to the exact Gitea item. Today keeps running either way.</p>
|
||||
<section class="today-progress-blocker" aria-labelledby="today-progress-blocker-title">
|
||||
<strong id="today-progress-blocker-title">Blocked?</strong>
|
||||
<p id="today-progress-blocker-recovery" class="today-progress-blocker-recovery small" role="status" hidden></p>
|
||||
<label for="today-blocker-return-at">Return this item to Later at</label>
|
||||
<input id="today-blocker-return-at" type="datetime-local" />
|
||||
<div class="today-blocker-presets" aria-label="Return time choices">
|
||||
<button data-today-blocker-preset="later-today" type="button">Later today</button>
|
||||
<button data-today-blocker-preset="tomorrow" type="button">Tomorrow</button>
|
||||
<button data-today-blocker-custom type="button">Choose time</button>
|
||||
</div>
|
||||
<button id="post-today-blocker" type="button">Post blocker & move on</button>
|
||||
<p class="small muted">Posts this update first, then defers the item and continues Today.</p>
|
||||
</section>
|
||||
<p id="today-progress-status" class="small" role="status" aria-live="polite"></p>
|
||||
<div class="today-progress-actions">
|
||||
<button id="save-today-progress" type="button">Save to this item</button>
|
||||
<button id="post-today-progress" type="button">Post now</button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
<div class="today-break-status" role="status" aria-live="polite">
|
||||
<span id="today-break-status" hidden></span>
|
||||
<button id="resume-today-break" type="button" hidden>Resume Today</button>
|
||||
</div>
|
||||
<dialog class="today-break-sheet" id="today-break-sheet" aria-labelledby="today-break-title">
|
||||
<section class="today-break-panel">
|
||||
<header><div><p class="small muted">Today timer</p><h2 id="today-break-title">Take a timed break</h2></div><button id="cancel-today-break" type="button">Cancel</button></header>
|
||||
<p class="small">Your current item stays selected and tracked time remains paused until you explicitly resume.</p>
|
||||
<div class="today-break-actions" aria-label="Break duration">
|
||||
<button type="button" data-today-break-minutes="5">5 minutes</button>
|
||||
<button type="button" data-today-break-minutes="15">15 minutes</button>
|
||||
<button type="button" data-today-break-minutes="30">30 minutes</button>
|
||||
</div>
|
||||
<form id="today-break-custom-form" class="today-break-custom">
|
||||
<label for="today-break-custom-minutes">Custom minutes</label>
|
||||
<input id="today-break-custom-minutes" type="number" inputmode="numeric" min="1" max="120" step="1" required />
|
||||
<button type="submit">Start break</button>
|
||||
<p id="today-break-error" class="small" role="alert"></p>
|
||||
</form>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
|
||||
<section class="mobile-queue-panel">
|
||||
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
|
||||
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
|
||||
<strong data-today-detour-label>Today paused</strong>
|
||||
<button data-return-from-detour type="button">Return to Today</button>
|
||||
</aside>
|
||||
<div id="mobile-update-outcome" class="mobile-update-outcome" role="status" aria-live="polite" hidden>
|
||||
<p id="mobile-update-outcome-summary"></p>
|
||||
<button id="review-kept-updates" type="button" hidden>Review kept unread</button>
|
||||
@@ -1541,6 +2010,8 @@
|
||||
</section>
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
|
||||
<button data-mobile-queue="week" type="button"><span><strong>Week Ahead</strong><small id="mobile-week-summary" aria-live="polite">Nothing planned</small></span></button>
|
||||
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
@@ -1568,6 +2039,22 @@
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog class="mobile-first-task" id="mobile-first-task" aria-labelledby="mobile-first-task-title">
|
||||
<section class="mobile-first-task-panel">
|
||||
<header>
|
||||
<div><p class="small muted">Welcome to Work</p><h2 id="mobile-first-task-title">Start your first task</h2></div>
|
||||
<button id="close-mobile-first-task" type="button">Close</button>
|
||||
</header>
|
||||
<p class="small">Choose work, start it, then return to Work whenever you want to continue.</p>
|
||||
<p id="mobile-first-task-status" class="small" role="status" aria-live="polite"></p>
|
||||
<div class="mobile-first-task-actions">
|
||||
<button id="mobile-first-task-find" type="button">Find & start</button>
|
||||
<button id="mobile-first-task-create" type="button">Create & start</button>
|
||||
<button id="mobile-first-task-setup" type="button">Make this phone work-ready</button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
@@ -1579,6 +2066,8 @@
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/private-data-registry.js"></script>
|
||||
<script src="static/private-data-inventory.js"></script>
|
||||
<script src="static/sign-out-review.js"></script>
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/conversation-action-hydrator.js"></script>
|
||||
@@ -1610,14 +2099,20 @@
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/agenda-replan.js"></script>
|
||||
<script src="static/agenda-calendar.js"></script>
|
||||
<script src="static/protect-today.js"></script>
|
||||
<script src="static/notification-undo.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/work-selection.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
<script src="static/today-break.js"></script>
|
||||
<script src="static/today-progress.js"></script>
|
||||
<script src="static/today-lock-screen.js"></script>
|
||||
<script src="static/today-session-sync.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
<script src="static/today-wrap-up.js"></script>
|
||||
<script src="static/today-summary.js"></script>
|
||||
<script src="static/today-handoff.js"></script>
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
@@ -1626,6 +2121,12 @@
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-readiness.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/tomorrow-plan.js"></script>
|
||||
<script src="static/week-calendar.js"></script>
|
||||
<script src="static/week-calendar-import.js"></script>
|
||||
<script src="static/week-plan.js"></script>
|
||||
<script src="static/today-week-reschedule.js"></script>
|
||||
<script src="static/search-week-plan.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/today-rollover.js"></script>
|
||||
<script src="static/update-ownership.js"></script>
|
||||
@@ -1656,12 +2157,15 @@
|
||||
<script src="static/queue-today.js"></script>
|
||||
<script src="static/pull-sheet.js"></script>
|
||||
<script src="static/review-sheet.js"></script>
|
||||
<script src="static/release-receipt.js"></script>
|
||||
<script src="static/work-route.js"></script>
|
||||
<script src="static/task-overlay-history.js"></script>
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script src="static/live-data-status.js"></script>
|
||||
<script src="static/mobile-today-command-bar.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-pull-refresh.js"></script>
|
||||
<script src="static/mobile-first-task.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/mobile-delivery-recovery.js"></script>
|
||||
@@ -1677,8 +2181,8 @@
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-insights.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/mobile-app-badge.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/private-data-inventory.js"></script>
|
||||
<script src="static/private-device-data.js"></script>
|
||||
<script src="static/device-storage.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,5 +74,112 @@
|
||||
return { run };
|
||||
}
|
||||
|
||||
return { describe, createRefreshController };
|
||||
function createSheetController(options) {
|
||||
let ownsDetour = false;
|
||||
let backgroundState = null;
|
||||
|
||||
function focusableControls() {
|
||||
return [...options.sheet.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])')]
|
||||
.filter(control => !control.hidden);
|
||||
}
|
||||
|
||||
function containBackground() {
|
||||
if (backgroundState) return;
|
||||
backgroundState = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
|
||||
backgroundState.forEach((_wasInert, element) => { element.inert = true; });
|
||||
}
|
||||
|
||||
function releaseBackground() {
|
||||
if (!backgroundState) return;
|
||||
backgroundState.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundState = null;
|
||||
}
|
||||
|
||||
function finishClose() {
|
||||
if (options.sheet.hidden) return;
|
||||
options.sheet.hidden = true;
|
||||
options.trigger.setAttribute('aria-expanded', 'false');
|
||||
if (options.pausedStatus) options.pausedStatus.hidden = true;
|
||||
releaseBackground();
|
||||
if (ownsDetour) options.timerView?.finishDetour?.();
|
||||
ownsDetour = false;
|
||||
options.trigger.focus?.();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (options.history?.state?.liveDataStatus) {
|
||||
options.history.back();
|
||||
return;
|
||||
}
|
||||
finishClose();
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!options.sheet.hidden) return;
|
||||
ownsDetour = options.timerView?.beginDetour?.('live-data-status')?.reason === 'live-data-status';
|
||||
if (options.pausedStatus) options.pausedStatus.hidden = !ownsDetour;
|
||||
if (options.returnButton) options.returnButton.hidden = !ownsDetour;
|
||||
containBackground();
|
||||
options.sheet.hidden = false;
|
||||
options.trigger.setAttribute('aria-expanded', 'true');
|
||||
if (options.history && !options.history.state?.liveDataStatus) {
|
||||
options.history.pushState({ ...options.history.state, liveDataStatus:true }, '');
|
||||
}
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.trigger.addEventListener('click', open);
|
||||
options.closeButton.addEventListener('click', close);
|
||||
options.returnButton?.addEventListener('click', close);
|
||||
options.sheet.addEventListener('click', event => {
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget?.addEventListener('keydown', event => {
|
||||
if (options.sheet.hidden) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
options.historyTarget?.addEventListener('popstate', event => {
|
||||
if (!options.sheet.hidden && !event.state?.liveDataStatus) finishClose();
|
||||
});
|
||||
}
|
||||
|
||||
return { start, open, close };
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
const document = options.document;
|
||||
const qs = selector => document.querySelector(selector);
|
||||
const trigger = qs('#open-live-data-status');
|
||||
trigger.addEventListener('click', options.onOpen);
|
||||
return createSheetController({
|
||||
sheet:qs('#live-data-status-sheet'), trigger,
|
||||
closeButton:qs('#close-live-data-status'),
|
||||
returnButton:qs('#return-from-live-data-status'),
|
||||
pausedStatus:qs('#live-data-status-today-paused'),
|
||||
timerView:options.timerView,
|
||||
history:options.window.history,
|
||||
historyTarget:options.window,
|
||||
escapeTarget:document,
|
||||
backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')].filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
return { describe, createRefreshController, createSheetController, mount };
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
(function(root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileAppBadge = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileAppBadge({
|
||||
control,
|
||||
status,
|
||||
container,
|
||||
navigator,
|
||||
storage,
|
||||
serviceWorker,
|
||||
}) {
|
||||
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
|
||||
let enabled = false;
|
||||
let confirmedCount = 0;
|
||||
let renderedCount = null;
|
||||
|
||||
function available() {
|
||||
return typeof navigator?.setAppBadge === 'function'
|
||||
&& typeof navigator?.clearAppBadge === 'function';
|
||||
}
|
||||
|
||||
async function syncPreference() {
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({type:'stackchain-app-badge-preference', enabled});
|
||||
return Boolean(registration?.active);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function render() {
|
||||
if (!enabled || !available() || renderedCount === confirmedCount) return true;
|
||||
try {
|
||||
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
|
||||
else await navigator.clearAppBadge();
|
||||
renderedCount = confirmedCount;
|
||||
if (status) status.textContent = 'App icon badge is on and up to date.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) status.textContent = 'Could not update the app icon badge. Stackchain still has the confirmed count.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function change() {
|
||||
enabled = Boolean(control?.checked);
|
||||
if (enabled) storage?.setItem(ENABLED_KEY, 'true');
|
||||
else storage?.removeItem(ENABLED_KEY);
|
||||
await syncPreference();
|
||||
if (!enabled && available()) {
|
||||
try {
|
||||
await navigator.clearAppBadge();
|
||||
renderedCount = null;
|
||||
if (status) status.textContent = 'App icon badge is off.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) status.textContent = 'App icon badge is off, but the browser could not clear the old count.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return render();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!available()) {
|
||||
if (container) container.hidden = true;
|
||||
if (control) control.disabled = true;
|
||||
if (status) status.textContent = 'App icon badges are unavailable in this browser.';
|
||||
return false;
|
||||
}
|
||||
enabled = storage?.getItem(ENABLED_KEY) === 'true';
|
||||
if (enabled) void syncPreference();
|
||||
if (control) {
|
||||
control.checked = enabled;
|
||||
control.addEventListener('change', change);
|
||||
}
|
||||
if (status) status.textContent = enabled ? 'App icon badge is on.' : 'App icon badge is off.';
|
||||
return true;
|
||||
}
|
||||
|
||||
function readiness() {
|
||||
if (!available()) return {state:'unavailable', detail:'App icon badges are unavailable in this browser.'};
|
||||
return enabled
|
||||
? {state:'complete', detail:'The app icon shows the confirmed unread Updates count.'}
|
||||
: {state:'incomplete', detail:'Show the confirmed unread Updates count without opening Stackchain.'};
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
if (!available()) return false;
|
||||
if (control) control.checked = true;
|
||||
await change();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reconcile(count, {authoritative = false} = {}) {
|
||||
if (!authoritative || !Number.isSafeInteger(count) || count < 0) return false;
|
||||
confirmedCount = count;
|
||||
return render();
|
||||
}
|
||||
|
||||
return {start, change, reconcile, readiness, enable};
|
||||
});
|
||||
@@ -5,12 +5,35 @@
|
||||
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
|
||||
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const steps = [
|
||||
['install', options.installButton, options.installStatus, options.install],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
||||
];
|
||||
['install', options.installButton, options.installStatus, options.install, 'Install'],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
|
||||
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
|
||||
['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable'],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
|
||||
].filter(([_name, button, status, action]) => button && status && action);
|
||||
let trigger = options.launcher;
|
||||
let ownsDetour = false;
|
||||
let backgroundInert = null;
|
||||
let pendingResume = true;
|
||||
|
||||
function focusableControls() {
|
||||
return [...options.sheet.querySelectorAll(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)].filter(control => !control.hidden);
|
||||
}
|
||||
|
||||
function containBackground() {
|
||||
if (backgroundInert) return;
|
||||
backgroundInert = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
|
||||
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
|
||||
}
|
||||
|
||||
function releaseBackground() {
|
||||
if (!backgroundInert) return;
|
||||
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundInert = null;
|
||||
}
|
||||
|
||||
function readinessCounts(readiness) {
|
||||
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
|
||||
@@ -33,9 +56,10 @@
|
||||
const readiness = await options.getReadiness();
|
||||
let available = 0;
|
||||
let complete = 0;
|
||||
steps.forEach(([name, button, status]) => {
|
||||
steps.forEach(([name, button, status, _action, defaultActionLabel]) => {
|
||||
const step = readiness[name];
|
||||
status.textContent = step.detail;
|
||||
button.textContent = step.actionLabel || defaultActionLabel;
|
||||
button.hidden = step.state === 'complete' || step.state === 'unavailable';
|
||||
button.disabled = step.state === 'pending';
|
||||
if (step.state !== 'unavailable') available += 1;
|
||||
@@ -50,16 +74,35 @@
|
||||
|
||||
async function open(event) {
|
||||
trigger = event?.currentTarget || options.launcher;
|
||||
if (options.isMobile?.()) {
|
||||
ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup';
|
||||
}
|
||||
await render();
|
||||
containBackground();
|
||||
options.sheet.hidden = false;
|
||||
if (options.history && !options.history.state?.deviceSetup) {
|
||||
options.history.pushState({...options.history.state, deviceSetup:true}, '');
|
||||
}
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
function finishClose(resume = true) {
|
||||
options.sheet.hidden = true;
|
||||
releaseBackground();
|
||||
if (resume && ownsDetour) options.timerView?.finishDetour?.();
|
||||
ownsDetour = false;
|
||||
trigger?.focus?.();
|
||||
}
|
||||
|
||||
function close(resume = true) {
|
||||
if (options.history?.state?.deviceSetup) {
|
||||
pendingResume = resume;
|
||||
options.history.back();
|
||||
return;
|
||||
}
|
||||
finishClose(resume);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
options.launcher.addEventListener('click', open);
|
||||
options.promptLauncher?.addEventListener('click', open);
|
||||
@@ -70,11 +113,33 @@
|
||||
options.promptCard.hidden = true;
|
||||
});
|
||||
options.closeButton.addEventListener('click', close);
|
||||
options.returnButton?.addEventListener('click', () => close(false));
|
||||
options.sheet.addEventListener('click', event => {
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) close();
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
}
|
||||
if (event.key !== 'Tab' || options.sheet.hidden) return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
options.historyTarget?.addEventListener('popstate', event => {
|
||||
if (!options.sheet.hidden && !event.state?.deviceSetup) {
|
||||
finishClose(pendingResume);
|
||||
pendingResume = true;
|
||||
}
|
||||
});
|
||||
steps.forEach(([_name, button, _status, action]) => {
|
||||
button.addEventListener('click', async () => {
|
||||
@@ -97,18 +162,31 @@ if (typeof module === 'object' && module.exports) {
|
||||
|
||||
function mountMobileDeviceSetup(options) {
|
||||
const qs = selector => options.document.querySelector(selector);
|
||||
const window = options.window || options.document.defaultView;
|
||||
return createMobileDeviceSetup({
|
||||
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
protectionButton:qs('#device-setup-protection'),
|
||||
appBadgeButton:qs('#device-setup-app-badge'),
|
||||
deadlineButton:qs('#device-setup-deadline'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
protectionStatus:qs('#device-setup-protection-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||
appBadgeStatus:qs('#device-setup-app-badge-status'),
|
||||
readyStatus:qs('#device-setup-ready-status'),
|
||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
||||
promptStorage:options.promptStorage,
|
||||
returnButton:qs('#return-from-device-setup'),
|
||||
timerView:options.timerView,
|
||||
isMobile:options.isMobile || (() => innerWidth <= 600),
|
||||
escapeTarget:options.document,
|
||||
history:window?.history,
|
||||
historyTarget:window,
|
||||
backgroundElements:[
|
||||
qs('header'), qs('main'), qs('#mobile-task-dock'),
|
||||
].filter(Boolean),
|
||||
getReadiness:() => ({
|
||||
install:options.installApp.state(),
|
||||
offline:!options.offlineAvailable()
|
||||
@@ -116,16 +194,16 @@ function mountMobileDeviceSetup(options) {
|
||||
: options.offlineEnabled()
|
||||
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
|
||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||
push:qs('#push-updates').disabled
|
||||
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
|
||||
: qs('#push-updates').checked
|
||||
? {state:'complete', detail:'New update notifications are enabled.'}
|
||||
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
|
||||
protection:options.storageProtectionReadiness(),
|
||||
push:options.notificationReadiness(),
|
||||
appBadge:options.appBadgeReadiness(),
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
protectStorage:options.protectStorage,
|
||||
enablePush:options.enablePush,
|
||||
enableAppBadge:options.enableAppBadge,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileFirstTask = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileFirstTask(options) {
|
||||
const doc = typeof document !== 'undefined' ? document : null;
|
||||
const win = typeof window !== 'undefined' ? window : null;
|
||||
options = Object.assign({
|
||||
storage: typeof localStorage !== 'undefined' ? localStorage : null,
|
||||
isOnline: () => typeof navigator === 'undefined' || navigator.onLine,
|
||||
mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false},
|
||||
eventTarget: win,
|
||||
setTimer: (...args) => globalThis.setTimeout(...args),
|
||||
sheet: doc?.querySelector('#mobile-first-task'),
|
||||
title: doc?.querySelector('#mobile-first-task-title'),
|
||||
findButton: doc?.querySelector('#mobile-first-task-find'),
|
||||
createButton: doc?.querySelector('#mobile-first-task-create'),
|
||||
setupButton: doc?.querySelector('#mobile-first-task-setup'),
|
||||
closeButton: doc?.querySelector('#close-mobile-first-task'),
|
||||
status: doc?.querySelector('#mobile-first-task-status'),
|
||||
coach: doc?.querySelector('[data-mobile-first-task-coach]'),
|
||||
receipt: doc?.querySelector('#mobile-first-task-receipt'),
|
||||
isTodayActive: () => false,
|
||||
onFind: () => doc?.querySelector('#find-work')?.click(),
|
||||
onCreate: () => doc?.querySelector('#new-issue')?.click(),
|
||||
onSetup: () => doc?.querySelector('#open-device-setup')?.click(),
|
||||
}, options);
|
||||
const prefix = 'stackchain.first-task.v1:';
|
||||
|
||||
function account() {
|
||||
return String(options.getLogin?.() || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const login = account();
|
||||
return login ? prefix + login : '';
|
||||
}
|
||||
|
||||
function state() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return '';
|
||||
try { return options.storage.getItem(currentKey) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
}
|
||||
|
||||
function store(value) {
|
||||
const currentKey = key();
|
||||
const current = state();
|
||||
if (!currentKey || current === value || current === 'complete') return false;
|
||||
try {
|
||||
options.storage.setItem(currentKey, value);
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function adoptRemote(value) {
|
||||
return ['coaching', 'complete'].includes(value) && store(value);
|
||||
}
|
||||
|
||||
function required() {
|
||||
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !state());
|
||||
}
|
||||
|
||||
function renderCoach() {
|
||||
if (!options.coach) return;
|
||||
const visible = options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive();
|
||||
options.coach.hidden = !visible;
|
||||
const host = options.coach.closest?.('[data-mobile-today-hud]');
|
||||
if (visible && host) host.hidden = false;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const online = options.isOnline();
|
||||
const awaitingStart = options.hasWork();
|
||||
options.findButton.disabled = !online;
|
||||
if (options.title) options.title.textContent = awaitingStart ? 'Finish starting your first task' : 'Start your first task';
|
||||
options.findButton.textContent = 'Find & start';
|
||||
options.createButton.textContent = 'Create & start';
|
||||
options.status.textContent = awaitingStart ?
|
||||
'Your task is ready. Start it from Find or create and start another task.' : online ?
|
||||
'Choose a task to claim and start, or create and start one of your own.' :
|
||||
'You are offline. Create a task now and it will stay in Drafts until you reconnect.';
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!required()) return false;
|
||||
render();
|
||||
if (!options.sheet.open) options.sheet.showModal();
|
||||
(options.findButton.disabled ? options.createButton : options.findButton).focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (account() && options.isTodayActive() && state() !== 'complete') {
|
||||
store('coaching');
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
renderCoach();
|
||||
return 'coaching';
|
||||
}
|
||||
renderCoach();
|
||||
if (options.sheet.open) render();
|
||||
if (required() && options.hasWork()) return 'awaiting-start';
|
||||
return required() ? 'required' : 'inactive';
|
||||
}
|
||||
|
||||
function completeOutcome() {
|
||||
if (state() !== 'coaching') return false;
|
||||
store('complete');
|
||||
options.eventTarget?.dispatchEvent?.(new CustomEvent('stackchain:first-task-complete'));
|
||||
renderCoach();
|
||||
if (options.receipt) {
|
||||
options.receipt.hidden = false;
|
||||
options.setTimer?.(() => { options.receipt.hidden = true; }, 6000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handoff(callback, requiresOnline = false) {
|
||||
if (requiresOnline && !options.isOnline()) return;
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
callback();
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.findButton.addEventListener('click', () => handoff(options.onFind, true));
|
||||
options.createButton.addEventListener('click', () => handoff(options.onCreate));
|
||||
options.setupButton.addEventListener('click', () => handoff(options.onSetup));
|
||||
options.closeButton.addEventListener('click', () => {
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
});
|
||||
options.eventTarget?.addEventListener('online', render);
|
||||
options.eventTarget?.addEventListener('offline', render);
|
||||
}
|
||||
|
||||
globalThis.stackchainFirstTaskCompleting = () => state() === 'coaching';
|
||||
return {required, open, refresh, render, start, completeOutcome, adoptRemote};
|
||||
});
|
||||
@@ -3,8 +3,11 @@
|
||||
else root.createMobileInsights = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function (o) {
|
||||
const route = '#/insights';
|
||||
const events = o.eventTarget || self;
|
||||
const location = o.location || self.location;
|
||||
let active = false;
|
||||
let previous = '#/my-work';
|
||||
let retries = 0;
|
||||
|
||||
function attr(element, name, value) {
|
||||
if (value === null) element.removeAttribute(name);
|
||||
@@ -12,7 +15,14 @@
|
||||
}
|
||||
|
||||
function render(open, focus = false) {
|
||||
const wasActive = active;
|
||||
active = Boolean(open && o.mediaQuery.matches);
|
||||
const detour = active ? o.detour?.() : null;
|
||||
const started = detour?.beginDetour('insights');
|
||||
if (detour && !started && retries++ < 40) setTimeout(sync, 250);
|
||||
else if (started && retries) { retries = 0; setTimeout(sync, 500); }
|
||||
else retries = 0;
|
||||
if (wasActive && !active) o.detour?.()?.finishDetour();
|
||||
attr(o.root, 'data-mobile-open', active ? 'true' : null);
|
||||
attr(o.root, 'role', active ? 'dialog' : null);
|
||||
attr(o.root, 'aria-modal', active ? 'true' : null);
|
||||
@@ -26,20 +36,21 @@
|
||||
}
|
||||
|
||||
function url(hash) {
|
||||
return (o.location.pathname || '') + (o.location.search || '') + hash;
|
||||
return (location.pathname || '') + (location.search || '') + hash;
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!o.mediaQuery.matches || active) return false;
|
||||
previous = o.location.hash && o.location.hash !== route ? o.location.hash : '#/my-work';
|
||||
if (o.menu) o.menu.open = false;
|
||||
previous = location.hash && location.hash !== route ? location.hash : '#/my-work';
|
||||
const menu = o.menu || o.launcher.closest?.('details');
|
||||
if (menu) menu.open = false;
|
||||
o.history.pushState({ ...(o.history.state || {}), mobileInsights:true, previousHash:previous }, '', url(route));
|
||||
render(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
render(o.location.hash === route, active && o.location.hash !== route);
|
||||
render(location.hash === route, active && location.hash !== route);
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -55,12 +66,23 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function returnToToday() {
|
||||
if (!active) return false;
|
||||
const state = { ...(o.history.state || {}) };
|
||||
delete state.mobileInsights;
|
||||
delete state.previousHash;
|
||||
o.history.replaceState(state, '', url(previous));
|
||||
render(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
o.launcher.addEventListener('click', open);
|
||||
o.closeButton.addEventListener('click', close);
|
||||
o.eventTarget.addEventListener('popstate', sync);
|
||||
o.eventTarget.addEventListener('hashchange', sync);
|
||||
o.eventTarget.addEventListener('keydown', event => {
|
||||
(o.returnButton || o.root.querySelector?.('[data-return-from-detour]'))?.addEventListener('click', returnToToday);
|
||||
events.addEventListener('popstate', sync);
|
||||
events.addEventListener('hashchange', sync);
|
||||
events.addEventListener('keydown', event => {
|
||||
if (active && event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
@@ -70,5 +92,5 @@
|
||||
sync();
|
||||
}
|
||||
|
||||
return { start, open, close, current:() => active };
|
||||
return { start, open, close, sync, current:() => active };
|
||||
});
|
||||
@@ -11,11 +11,44 @@
|
||||
['update', 'Updates'],
|
||||
['filed', 'Filed'],
|
||||
];
|
||||
const anonymousItems = new WeakMap();
|
||||
let anonymousItemSequence = 0;
|
||||
|
||||
function count(value) {
|
||||
return Math.max(0, Number(value) || 0);
|
||||
}
|
||||
|
||||
function itemIdentity(item) {
|
||||
if (item === null || item === undefined) return '';
|
||||
if (typeof item !== 'object') return String(item);
|
||||
const explicit = item.outbox_id || item.id || item.key || item.url;
|
||||
if (explicit !== null && explicit !== undefined && String(explicit)) {
|
||||
return String(item.kind || 'item') + ':' + String(explicit);
|
||||
}
|
||||
if (!anonymousItems.has(item)) anonymousItems.set(item, 'anonymous:' + (++anonymousItemSequence));
|
||||
return anonymousItems.get(item);
|
||||
}
|
||||
|
||||
function reviewPhases(counts) {
|
||||
if (!options.getPhaseItems) {
|
||||
return reviewOrder
|
||||
.map(([name, label]) => ({name, label, count: count(counts[name])}))
|
||||
.filter(phase => phase.count > 0);
|
||||
}
|
||||
const items = options.getPhaseItems() || {};
|
||||
const seen = new Set();
|
||||
return reviewOrder.flatMap(([name, label]) => {
|
||||
let phaseCount = 0;
|
||||
(items[name] || []).forEach(item => {
|
||||
const identity = itemIdentity(item);
|
||||
if (!identity || seen.has(identity)) return;
|
||||
seen.add(identity);
|
||||
phaseCount += 1;
|
||||
});
|
||||
return phaseCount ? [{name, label, count:phaseCount}] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function localDay() {
|
||||
const now = new Date();
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
@@ -69,12 +102,10 @@
|
||||
|
||||
function briefing() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const phases = reviewOrder
|
||||
.map(([name, label]) => ({name, label, count: count(counts[name])}))
|
||||
.filter(phase => phase.count > 0);
|
||||
const phases = reviewPhases(counts);
|
||||
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
|
||||
const today = count(counts.today);
|
||||
const delivery = count(counts.delivery);
|
||||
const delivery = phases.find(phase => phase.name === 'delivery')?.count || 0;
|
||||
const other = total - delivery;
|
||||
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
|
||||
const nextLabel = next === 'delivery' ? 'Review Delivery' :
|
||||
|
||||
@@ -33,8 +33,9 @@
|
||||
options.queueSheet.showModal();
|
||||
}
|
||||
|
||||
function closeQueues() {
|
||||
function closeQueues(returnToToday = false) {
|
||||
options.queueSheet?.open && options.queueSheet.close();
|
||||
if (returnToToday) options.detour?.()?.finishDetour();
|
||||
}
|
||||
|
||||
function start() {
|
||||
@@ -42,14 +43,16 @@
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
|
||||
if (name === 'queues') options.detour?.()?.beginDetour(name);
|
||||
options.actions?.[name]?.();
|
||||
if (name === 'queues' && options.queueSheet) openQueues();
|
||||
});
|
||||
});
|
||||
if (options.queueSheet) {
|
||||
options.queueClose?.addEventListener('click', closeQueues);
|
||||
options.queueClose?.addEventListener('click', () => closeQueues(true));
|
||||
options.queueSheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
closeQueues();
|
||||
closeQueues(true);
|
||||
});
|
||||
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
|
||||
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
||||
@@ -66,13 +69,16 @@
|
||||
function updateWork(mode) {
|
||||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
prepare:'Prepare', 'prepare-resume':'Resume prep',
|
||||
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', queue
|
||||
? (mode === 'update' ? 'Resume ' : 'Open ') + text
|
||||
: mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today'));
|
||||
const actionLabel = mode === 'prepare' ? 'Prepare Today' :
|
||||
mode === 'prepare-resume' ? 'Resume preparation' :
|
||||
queue ? (mode === 'update' ? 'Resume ' : 'Open ') + text :
|
||||
mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today');
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', actionLabel);
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.createMobileTodayCommandBar = factory;
|
||||
})(typeof window !== 'undefined' ? window : this, function createMobileTodayCommandBar({
|
||||
more,
|
||||
sheet,
|
||||
close,
|
||||
firstAction,
|
||||
endAction,
|
||||
existingEndControl,
|
||||
actionButtons = [],
|
||||
hud,
|
||||
dock,
|
||||
style,
|
||||
ResizeObserverImpl = typeof ResizeObserver === 'undefined' ? null : ResizeObserver,
|
||||
}) {
|
||||
if (!more || !sheet) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
if (sheet.open) sheet.close();
|
||||
};
|
||||
const open = () => {
|
||||
if (sheet.open) return;
|
||||
more.setAttribute('aria-expanded', 'true');
|
||||
sheet.showModal();
|
||||
firstAction?.focus();
|
||||
};
|
||||
|
||||
more.setAttribute('aria-expanded', 'false');
|
||||
more.addEventListener('click', open);
|
||||
close?.addEventListener('click', dismiss);
|
||||
actionButtons.forEach(button => button?.addEventListener('click', dismiss));
|
||||
endAction?.addEventListener('click', () => {
|
||||
dismiss();
|
||||
existingEndControl?.click();
|
||||
});
|
||||
sheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
dismiss();
|
||||
});
|
||||
sheet.addEventListener('close', () => {
|
||||
more.setAttribute('aria-expanded', 'false');
|
||||
more.focus();
|
||||
});
|
||||
|
||||
const measure = () => {
|
||||
if (!style || !hud || !dock) return;
|
||||
style.setProperty('--mobile-today-clearance', `${hud.offsetHeight + dock.offsetHeight + 16}px`);
|
||||
};
|
||||
const observer = ResizeObserverImpl && hud && dock ? new ResizeObserverImpl(measure) : null;
|
||||
observer?.observe(hud);
|
||||
observer?.observe(dock);
|
||||
measure();
|
||||
|
||||
return { open, close:dismiss, measure, destroy:() => observer?.disconnect() };
|
||||
});
|
||||
@@ -4,20 +4,26 @@
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
const preparation = options.getPreparationState?.();
|
||||
if (preparation?.active) return 'prepare-resume';
|
||||
if (preparation?.next && !['today', 'find'].includes(preparation.next)) return 'prepare';
|
||||
const recommended = options.queueLauncher && options.queueLauncher.recommend();
|
||||
if (recommended && !['today', 'find'].includes(recommended.name)) return recommended.name;
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
if (options.shouldActivate?.()) return 'activate';
|
||||
return 'find';
|
||||
}
|
||||
|
||||
function open() {
|
||||
const current = mode();
|
||||
if (current === 'continue') options.continueToday();
|
||||
else if (['prepare', 'prepare-resume'].includes(current)) options.prepareToday();
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else if (current === 'activate') options.openActivation();
|
||||
else if (current === 'find') options.findWork();
|
||||
else options.queueLauncher.open(current);
|
||||
return current;
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
'stackchain-voice-transcripts-v1',
|
||||
'stackchain-search-reply-drafts-v1',
|
||||
'stackchain-conversation-reply-drafts-v1',
|
||||
'stackchain-today-action-mailbox-v1',
|
||||
'stackchain-app-badge-preference-v1',
|
||||
]);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
|
||||
else root.stackchainPrivateDatabases = databases;
|
||||
|
||||
@@ -2,9 +2,69 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createPushNotifications = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, notification, serviceWorker, fetchJson,
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
||||
notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
let configuration = null;
|
||||
let pendingIntent = null;
|
||||
let recoveryPromise = null;
|
||||
|
||||
function blockedReadiness() {
|
||||
return {
|
||||
state:'blocked',
|
||||
detail:'Notifications are blocked. Allow them in browser settings, then check again.',
|
||||
actionLabel:'Check again',
|
||||
};
|
||||
}
|
||||
|
||||
function pendingRecoveryReadiness() {
|
||||
return notification.permission === 'granted'
|
||||
? {state:'blocked', detail:'Notification permission changed. Check again to finish setup.', actionLabel:'Check again'}
|
||||
: blockedReadiness();
|
||||
}
|
||||
|
||||
function notificationReadiness() {
|
||||
if (!configuration?.available || control?.disabled) {
|
||||
return {state:'unavailable', detail:status?.textContent || 'Update notifications are unavailable.'};
|
||||
}
|
||||
if (configuration.subscribed) {
|
||||
return {state:'complete', detail:'New update notifications are enabled.'};
|
||||
}
|
||||
if (pendingIntent === 'updates') return pendingRecoveryReadiness();
|
||||
if (notification.permission === 'denied') return blockedReadiness();
|
||||
return {state:'incomplete', detail:status?.textContent || 'New update notifications are off.'};
|
||||
}
|
||||
|
||||
function renderDeliveryHealth() {
|
||||
const health = Object.values(configuration?.delivery_health || {});
|
||||
const degraded = health.find(item => item?.state === 'degraded');
|
||||
if (testControl) testControl.hidden = !configuration?.subscribed;
|
||||
if (configuration?.subscribed && degraded) {
|
||||
const count = Number(degraded.consecutive_failures || 1);
|
||||
status.textContent = `Update notifications need attention after ${count} failed ${count === 1 ? 'delivery' : 'deliveries'}. Send a test notification.`;
|
||||
return;
|
||||
}
|
||||
status.textContent = configuration?.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
}
|
||||
|
||||
async function testDelivery() {
|
||||
if (!testControl) return;
|
||||
testControl.disabled = true;
|
||||
status.textContent = 'Sending a test notification…';
|
||||
try {
|
||||
await fetchJson('api/v1/push-subscription/test', {method:'POST'});
|
||||
configuration.delivery_health = {unread:{state:'healthy',consecutive_failures:0}};
|
||||
status.textContent = 'Test delivered. Update notifications are working on this device.';
|
||||
} catch (error) {
|
||||
status.textContent = 'Test delivery failed. Check your connection, then try again.';
|
||||
} finally {
|
||||
testControl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formattedHour(value) {
|
||||
return `${String(Number(value)).padStart(2, '0')}:00`;
|
||||
@@ -16,10 +76,37 @@
|
||||
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
|
||||
}
|
||||
|
||||
function renderDeadlineSnooze() {
|
||||
if (!deadlineSnooze) return;
|
||||
const wakeAt = Number(configuration?.snoozed_until || 0);
|
||||
deadlineSnooze.hidden = !wakeAt;
|
||||
if (wakeAt && deadlineSnoozeStatus) {
|
||||
const localTime = new Intl.DateTimeFormat(undefined, {
|
||||
hour:'numeric', minute:'2-digit',
|
||||
}).format(new Date(wakeAt * 1000));
|
||||
deadlineSnoozeStatus.textContent = `Deadline reminders snoozed until ${localTime}.`;
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewSnoozedDeadlines() {
|
||||
deadlineSnoozeReview.disabled = true;
|
||||
try {
|
||||
await fetchJson('api/v1/push-subscription/deadlines/snooze', {method:'DELETE'});
|
||||
configuration.snoozed_until = null;
|
||||
renderDeadlineSnooze();
|
||||
onReviewDeadlines?.();
|
||||
} catch (error) {
|
||||
deadlineSnoozeStatus.textContent = 'Could not resume deadline reminders. Check your connection and try again.';
|
||||
} finally {
|
||||
deadlineSnoozeReview.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function deadlineReadiness() {
|
||||
if (!configuration?.available || deadlineControl?.disabled) {
|
||||
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
||||
}
|
||||
if (pendingIntent === 'deadline') return pendingRecoveryReadiness();
|
||||
return configuration.deadline_enabled
|
||||
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
|
||||
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
|
||||
@@ -39,16 +126,24 @@
|
||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||
await subscription?.unsubscribe?.();
|
||||
control.checked = false;
|
||||
if (testControl) testControl.hidden = true;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
if (startDayControl) startDayControl.checked = false;
|
||||
configuration.subscribed = false;
|
||||
configuration.deadline_enabled = false;
|
||||
configuration.start_day_enabled = false;
|
||||
pendingIntent = null;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
async function ensureSubscription() {
|
||||
const permission = await notification.requestPermission();
|
||||
const permission = notification.permission === 'granted'
|
||||
? 'granted'
|
||||
: await notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
control.checked = false;
|
||||
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
|
||||
status.textContent = blockedReadiness().detail;
|
||||
return null;
|
||||
}
|
||||
const registration = await serviceWorker.ready;
|
||||
@@ -67,11 +162,15 @@
|
||||
control.checked = true;
|
||||
status.textContent = 'New update notifications enabled for this device.';
|
||||
configuration.subscribed = true;
|
||||
if (testControl) testControl.hidden = false;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
await ensureSubscription();
|
||||
pendingIntent = 'updates';
|
||||
const subscription = await ensureSubscription();
|
||||
if (subscription) pendingIntent = null;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function change() {
|
||||
@@ -94,6 +193,7 @@
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (deadlineControl.checked) pendingIntent = 'deadline';
|
||||
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (deadlineControl.checked && !subscription) {
|
||||
deadlineControl.checked = false;
|
||||
@@ -112,6 +212,7 @@
|
||||
configuration.reminder_hour = reminderHour;
|
||||
configuration.reminder_days = reminderDays;
|
||||
configuration.timezone = timezone;
|
||||
pendingIntent = null;
|
||||
deadlineStatus.textContent = deadlineControl.checked
|
||||
? enabledDeadlineText(reminderHour, reminderDays)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
@@ -127,33 +228,101 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStartDay() {
|
||||
startDayControl.disabled = true;
|
||||
if (startDayHour) startDayHour.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (startDayControl.checked) pendingIntent = 'start-day';
|
||||
if (startDayControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (startDayControl.checked && !subscription) {
|
||||
startDayControl.checked = false;
|
||||
startDayStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const reminderHour = Number(startDayHour?.value ?? configuration?.start_day_reminder_hour ?? 9);
|
||||
await fetchJson('api/v1/push-subscription/start-day', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:startDayControl.checked, timezone, reminder_hour:reminderHour}),
|
||||
});
|
||||
configuration.start_day_enabled = startDayControl.checked;
|
||||
configuration.start_day_timezone = timezone;
|
||||
configuration.start_day_reminder_hour = reminderHour;
|
||||
pendingIntent = null;
|
||||
startDayStatus.textContent = startDayControl.checked
|
||||
? `Start-day reminder enabled for ${formattedHour(reminderHour)} local time.`
|
||||
: 'Start-day reminders are off for this device.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
startDayControl.checked = !startDayControl.checked;
|
||||
startDayStatus.textContent = 'Could not change start-day reminders. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
startDayControl.disabled = false;
|
||||
if (startDayHour) startDayHour.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDeadline() {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function recoverPermission(intent = null) {
|
||||
if (!pendingIntent && ['updates', 'deadline', 'start-day'].includes(intent)) pendingIntent = intent;
|
||||
if (!pendingIntent || notification.permission !== 'granted') return false;
|
||||
if (recoveryPromise) return recoveryPromise;
|
||||
recoveryPromise = (async () => {
|
||||
if (pendingIntent === 'deadline') {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
if (pendingIntent === 'start-day') {
|
||||
startDayControl.checked = true;
|
||||
return changeStartDay();
|
||||
}
|
||||
return Boolean(await enable());
|
||||
})();
|
||||
try {
|
||||
return await recoveryPromise;
|
||||
} finally {
|
||||
recoveryPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
testControl?.addEventListener('click', testDelivery);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
startDayControl?.addEventListener('change', changeStartDay);
|
||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
if (startDayControl) startDayControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
status.textContent = configuration.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
|
||||
renderDeliveryHealth();
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
|
||||
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
|
||||
: 'Start-day reminders are off for this device.';
|
||||
renderDeadlineSnooze();
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
|
||||
return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
|
||||
const prefix = 'stackchain.release-receipt.v1:';
|
||||
const limit = 12;
|
||||
let entries = [];
|
||||
let refreshing = null;
|
||||
let timer = null;
|
||||
let bound = false;
|
||||
documentRef ||= typeof document !== 'undefined' ? document : null;
|
||||
windowRef ||= typeof window !== 'undefined' ? window : null;
|
||||
|
||||
const login = () => String(getLogin?.() || '').trim().toLowerCase();
|
||||
const key = () => prefix + login();
|
||||
const identity = value => String(value.repository || '') + '@' + String(value.commit_sha || '');
|
||||
const path = value => 'api/v1/repos/' + String(value.repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/') + '/release-receipt/' + encodeURIComponent(value.commit_sha);
|
||||
|
||||
function valid(value, account) {
|
||||
return value && value.account === account && value.repository && value.commit_sha;
|
||||
}
|
||||
|
||||
function persist() {
|
||||
if (!entries.length) storage?.removeItem(key());
|
||||
else storage?.setItem(key(), JSON.stringify({ version: 2, account: login(), entries }));
|
||||
}
|
||||
|
||||
const hasPending = () => entries.some(entry => !entry.status?.release && entry.status?.label !== 'Checks failed');
|
||||
|
||||
function schedule(delay = pollMs) {
|
||||
if (!bound || documentRef?.hidden || !hasPending()) return;
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = setTimer(async () => {
|
||||
timer = null;
|
||||
try { await refresh(); }
|
||||
catch (error) { if (statusNode) statusNode.textContent = error.message + ' Retry when connected.'; }
|
||||
schedule();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function restore() {
|
||||
const account = login();
|
||||
if (!account) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(storage?.getItem(prefix + account) || 'null');
|
||||
const stored = parsed?.version === 2 && parsed.account === account
|
||||
? parsed.entries : (valid(parsed, account) ? [parsed] : []);
|
||||
entries = Array.isArray(stored) ? stored.filter(value => valid(value, account)).slice(-limit) : [];
|
||||
if (entries.length && parsed?.version !== 2) persist();
|
||||
} catch (_error) { entries = []; }
|
||||
render();
|
||||
schedule();
|
||||
return entries.map(value => ({ ...value }));
|
||||
}
|
||||
|
||||
function capture(item, mergeResult) {
|
||||
const account = login();
|
||||
const commitSha = String(mergeResult?.merge_commit_sha || '').trim();
|
||||
if (!account || !item?.repository || !commitSha) throw new Error('The exact merge commit is unavailable.');
|
||||
if (!entries.length) restore();
|
||||
const entry = {
|
||||
account,
|
||||
repository: item.repository,
|
||||
number: Number(item.number),
|
||||
key: item.key || item.repository + '#' + item.number,
|
||||
commit_sha: commitSha,
|
||||
status: null,
|
||||
captured_at: new Date().toISOString(),
|
||||
};
|
||||
const existing = entries.findIndex(value => identity(value) === identity(entry));
|
||||
if (existing >= 0) {
|
||||
entry.status = entries[existing].status || null;
|
||||
entries.splice(existing, 1, entry);
|
||||
} else {
|
||||
entries.push(entry);
|
||||
entries = entries.slice(-limit);
|
||||
}
|
||||
persist();
|
||||
render();
|
||||
schedule();
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
function summarize(payload) {
|
||||
const checks = Array.isArray(payload?.checks) ? payload.checks : [];
|
||||
const failing = checks.filter(check => ['failure', 'error'].includes(check.state)).map(check => check.name).filter(Boolean);
|
||||
const pending = checks.filter(check => check.state === 'pending').map(check => check.name).filter(Boolean);
|
||||
if (payload?.release) return { ...payload, label: 'Released · ' + payload.release.tag, checks: [] };
|
||||
if (failing.length) return { ...payload, label: 'Checks failed', checks: failing };
|
||||
if (payload?.ci_state === 'success') return { ...payload, label: 'Checks passed · waiting for release', checks: [] };
|
||||
return { ...payload, label: 'Checks running', checks: pending };
|
||||
}
|
||||
|
||||
function render() {
|
||||
const visible = entries.map((entry, index) => ({ entry, index }))
|
||||
.sort((a, b) => Number(b.entry.status?.label === 'Checks failed') - Number(a.entry.status?.label === 'Checks failed') || a.index - b.index)
|
||||
.map(value => value.entry);
|
||||
const first = visible[0] || null;
|
||||
const failed = visible.filter(entry => entry.status?.label === 'Checks failed').length;
|
||||
if (launcher) {
|
||||
launcher.hidden = !entries.length;
|
||||
launcher.textContent = failed
|
||||
? failed + ' release ' + (failed === 1 ? 'failure' : 'failures') + ' · ' + visible.length + ' tracked'
|
||||
: visible.length + ' ' + (visible.length === 1 ? 'merge' : 'merges') + ' · tracking release';
|
||||
}
|
||||
if (statusNode) statusNode.textContent = first?.status?.label || 'Checking the exact merge commit…';
|
||||
if (checksNode) checksNode.textContent = (first?.status?.checks || []).join(', ');
|
||||
if (releaseNode) {
|
||||
releaseNode.hidden = !first?.status?.release?.url;
|
||||
if (first?.status?.release?.url) {
|
||||
releaseNode.href = first.status.release.url;
|
||||
releaseNode.textContent = 'Open release ' + first.status.release.tag;
|
||||
}
|
||||
}
|
||||
if (listNode) {
|
||||
const rows = visible.map(entry => {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'release-watchlist-item';
|
||||
const copy = document.createElement('div');
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = entry.key;
|
||||
const state = document.createElement('span');
|
||||
state.className = 'small';
|
||||
state.textContent = entry.status?.label || 'Checking the exact merge commit…';
|
||||
copy.append(title, state);
|
||||
if (entry.status?.release?.url) {
|
||||
const link = document.createElement('a');
|
||||
link.href = entry.status.release.url;
|
||||
link.textContent = 'Open release ' + entry.status.release.tag;
|
||||
copy.append(link);
|
||||
}
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = 'Dismiss';
|
||||
button.setAttribute('aria-label', 'Dismiss ' + entry.key + ' from release tracking');
|
||||
button.addEventListener('click', () => dismiss(entry.repository, entry.commit_sha));
|
||||
row.append(copy, button);
|
||||
return row;
|
||||
});
|
||||
listNode.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function refreshEntry(entry) {
|
||||
const payload = await fetchJson(path(entry), { headers: { Accept: 'application/json' } });
|
||||
if (payload?.commit_sha !== entry.commit_sha) throw new Error('Release evidence did not match the merged commit.');
|
||||
entry.status = summarize(payload);
|
||||
return entry.status;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (refreshing) return refreshing;
|
||||
if (!entries.length) restore();
|
||||
if (!entries.length) return [];
|
||||
refreshing = (async () => {
|
||||
const statuses = [];
|
||||
for (const entry of entries) {
|
||||
try { statuses.push(await refreshEntry(entry)); }
|
||||
catch (error) {
|
||||
entry.status = { label: 'Status unavailable', checks: [], error: String(error?.message || error) };
|
||||
statuses.push(entry.status);
|
||||
}
|
||||
}
|
||||
persist();
|
||||
render();
|
||||
return statuses;
|
||||
})();
|
||||
try { return await refreshing; }
|
||||
finally { refreshing = null; }
|
||||
}
|
||||
|
||||
function dismiss(repository, commitSha) {
|
||||
if (repository && commitSha) entries = entries.filter(entry => identity(entry) !== repository + '@' + commitSha);
|
||||
else entries = [];
|
||||
persist();
|
||||
render();
|
||||
schedule();
|
||||
if (!entries.length && dialog?.open) dialog.close();
|
||||
return entries.map(value => ({ ...value }));
|
||||
}
|
||||
|
||||
function bind() {
|
||||
bound = true;
|
||||
launcher?.addEventListener('click', async () => {
|
||||
dialog?.showModal?.();
|
||||
try { await refresh(); }
|
||||
catch (error) { if (statusNode) statusNode.textContent = error.message + ' Retry when connected.'; }
|
||||
});
|
||||
documentRef?.addEventListener('visibilitychange', () => {
|
||||
if (documentRef.hidden) {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
} else schedule(0);
|
||||
});
|
||||
windowRef?.addEventListener('online', () => schedule(0));
|
||||
schedule();
|
||||
}
|
||||
|
||||
return { capture, restore, refresh, dismiss, bind, fetchJson };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;
|
||||
@@ -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;}
|
||||
@@ -1,7 +1,7 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.attachSecurityCenter = boundary => factory({ root, boundary });
|
||||
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) {
|
||||
else root.attachSecurityCenter = (boundary, options = {}) => factory({ root, boundary, ...options });
|
||||
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary, onOpen = () => {}, onClose = () => {} }) {
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
@@ -19,6 +19,31 @@
|
||||
section, root.document.getElementById(`security-${section}-section`),
|
||||
]));
|
||||
let activityCursor = null;
|
||||
let backgroundInert = null;
|
||||
|
||||
const backgroundElements = ['header', 'main', '#mobile-task-dock']
|
||||
.map(selector => root.document.querySelector?.(selector))
|
||||
.filter(Boolean);
|
||||
const focusableControls = () => [...(devicesSheet?.querySelectorAll?.(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
|
||||
) || [])].filter(control => !control.hidden && !control.disabled);
|
||||
const containBackground = () => {
|
||||
if (!backgroundInert) {
|
||||
backgroundInert = new Map(backgroundElements.map(element => [element, element.inert]));
|
||||
}
|
||||
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
|
||||
};
|
||||
const releaseBackground = () => {
|
||||
if (!backgroundInert) return;
|
||||
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundInert = null;
|
||||
};
|
||||
const finishClose = () => {
|
||||
devicesSheet.hidden = true;
|
||||
releaseBackground();
|
||||
devicesButton?.focus();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const navigate = (section, { history = true } = {}) => {
|
||||
if (!sections[section]) section = 'activity';
|
||||
@@ -43,15 +68,41 @@
|
||||
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
});
|
||||
root.addEventListener?.('popstate', event => {
|
||||
const section = event.state?.stackchainSecuritySection;
|
||||
const handleRouteChange = section => {
|
||||
if (section && !devicesSheet.hidden) {
|
||||
containBackground();
|
||||
navigate(section, { history: false });
|
||||
return;
|
||||
}
|
||||
if (!devicesSheet.hidden) {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
finishClose();
|
||||
}
|
||||
};
|
||||
root.addEventListener?.('popstate', event => {
|
||||
handleRouteChange(event.state?.stackchainSecuritySection);
|
||||
});
|
||||
root.addEventListener?.('hashchange', () => {
|
||||
handleRouteChange(root.history?.state?.stackchainSecuritySection);
|
||||
});
|
||||
root.document.addEventListener?.('keydown', event => {
|
||||
if (devicesSheet.hidden) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
else finishClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -80,7 +131,8 @@
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
if (device.current && boundary.requestSignOut) await boundary.requestSignOut(revoke);
|
||||
else if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) {
|
||||
await Promise.all([renderDevices(), renderSecurityActivity()]);
|
||||
}
|
||||
@@ -222,6 +274,8 @@
|
||||
|
||||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
onOpen();
|
||||
containBackground();
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
|
||||
|
||||
+289
-12
@@ -1,12 +1,118 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v117';
|
||||
const CACHE = 'stackchain-dashboard-shell-v130';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
||||
const TODAY_ACTION_TTL_MS = 2 * 60 * 1000;
|
||||
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
|
||||
|
||||
function createAppBadgePreference() {
|
||||
const dbName = 'stackchain-app-badge-preference-v1';
|
||||
const storeName = 'preferences';
|
||||
const open = () => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
|
||||
});
|
||||
return {
|
||||
async get() {
|
||||
const database = await open();
|
||||
const enabled = await new Promise((resolve, reject) => {
|
||||
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('enabled');
|
||||
request.onsuccess = () => resolve(request.result === true);
|
||||
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
|
||||
});
|
||||
database.close();
|
||||
return enabled;
|
||||
},
|
||||
async set(enabled) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put(enabled === true, 'enabled');
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('App badge preference could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference();
|
||||
let renderedBackgroundBadgeCount = null;
|
||||
|
||||
async function reconcileBackgroundAppBadge(count) {
|
||||
if (!Number.isSafeInteger(count) || count < 0 || count > 9999
|
||||
|| typeof self.registration.setAppBadge !== 'function'
|
||||
|| typeof self.registration.clearAppBadge !== 'function') return false;
|
||||
let enabled = false;
|
||||
try { enabled = await appBadgePreference.get(); } catch (_error) { return false; }
|
||||
if (!enabled || renderedBackgroundBadgeCount === count) return false;
|
||||
try {
|
||||
if (count > 0) await self.registration.setAppBadge(count);
|
||||
else await self.registration.clearAppBadge();
|
||||
renderedBackgroundBadgeCount = count;
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function createTodayActionStore() {
|
||||
const dbName = 'stackchain-today-action-mailbox-v1';
|
||||
const storeName = 'commands';
|
||||
const open = () => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(storeName, {keyPath:'clientId'});
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Today action mailbox unavailable.'));
|
||||
});
|
||||
return {
|
||||
async put(clientId, command) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put({...command, clientId});
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Today action could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
async claim(clientId, now) {
|
||||
const database = await open();
|
||||
let command = null;
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.get(clientId);
|
||||
request.onsuccess = () => {
|
||||
command = request.result || null;
|
||||
if (command) store.delete(clientId);
|
||||
};
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Today action could not be claimed.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
return command && command.expiresAt > now ? command : null;
|
||||
},
|
||||
async purge() {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).clear();
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Today actions could not be purged.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
const todayActionStore = self.__STACKCHAIN_TODAY_ACTION_STORE || createTodayActionStore();
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
@@ -15,6 +121,7 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/sign-out-review.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/workspace-bootstrap.js',
|
||||
BASE + 'static/conversation-action-hydrator.js',
|
||||
@@ -45,14 +152,20 @@ const SHELL = [
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/agenda-replan.js',
|
||||
BASE + 'static/agenda-calendar.js',
|
||||
BASE + 'static/protect-today.js',
|
||||
BASE + 'static/notification-undo.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/work-selection.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-break.js',
|
||||
BASE + 'static/today-progress.js',
|
||||
BASE + 'static/today-lock-screen.js',
|
||||
BASE + 'static/today-session-sync.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
BASE + 'static/today-wrap-up.js',
|
||||
BASE + 'static/today-summary.js',
|
||||
BASE + 'static/today-handoff.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
@@ -61,6 +174,12 @@ const SHELL = [
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-readiness.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/tomorrow-plan.js',
|
||||
BASE + 'static/week-calendar.js',
|
||||
BASE + 'static/week-calendar-import.js',
|
||||
BASE + 'static/week-plan.js',
|
||||
BASE + 'static/today-week-reschedule.js',
|
||||
BASE + 'static/search-week-plan.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
@@ -99,11 +218,14 @@ const SHELL = [
|
||||
BASE + 'static/queue-today.js',
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/release-receipt.js',
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/task-overlay-history.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/live-data-status.js',
|
||||
BASE + 'static/mobile-today-command-bar.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-first-task.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-delivery-recovery.js',
|
||||
@@ -119,6 +241,7 @@ const SHELL = [
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-insights.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/mobile-app-badge.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/private-data-inventory.js',
|
||||
BASE + 'static/private-device-data.js',
|
||||
@@ -418,7 +541,53 @@ self.addEventListener('sync', event => {
|
||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
||||
});
|
||||
|
||||
async function updateTodayLockScreen(active, running, rawActionToken = '', rawBreakDeadline = 0) {
|
||||
const tag = 'stackchain-today-session';
|
||||
if (!active) {
|
||||
const notifications = await self.registration.getNotifications({ tag });
|
||||
notifications.forEach(notification => notification.close());
|
||||
return;
|
||||
}
|
||||
const actionToken = /^[A-Za-z0-9_-]{16,128}$/.test(rawActionToken) ? rawActionToken : '';
|
||||
const now = Date.now();
|
||||
const breakDeadline = Number(rawBreakDeadline);
|
||||
const onBreak = !running && actionToken && Number.isSafeInteger(breakDeadline) &&
|
||||
breakDeadline > now && breakDeadline <= now + 120 * 60 * 1000;
|
||||
const title = onBreak ? 'On a Today break' :
|
||||
running ? 'Today session running' : 'Today session paused';
|
||||
const body = onBreak ? 'Return at ' + new Date(breakDeadline).toLocaleTimeString([], {
|
||||
hour:'numeric', minute:'2-digit',
|
||||
}) : running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.';
|
||||
await self.registration.showNotification(title, {
|
||||
body,
|
||||
tag,
|
||||
renotify:false,
|
||||
silent:true,
|
||||
actions: onBreak ? [
|
||||
{ action:'resume-today', title:'Resume now' },
|
||||
{ action:'open-today', title:'Open Today' },
|
||||
] : [
|
||||
{ action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' },
|
||||
...(actionToken ? [{ action:'finish-today', title:'Finish current' }] : []),
|
||||
],
|
||||
data: { route:'#/my-work/today', ...(actionToken ? { actionToken } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-app-badge-preference') {
|
||||
event.waitUntil((async () => {
|
||||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
const enabled = event.data.enabled === true;
|
||||
try {
|
||||
await appBadgePreference.set(enabled);
|
||||
renderedBackgroundBadgeCount = null;
|
||||
if (!enabled && typeof self.registration.clearAppBadge === 'function') {
|
||||
await self.registration.clearAppBadge();
|
||||
}
|
||||
} catch (_error) { /* Page preference remains authoritative on next launch. */ }
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
|
||||
await issueSync.resume();
|
||||
await flushAndNotify();
|
||||
@@ -429,12 +598,43 @@ self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
await todayActionStore.purge();
|
||||
await deletePrivateDatabases();
|
||||
await updateTodayLockScreen(false, false);
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
}
|
||||
})());
|
||||
if (event.data?.type === 'stackchain-today-lock-screen') {
|
||||
event.waitUntil((async () => {
|
||||
const active = event.data.active === true;
|
||||
if (!active) await todayActionStore.purge();
|
||||
await updateTodayLockScreen(
|
||||
active,
|
||||
event.data.running === true,
|
||||
String(event.data.actionToken || ''),
|
||||
Number(event.data.breakDeadlineAt || 0)
|
||||
);
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-claim-today-action') {
|
||||
event.waitUntil((async () => {
|
||||
const source = event.source;
|
||||
if (!source?.id || !String(source.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
let command;
|
||||
try { command = await todayActionStore.claim(source.id, Date.now()); }
|
||||
catch (_error) { return; }
|
||||
if (!command || !['pause', 'resume', 'complete'].includes(command.action)) return;
|
||||
if (command.actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(command.actionToken)) return;
|
||||
if (command.action === 'complete' && !command.actionToken) return;
|
||||
source.postMessage?.({
|
||||
type:'stackchain-today-timer-action',
|
||||
action:command.action,
|
||||
...(command.actionToken ? {actionToken:command.actionToken} : {}),
|
||||
});
|
||||
})());
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
@@ -446,7 +646,22 @@ self.addEventListener('push', event => {
|
||||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
const planDate = String(payload.plan_date || '');
|
||||
if (
|
||||
route === '#/my-work/start-day'
|
||||
&& /^\d{4}-\d{2}-\d{2}$/.test(planDate)
|
||||
&& tag === 'stackchain-start-day-' + planDate
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification('Your planned day is ready', {
|
||||
body: 'Open Stackchain to prepare Today.',
|
||||
tag,
|
||||
actions: [{ action: 'prepare-today', title: 'Prepare Today' }],
|
||||
data: {route},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/agenda'
|
||||
&& protectRoute === '#/my-work/agenda/protect-today'
|
||||
@@ -462,7 +677,7 @@ self.addEventListener('push', event => {
|
||||
tag,
|
||||
actions: [
|
||||
{ action: 'protect-today', title: 'Protect Today' },
|
||||
{ action: 'open-agenda', title: 'Open Agenda' },
|
||||
{ action: 'snooze-deadline', title: 'Remind in 1 hour' },
|
||||
],
|
||||
data: {route, protectRoute},
|
||||
}
|
||||
@@ -476,11 +691,12 @@ self.addEventListener('push', event => {
|
||||
&& updateCount > 0
|
||||
&& updateCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
}));
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge(unreadCount),
|
||||
self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.', tag, data: {route},
|
||||
}),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
|
||||
@@ -500,7 +716,10 @@ self.addEventListener('push', event => {
|
||||
];
|
||||
options.data.notificationId = notificationId;
|
||||
}
|
||||
event.waitUntil(self.registration.showNotification('New work update', options));
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge(unreadCount),
|
||||
self.registration.showNotification('New work update', options),
|
||||
]));
|
||||
});
|
||||
|
||||
async function openWorkRoute(route) {
|
||||
@@ -527,9 +746,51 @@ async function openCanonicalIssueUrl(rawUrl) {
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
async function applyTodayTimerAction(action, actionToken = '') {
|
||||
if (!['pause', 'resume', 'complete'].includes(action)) return;
|
||||
if (actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(actionToken)) return;
|
||||
if (action === 'complete' && !actionToken) return;
|
||||
const route = '#/my-work/today';
|
||||
const windows = await self.clients.matchAll({ type:'window', includeUncontrolled:true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (client) {
|
||||
client.postMessage?.({
|
||||
type:'stackchain-today-timer-action', action,
|
||||
...(actionToken ? { actionToken } : {}),
|
||||
});
|
||||
return client.focus?.();
|
||||
}
|
||||
const target = new URL(BASE + route, self.location.origin).href;
|
||||
const opened = await self.clients.openWindow(target);
|
||||
if (!opened?.id) return opened;
|
||||
try {
|
||||
await todayActionStore.put(opened.id, {
|
||||
action,
|
||||
...(actionToken ? {actionToken} : {}),
|
||||
expiresAt:Date.now() + TODAY_ACTION_TTL_MS,
|
||||
});
|
||||
} catch (_error) { /* The clean Today route remains safe; fail the action closed. */ }
|
||||
return opened;
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
const route = String(event.notification.data?.route || '');
|
||||
const issueUrl = String(event.notification.data?.url || '');
|
||||
if (
|
||||
event.notification.tag === 'stackchain-today-session'
|
||||
&& route === '#/my-work/today'
|
||||
&& ['pause-today', 'resume-today', 'finish-today', 'open-today', ''].includes(event.action)
|
||||
) {
|
||||
event.notification.close();
|
||||
if (['pause-today', 'resume-today', 'finish-today'].includes(event.action)) {
|
||||
const action = event.action === 'pause-today' ? 'pause' :
|
||||
event.action === 'resume-today' ? 'resume' : 'complete';
|
||||
event.waitUntil(applyTodayTimerAction(action, String(event.notification.data?.actionToken || '')));
|
||||
} else {
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (issueUrl) {
|
||||
event.notification.close();
|
||||
event.waitUntil(openCanonicalIssueUrl(issueUrl));
|
||||
@@ -545,10 +806,26 @@ self.addEventListener('notificationclick', event => {
|
||||
event.waitUntil(openWorkRoute(protectRoute));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'open-agenda') {
|
||||
if (route !== '#/my-work/agenda') return;
|
||||
event.notification.close();
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
if (event.action === 'snooze-deadline') {
|
||||
if (
|
||||
route !== '#/my-work/agenda'
|
||||
|| !/^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(event.notification.tag)
|
||||
) return;
|
||||
event.waitUntil((async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
||||
try {
|
||||
await fetchJson(BASE + 'api/v1/push-subscription/deadlines/snooze', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
|
||||
});
|
||||
event.notification.close();
|
||||
} catch (_error) {
|
||||
await openWorkRoute(route);
|
||||
event.notification.close();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
if (event.action === 'tomorrow') {
|
||||
|
||||
+38
-15
@@ -38,35 +38,53 @@
|
||||
boundary.startActivityHeartbeat();
|
||||
boundary.startReconnectResume();
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
const allButton = root.document.getElementById('sign-out-all');
|
||||
let review;
|
||||
const features = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'sign-out': root.document.querySelector('meta[name="stackchain-feature-sign-out"]')?.content || '',
|
||||
'security-center': root.document.querySelector('meta[name="stackchain-feature-security-center"]')?.content || '',
|
||||
},
|
||||
});
|
||||
const openReview = (mode, source) => features.run(
|
||||
'sign-out', { trigger: source }, () => {
|
||||
if (!review) review = root.createSignOutReview.mount(boundary);
|
||||
review.show(mode, source);
|
||||
},
|
||||
);
|
||||
[[button, 'current'], [allButton, 'all']].forEach(([source, mode]) =>
|
||||
source?.addEventListener('click', () => openReview(mode, source))
|
||||
);
|
||||
boundary.requestSignOut = source => openReview('current', source || button);
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const closeDevices = root.document.getElementById('close-active-devices');
|
||||
const securityFeatures = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'security-center': root.document.querySelector(
|
||||
'meta[name="stackchain-feature-security-center"]'
|
||||
)?.content || '',
|
||||
},
|
||||
});
|
||||
const returnFromSecurity = root.document.getElementById('return-from-security-center');
|
||||
const beginSecurityDetour = () => {
|
||||
if (root.matchMedia?.('(max-width: 600px)')?.matches) {
|
||||
root.stackchainTodayTimerView?.beginDetour?.('security-center');
|
||||
}
|
||||
};
|
||||
let loadingSecurityCenter = false;
|
||||
const openSecurityCenter = async () => {
|
||||
if (loadingSecurityCenter) return;
|
||||
loadingSecurityCenter = true;
|
||||
beginSecurityDetour();
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
try {
|
||||
await securityFeatures.run('security-center', {
|
||||
await features.run('security-center', {
|
||||
trigger: devicesButton,
|
||||
status: devicesStatus,
|
||||
retryLabel: 'Tap Active devices to retry.',
|
||||
}, () => {
|
||||
devicesButton.removeEventListener('click', openSecurityCenter);
|
||||
root.attachSecurityCenter(boundary).open();
|
||||
root.attachSecurityCenter(boundary, {
|
||||
onOpen: beginSecurityDetour,
|
||||
onClose: () => root.stackchainTodayTimerView?.finishDetour?.('security-center'),
|
||||
}).open();
|
||||
});
|
||||
} finally {
|
||||
loadingSecurityCenter = false;
|
||||
@@ -78,6 +96,11 @@
|
||||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
root.stackchainTodayTimerView?.finishDetour?.('security-center');
|
||||
});
|
||||
if (returnFromSecurity && devicesSheet) returnFromSecurity.addEventListener('click', () => {
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
else devicesSheet.hidden = true;
|
||||
});
|
||||
boundary.refreshOfflineLease().then(valid => {
|
||||
if (valid) boundary.resumeQueuedWork();
|
||||
@@ -592,8 +615,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function signOutAllDevices() {
|
||||
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
async function signOutAllDevices({ reviewed = false } = {}) {
|
||||
const confirmed = reviewed || confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Could not sign out all devices');
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else {
|
||||
root.createSignOutReview = factory;
|
||||
root.createSignOutReview.mount = boundary => {
|
||||
const document = root.document;
|
||||
const review = factory({
|
||||
sheet: document.getElementById('sign-out-review-sheet'),
|
||||
heading: document.getElementById('sign-out-review-heading'),
|
||||
summary: document.getElementById('sign-out-review-summary'),
|
||||
warning: document.getElementById('sign-out-review-warning'),
|
||||
cancelButton: document.getElementById('cancel-sign-out'),
|
||||
confirmButton: document.getElementById('confirm-sign-out'),
|
||||
escapeTarget: document,
|
||||
historyTarget: root,
|
||||
history: root.history,
|
||||
backgroundTargets: Array.from(document.querySelectorAll('body > :not(#sign-out-review-sheet)')),
|
||||
getActiveElement: () => document.activeElement,
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
privateDatabases: root.stackchainPrivateDatabases,
|
||||
inspectPrivateDatabases: root.inspectStackchainPrivateDatabases,
|
||||
onConfirm: mode => mode === 'all'
|
||||
? boundary.signOutAllDevices({ reviewed: true })
|
||||
: boundary.signOut(),
|
||||
});
|
||||
review.start();
|
||||
return review;
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function createSignOutReview(options) {
|
||||
let mode = 'current';
|
||||
let launcher = null;
|
||||
let open = false;
|
||||
let historyEntry = false;
|
||||
|
||||
function ownedItemCount(storage) {
|
||||
if (!storage) return 0;
|
||||
let count = 0;
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
if (storage.key(index)?.startsWith('stackchain.')) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function show(nextMode, source) {
|
||||
mode = nextMode;
|
||||
launcher = source;
|
||||
const itemCount = ownedItemCount(options.localStorage)
|
||||
+ (options.sessionStorage === options.localStorage ? 0 : ownedItemCount(options.sessionStorage));
|
||||
let inventory = { recordCount: 0, unavailable: true };
|
||||
try {
|
||||
inventory = await options.inspectPrivateDatabases?.(options.privateDatabases) || inventory;
|
||||
} catch (_error) { /* Unknown private work must use the guarded confirmation path. */ }
|
||||
const recordCount = Math.max(0, Number(inventory.recordCount) || 0);
|
||||
options.heading.textContent = mode === 'all' ? 'Review sign out on every device' : 'Review sign out';
|
||||
options.summary.textContent = inventory.unavailable
|
||||
? `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'}; private work status is unknown.`
|
||||
: `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'} and ${recordCount} private work record${recordCount === 1 ? '' : 's'}.`;
|
||||
const atRisk = itemCount > 0 || recordCount > 0 || inventory.unavailable;
|
||||
if (mode === 'all') {
|
||||
options.warning.textContent = atRisk
|
||||
? 'Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.'
|
||||
: 'Every device will need to sign in again. This device will be cleared after its session is revoked.';
|
||||
} else {
|
||||
options.warning.textContent = atRisk
|
||||
? 'Private drafts or queued work may not be synced. Signing out erases them from this device.'
|
||||
: 'Signing out clears this device after the session is revoked.';
|
||||
}
|
||||
options.confirmButton.textContent = atRisk
|
||||
? 'Sign out and erase private work'
|
||||
: (mode === 'all' ? 'Sign out all devices' : 'Sign out');
|
||||
options.sheet.hidden = false;
|
||||
(options.backgroundTargets || []).forEach(target => { target.inert = true; });
|
||||
open = true;
|
||||
options.history?.pushState?.({ stackchainSignOutReview: true }, '');
|
||||
historyEntry = Boolean(options.history?.back);
|
||||
options.cancelButton.focus();
|
||||
}
|
||||
|
||||
function close({ restoreFocus = true } = {}) {
|
||||
if (!open) return;
|
||||
options.sheet.hidden = true;
|
||||
(options.backgroundTargets || []).forEach(target => { target.inert = false; });
|
||||
open = false;
|
||||
if (restoreFocus) launcher?.focus?.();
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (historyEntry) {
|
||||
historyEntry = false;
|
||||
options.history.back();
|
||||
} else close();
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
options.confirmButton.disabled = true;
|
||||
try {
|
||||
await options.onConfirm(mode);
|
||||
close({ restoreFocus: false });
|
||||
} catch (_error) {
|
||||
options.warning.textContent = 'Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.';
|
||||
} finally {
|
||||
options.confirmButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.launcher?.addEventListener('click', event => show('current', event.currentTarget || options.launcher));
|
||||
options.allLauncher?.addEventListener('click', event => show('all', event.currentTarget || options.allLauncher));
|
||||
options.cancelButton?.addEventListener('click', dismiss);
|
||||
options.confirmButton?.addEventListener('click', confirm);
|
||||
options.escapeTarget?.addEventListener('keydown', event => {
|
||||
if (!open) return;
|
||||
if (event.key === 'Escape') { event.preventDefault(); dismiss(); return; }
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = Array.from(options.sheet.querySelectorAll('button:not([disabled])'));
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = options.getActiveElement?.();
|
||||
if (!event.shiftKey && active === last) { event.preventDefault(); first?.focus(); }
|
||||
if (event.shiftKey && active === first) { event.preventDefault(); last?.focus(); }
|
||||
});
|
||||
options.historyTarget?.addEventListener('popstate', () => {
|
||||
historyEntry = false;
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
return { start, show, close };
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
function createTodayBreak({
|
||||
timer, sheet, openButtons = null, cancelButton, presetButtons = null, form = null,
|
||||
minutesInput = null, error = null, status, resumeButton,
|
||||
qs = null, queryAll = null,
|
||||
historyRef = null, windowRef = null,
|
||||
now = () => Date.now(), setIntervalRef = setInterval, clearIntervalRef = clearInterval,
|
||||
onChange = () => {}, onResume = () => {},
|
||||
}) {
|
||||
queryAll ||= selector => globalThis.document?.querySelectorAll(selector) || [];
|
||||
historyRef ||= globalThis.history;
|
||||
windowRef ||= globalThis.window;
|
||||
sheet ||= qs?.('#today-break-sheet');
|
||||
openButtons ||= queryAll?.('[data-today-break-open]') || [];
|
||||
cancelButton ||= qs?.('#cancel-today-break');
|
||||
presetButtons ||= queryAll?.('[data-today-break-minutes]') || [];
|
||||
form ||= qs?.('#today-break-custom-form');
|
||||
minutesInput ||= qs?.('#today-break-custom-minutes');
|
||||
error ||= qs?.('#today-break-error');
|
||||
status ||= qs?.('#today-break-status');
|
||||
resumeButton ||= qs?.('#resume-today-break');
|
||||
let ticker = null;
|
||||
let historyEntry = false;
|
||||
|
||||
const stopTicker = () => {
|
||||
if (ticker !== null) clearIntervalRef(ticker);
|
||||
ticker = null;
|
||||
};
|
||||
const render = () => {
|
||||
const pending = timer.breakSnapshot();
|
||||
if (!pending) {
|
||||
stopTicker();
|
||||
status.hidden = true;
|
||||
resumeButton.hidden = true;
|
||||
openButtons.forEach(button => { button.hidden = false; });
|
||||
return false;
|
||||
}
|
||||
status.hidden = false;
|
||||
resumeButton.hidden = false;
|
||||
openButtons.forEach(button => { button.hidden = true; });
|
||||
const remaining = Math.max(0, pending.deadline_at - now());
|
||||
if (pending.expired || remaining <= 0) {
|
||||
status.textContent = 'Break over · ready to resume';
|
||||
} else {
|
||||
const seconds = Math.ceil(remaining / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
status.textContent = 'On break · resume in ' + minutes + ':' + String(seconds % 60).padStart(2, '0');
|
||||
}
|
||||
if (ticker === null) ticker = setIntervalRef(render, 1000);
|
||||
return true;
|
||||
};
|
||||
const close = () => {
|
||||
if (sheet.open) sheet.close();
|
||||
if (historyEntry) {
|
||||
historyEntry = false;
|
||||
historyRef?.back();
|
||||
}
|
||||
};
|
||||
const start = minutes => {
|
||||
if (!timer.startBreak(Number(minutes))) return false;
|
||||
if (error) error.textContent = '';
|
||||
close();
|
||||
render();
|
||||
onChange();
|
||||
return true;
|
||||
};
|
||||
const open = () => {
|
||||
if (!timer.snapshot().identity) return false;
|
||||
if (!sheet.open) {
|
||||
sheet.showModal();
|
||||
if (historyRef) {
|
||||
historyRef.pushState({...(historyRef.state || {}), todayBreak:true}, '');
|
||||
historyEntry = true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
openButtons.forEach(button => button.addEventListener('click', open));
|
||||
cancelButton?.addEventListener('click', close);
|
||||
windowRef?.addEventListener('popstate', () => {
|
||||
historyEntry = false;
|
||||
if (sheet.open) sheet.close();
|
||||
});
|
||||
presetButtons.forEach(button => button.addEventListener('click', () =>
|
||||
start(button.dataset.todayBreakMinutes)
|
||||
));
|
||||
form?.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
if (!start(Number(minutesInput?.value))) {
|
||||
if (error) error.textContent = 'Choose a whole number from 1 to 120 minutes.';
|
||||
minutesInput?.focus();
|
||||
}
|
||||
});
|
||||
resumeButton.addEventListener('click', () => {
|
||||
const pending = timer.breakSnapshot();
|
||||
if (!timer.resumeBreak()) return;
|
||||
onResume(pending.identity);
|
||||
render();
|
||||
onChange();
|
||||
});
|
||||
render();
|
||||
|
||||
return { open, close, start, render };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayBreak;
|
||||
@@ -1,17 +1,91 @@
|
||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
|
||||
return function completeTodayItem(item, options = {}) {
|
||||
if (!item || !todayWork.remove(item)) {
|
||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null,
|
||||
completeActivation = () => globalThis.stackchainFirstTaskCompleting?.(),
|
||||
now = Date.now, ttlMs = 10000, onOffer = null, onClear = null,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout,
|
||||
undo = [globalThis.document?.getElementById('today-completion-undo'),
|
||||
globalThis.document?.getElementById('undo-today-completion')] }) {
|
||||
const [undoReceipt, undoControl] = undo;
|
||||
let pending = null;
|
||||
let expiryTimer = null;
|
||||
|
||||
function clear() {
|
||||
if (expiryTimer !== null) clearTimer?.(expiryTimer);
|
||||
expiryTimer = null;
|
||||
pending = null;
|
||||
if (undoReceipt) undoReceipt.hidden = true;
|
||||
onClear?.();
|
||||
}
|
||||
|
||||
function completeTodayItem(item, options = {}) {
|
||||
const identity = item && todayWork.identity(item);
|
||||
const snapshot = item && (todayWork.capture?.(item) ||
|
||||
(identity ? { id: identity, index: 0, ids: [identity], plan: { capacity_minutes: null, estimates: {} } } : null));
|
||||
if (!snapshot || !todayWork.remove(item)) {
|
||||
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.enqueue('remove', todayWork.identity(item));
|
||||
const operations = [
|
||||
{action:'remove', item_id:identity},
|
||||
...(completeActivation() ? [{action:'activate', item_id:'first-task', activation_state:'complete'}] : []),
|
||||
];
|
||||
const admitted = todaySync.enqueueBatch ? todaySync.enqueueBatch(operations) :
|
||||
todaySync.enqueue('remove', identity);
|
||||
if (!admitted) {
|
||||
todayWork.restore?.(snapshot);
|
||||
announce(options.admissionFailureMessage ||
|
||||
'Device storage is full. Free space and try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.flush();
|
||||
refresh();
|
||||
warm();
|
||||
(advance || (() => workSession.complete()))();
|
||||
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
|
||||
pending = { snapshot, expires_at: now() + ttlMs };
|
||||
if (undoReceipt) undoReceipt.hidden = false;
|
||||
if (undoControl) undoControl.disabled = false;
|
||||
onOffer?.({ identity: snapshot.id, expires_at: pending.expires_at });
|
||||
expiryTimer = setTimer?.(clear, ttlMs) ?? null;
|
||||
expiryTimer?.unref?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
completeTodayItem.undo = () => {
|
||||
if (!pending) return 'missing';
|
||||
if (now() >= pending.expires_at) {
|
||||
clear();
|
||||
announce('Undo expired. Add the item back to Today from My Work.');
|
||||
return 'expired';
|
||||
}
|
||||
const snapshot = pending.snapshot;
|
||||
const result = todayWork.restore?.(snapshot) || 'unavailable';
|
||||
if (result !== 'restored') {
|
||||
const messages = {
|
||||
full: 'Today is full. Remove another item before adding this work back.',
|
||||
changed: 'Today changed on this device. Add the item back from My Work.',
|
||||
unavailable: 'Could not restore Today on this device. Add the item back from My Work.',
|
||||
};
|
||||
clear();
|
||||
announce(messages[result] || messages.unavailable);
|
||||
return result;
|
||||
}
|
||||
todaySync.enqueue('add', snapshot.id);
|
||||
for (let index = snapshot.ids.length - 1; index > snapshot.index; index -= 1) {
|
||||
todaySync.enqueue('move', snapshot.id, 'up');
|
||||
}
|
||||
todaySync.flush();
|
||||
refresh();
|
||||
warm();
|
||||
clear();
|
||||
announce('Restored to Today. Your current work session is unchanged.');
|
||||
return 'restored';
|
||||
};
|
||||
completeTodayItem.clear = clear;
|
||||
undoControl?.addEventListener?.('click', () => {
|
||||
undoControl.disabled = true;
|
||||
if (completeTodayItem.undo() !== 'restored') undoControl.disabled = false;
|
||||
});
|
||||
return completeTodayItem;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;
|
||||
@@ -0,0 +1,171 @@
|
||||
function createTodayLockScreen({
|
||||
storage,
|
||||
getLogin,
|
||||
serviceWorker,
|
||||
NotificationRef,
|
||||
control,
|
||||
status,
|
||||
locationRef,
|
||||
historyRef,
|
||||
randomToken = () => {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
|
||||
},
|
||||
fingerprint = async (token, identity) => {
|
||||
const input = new TextEncoder().encode(token + '\0' + identity);
|
||||
const digest = await globalThis.crypto.subtle.digest('SHA-256', input);
|
||||
return Array.from(new Uint8Array(digest), value =>
|
||||
value.toString(16).padStart(2, '0')).join('');
|
||||
},
|
||||
onAction = () => {},
|
||||
}) {
|
||||
let activeIdentity = '';
|
||||
let activeActionIdentity = '';
|
||||
let activeBreakDeadline = 0;
|
||||
const preferenceKey = () => {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-lock-screen.v1.' + encodeURIComponent(login) : '';
|
||||
};
|
||||
const supported = Boolean(serviceWorker && NotificationRef);
|
||||
const actionKey = () => {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-lock-screen-action.v1.' + encodeURIComponent(login) : '';
|
||||
};
|
||||
const readAction = () => {
|
||||
const key = actionKey();
|
||||
if (!key) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(key) || 'null');
|
||||
return typeof value?.token === 'string' && value.token &&
|
||||
typeof value.fingerprint === 'string' && value.fingerprint ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
};
|
||||
const clearAction = () => {
|
||||
const key = actionKey();
|
||||
try { if (key) storage?.removeItem(key); }
|
||||
catch (_error) { return false; }
|
||||
return true;
|
||||
};
|
||||
const actionFor = async identity => {
|
||||
const existing = readAction();
|
||||
if (existing && await fingerprint(existing.token, identity) === existing.fingerprint) return existing;
|
||||
const key = actionKey();
|
||||
if (!key) return null;
|
||||
try {
|
||||
const token = String(randomToken() || '');
|
||||
const value = { token, fingerprint:token ? await fingerprint(token, identity) : '' };
|
||||
if (!value.token) return null;
|
||||
storage?.setItem(key, JSON.stringify(value));
|
||||
return value;
|
||||
} catch (_error) { return null; }
|
||||
};
|
||||
const enabled = () => {
|
||||
const key = preferenceKey();
|
||||
return Boolean(key && storage?.getItem(key) === '1');
|
||||
};
|
||||
const setStatus = message => {
|
||||
if (status) status.textContent = message;
|
||||
};
|
||||
const post = async message => {
|
||||
const target = serviceWorker?.controller || (await serviceWorker?.ready)?.active;
|
||||
target?.postMessage?.(message);
|
||||
};
|
||||
const hide = () => post({
|
||||
type:'stackchain-today-lock-screen', active:false, running:false,
|
||||
});
|
||||
const render = () => {
|
||||
if (control) {
|
||||
control.checked = enabled();
|
||||
control.disabled = !supported;
|
||||
}
|
||||
if (!supported) setStatus('Lock-screen controls are not supported on this device.');
|
||||
else if (enabled()) setStatus('Lock-screen Today controls are on.');
|
||||
};
|
||||
const consumeAction = async (action, token = '') => {
|
||||
if (!enabled() || !['pause', 'resume', 'complete'].includes(action)) return false;
|
||||
const requiresToken = action === 'complete' || (action === 'resume' && activeBreakDeadline > 0);
|
||||
if (!requiresToken) {
|
||||
onAction(action, null);
|
||||
return true;
|
||||
}
|
||||
const pending = readAction();
|
||||
if (!pending || !token || !activeIdentity || pending.token !== token ||
|
||||
await fingerprint(token, activeActionIdentity) !== pending.fingerprint) return false;
|
||||
if (!clearAction()) return false;
|
||||
onAction(action, activeIdentity);
|
||||
return true;
|
||||
};
|
||||
const api = {
|
||||
enabled,
|
||||
async enable() {
|
||||
if (!supported) {
|
||||
render();
|
||||
return false;
|
||||
}
|
||||
const permission = NotificationRef.permission === 'granted' ?
|
||||
'granted' : await NotificationRef.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
if (control) control.checked = false;
|
||||
setStatus(permission === 'denied' ?
|
||||
'Lock-screen controls are blocked in browser settings.' :
|
||||
'Lock-screen controls were not enabled.');
|
||||
return false;
|
||||
}
|
||||
const key = preferenceKey();
|
||||
if (!key) return false;
|
||||
storage?.setItem(key, '1');
|
||||
render();
|
||||
return true;
|
||||
},
|
||||
async disable() {
|
||||
const key = preferenceKey();
|
||||
if (key) storage?.removeItem(key);
|
||||
clearAction();
|
||||
if (control) control.checked = false;
|
||||
setStatus('Lock-screen Today controls are off.');
|
||||
await hide();
|
||||
return true;
|
||||
},
|
||||
async sync(snapshot, active) {
|
||||
if (!enabled() || NotificationRef?.permission !== 'granted') return false;
|
||||
const visible = Boolean(active && snapshot?.identity);
|
||||
activeIdentity = visible ? snapshot.identity : '';
|
||||
const rawBreakDeadline = Number(snapshot?.break_deadline_at);
|
||||
activeBreakDeadline = visible && Number.isSafeInteger(rawBreakDeadline) && rawBreakDeadline > 0 ?
|
||||
rawBreakDeadline : 0;
|
||||
activeActionIdentity = activeIdentity + (activeBreakDeadline ? '\0break:' + activeBreakDeadline : '');
|
||||
const pending = visible ? await actionFor(activeActionIdentity) : null;
|
||||
if (!visible) clearAction();
|
||||
await post({
|
||||
type:'stackchain-today-lock-screen',
|
||||
active:visible,
|
||||
running:visible && Boolean(snapshot.running),
|
||||
...(activeBreakDeadline ? { breakDeadlineAt:activeBreakDeadline } : {}),
|
||||
...(pending?.token ? { actionToken:pending.token } : {}),
|
||||
});
|
||||
if (visible && !pending) setStatus('Finish current is unavailable because its one-time action could not be saved.');
|
||||
return true;
|
||||
},
|
||||
consumeAction,
|
||||
async consumeLaunchAction() {
|
||||
if (!enabled()) return false;
|
||||
await post({type:'stackchain-claim-today-action'});
|
||||
return true;
|
||||
},
|
||||
render,
|
||||
};
|
||||
control?.addEventListener?.('change', () => {
|
||||
if (control.checked) api.enable();
|
||||
else api.disable();
|
||||
});
|
||||
serviceWorker?.addEventListener?.('message', event => {
|
||||
if (event.data?.type === 'stackchain-today-timer-action') {
|
||||
consumeAction(String(event.data.action || ''), String(event.data.actionToken || ''));
|
||||
}
|
||||
});
|
||||
render();
|
||||
return api;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayLockScreen;
|
||||
@@ -0,0 +1,553 @@
|
||||
function createTodayProgress({ storage, getLogin = () => '', admit, makeId = () =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
maxLength = 2000, maxItems = 20 }) {
|
||||
const prefix = 'stackchain.today-progress.v1.';
|
||||
const login = () => String(getLogin() || '').trim().toLowerCase();
|
||||
const storageKey = () => login() ? prefix + encodeURIComponent(login()) : '';
|
||||
const validIdentity = identity => typeof identity === 'string' && identity.length > 0 && identity.length <= 500;
|
||||
const attachmentFingerprint = attachments => JSON.stringify((Array.isArray(attachments) ? attachments : []).filter(Boolean).slice(0, 5).map(value => ({
|
||||
filename:String(value.filename || ''), contentType:String(value.contentType || ''),
|
||||
note:String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240),
|
||||
operationId:String(value.operationId || '').slice(0, 128),
|
||||
markdown:String(value.confirmed?.markdown || ''),
|
||||
})));
|
||||
const payloadFingerprint = (body, attachments) => JSON.stringify({body, attachments:attachmentFingerprint(attachments)});
|
||||
const validRecord = record => record && typeof record.body === 'string' && (record.body.length > 0 || record.has_attachments === true) &&
|
||||
record.body.length <= maxLength && typeof record.operation_id === 'string' && record.operation_id.length > 0 &&
|
||||
record.operation_id.length <= 128;
|
||||
|
||||
function read() {
|
||||
const key = storageKey();
|
||||
if (!storage || !key) return {};
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (!saved || saved.version !== 1 || !saved.drafts || typeof saved.drafts !== 'object' ||
|
||||
Array.isArray(saved.drafts)) return {};
|
||||
const entries = Object.entries(saved.drafts).filter(([identity, record]) =>
|
||||
validIdentity(identity) && validRecord(record)
|
||||
).slice(0, maxItems);
|
||||
return Object.fromEntries(entries);
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function write(drafts) {
|
||||
const key = storageKey();
|
||||
if (!storage || !key) return false;
|
||||
try {
|
||||
if (Object.keys(drafts).length) storage.setItem(key, JSON.stringify({ version:1, drafts }));
|
||||
else storage.removeItem(key);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function load(identity) {
|
||||
if (!validIdentity(identity)) return '';
|
||||
return read()[identity]?.body || '';
|
||||
}
|
||||
|
||||
function save(identity, value, attachments = []) {
|
||||
if (!validIdentity(identity) || !storageKey()) return false;
|
||||
const body = String(value || '').trim();
|
||||
const hasAttachments = attachments === true || (Array.isArray(attachments) && attachments.filter(Boolean).length > 0);
|
||||
if (body.length > maxLength) return false;
|
||||
const drafts = read();
|
||||
if (!body && !hasAttachments) {
|
||||
delete drafts[identity];
|
||||
return write(drafts);
|
||||
}
|
||||
if (!drafts[identity] && Object.keys(drafts).length >= maxItems) return false;
|
||||
const previous = drafts[identity];
|
||||
const fingerprint = payloadFingerprint(body, Array.isArray(attachments) ? attachments : []);
|
||||
const previousFingerprint = previous?.payload_fingerprint ||
|
||||
(previous && !previous.has_attachments ? payloadFingerprint(previous.body, []) : '');
|
||||
drafts[identity] = {
|
||||
body,
|
||||
operation_id: previousFingerprint === fingerprint ? previous.operation_id : String(makeId()).slice(0, 128),
|
||||
payload_fingerprint:fingerprint,
|
||||
...(hasAttachments ? {has_attachments:true} : {}),
|
||||
};
|
||||
return write(drafts);
|
||||
}
|
||||
|
||||
function validTarget(target) {
|
||||
return target && ['issue', 'pull'].includes(target.kind) && validIdentity(target.identity) &&
|
||||
typeof target.repository === 'string' && target.repository.length > 0 && target.repository.length <= 200 &&
|
||||
Number.isInteger(target.number) && target.number > 0;
|
||||
}
|
||||
|
||||
async function post(target, value, attachments = [], completeEvidence) {
|
||||
if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
|
||||
const pendingRecord = read()[target.identity];
|
||||
if (pendingRecord?.cleanup_pending === true) {
|
||||
try {
|
||||
if (typeof completeEvidence === 'function') await completeEvidence();
|
||||
} catch (_error) {
|
||||
const error = new Error('Progress update is already queued; photo cleanup is pending.');
|
||||
error.deliveryAdmitted = true;
|
||||
throw error;
|
||||
}
|
||||
const pendingDrafts = read();
|
||||
if (pendingDrafts[target.identity]?.operation_id === pendingRecord.operation_id) {
|
||||
delete pendingDrafts[target.identity];
|
||||
write(pendingDrafts);
|
||||
}
|
||||
return { background:true, alreadyAdmitted:true, cleanupRecovered:true };
|
||||
}
|
||||
const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
|
||||
if ((value !== undefined || evidence.length) && !save(target.identity, value ?? load(target.identity), evidence)) {
|
||||
throw new Error('Progress update could not be saved on this device.');
|
||||
}
|
||||
const record = read()[target.identity];
|
||||
if (!record) throw new Error('Write a progress update before posting.');
|
||||
if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
|
||||
const admission = await admit({
|
||||
kind:target.kind + '-comment', repository:target.repository, number:target.number,
|
||||
body:record.body, operationId:record.operation_id,
|
||||
...(evidence.length ? {attachments:evidence} : {}),
|
||||
});
|
||||
const admittedDrafts = read();
|
||||
if (admittedDrafts[target.identity]?.operation_id === record.operation_id) {
|
||||
admittedDrafts[target.identity] = { ...admittedDrafts[target.identity], cleanup_pending:true };
|
||||
write(admittedDrafts);
|
||||
}
|
||||
try {
|
||||
if (typeof completeEvidence === 'function') await completeEvidence();
|
||||
} catch (_error) {
|
||||
const error = new Error('Progress update is already queued; photo cleanup is pending.');
|
||||
error.deliveryAdmitted = true;
|
||||
throw error;
|
||||
}
|
||||
const drafts = read();
|
||||
if (drafts[target.identity]?.operation_id === record.operation_id) {
|
||||
delete drafts[target.identity];
|
||||
write(drafts);
|
||||
}
|
||||
return admission;
|
||||
}
|
||||
|
||||
async function postBlocker(target, value, attachments = [], completeEvidence, until, transition) {
|
||||
if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
|
||||
if (typeof transition !== 'function') throw new Error('Today planning is unavailable.');
|
||||
let record = read()[target.identity];
|
||||
let admission = { background:true, alreadyAdmitted:true };
|
||||
if (record?.blocker_pending !== true) {
|
||||
const body = String(value ?? record?.body ?? '').trim();
|
||||
if (!body) throw new Error('Describe what is blocking this Today item.');
|
||||
const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
|
||||
if (!save(target.identity, body, evidence)) {
|
||||
throw new Error('Blocker update could not be saved on this device.');
|
||||
}
|
||||
record = read()[target.identity];
|
||||
if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
|
||||
admission = await admit({
|
||||
kind:target.kind + '-comment', repository:target.repository, number:target.number,
|
||||
body:record.body, operationId:record.operation_id,
|
||||
...(evidence.length ? {attachments:evidence} : {}),
|
||||
});
|
||||
const drafts = read();
|
||||
if (drafts[target.identity]?.operation_id === record.operation_id) {
|
||||
drafts[target.identity] = { ...drafts[target.identity], blocker_pending:true, blocker_until:String(until || '') };
|
||||
write(drafts);
|
||||
}
|
||||
} else {
|
||||
const replacementUntil = String(until || '').trim();
|
||||
until = replacementUntil || record.blocker_until;
|
||||
if (replacementUntil && replacementUntil !== record.blocker_until) {
|
||||
const drafts = read();
|
||||
if (drafts[target.identity]?.operation_id === record.operation_id) {
|
||||
drafts[target.identity] = { ...drafts[target.identity], blocker_until:replacementUntil };
|
||||
write(drafts);
|
||||
record = drafts[target.identity];
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (typeof completeEvidence === 'function') await completeEvidence();
|
||||
} catch (_error) {
|
||||
const error = new Error('Blocker is already queued; photo cleanup is pending.');
|
||||
error.deliveryAdmitted = true;
|
||||
throw error;
|
||||
}
|
||||
if (await transition(target, until) !== true) {
|
||||
const error = new Error('Blocker was posted, but Today could not move on. Retry the planning step.');
|
||||
error.deliveryAdmitted = true;
|
||||
throw error;
|
||||
}
|
||||
const drafts = read();
|
||||
if (drafts[target.identity]?.operation_id === record.operation_id) {
|
||||
delete drafts[target.identity];
|
||||
write(drafts);
|
||||
}
|
||||
return admission;
|
||||
}
|
||||
|
||||
function blockerRecovery(identity) {
|
||||
if (!validIdentity(identity)) return null;
|
||||
const record = read()[identity];
|
||||
return record?.blocker_pending === true ? { pending:true, until:String(record.blocker_until || '') } : null;
|
||||
}
|
||||
|
||||
return { load, save, discard:identity => save(identity, ''), post, postBlocker, blockerRecovery };
|
||||
}
|
||||
|
||||
function createTodayProgressActivity({ fetchJson, createPager, getActions, onActions, surfaceStatus, paint = () => {}, setStatus = () => {} }) {
|
||||
let requestToken = 0;
|
||||
let pager = null;
|
||||
let target = null;
|
||||
let actions = null;
|
||||
|
||||
const pathFor = (value, page) => {
|
||||
const repository = String(value.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const resource = value.kind === 'pull' ? 'pulls' : 'issues';
|
||||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(value.number) +
|
||||
'/comments?' + (Number.isInteger(page) ? 'page=' + encodeURIComponent(page) + '&' : '') + 'limit=20';
|
||||
};
|
||||
const loadPage = page => fetchJson(pathFor(target, page));
|
||||
|
||||
async function open(value) {
|
||||
const token = ++requestToken;
|
||||
target = { ...value };
|
||||
setStatus('Loading recent activity…');
|
||||
try {
|
||||
const page = await loadPage();
|
||||
if (token !== requestToken || target.identity !== value.identity) return false;
|
||||
pager = createPager({ loadPage });
|
||||
actions = typeof getActions === 'function' ? await getActions() : null;
|
||||
if (token !== requestToken || target.identity !== value.identity) return false;
|
||||
onActions?.(actions);
|
||||
paint(pager.reset(page));
|
||||
setStatus('');
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (token === requestToken) setStatus('Recent activity unavailable. Retry.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
if (!pager || !target) return false;
|
||||
const token = requestToken;
|
||||
setStatus('Loading older activity…');
|
||||
try {
|
||||
const page = await pager.loadOlder();
|
||||
if (token !== requestToken) return false;
|
||||
paint(page);
|
||||
setStatus('');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (token === requestToken) setStatus('Older activity unavailable. Retry.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
retry:() => target ? open(target) : Promise.resolve(false),
|
||||
loadOlder,
|
||||
actionHtml:comment => actions?.actionHtml?.(comment) || '',
|
||||
surface:() => ({
|
||||
context:{ kind:target?.kind, item:target }, pager,
|
||||
status:surfaceStatus, render:paint,
|
||||
}),
|
||||
close:() => { requestToken++; target = null; pager = null; actions = null; setStatus(''); },
|
||||
};
|
||||
}
|
||||
|
||||
function mountTodayProgressActivity(qs, fetchJson, actionSource = {}, render) {
|
||||
const options = actionSource.get && !actionSource.getActions ?
|
||||
{ getActions:actionSource.get, isOffline:() => !navigator.onLine } : actionSource;
|
||||
const escape = options.escape || (value => String(value).replace(/[&<>"']/g,
|
||||
character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[character]));
|
||||
render ||= options.render;
|
||||
const list = qs('#today-progress-activity-list');
|
||||
const optionsStatus = qs('#today-progress-activity-status');
|
||||
let actionsWired = false;
|
||||
const activity = createTodayProgressActivity({
|
||||
fetchJson,
|
||||
createPager:options.createPager || createConversationPager,
|
||||
getActions:options.getActions,
|
||||
surfaceStatus:optionsStatus,
|
||||
onActions:actions => {
|
||||
if (actionsWired || !actions?.wire) return;
|
||||
actions.wire({ root:list, getSurface:() => activity.surface(),
|
||||
isOffline:options.isOffline || (() => false), escapeHtml:escape });
|
||||
actionsWired = true;
|
||||
},
|
||||
paint:state => {
|
||||
list.innerHTML = (state.comments || []).map(comment => {
|
||||
const author = comment.author || comment.user?.login || 'Unknown author';
|
||||
const timing = comment.created_at ? ' · ' + new Date(comment.created_at).toLocaleString() : '';
|
||||
return '<article class="today-progress-activity-item issue-comment" data-comment-id="' +
|
||||
escape(String(comment.id)) + '"><div class="small muted">' +
|
||||
escape(author) + escape(timing) + '</div><div class="markdown-content">' +
|
||||
render(comment.body || 'No message body provided.') + '</div>' +
|
||||
activity.actionHtml(comment) + '</article>';
|
||||
}).join('');
|
||||
qs('#today-progress-activity-count').textContent = state.total ?
|
||||
String(state.comments.length) + ' of ' + String(state.total) + ' messages' : '';
|
||||
qs('#load-older-today-progress-activity').hidden = !Number.isInteger(state.older_page);
|
||||
},
|
||||
setStatus:message => {
|
||||
optionsStatus.textContent = message;
|
||||
qs('#retry-today-progress-activity').hidden = !message.includes('unavailable');
|
||||
},
|
||||
});
|
||||
qs('#retry-today-progress-activity').addEventListener('click', () => activity.retry());
|
||||
qs('#load-older-today-progress-activity').addEventListener('click', async () => {
|
||||
const list = qs('#today-progress-activity-list');
|
||||
const previousHeight = list.scrollHeight;
|
||||
const button = qs('#load-older-today-progress-activity');
|
||||
button.disabled = true;
|
||||
await activity.loadOlder();
|
||||
list.scrollTop += list.scrollHeight - previousHeight;
|
||||
button.disabled = false;
|
||||
});
|
||||
return activity;
|
||||
}
|
||||
|
||||
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {}, moveOn = null, now = () => new Date() }) {
|
||||
const sheet = qs('#today-progress-sheet');
|
||||
const panel = qs('.today-progress-panel');
|
||||
const title = qs('#today-progress-title');
|
||||
const body = qs('#today-progress-body');
|
||||
const bodyLabel = qs('#today-progress-body-label');
|
||||
const status = qs('#today-progress-status');
|
||||
const launcher = qs('[data-mobile-today-update]');
|
||||
const blockerLauncher = qs('[data-mobile-today-blocked]');
|
||||
const blockerButton = qs('#post-today-blocker');
|
||||
const blockerReturn = qs('#today-blocker-return-at');
|
||||
const blockerRecovery = qs('#today-progress-blocker-recovery');
|
||||
const saveButton = qs('#save-today-progress');
|
||||
const postButton = qs('#post-today-progress');
|
||||
let openedTarget = null;
|
||||
|
||||
const localDateTime = value => {
|
||||
const date = new Date(value || '');
|
||||
if (!Number.isFinite(date.getTime())) return '';
|
||||
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
};
|
||||
const showBlockerRecovery = recovery => {
|
||||
const pending = recovery?.pending === true;
|
||||
body.disabled = pending;
|
||||
saveButton.disabled = pending;
|
||||
postButton.disabled = pending;
|
||||
if (blockerButton) blockerButton.textContent = pending ? 'Finish moving on' : 'Post blocker & move on';
|
||||
if (blockerReturn) blockerReturn.value = pending ? localDateTime(recovery.until) : '';
|
||||
if (blockerRecovery) {
|
||||
blockerRecovery.hidden = !pending;
|
||||
blockerRecovery.textContent = pending ? 'Blocker queued—finish moving on. The posted update cannot be changed.' : '';
|
||||
}
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
const target = currentTarget();
|
||||
launcher.hidden = !target;
|
||||
if (blockerLauncher) blockerLauncher.hidden = !target;
|
||||
if (openedTarget && target?.identity !== openedTarget.identity) mentions?.dismiss?.();
|
||||
};
|
||||
const checkpoint = async () => {
|
||||
if (!openedTarget) return false;
|
||||
try {
|
||||
await photos?.checkpoint?.();
|
||||
const checkpointAttachments = await photos?.serialize?.() || [];
|
||||
if (progress.save(openedTarget.identity, body.value, checkpointAttachments)) return true;
|
||||
status.textContent = 'Update must be 2,000 characters or fewer and device storage must be available.';
|
||||
return false;
|
||||
} catch (error) {
|
||||
status.textContent = error.message + ' Your photos remain here; retry.';
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const close = () => {
|
||||
voice?.cancel?.();
|
||||
activity?.close?.();
|
||||
mentions?.dismiss?.();
|
||||
if (sheet.open) sheet.close();
|
||||
panel?.classList?.remove('blocker-mode');
|
||||
openedTarget = null;
|
||||
};
|
||||
|
||||
const open = async blockerMode => {
|
||||
const target = currentTarget();
|
||||
if (!target) return;
|
||||
mentions?.dismiss?.();
|
||||
openedTarget = target;
|
||||
panel?.classList?.[blockerMode ? 'add' : 'remove']('blocker-mode');
|
||||
if (title) title.textContent = blockerMode ? 'Report blocker' : 'Add progress update';
|
||||
if (bodyLabel) bodyLabel.textContent = blockerMode ? 'Blocker description' : 'Update';
|
||||
qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
|
||||
body.value = progress.load(target.identity);
|
||||
showBlockerRecovery(progress.blockerRecovery?.(target.identity));
|
||||
sheet.showModal();
|
||||
if (!blockerMode) {
|
||||
status.textContent = 'Restoring saved photo evidence…';
|
||||
try {
|
||||
await voice?.open?.(target.identity);
|
||||
await photos?.open?.(target);
|
||||
await activity?.open?.(target);
|
||||
status.textContent = '';
|
||||
}
|
||||
catch (error) { status.textContent = error.message + ' You can retry by reopening this update.'; }
|
||||
}
|
||||
else status.textContent = '';
|
||||
body.focus();
|
||||
};
|
||||
launcher.addEventListener('click', () => open(false));
|
||||
blockerLauncher?.addEventListener('click', () => open(true));
|
||||
|
||||
const setReturn = preset => {
|
||||
const date = new Date(now());
|
||||
if (preset === 'later-today') date.setHours(date.getHours() + 4);
|
||||
else {
|
||||
date.setDate(date.getDate() + 1);
|
||||
date.setHours(9, 0, 0, 0);
|
||||
}
|
||||
blockerReturn.value = localDateTime(date);
|
||||
};
|
||||
qs('[data-today-blocker-preset="later-today"]')?.addEventListener('click', () => setReturn('later-today'));
|
||||
qs('[data-today-blocker-preset="tomorrow"]')?.addEventListener('click', () => setReturn('tomorrow'));
|
||||
qs('[data-today-blocker-custom]')?.addEventListener('click', () => blockerReturn?.focus?.());
|
||||
qs('#cancel-today-progress').addEventListener('click', async () => {
|
||||
if (await checkpoint()) close();
|
||||
});
|
||||
sheet.addEventListener('cancel', async event => {
|
||||
event.preventDefault();
|
||||
if (await checkpoint()) close();
|
||||
});
|
||||
qs('#save-today-progress').addEventListener('click', async () => {
|
||||
if (!await checkpoint()) return;
|
||||
announce('Progress update saved privately to this Today item.');
|
||||
close();
|
||||
});
|
||||
blockerButton?.addEventListener('click', async () => {
|
||||
const target = currentTarget();
|
||||
if (!target || target.identity !== openedTarget?.identity) {
|
||||
status.textContent = 'The active Today item changed. Close and open its update again.';
|
||||
return;
|
||||
}
|
||||
const returnInput = blockerReturn;
|
||||
const wakeAt = new Date(returnInput?.value || '');
|
||||
if (!body.value.trim()) {
|
||||
status.textContent = 'Describe what is blocking this Today item.';
|
||||
body.focus();
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(wakeAt.getTime()) || wakeAt.getTime() <= Date.now()) {
|
||||
status.textContent = 'Choose a valid future return time.';
|
||||
returnInput?.focus?.();
|
||||
return;
|
||||
}
|
||||
blockerButton.disabled = true;
|
||||
status.textContent = 'Posting blocker before moving Today…';
|
||||
try {
|
||||
await photos?.checkpoint?.();
|
||||
const attachments = await photos?.serialize?.() || [];
|
||||
const admission = await progress.postBlocker(
|
||||
target, body.value, attachments, () => photos?.complete?.(), wakeAt.toISOString(), moveOn
|
||||
);
|
||||
onAdmitted(admission);
|
||||
announce('Blocker queued, deferred to Later, and Today moved on.');
|
||||
close();
|
||||
} catch (error) {
|
||||
status.textContent = error.deliveryAdmitted ? error.message : error.message + ' Your blocker remains here; retry.';
|
||||
body.focus();
|
||||
} finally {
|
||||
blockerButton.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#post-today-progress').addEventListener('click', async () => {
|
||||
const target = currentTarget();
|
||||
if (!target || target.identity !== openedTarget?.identity) {
|
||||
status.textContent = 'The active Today item changed. Close and open its update again.';
|
||||
return;
|
||||
}
|
||||
const button = qs('#post-today-progress');
|
||||
button.disabled = true;
|
||||
status.textContent = 'Saving for delivery…';
|
||||
try {
|
||||
await photos?.checkpoint?.();
|
||||
const attachments = await photos?.serialize?.() || [];
|
||||
const admission = await progress.post(target, body.value, attachments, () => photos?.complete?.());
|
||||
onAdmitted(admission);
|
||||
announce(admission.background ?
|
||||
'Progress update queued for delivery. Today is still on the same item.' :
|
||||
'Progress update saved for next launch. Today is still on the same item.');
|
||||
close();
|
||||
} catch (error) {
|
||||
status.textContent = error.deliveryAdmitted ?
|
||||
error.message + ' Retry to finish local cleanup; delivery will not be queued again.' :
|
||||
error.message + ' Your update remains on this item; retry.';
|
||||
body.focus();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
update();
|
||||
return { update };
|
||||
}
|
||||
|
||||
function createTodayProgressPhotos({ qs, document, issueAttachment, fetchJson, createStore, createDrafts, getLogin }) {
|
||||
let target = null;
|
||||
let drafts = null;
|
||||
const controller = issueAttachment.mount({
|
||||
maxFiles:5, input:qs('#today-progress-attachment'),
|
||||
inputs:[qs('#take-today-progress-photo'), qs('#today-progress-attachment')],
|
||||
preview:qs('#today-progress-attachment-preview'), image:qs('#today-progress-attachment-image'),
|
||||
meta:qs('#today-progress-attachment-meta'), remove:qs('#remove-today-progress-attachment'),
|
||||
tray:qs('#today-progress-attachment-tray'), earlier:qs('#move-today-progress-attachment-earlier'),
|
||||
later:qs('#move-today-progress-attachment-later'), note:qs('#today-progress-attachment-note'),
|
||||
noteLabel:qs('#today-progress-attachment-note-label'), status:qs('#today-progress-status'),
|
||||
onChange:() => drafts?.checkpoint('today').catch(() => {}),
|
||||
onCheckpoint:() => drafts.checkpoint('today'),
|
||||
readyMessage:'Photo ready to post with this Today update.',
|
||||
removedMessage:'Photo removed. Your progress text is unchanged.',
|
||||
editor:{
|
||||
document, edit:qs('#edit-today-progress-attachment'), dialog:qs('#issue-evidence-editor'),
|
||||
canvas:qs('#issue-evidence-editor-canvas'), exportCanvas:qs('#issue-evidence-editor-export'),
|
||||
crop:qs('#crop-issue-evidence'), redact:qs('#redact-issue-evidence'),
|
||||
highlight:qs('#highlight-issue-evidence'), arrow:qs('#arrow-issue-evidence'),
|
||||
undo:qs('#undo-issue-evidence-edit'), reset:qs('#reset-issue-evidence-edit'),
|
||||
cancel:qs('#cancel-issue-evidence-edit'), apply:qs('#apply-issue-evidence-edit'),
|
||||
status:qs('#issue-evidence-editor-status'), appliedMessage:'Edited photo ready for this Today update.',
|
||||
},
|
||||
createObjectURL:file => URL.createObjectURL(file), revokeObjectURL:url => URL.revokeObjectURL(url),
|
||||
upload:payload => {
|
||||
if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
|
||||
return Promise.reject(new Error('The active Today item changed. Reopen its update before posting.'));
|
||||
}
|
||||
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const resource = target.kind === 'pull' ? 'pulls' : 'issues';
|
||||
return fetchJson('api/v1/repos/' + repository + '/' + resource + '/' +
|
||||
encodeURIComponent(payload.number) + '/attachments', {
|
||||
method:'POST', headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
|
||||
body:issueAttachment.multipart(payload),
|
||||
});
|
||||
},
|
||||
});
|
||||
const store = createStore({ indexedDB:globalThis.indexedDB, getOwnerLogin:getLogin, scope:'today-progress' });
|
||||
drafts = createDrafts({ store, lanes:{ today:{ controller, onError:error => {
|
||||
qs('#today-progress-status').textContent = error.message + ' Your photos remain here; retry.';
|
||||
} } } });
|
||||
return {
|
||||
has:() => Boolean(controller.state()), serialize:() => controller.serialize(),
|
||||
checkpoint:() => drafts.checkpoint('today'),
|
||||
open:async value => {
|
||||
if (drafts.hasTarget('today')) await drafts.switchTo('today', value);
|
||||
else await drafts.open('today', value);
|
||||
target = { ...value };
|
||||
},
|
||||
complete:async () => { await drafts.complete('today'); target = null; },
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createTodayProgress;
|
||||
module.exports.createView = createTodayProgressView;
|
||||
module.exports.createPhotos = createTodayProgressPhotos;
|
||||
module.exports.createActivity = createTodayProgressActivity;
|
||||
module.exports.mountActivity = mountTodayProgressActivity;
|
||||
}
|
||||
@@ -283,6 +283,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#open-today-recaps').focus();
|
||||
};
|
||||
const saveDraft = async button => {
|
||||
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Saving recap…';
|
||||
try {
|
||||
@@ -290,7 +291,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
openWrapUp(handoff.actual_minutes);
|
||||
openWrapUp(handoff.actual_minutes, workedItems);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
||||
} finally { button.disabled = false; }
|
||||
@@ -303,6 +304,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
return;
|
||||
}
|
||||
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
|
||||
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Logging selected time…';
|
||||
try {
|
||||
@@ -310,7 +312,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
openWrapUp(actualMinutes);
|
||||
openWrapUp(actualMinutes, workedItems);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
|
||||
render();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
function createTodaySessionSync({
|
||||
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
|
||||
onOwnedRestore = () => {},
|
||||
setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval,
|
||||
}) {
|
||||
let current = null;
|
||||
let ownedRevision = 0;
|
||||
let pollTimer = null;
|
||||
let requestTail = Promise.resolve();
|
||||
let latestSnapshot = null;
|
||||
let publishRequested = 0;
|
||||
let publishSent = 0;
|
||||
const endpoint = 'api/v1/today/session';
|
||||
const deviceId = () => String(getDeviceId?.() || '').trim();
|
||||
|
||||
function serialize(operation) {
|
||||
const result = requestTail.then(operation, operation);
|
||||
requestTail = result.catch(() => null);
|
||||
return result;
|
||||
}
|
||||
|
||||
function adopt(session) {
|
||||
if (!session || !Number.isInteger(session.revision)) return null;
|
||||
const previousOwned = current?.device_id === deviceId() &&
|
||||
(current?.running || Number.isFinite(current?.break_deadline_at));
|
||||
current = session;
|
||||
if (session.device_id === deviceId()) {
|
||||
ownedRevision = session.revision;
|
||||
if (Number.isFinite(session.break_deadline_at) &&
|
||||
timer?.restoreBreak?.(session.identity, session.break_deadline_at)) onOwnedRestore(session);
|
||||
onRemote(null);
|
||||
} else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) {
|
||||
if (previousOwned) {
|
||||
timer?.pause?.();
|
||||
onTransferred(session);
|
||||
}
|
||||
onRemote(session);
|
||||
} else {
|
||||
onRemote(null);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async function refreshNow() {
|
||||
try {
|
||||
onStatus('syncing');
|
||||
const session = await fetchJson(endpoint);
|
||||
onStatus('online');
|
||||
return adopt(session);
|
||||
} catch (error) {
|
||||
onStatus('offline', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = () => serialize(refreshNow);
|
||||
|
||||
function claim() {
|
||||
return serialize(async () => {
|
||||
if ((!current?.running && !Number.isFinite(current?.break_deadline_at)) ||
|
||||
!current.identity || current.device_id === deviceId() || !deviceId()) return null;
|
||||
try {
|
||||
onStatus('syncing');
|
||||
const body = {
|
||||
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
|
||||
elapsed_ms:current.elapsed_ms, entries:current.entries, running:true,
|
||||
};
|
||||
if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null;
|
||||
const session = await fetchJson(endpoint, {
|
||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body),
|
||||
});
|
||||
adopt(session);
|
||||
timer?.adopt?.(session.identity, session.elapsed_ms, session.running, session.entries);
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error?.status === 409 || error?.code === 'session_changed') {
|
||||
onStatus('conflict', error);
|
||||
await refreshNow();
|
||||
} else {
|
||||
onStatus('offline', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function publishNow(snapshot) {
|
||||
if (!snapshot?.identity || !deviceId()) return null;
|
||||
try {
|
||||
onStatus('syncing');
|
||||
const body = {
|
||||
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
|
||||
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
|
||||
entries:Array.isArray(snapshot.entries) ? snapshot.entries : undefined,
|
||||
running:Boolean(snapshot.running),
|
||||
};
|
||||
if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) {
|
||||
body.break_deadline_at = Number.isFinite(snapshot.break_deadline_at) ?
|
||||
Math.floor(snapshot.break_deadline_at) : null;
|
||||
}
|
||||
const session = await fetchJson(endpoint, {
|
||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body),
|
||||
});
|
||||
adopt(session);
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error?.status === 409 || error?.code === 'session_changed') {
|
||||
onStatus('conflict', error);
|
||||
await refreshNow();
|
||||
} else {
|
||||
onStatus('offline', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function publish(snapshot = timer?.sessionSnapshot?.() || timer?.snapshot?.()) {
|
||||
snapshot = snapshot?.identity ? snapshot : latestSnapshot;
|
||||
if (!snapshot?.identity || !deviceId()) return Promise.resolve(null);
|
||||
latestSnapshot = snapshot;
|
||||
publishRequested += 1;
|
||||
return serialize(async () => {
|
||||
if (publishSent === publishRequested) return current;
|
||||
if (current?.device_id && current.device_id !== deviceId()) {
|
||||
publishSent = publishRequested;
|
||||
return current;
|
||||
}
|
||||
const version = publishRequested;
|
||||
const result = await publishNow(latestSnapshot);
|
||||
publishSent = current?.device_id && current.device_id !== deviceId()
|
||||
? publishRequested
|
||||
: version;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
const pulse = () => current?.device_id === deviceId() && current?.running
|
||||
? publish()
|
||||
: refresh();
|
||||
|
||||
return {
|
||||
refresh, claim, publish,
|
||||
session:() => current,
|
||||
start(intervalMs = 15000) {
|
||||
if (pollTimer !== null) return false;
|
||||
pulse();
|
||||
pollTimer = setInterval?.(pulse, intervalMs);
|
||||
pollTimer?.unref?.();
|
||||
return true;
|
||||
},
|
||||
stop() {
|
||||
if (pollTimer === null) return false;
|
||||
clearInterval?.(pollTimer);
|
||||
pollTimer = null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function todaySessionHandoffSummary(session, item) {
|
||||
if (Number.isFinite(session?.break_deadline_at)) {
|
||||
const end = new Date(session.break_deadline_at).toLocaleTimeString([], {
|
||||
hour:'numeric', minute:'2-digit',
|
||||
});
|
||||
return `On break until ${end} · ready to resume here`;
|
||||
}
|
||||
const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000));
|
||||
if (Array.isArray(session?.entries) && session.entries.length > 1) {
|
||||
const total = session.entries.reduce((sum, entry) => sum + Math.max(0, Number(entry?.elapsed_ms) || 0), 0);
|
||||
return `${session.entries.length} tracked items · ${Math.floor(total / 60000)} min total`;
|
||||
}
|
||||
return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
|
||||
}
|
||||
|
||||
function attachTodaySessionHandoff({
|
||||
fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer,
|
||||
}) {
|
||||
const deviceKey = 'stackchain.today-session-device.v1';
|
||||
const getDeviceId = () => {
|
||||
let value = storage.getItem(deviceKey);
|
||||
if (!value) {
|
||||
value = globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2);
|
||||
storage.setItem(deviceKey, value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
let offered = null;
|
||||
const sessionStatus = qs('#today-session-sync-status');
|
||||
const showSessionStatus = message => {
|
||||
sessionStatus.textContent = message;
|
||||
sessionStatus.hidden = !message;
|
||||
};
|
||||
const sync = createTodaySessionSync({
|
||||
fetchJson, getDeviceId, timer,
|
||||
onOwnedRestore:renderTimer,
|
||||
onStatus:state => showSessionStatus(
|
||||
state === 'syncing' ? 'Session syncing…' :
|
||||
(state === 'offline' ? 'Session offline · will retry.' : '')
|
||||
),
|
||||
onRemote:session => {
|
||||
offered = session;
|
||||
const handoff = qs('#today-session-handoff');
|
||||
handoff.hidden = !session;
|
||||
if (!session) return;
|
||||
const item = items().find(entry => identity(entry) === session.identity);
|
||||
qs('#today-session-handoff-summary').textContent = todaySessionHandoffSummary(session, item);
|
||||
qs('#continue-today-session').textContent = Number.isFinite(session.break_deadline_at) ?
|
||||
'Resume Today here' : 'Continue here';
|
||||
},
|
||||
onTransferred:() => {
|
||||
showSessionStatus('Session continued on another device.');
|
||||
announce('Today continued on another device. Timer paused here.');
|
||||
renderTimer();
|
||||
},
|
||||
});
|
||||
qs('#continue-today-session').addEventListener('click', async () => {
|
||||
const target = offered;
|
||||
const claimed = await sync.claim();
|
||||
if (!claimed || !target) return;
|
||||
selectToday();
|
||||
const item = items().find(entry => identity(entry) === claimed.identity);
|
||||
if (item) startItem(item);
|
||||
else announce('Today session moved here; refresh work to open its item.');
|
||||
renderTimer();
|
||||
});
|
||||
sync.start(5000);
|
||||
return sync;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = {
|
||||
createTodaySessionSync, attachTodaySessionHandoff, todaySessionHandoffSummary,
|
||||
};
|
||||
@@ -0,0 +1,359 @@
|
||||
function createTodaySummary({
|
||||
storage = null, getLogin = () => '', share = null, copy = null,
|
||||
resolveTarget = null, enqueueDurably = null,
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
} = {}) {
|
||||
let draft = null;
|
||||
let draftKey = '';
|
||||
let postPromise = null;
|
||||
|
||||
const storageKey = () => {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-summary-draft.v1.' + login : '';
|
||||
};
|
||||
const syncAccount = () => {
|
||||
const key = storageKey();
|
||||
if (key !== draftKey) {
|
||||
draft = null;
|
||||
draftKey = key;
|
||||
}
|
||||
};
|
||||
const copyRows = rows => rows.map(row => ({ ...row }));
|
||||
const snapshot = () => {
|
||||
syncAccount();
|
||||
return draft ? {
|
||||
...draft,
|
||||
worked:copyRows(draft.worked),
|
||||
tomorrow:copyRows(draft.tomorrow),
|
||||
target:draft.target ? { ...draft.target } : null,
|
||||
} : null;
|
||||
};
|
||||
const persist = () => {
|
||||
const key = storageKey();
|
||||
if (storage && key && draft) storage.setItem(key, JSON.stringify(draft));
|
||||
};
|
||||
const validRows = (rows, includeTime) => Array.isArray(rows) && rows.length <= 20 && rows.every(row =>
|
||||
row && typeof row.identity === 'string' && row.identity.length > 0 && row.identity.length <= 500 &&
|
||||
typeof row.title === 'string' && row.title.length <= 180 && typeof row.context === 'string' &&
|
||||
row.context.length <= 180 && typeof row.include === 'boolean' && (!includeTime ||
|
||||
(Number.isInteger(row.actual_minutes) && row.actual_minutes >= 0 && row.actual_minutes <= 1440))
|
||||
);
|
||||
const load = () => {
|
||||
const key = storageKey();
|
||||
if (!storage || !key) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
const validTarget = Boolean(saved) && (saved.target === null || saved.target === undefined || (saved.target &&
|
||||
typeof saved.target.repository === 'string' && /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(saved.target.repository) &&
|
||||
Number.isInteger(saved.target.number) && saved.target.number > 0 &&
|
||||
['issue', 'pull'].includes(saved.target.kind) && typeof saved.target.title === 'string' &&
|
||||
typeof saved.target.state === 'string'));
|
||||
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
|
||||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000 ||
|
||||
!['string', 'undefined'].includes(typeof saved.destination) || !validTarget ||
|
||||
!['string', 'undefined'].includes(typeof saved.operation_id)) {
|
||||
if (saved !== null) storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return saved;
|
||||
} catch (_error) {
|
||||
try { storage.removeItem(key); } catch (_ignored) {}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const cleanRow = (row, includeTime) => {
|
||||
const cleaned = {
|
||||
identity:String(row.identity),
|
||||
title:String(row.title || row.label || row.identity).slice(0, 180),
|
||||
context:String(row.context || '').slice(0, 180),
|
||||
};
|
||||
if (includeTime) cleaned.actual_minutes = Math.min(1440, Math.max(0, Number(row.actual_minutes) || 0));
|
||||
cleaned.include = true;
|
||||
return cleaned;
|
||||
};
|
||||
const selectedLines = (rows, showTime = false) => rows.filter(row => row.include).map(row =>
|
||||
'- ' + row.title + (showTime ? ' (' + row.actual_minutes + 'm)' : '')
|
||||
);
|
||||
const discard = () => {
|
||||
const key = storageKey();
|
||||
if (storage && key) storage.removeItem(key);
|
||||
draft = null;
|
||||
};
|
||||
|
||||
return {
|
||||
begin(worked, tomorrow) {
|
||||
draftKey = storageKey();
|
||||
draft = {
|
||||
worked:(worked || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, true)),
|
||||
tomorrow:(tomorrow || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, false)),
|
||||
include_actuals:false,
|
||||
note:'',
|
||||
destination:'',
|
||||
target:null,
|
||||
operation_id:'',
|
||||
};
|
||||
persist();
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
restore() {
|
||||
draftKey = storageKey();
|
||||
draft = load();
|
||||
return Boolean(draft);
|
||||
},
|
||||
choose(section, identity, include) {
|
||||
syncAccount();
|
||||
const row = draft?.[section]?.find(candidate => candidate.identity === identity);
|
||||
if (!row || typeof include !== 'boolean') return false;
|
||||
row.include = include;
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
includeActuals(include) {
|
||||
syncAccount();
|
||||
if (!draft || typeof include !== 'boolean') return false;
|
||||
draft.include_actuals = include;
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
setNote(note) {
|
||||
syncAccount();
|
||||
if (!draft) return false;
|
||||
draft.note = String(note || '').trim().slice(0, 1000);
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
setDestination(value) {
|
||||
syncAccount();
|
||||
if (!draft) return null;
|
||||
const match = String(value || '').trim().match(/^([a-z0-9_.-]+)\s*\/\s*([a-z0-9_.-]+)\s*#\s*([1-9][0-9]*)$/i);
|
||||
draft.destination = String(value || '').trim().slice(0, 260);
|
||||
draft.target = null;
|
||||
draft.operation_id = '';
|
||||
persist();
|
||||
if (!match) return null;
|
||||
const repository = (match[1] + '/' + match[2]).toLowerCase();
|
||||
return { repository, number:Number(match[3]), label:repository + '#' + Number(match[3]) };
|
||||
},
|
||||
async validateDestination() {
|
||||
syncAccount();
|
||||
const parsed = this.setDestination(draft?.destination || '');
|
||||
if (!parsed) throw new Error('Enter a destination like owner/repo#42.');
|
||||
if (typeof resolveTarget !== 'function') throw new Error('Destination validation is unavailable.');
|
||||
const resolved = await resolveTarget({ repository:parsed.repository, number:parsed.number });
|
||||
if (!resolved || resolved.repository !== parsed.repository || Number(resolved.number) !== parsed.number ||
|
||||
!['issue', 'pull'].includes(resolved.kind)) throw new Error('Choose an exact visible issue or pull request.');
|
||||
draft.target = {
|
||||
repository:parsed.repository, number:parsed.number, kind:resolved.kind,
|
||||
title:String(resolved.title || '').slice(0, 180), state:String(resolved.state || ''),
|
||||
};
|
||||
draft.operation_id = String(createOperationId()).slice(0, 128);
|
||||
persist();
|
||||
return { ...draft.target };
|
||||
},
|
||||
postSummary() {
|
||||
if (postPromise) return postPromise;
|
||||
syncAccount();
|
||||
const body = this.text();
|
||||
if (!body) throw new Error('Select at least one summary item or add a note.');
|
||||
if (!draft?.target || !draft.operation_id) throw new Error('Validate an exact Gitea destination first.');
|
||||
if (typeof enqueueDurably !== 'function') throw new Error('Gitea posting is unavailable.');
|
||||
const target = { ...draft.target };
|
||||
const message = {
|
||||
kind:'search-reply', repository:target.repository, number:target.number,
|
||||
targetKind:target.kind, body, operationId:draft.operation_id,
|
||||
};
|
||||
postPromise = (async () => {
|
||||
try {
|
||||
const admission = await enqueueDurably(message);
|
||||
discard();
|
||||
return { status:'queued', durability:admission?.durability || 'foreground-only', target };
|
||||
} finally {
|
||||
postPromise = null;
|
||||
}
|
||||
})();
|
||||
return postPromise;
|
||||
},
|
||||
discard,
|
||||
text() {
|
||||
syncAccount();
|
||||
if (!draft) return '';
|
||||
const sections = [];
|
||||
const worked = selectedLines(draft.worked, draft.include_actuals);
|
||||
const tomorrow = selectedLines(draft.tomorrow);
|
||||
if (worked.length) sections.push(['Today', ...worked].join('\n'));
|
||||
if (tomorrow.length) sections.push(['Tomorrow', ...tomorrow].join('\n'));
|
||||
if (draft.note) sections.push('Note\n' + draft.note);
|
||||
return sections.join('\n\n');
|
||||
},
|
||||
async shareSummary() {
|
||||
const text = this.text();
|
||||
if (!text) throw new Error('Select at least one summary item or add a note.');
|
||||
if (typeof share === 'function') {
|
||||
try {
|
||||
await share({ title:'Today summary', text });
|
||||
discard();
|
||||
return { status:'shared' };
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return { status:'canceled' };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (typeof copy !== 'function') throw new Error('Sharing is unavailable on this device.');
|
||||
await copy(text);
|
||||
discard();
|
||||
return { status:'copied' };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTodaySummaryView({ summary, qs, escapeHtml }) {
|
||||
const rowMarkup = (section, row) => '<label class="today-summary-item"><input type="checkbox" data-summary-section="' +
|
||||
section + '" data-summary-identity="' + escapeHtml(row.identity) + '"' + (row.include ? ' checked' : '') +
|
||||
'><span><strong>' + escapeHtml(row.title) + '</strong><span class="small muted">' +
|
||||
escapeHtml(row.context) + '</span></span></label>';
|
||||
|
||||
function close() {
|
||||
qs('#today-summary-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
qs('#start-work-session')?.focus();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const draft = summary.snapshot();
|
||||
if (!draft) return;
|
||||
qs('#today-summary-worked').innerHTML = draft.worked.map(row => rowMarkup('worked', row)).join('') ||
|
||||
'<p class="small muted">No worked items selected.</p>';
|
||||
qs('#today-summary-tomorrow').innerHTML = draft.tomorrow.map(row => rowMarkup('tomorrow', row)).join('') ||
|
||||
'<p class="small muted">Nothing scheduled for tomorrow.</p>';
|
||||
qs('#today-summary-include-actuals').checked = draft.include_actuals;
|
||||
qs('#today-summary-note').value = draft.note;
|
||||
qs('#today-summary-preview').textContent = summary.text();
|
||||
qs('#today-summary-destination').value = draft.destination || '';
|
||||
qs('#today-summary-target').textContent = draft.target ?
|
||||
(draft.target.repository + '#' + draft.target.number + ' · ' + draft.target.kind + ' · ' + draft.target.title) : '';
|
||||
qs('#post-today-summary').disabled = !draft.target;
|
||||
qs('#today-summary-sheet').querySelectorAll('[data-summary-identity]').forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked);
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function show() {
|
||||
qs('#today-summary-status').textContent = '';
|
||||
render();
|
||||
qs('#today-summary-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
requestAnimationFrame(() => (qs('#today-summary-worked input') || qs('#share-today-summary')).focus());
|
||||
}
|
||||
|
||||
function open(worked, tomorrow) {
|
||||
summary.begin(worked, tomorrow);
|
||||
show();
|
||||
}
|
||||
|
||||
async function shareDraft(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-summary-status').textContent = 'Opening share options…';
|
||||
try {
|
||||
const result = await summary.shareSummary();
|
||||
if (result.status === 'canceled') {
|
||||
qs('#today-summary-status').textContent = 'Share canceled. Your private draft is still here.';
|
||||
return;
|
||||
}
|
||||
qs('#my-work-action-status').textContent = result.status === 'shared' ?
|
||||
'Today summary shared.' : 'Today summary copied to your clipboard.';
|
||||
close();
|
||||
} catch (error) {
|
||||
qs('#today-summary-status').textContent = error.message || 'Summary could not be shared. Your draft is unchanged.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDestination(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-summary-status').textContent = 'Checking destination…';
|
||||
try {
|
||||
await summary.validateDestination();
|
||||
render();
|
||||
qs('#today-summary-status').textContent = 'Destination confirmed. Review it, then post once.';
|
||||
} catch (error) {
|
||||
qs('#today-summary-status').textContent = error.message || 'Destination could not be confirmed.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function postDraft(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-summary-status').textContent = 'Saving comment for delivery…';
|
||||
try {
|
||||
const result = await summary.postSummary();
|
||||
qs('#my-work-action-status').textContent = 'Today summary queued for ' +
|
||||
result.target.repository + '#' + result.target.number + '.';
|
||||
close();
|
||||
} catch (error) {
|
||||
qs('#today-summary-status').textContent = error.message || 'Summary could not be queued. Your review is unchanged.';
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
qs('#close-today-summary').addEventListener('click', close);
|
||||
qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); });
|
||||
qs('#today-summary-include-actuals').addEventListener('change', event => {
|
||||
summary.includeActuals(event.currentTarget.checked); render();
|
||||
});
|
||||
qs('#today-summary-note').addEventListener('input', event => {
|
||||
summary.setNote(event.currentTarget.value);
|
||||
qs('#today-summary-preview').textContent = summary.text();
|
||||
});
|
||||
qs('#today-summary-destination').addEventListener('input', event => {
|
||||
summary.setDestination(event.currentTarget.value);
|
||||
qs('#today-summary-target').textContent = '';
|
||||
qs('#post-today-summary').disabled = true;
|
||||
});
|
||||
qs('#validate-today-summary-destination').addEventListener('click', event => validateDestination(event.currentTarget));
|
||||
qs('#post-today-summary').addEventListener('click', event => postDraft(event.currentTarget));
|
||||
qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
|
||||
}
|
||||
|
||||
return {
|
||||
open, close, render, bind, shareDraft, validateDestination, postDraft,
|
||||
resume() { if (summary.restore()) show(); },
|
||||
};
|
||||
}
|
||||
|
||||
function setupTodaySummary({ qs, escapeHtml, getLogin, resolveTarget, enqueueDurably, fetchJson }) {
|
||||
const targetResolver = resolveTarget || (async target => {
|
||||
if (typeof fetchJson !== 'function') throw new Error('Destination validation is unavailable.');
|
||||
const item = await fetchJson('api/v1/repos/' + target.repository + '/issues/' + target.number +
|
||||
'/preview?kind=issue');
|
||||
if (String(item.repository || '').toLowerCase() !== target.repository ||
|
||||
Number(item.number) !== target.number || !['issue', 'pull'].includes(item.kind)) {
|
||||
throw new Error('That exact issue or pull request is not visible to this account.');
|
||||
}
|
||||
return { repository:target.repository, number:target.number, kind:item.kind,
|
||||
title:item.title || '', state:item.state || '' };
|
||||
});
|
||||
const summary = createTodaySummary({
|
||||
storage:localStorage,
|
||||
getLogin,
|
||||
share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null,
|
||||
copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null,
|
||||
resolveTarget:targetResolver,
|
||||
enqueueDurably,
|
||||
});
|
||||
const view = createTodaySummaryView({ summary, qs, escapeHtml });
|
||||
view.bind();
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodaySummary.createView = createTodaySummaryView;
|
||||
createTodaySummary.setup = setupTodaySummary;
|
||||
module.exports = createTodaySummary;
|
||||
}
|
||||
+97
-25
@@ -10,6 +10,8 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
let expiredCount = 0;
|
||||
let discardedCount = 0;
|
||||
let recoveryNotice = { discarded: 0, until: 0 };
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
@@ -59,12 +61,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
};
|
||||
if (['coaching', 'complete'].includes(plan.first_task_state)) {
|
||||
snapshot.first_task_state = plan.first_task_state;
|
||||
}
|
||||
if (plan.plan_date) {
|
||||
snapshot.plan_date = plan.plan_date;
|
||||
snapshot.timezone = plan.timezone || null;
|
||||
}
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
const activationKey = login && 'stackchain.first-task.v1:' + login;
|
||||
const current = activationKey && storage?.getItem(activationKey);
|
||||
const rank = {'': 0, coaching: 1, complete: 2};
|
||||
if (activationKey && rank[snapshot.first_task_state] > (rank[current] || 0)) {
|
||||
storage?.setItem(activationKey, snapshot.first_task_state);
|
||||
}
|
||||
} catch (_error) {
|
||||
// A storage quota failure must not prevent the current tab from using server truth.
|
||||
}
|
||||
@@ -108,10 +120,31 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
const candidate = storage.key?.(index);
|
||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||
}
|
||||
const records = [...keys].map(recordKey => {
|
||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
return record ? { ...record, recordKey } : null;
|
||||
}).filter(record => record?.operation);
|
||||
const records = [];
|
||||
for (const recordKey of keys) {
|
||||
let record;
|
||||
try {
|
||||
record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
} catch (_error) {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
discardedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const operations = Array.isArray(record?.operations) ? record.operations : [record?.operation];
|
||||
const valid = operations.length && operations.every(operation => operation &&
|
||||
typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) &&
|
||||
typeof operation.item_id === 'string') && Number.isFinite(Number(record.queued_at));
|
||||
if (valid) operations.forEach((operation, index) => {
|
||||
records.push({operation, queued_at:Number(record.queued_at) + index / 1000, recordKey});
|
||||
});
|
||||
else {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
discardedCount += 1;
|
||||
}
|
||||
}
|
||||
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
||||
now() - Number(record.queued_at) > maxOfflineMs);
|
||||
expired.forEach(record => {
|
||||
@@ -131,7 +164,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||
}))
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
@@ -156,7 +189,25 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null) {
|
||||
function saveOperations(operations, queuedAt = now()) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const batched = operations.length > 1;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operations[0].operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify(batched ?
|
||||
{operations, queued_at:queuedAt} : {operation:operations[0], queued_at:queuedAt}));
|
||||
knownOperationKeys.add(recordKey);
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null, fields = {}) {
|
||||
const operations = pending();
|
||||
if (action === 'remove' && operations.some(operation =>
|
||||
operation.action === 'remove' && operation.item_id === itemId
|
||||
@@ -166,20 +217,17 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
}
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, direction,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
base_revision: Math.max(0, savedRevision()), ...fields,
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('today');
|
||||
} catch (_error) { /* Report the persistence failure below. */ }
|
||||
onStatus?.(saved ? 'pending' : 'error');
|
||||
return saved;
|
||||
return saveOperations([operation], now() + operations.length);
|
||||
}
|
||||
|
||||
function enqueueBatch(specifications) {
|
||||
const operations = specifications.map(specification => ({
|
||||
operation_id: operationId(), direction: specification.direction ?? null,
|
||||
base_revision: Math.max(0, savedRevision()), ...specification,
|
||||
}));
|
||||
return saveOperations(operations);
|
||||
}
|
||||
|
||||
function enqueueConfiguration(capacityMinutes, estimates) {
|
||||
@@ -203,6 +251,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueActivation(state) {
|
||||
if (!['coaching', 'complete'].includes(state)) return false;
|
||||
const rank = {coaching: 1, complete: 2};
|
||||
const queued = pending().filter(operation => operation.action === 'activate');
|
||||
if (queued.some(operation => rank[operation.activation_state] >= rank[state])) return true;
|
||||
queued.forEach(operation => removeOperation(operation.operation_id));
|
||||
return enqueue('activate', 'first-task', null, {activation_state: state});
|
||||
}
|
||||
|
||||
function enqueueRollover(proposed) {
|
||||
if (!proposed || proposed.action !== 'rollover') return false;
|
||||
const operation = {
|
||||
@@ -246,7 +303,10 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
let operations = pending();
|
||||
let plan;
|
||||
let hadConflict = false;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/today');
|
||||
if (!operations.length) {
|
||||
plan = await fetchJson('api/v1/today');
|
||||
operations = pending();
|
||||
}
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
const batch = operations.slice(0, 50);
|
||||
@@ -272,14 +332,23 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
|
||||
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
|
||||
const stillPending = pending().length;
|
||||
if (discardedCount) recoveryNotice = { discarded: discardedCount, until: now() + 5000 };
|
||||
const recovered = recoveryNotice.until > now() ? recoveryNotice.discarded : 0;
|
||||
onStatus?.(stillPending ? 'pending' : hadConflict ? 'full' :
|
||||
expiredCount ? 'expired' : recovered ? 'recovered' : 'saved',
|
||||
expiredCount ? { count: expiredCount } : recovered ? { discarded: recovered } : {});
|
||||
if (!stillPending) discardedCount = 0;
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return !hadConflict;
|
||||
} catch (error) {
|
||||
if (pending().length) scheduleRetry(error, ownerKey);
|
||||
else onStatus?.('error');
|
||||
const stillPending = pending().length;
|
||||
if (discardedCount) recoveryNotice = { discarded: discardedCount, until: now() + 5000 };
|
||||
const recovered = recoveryNotice.until > now() ? recoveryNotice.discarded : 0;
|
||||
if (stillPending) scheduleRetry(error, ownerKey);
|
||||
else onStatus?.(recovered ? 'recovered' : 'error', recovered ? { discarded: recovered } : {});
|
||||
discardedCount = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -294,6 +363,9 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
|
||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||
windowObject?.addEventListener?.('online', flush);
|
||||
windowObject?.addEventListener?.('stackchain:first-task-complete', () => {
|
||||
if (enqueueActivation('complete')) flush();
|
||||
});
|
||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||
documentObject.hidden ? false : flush()
|
||||
);
|
||||
@@ -303,7 +375,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
return { enqueue, enqueueBatch, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
+403
-10
@@ -1,11 +1,11 @@
|
||||
function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange = () => {} }) {
|
||||
const key = () => {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
|
||||
};
|
||||
const empty = () => ({
|
||||
version:1, active_identity:'', entries:{}, away_at:null,
|
||||
pending_interruption:null, attention_interruption:null,
|
||||
pending_interruption:null, attention_interruption:null, capture_interruption:null, search_interruption:null, detour_interruption:null, timed_break:null,
|
||||
});
|
||||
const read = () => {
|
||||
const ownerKey = key();
|
||||
@@ -24,6 +24,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
if (!ownerKey || !storage) return false;
|
||||
try {
|
||||
storage.setItem(ownerKey, JSON.stringify(state));
|
||||
onChange(snapshot());
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
@@ -41,6 +42,30 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
typeof pending.resume === 'boolean' ?
|
||||
{ identity:pending.identity, resume:pending.resume } : null;
|
||||
};
|
||||
const validCapture = state => {
|
||||
const pending = state.capture_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' ?
|
||||
{ identity:pending.identity, resume:pending.resume } : null;
|
||||
};
|
||||
const validSearch = state => {
|
||||
const pending = state.search_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' ?
|
||||
{ identity:pending.identity, resume:pending.resume } : null;
|
||||
};
|
||||
const validDetour = state => {
|
||||
const pending = state.detour_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(pending.reason) ?
|
||||
{ identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
|
||||
};
|
||||
const validBreak = state => {
|
||||
const value = state.timed_break;
|
||||
return value && typeof value.identity === 'string' && value.identity &&
|
||||
Number.isFinite(value.deadline_at) && value.deadline_at >= 0 ?
|
||||
{ identity:value.identity, deadline_at:value.deadline_at, expired:now() >= value.deadline_at } : null;
|
||||
};
|
||||
const settle = (state, at = now()) => {
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry?.running) return state;
|
||||
@@ -50,20 +75,65 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
entry.running = false;
|
||||
return state;
|
||||
};
|
||||
const snapshot = (identity = '') => {
|
||||
const snapshot = (identity = '', includeEntries = false) => {
|
||||
const state = read();
|
||||
const sampledAt = now();
|
||||
const selected = identity || state.active_identity;
|
||||
const entry = state.entries[selected];
|
||||
if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false };
|
||||
const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0);
|
||||
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
|
||||
const result = !selected || !entry ?
|
||||
{ identity:selected, elapsed_ms:0, running:false } :
|
||||
{
|
||||
identity:selected,
|
||||
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
||||
Math.max(0, sampledAt - Number(entry.started_at ?? sampledAt)) : 0),
|
||||
running:Boolean(entry.running),
|
||||
};
|
||||
const timedBreak = validBreak(state);
|
||||
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
|
||||
if (includeEntries) {
|
||||
result.entries = Object.entries(state.entries).slice(0, 20).map(([entryIdentity, value]) => ({
|
||||
identity:entryIdentity,
|
||||
elapsed_ms:Math.max(0, Math.floor(Number(value.elapsed_ms) || 0) + (value.running ?
|
||||
Math.max(0, sampledAt - Number(value.started_at ?? sampledAt)) : 0)),
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
adopt(identity, elapsedMs, running, entries = null) {
|
||||
if (!key() || typeof identity !== 'string' || !identity ||
|
||||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
|
||||
const incoming = entries === null ? [{identity, elapsed_ms:elapsedMs}] : entries;
|
||||
if (!Array.isArray(incoming) || incoming.length > 20) return false;
|
||||
const seen = new Set();
|
||||
const ledger = {};
|
||||
for (const entry of incoming) {
|
||||
if (typeof entry?.identity !== 'string' || !entry.identity || entry.identity.length > 500 ||
|
||||
seen.has(entry.identity) || !Number.isFinite(Number(entry.elapsed_ms)) || Number(entry.elapsed_ms) < 0) return false;
|
||||
seen.add(entry.identity);
|
||||
ledger[entry.identity] = {
|
||||
elapsed_ms:Math.floor(Number(entry.elapsed_ms)), started_at:null, running:false,
|
||||
};
|
||||
}
|
||||
if (!ledger[identity] || ledger[identity].elapsed_ms !== Math.floor(Number(elapsedMs))) return false;
|
||||
const state = read();
|
||||
state.active_identity = identity;
|
||||
state.entries = ledger;
|
||||
state.entries[identity].started_at = running ? now() : null;
|
||||
state.entries[identity].running = Boolean(running);
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
state.search_interruption = null;
|
||||
state.detour_interruption = null;
|
||||
state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
activate(identity) {
|
||||
if (!key() || typeof identity !== 'string' || !identity) return false;
|
||||
const state = read();
|
||||
if (state.active_identity === identity && state.entries[identity]?.running) return true;
|
||||
if (state.active_identity === identity &&
|
||||
(state.entries[identity]?.running || validBreak(state)?.identity === identity)) return true;
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
|
||||
@@ -73,6 +143,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
state.search_interruption = null;
|
||||
state.detour_interruption = null;
|
||||
state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
pause() {
|
||||
@@ -89,6 +162,41 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
startBreak(minutes) {
|
||||
const duration = Number(minutes);
|
||||
if (!Number.isInteger(duration) || duration < 1 || duration > 120) return false;
|
||||
const state = read();
|
||||
const identity = state.active_identity;
|
||||
if (!identity || !state.entries[identity]) return false;
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.timed_break = { identity, deadline_at:now() + duration * 60000 };
|
||||
return write(state) ? validBreak(state) : false;
|
||||
},
|
||||
restoreBreak(identity, deadlineAt) {
|
||||
const deadline = Number(deadlineAt);
|
||||
if (typeof identity !== 'string' || !identity || !Number.isFinite(deadline) || deadline <= now()) return false;
|
||||
const state = read();
|
||||
const entry = state.entries[identity];
|
||||
if (state.active_identity !== identity || !entry || entry.running || validBreak(state)) return false;
|
||||
state.timed_break = { identity, deadline_at:Math.floor(deadline) };
|
||||
return write(state);
|
||||
},
|
||||
breakSnapshot() {
|
||||
return validBreak(read());
|
||||
},
|
||||
resumeBreak() {
|
||||
const state = read();
|
||||
const pending = validBreak(state);
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry || state.active_identity !== pending.identity || entry.running) return false;
|
||||
state.timed_break = null;
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
return write(state);
|
||||
},
|
||||
stop() {
|
||||
@@ -97,6 +205,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
state.search_interruption = null;
|
||||
state.detour_interruption = null;
|
||||
state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
beginAttention() {
|
||||
@@ -129,6 +240,113 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed:pending.resume } : null;
|
||||
},
|
||||
beginCapture() {
|
||||
const state = read();
|
||||
const existing = validCapture(state);
|
||||
if (existing) return existing;
|
||||
const identity = state.active_identity;
|
||||
const entry = state.entries[identity];
|
||||
if (!identity || !entry) return null;
|
||||
const resume = Boolean(entry.running);
|
||||
if (resume) settle(state);
|
||||
state.away_at = null;
|
||||
state.capture_interruption = { identity, resume };
|
||||
return write(state) ? { ...state.capture_interruption } : null;
|
||||
},
|
||||
captureInterruption() {
|
||||
return validCapture(read());
|
||||
},
|
||||
abandonCapture() {
|
||||
const state = read();
|
||||
const pending = validCapture(state);
|
||||
if (!pending) return null;
|
||||
state.capture_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed:false } : null;
|
||||
},
|
||||
returnFromCapture() {
|
||||
const state = read();
|
||||
const pending = validCapture(state);
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry) return null;
|
||||
const resumed = pending.resume && state.active_identity === pending.identity;
|
||||
if (resumed && !entry.running) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.capture_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed } : null;
|
||||
},
|
||||
beginSearch() {
|
||||
const state = read();
|
||||
const existing = validSearch(state);
|
||||
if (existing) return existing;
|
||||
const identity = state.active_identity;
|
||||
const entry = state.entries[identity];
|
||||
if (!identity || !entry?.running) return null;
|
||||
const resume = true;
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
state.search_interruption = { identity, resume };
|
||||
return write(state) ? { ...state.search_interruption } : null;
|
||||
},
|
||||
searchInterruption() {
|
||||
return validSearch(read());
|
||||
},
|
||||
abandonSearch() {
|
||||
const state = read();
|
||||
const pending = validSearch(state);
|
||||
if (!pending) return null;
|
||||
state.search_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed:false } : null;
|
||||
},
|
||||
returnFromSearch() {
|
||||
const state = read();
|
||||
const pending = validSearch(state);
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry) return null;
|
||||
const resumed = pending.resume && state.active_identity === pending.identity;
|
||||
if (resumed && !entry.running) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.search_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed } : null;
|
||||
},
|
||||
beginDetour(reason) {
|
||||
if (!['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(reason)) return null;
|
||||
const state = read();
|
||||
const existing = validDetour(state);
|
||||
if (existing) return existing;
|
||||
const identity = state.active_identity;
|
||||
const entry = state.entries[identity];
|
||||
if (!identity || !entry?.running) return null;
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
state.detour_interruption = { identity, resume:true, reason };
|
||||
return write(state) ? { ...state.detour_interruption } : null;
|
||||
},
|
||||
detourInterruption() {
|
||||
return validDetour(read());
|
||||
},
|
||||
returnFromDetour(reason = '') {
|
||||
const state = read();
|
||||
const pending = validDetour(state);
|
||||
if (reason && pending?.reason !== reason) return null;
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry || state.active_identity !== pending.identity) return null;
|
||||
const resumed = pending.resume && !entry.running;
|
||||
if (resumed) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.detour_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed, reason:pending.reason } : null;
|
||||
},
|
||||
markAway() {
|
||||
const state = read();
|
||||
const entry = state.entries[state.active_identity];
|
||||
@@ -197,12 +415,32 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
}, 0);
|
||||
},
|
||||
snapshot,
|
||||
sessionSnapshot:(identity = '') => snapshot(identity, true),
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
|
||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onResume, onComplete, onCapture }) {
|
||||
let progress = null;
|
||||
let runway = null;
|
||||
let breakView = null;
|
||||
if (typeof createTodayBreak === 'function') {
|
||||
breakView = createTodayBreak({timer, qs:selector => queryAll(selector)[0], queryAll, onChange:() => render(), onResume:onResume || onReopen});
|
||||
}
|
||||
const captureView = createTodayCaptureInterruption({
|
||||
timer,
|
||||
banner:queryAll('#today-capture-interruption')[0],
|
||||
label:queryAll('#today-capture-interruption-label')[0],
|
||||
getItem,
|
||||
onReturn:() => render(),
|
||||
});
|
||||
const searchView = createTodaySearchInterruption({
|
||||
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
|
||||
onReturn:identity => onReopen?.(identity),
|
||||
});
|
||||
const detourView = createTodayDetourInterruption({
|
||||
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
|
||||
onReturn:identity => onReopen?.(identity),
|
||||
});
|
||||
queryAll('[data-mobile-today-open]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const identity = timer.snapshot().identity;
|
||||
@@ -248,6 +486,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||
};
|
||||
const render = () => {
|
||||
const snapshot = timer.snapshot();
|
||||
breakView?.render();
|
||||
const active = Boolean(progress && isActive() && snapshot.identity);
|
||||
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
|
||||
queryAll('[data-mobile-today-open]').forEach(element => {
|
||||
@@ -302,19 +541,49 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||
button.setAttribute('aria-pressed', String(!snapshot.running));
|
||||
});
|
||||
};
|
||||
return {
|
||||
const view = {
|
||||
open(identity, active) {
|
||||
const current = timer.snapshot();
|
||||
if (active && current.identity !== identity) timer.activate(identity);
|
||||
else if (!active) timer.stop();
|
||||
render();
|
||||
},
|
||||
reopen(identity) { onReopen?.(identity); },
|
||||
search(action, ...args) { return searchView[action]?.(...args); },
|
||||
beginDetour(reason) { return detourView.open(reason); },
|
||||
finishDetour(reason) { return detourView.finish(reason); },
|
||||
finish() { timer.stop(); progress = null; runway = null; render(); },
|
||||
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
|
||||
reset() { progress = null; runway = null; render(); },
|
||||
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); },
|
||||
beginCapture() {
|
||||
const result = captureView.open();
|
||||
this.restoreCapture();
|
||||
render();
|
||||
return result;
|
||||
},
|
||||
restoreCapture() {
|
||||
const result = captureView.restore();
|
||||
const button = queryAll('#save-unfiled-issue')[0];
|
||||
if (button) button.textContent = result ? 'Save & return to Today' : 'Save to Drafts';
|
||||
return result;
|
||||
},
|
||||
finishCapture() { return captureView.finish(); },
|
||||
transferCapture() { return captureView.transfer(); },
|
||||
restore(recovery) {
|
||||
render();
|
||||
return Promise.resolve(recovery).catch(() => false).then(() => { render(); return !!timer.breakSnapshot?.(); });
|
||||
},
|
||||
render,
|
||||
};
|
||||
queryAll('#new-issue').forEach(button => button.addEventListener('click', () => {
|
||||
view.beginCapture();
|
||||
onCapture?.();
|
||||
}));
|
||||
if (timer.captureInterruption) view.restoreCapture();
|
||||
searchView.restore();
|
||||
detourView.restore();
|
||||
return view;
|
||||
}
|
||||
|
||||
function createTodayBudgetReplan({ timer, openPlan }) {
|
||||
@@ -366,9 +635,133 @@ function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel
|
||||
};
|
||||
}
|
||||
|
||||
function createTodaySearchInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
|
||||
const render = (pending = timer.searchInterruption?.()) => {
|
||||
queryAll('[data-search-today-interruption]').forEach(element => { element.hidden = !pending; });
|
||||
queryAll('[data-search-today-label]').forEach(element => {
|
||||
element.textContent = pending ? 'Today paused · ' +
|
||||
String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
|
||||
});
|
||||
onChange?.();
|
||||
return pending;
|
||||
};
|
||||
const view = {
|
||||
open() {
|
||||
const wasRunning = timer.snapshot().running;
|
||||
const pending = timer.beginSearch?.();
|
||||
render(pending || timer.searchInterruption?.());
|
||||
if (wasRunning && !pending) {
|
||||
const status = queryAll('#cmd-search-action-status')[0];
|
||||
if (status) status.textContent = 'Search is open, but Today timing could not be paused on this device.';
|
||||
}
|
||||
return pending;
|
||||
},
|
||||
restore() { return render(); },
|
||||
finish() {
|
||||
const result = timer.returnFromSearch?.();
|
||||
render(null);
|
||||
return result;
|
||||
},
|
||||
transfer() {
|
||||
const result = timer.abandonSearch?.();
|
||||
render(null);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
queryAll('#open-palette').forEach(button => button.addEventListener('click', () => view.open()));
|
||||
queryAll('[data-return-from-search]').forEach(button => button.addEventListener('click', () => {
|
||||
const pending = timer.searchInterruption?.();
|
||||
view.finish();
|
||||
if (pending) onReturn?.(pending.identity);
|
||||
}));
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const overlays = [...queryAll('#cmd-palette'), ...queryAll('#search-preview')];
|
||||
const observer = new MutationObserver(() => {
|
||||
if (overlays.every(element => !element.classList.contains('open'))) view.finish();
|
||||
});
|
||||
overlays.forEach(element => observer.observe(element, {attributes:true, attributeFilter:['class']}));
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
function createTodayCaptureInterruption({ timer, banner, label, getItem, getItemLabel, onReturn }) {
|
||||
banner ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption');
|
||||
label ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption-label');
|
||||
const render = pending => {
|
||||
if (!banner || !label) return;
|
||||
banner.hidden = !pending;
|
||||
label.textContent = pending ? 'Today paused · ' +
|
||||
String(getItem?.(pending.identity)?.title || getItemLabel?.(pending.identity) || 'Current Today item') : '';
|
||||
};
|
||||
return {
|
||||
open() {
|
||||
const pending = timer.beginCapture();
|
||||
render(pending);
|
||||
return pending;
|
||||
},
|
||||
restore() {
|
||||
const pending = timer.captureInterruption();
|
||||
render(pending);
|
||||
return pending;
|
||||
},
|
||||
finish() {
|
||||
const result = timer.returnFromCapture();
|
||||
render(null);
|
||||
if (result) onReturn?.(result);
|
||||
return result;
|
||||
},
|
||||
transfer() {
|
||||
const result = timer.abandonCapture();
|
||||
render(null);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayDetourInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
|
||||
const render = (pending = timer.detourInterruption?.()) => {
|
||||
queryAll('[data-today-detour]').forEach(element => { element.hidden = !pending; });
|
||||
queryAll('[data-today-detour-label]').forEach(element => {
|
||||
element.textContent = pending ? 'Today paused · ' +
|
||||
String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
|
||||
});
|
||||
onChange?.();
|
||||
return pending;
|
||||
};
|
||||
const view = {
|
||||
open(reason) {
|
||||
const pending = timer.beginDetour?.(reason);
|
||||
render(pending || timer.detourInterruption?.());
|
||||
return pending;
|
||||
},
|
||||
restore() { return render(); },
|
||||
finish(reason) {
|
||||
const result = timer.returnFromDetour?.(reason);
|
||||
if (result || !reason) render(null);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
queryAll('[data-return-from-detour]').forEach(button => button.addEventListener('click', () => {
|
||||
const pending = timer.detourInterruption?.();
|
||||
view.finish();
|
||||
if (pending) onReturn?.(pending.identity);
|
||||
}));
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
queryAll('#find-work-sheet').forEach(sheet => new MutationObserver(() => {
|
||||
const pending = timer.detourInterruption?.();
|
||||
if (sheet.classList.contains('open')) view.open('find');
|
||||
else if (pending?.reason === 'find') view.finish();
|
||||
}).observe(sheet, {attributes:true, attributeFilter:['class']}));
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayTimer.createView = createTodayTimerView;
|
||||
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
|
||||
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
|
||||
createTodayTimer.createSearchInterruption = createTodaySearchInterruption;
|
||||
createTodayTimer.createCaptureInterruption = createTodayCaptureInterruption;
|
||||
createTodayTimer.createDetourInterruption = createTodayDetourInterruption;
|
||||
module.exports = createTodayTimer;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
function createTodayWeekReschedule({
|
||||
week,
|
||||
api,
|
||||
getToday,
|
||||
adoptToday = () => {},
|
||||
operationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()),
|
||||
storage = null,
|
||||
getLogin = () => '',
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
let current = null;
|
||||
let flushing = null;
|
||||
const storagePrefix = 'stackchain.today-week-reschedule.v1.';
|
||||
|
||||
function storageKey() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? storagePrefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function validRecord(value) {
|
||||
return typeof value?.body?.operation_id === 'string' &&
|
||||
Array.isArray(value?.today?.ids) && Array.isArray(value?.week?.days);
|
||||
}
|
||||
|
||||
function readQueue() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (Array.isArray(value?.records)) return value.records.filter(validRecord);
|
||||
return validRecord(value) ? [value] : [];
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function saveQueue(records) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (!records.length) storage.removeItem(key);
|
||||
else storage.setItem(key, JSON.stringify({version:2, records}));
|
||||
return true;
|
||||
}
|
||||
catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function persist(record) { return saveQueue([...readQueue(), record]); }
|
||||
|
||||
function pending() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return null;
|
||||
return readQueue()[0] || null;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
const records = readQueue();
|
||||
const record = records[records.length - 1];
|
||||
if (!record) return null;
|
||||
adoptToday(record.today);
|
||||
week.adopt(record.week);
|
||||
return {...record, sync_pending:true, pending_count:records.length};
|
||||
}
|
||||
|
||||
async function restoreConflict(error) {
|
||||
if (error?.status !== 409) throw error;
|
||||
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
||||
adoptToday(today);
|
||||
week.adopt(weekState);
|
||||
const conflict = new Error('Plans changed on another device. Review and retry this saved move.');
|
||||
conflict.status = 409;
|
||||
throw conflict;
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (flushing) return flushing;
|
||||
if (!pending()) return Promise.resolve(false);
|
||||
flushing = (async () => {
|
||||
let result = null;
|
||||
while (true) {
|
||||
const records = readQueue();
|
||||
const record = records[0];
|
||||
if (!record) return result ? {...result, sync_pending:false, pending_count:0} : false;
|
||||
try {
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body),
|
||||
});
|
||||
} catch (error) { await restoreConflict(error); }
|
||||
adoptToday(result.today);
|
||||
week.adopt(result.week);
|
||||
const latest = readQueue();
|
||||
if (latest[0]?.body?.operation_id !== record.body.operation_id) continue;
|
||||
const remaining = latest.slice(1).map((entry, index) => index ? entry : ({
|
||||
...entry,
|
||||
body:{...entry.body, today_revision:result.today.revision, week_revision:result.week.revision},
|
||||
}));
|
||||
if (!saveQueue(remaining)) throw new Error('Move synced, but its local queue could not be updated.');
|
||||
}
|
||||
})().finally(() => { flushing = null; });
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function optimisticSnapshots(planDate, estimate) {
|
||||
const today = {
|
||||
...current.today,
|
||||
ids: current.today.ids.filter(id => id !== current.identity),
|
||||
estimates: {...(current.today.estimates || {})},
|
||||
};
|
||||
delete today.estimates[current.identity];
|
||||
const days = (current.week.days || []).map(day => {
|
||||
const estimates = {...(day.estimates || {})}; delete estimates[current.identity];
|
||||
return {...day, ids:(day.ids || []).filter(id => id !== current.identity), estimates};
|
||||
});
|
||||
let destination = days.find(day => day.plan_date === planDate);
|
||||
if (!destination) {
|
||||
destination = {plan_date:planDate, ids:[], capacity_minutes:null, estimates:{}};
|
||||
days.push(destination);
|
||||
}
|
||||
destination.ids.push(current.identity);
|
||||
destination.estimates[current.identity] = estimate;
|
||||
return {today, week:{revision:current.week_revision, timezone:current.week.timezone || null, days}};
|
||||
}
|
||||
|
||||
async function open(identity) {
|
||||
let today;
|
||||
const queued = readQueue().length;
|
||||
let offline = Boolean(queued);
|
||||
if (queued) {
|
||||
today = getToday?.();
|
||||
} else {
|
||||
try { today = await api('api/v1/today'); }
|
||||
catch (error) {
|
||||
if (error?.status === 401 || error?.status === 403) throw error;
|
||||
today = getToday?.(); offline = true;
|
||||
}
|
||||
}
|
||||
if (!identity || !today?.ids?.includes(identity)) {
|
||||
throw new Error('The active Today item changed. Reopen rescheduling.');
|
||||
}
|
||||
const loaded = queued ? week.state?.() : await week.load();
|
||||
if (!loaded) throw new Error('Reload Today before rescheduling another saved move.');
|
||||
if (loaded?.sync_pending) {
|
||||
throw new Error('Reconnect before rescheduling Today into Week Ahead.');
|
||||
}
|
||||
offline = offline || Boolean(loaded?.offline_snapshot);
|
||||
const estimate = Number(today.estimates?.[identity]);
|
||||
const estimateMinutes = Number.isFinite(estimate) && estimate > 0 ? estimate : null;
|
||||
const days = week.review().days.map(day => {
|
||||
const ids = (day.ids || []).filter(id => id !== identity);
|
||||
const planned = Number(day.planned_minutes) || 0;
|
||||
const existing = Number(day.estimates?.[identity]) || 0;
|
||||
return {
|
||||
...day,
|
||||
ids,
|
||||
planned_minutes: Math.max(0, planned - existing),
|
||||
load: `${Math.max(0, planned - existing)} / ${Number(day.capacity_minutes) || 0} min`,
|
||||
eligible: ids.length < 5,
|
||||
};
|
||||
});
|
||||
current = {
|
||||
identity,
|
||||
today_revision: today.revision,
|
||||
week_revision: loaded.revision,
|
||||
operation_id: operationId(),
|
||||
estimate_minutes: estimateMinutes,
|
||||
days,
|
||||
today,
|
||||
week: loaded,
|
||||
offline,
|
||||
};
|
||||
return {...current, days:days.map(day => ({...day, ids:[...day.ids]}))};
|
||||
}
|
||||
|
||||
async function confirm(planDate, estimateMinutes, {allowOverload = false} = {}) {
|
||||
if (!current) throw new Error('Open rescheduling before choosing a day.');
|
||||
const day = current.days.find(value => value.plan_date === planDate);
|
||||
if (!day) throw new Error('Choose one of the next seven days.');
|
||||
const estimate = Number(estimateMinutes);
|
||||
if (!Number.isFinite(estimate) || estimate < 5 || estimate > 1440) {
|
||||
throw new Error('Estimate must be between 5 and 1440 minutes.');
|
||||
}
|
||||
if (!day.eligible) throw new Error('That Week Ahead day already has five items.');
|
||||
const capacity = Number(day.capacity_minutes) || 0;
|
||||
if (capacity && day.planned_minutes + estimate > capacity && !allowOverload) {
|
||||
throw new Error("That move exceeds the day's capacity. Confirm overload before rescheduling.");
|
||||
}
|
||||
const body = {
|
||||
operation_id:current.operation_id,
|
||||
identity:current.identity,
|
||||
estimate_minutes:estimate,
|
||||
plan_date:planDate,
|
||||
today_revision:current.today_revision,
|
||||
week_revision:current.week_revision,
|
||||
allow_over_capacity:allowOverload,
|
||||
};
|
||||
const optimistic = optimisticSnapshots(planDate, estimate);
|
||||
const durable = Boolean(storageKey() && storage);
|
||||
const admitted = durable && persist({body, ...optimistic, queued_at:now()});
|
||||
if (durable && !admitted) {
|
||||
throw new Error('Could not save this move on this device. Nothing changed.');
|
||||
}
|
||||
if (admitted) {
|
||||
adoptToday(optimistic.today);
|
||||
week.adopt(optimistic.week);
|
||||
}
|
||||
if (current.offline) {
|
||||
current = null;
|
||||
return {...optimistic, sync_pending:true, pending_count:readQueue().length};
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = admitted ? await flush() : await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
||||
});
|
||||
} catch(error) {
|
||||
if (admitted && error?.status === 409) await restoreConflict(error);
|
||||
if (admitted && error?.status !== 401 && error?.status !== 403) {
|
||||
current = null;
|
||||
return {...optimistic, sync_pending:true};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
adoptToday(result.today);
|
||||
week.adopt(result.week);
|
||||
current = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
function cancel() { current = null; }
|
||||
return {open, confirm, cancel, state:() => current, pending, resume, flush};
|
||||
}
|
||||
|
||||
function mountTodayWeekReschedule({
|
||||
qs, document=globalThis.document, window=globalThis.window, week, api, getToday, adoptToday, currentTarget, closeActions,
|
||||
refresh, warm, continueToday, announce, storage=null, getLogin=()=>'', schedule=callback=>requestAnimationFrame(callback),
|
||||
}={}) {
|
||||
const dialog=qs('#today-week-reschedule'),daysRoot=qs('#today-week-reschedule-days');
|
||||
const estimate=qs('#today-week-reschedule-estimate'),status=qs('#today-week-reschedule-status');
|
||||
const confirm=qs('#confirm-today-week-reschedule'),launcher=qs('[data-work-session-reschedule-week]');
|
||||
let selectedDate=null,allowOverload=false;
|
||||
const controller=createTodayWeekReschedule({week,api,getToday,adoptToday,storage,getLogin});
|
||||
async function flushPending() {
|
||||
if(!controller.pending())return false;
|
||||
try {
|
||||
const result=await controller.flush();
|
||||
announce('Today-to-Week move synced to your account.');
|
||||
await refresh();warm();
|
||||
return result;
|
||||
} catch(error) {
|
||||
announce(`${error.message||'Sync unavailable.'} Move saved on this device · sync pending.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function resumePending() {
|
||||
const restored=controller.resume();
|
||||
if(!restored)return false;
|
||||
announce('Today-to-Week move saved on this device · sync pending.');
|
||||
flushPending();
|
||||
return restored;
|
||||
}
|
||||
window?.addEventListener('online', flushPending);
|
||||
document?.addEventListener('visibilitychange',()=>document.hidden?false:flushPending());
|
||||
let restoreAttempts=0;
|
||||
function restoreWhenOwned(){
|
||||
if(resumePending()||getLogin()||restoreAttempts++>=20)return;
|
||||
window?.setTimeout(restoreWhenOwned,250);
|
||||
}
|
||||
restoreWhenOwned();
|
||||
function close() {
|
||||
controller.cancel();selectedDate=null;allowOverload=false;
|
||||
if(dialog.open)dialog.close();schedule(()=>{
|
||||
qs('[data-mobile-today-more]').click();launcher.focus();
|
||||
});
|
||||
}
|
||||
function render(opened) {
|
||||
daysRoot.replaceChildren(...opened.days.map(day=>{
|
||||
const button=document.createElement('button');
|
||||
button.type='button';button.dataset.planDate=day.plan_date;button.setAttribute('role','radio');
|
||||
button.setAttribute('aria-checked','false');button.disabled=!day.eligible;
|
||||
const label=document.createElement('strong');label.textContent=day.label;
|
||||
const load=document.createElement('span');load.className='small';
|
||||
load.textContent=day.eligible?day.load:'5 items · full';button.append(label,load);
|
||||
button.addEventListener('click',()=>{
|
||||
selectedDate=day.plan_date;allowOverload=false;
|
||||
daysRoot.querySelectorAll('button').forEach(choice=>choice.setAttribute(
|
||||
'aria-checked',String(choice===button)
|
||||
));
|
||||
status.textContent='';confirm.disabled=false;confirm.textContent='Move to Week Ahead & continue';
|
||||
});
|
||||
return button;
|
||||
}));
|
||||
}
|
||||
launcher.addEventListener('click',async()=>{
|
||||
const target=currentTarget();
|
||||
if(!target){announce('Start or resume a checkpointed Today item before rescheduling.');return;}
|
||||
closeActions();status.textContent='Loading Week Ahead…';confirm.disabled=true;dialog.showModal();
|
||||
try{
|
||||
const opened=await controller.open(target.identity);render(opened);
|
||||
estimate.value=opened.estimate_minutes||'';
|
||||
status.textContent=opened.estimate_minutes?'Choose a future day.':'Add an estimate, then choose a future day.';
|
||||
schedule(()=>daysRoot.querySelector('button:not(:disabled)')?.focus());
|
||||
}catch(error){status.textContent=error.message;}
|
||||
});
|
||||
qs('#cancel-today-week-reschedule').addEventListener('click',close);
|
||||
dialog.addEventListener('cancel',event=>{event.preventDefault();close();});
|
||||
confirm.addEventListener('click',async()=>{
|
||||
if(!selectedDate)return;
|
||||
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';
|
||||
try{
|
||||
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
||||
dialog.close();
|
||||
if(result.sync_pending){
|
||||
announce('Moved locally. Saved on this device · sync pending.');warm();
|
||||
}else{
|
||||
await refresh();warm();announce('Moved to Week Ahead. Continuing Today.');
|
||||
}
|
||||
await continueToday();
|
||||
}catch(error){
|
||||
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
||||
status.textContent=error.message;
|
||||
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
|
||||
confirm.disabled=false;
|
||||
}
|
||||
});
|
||||
return {controller,close,flushPending,resumePending};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createTodayWeekReschedule;
|
||||
module.exports.mount = mountTodayWeekReschedule;
|
||||
}
|
||||
+24
-1
@@ -127,6 +127,29 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
return saved;
|
||||
}
|
||||
|
||||
function capture(item) {
|
||||
const id = identity(item);
|
||||
const ids = read();
|
||||
const index = ids.indexOf(id);
|
||||
if (index < 0) return null;
|
||||
return { id, index, ids: ids.slice(), plan: planning() };
|
||||
}
|
||||
|
||||
function restore(snapshot) {
|
||||
if (!snapshot || typeof snapshot.id !== 'string' || !Array.isArray(snapshot.ids)) return 'unavailable';
|
||||
const current = read();
|
||||
const expected = snapshot.ids.filter(id => id !== snapshot.id);
|
||||
if (current.includes(snapshot.id)) return 'changed';
|
||||
if (current.length >= limit) return 'full';
|
||||
if (current.length !== expected.length || current.some((id, index) => id !== expected[index])) return 'changed';
|
||||
if (!write(snapshot.ids)) return 'unavailable';
|
||||
if (!replacePlanning(snapshot.plan)) {
|
||||
write(current);
|
||||
return 'unavailable';
|
||||
}
|
||||
return 'restored';
|
||||
}
|
||||
|
||||
function move(item, direction) {
|
||||
const ids = read();
|
||||
const index = ids.indexOf(identity(item));
|
||||
@@ -177,7 +200,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, capture, restore, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
+51
-14
@@ -1,4 +1,4 @@
|
||||
function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
function createTodayWrapUp({ todayWork, tomorrowPlan, todaySync, limit = 5 }) {
|
||||
let items = [];
|
||||
const selected = new Set();
|
||||
|
||||
@@ -28,16 +28,39 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
const wake = laterWork.presetUntil('tomorrow');
|
||||
let scheduled = 0;
|
||||
for (const item of items) {
|
||||
const loaded = await tomorrowPlan.load();
|
||||
const currentIds = Array.isArray(loaded?.ids) ? [...loaded.ids] : [];
|
||||
const carried = items.filter(item => selected.has(todayWork.identity(item)) && todayWork.contains(item));
|
||||
if (!carried.length) {
|
||||
return {
|
||||
scheduled:0,
|
||||
left:items.filter(item => todayWork.contains(item)).length,
|
||||
plan_count:currentIds.length,
|
||||
plan_date:loaded?.plan_date,
|
||||
sync_pending:Boolean(loaded?.sync_pending),
|
||||
};
|
||||
}
|
||||
const combinedIds = [...currentIds];
|
||||
for (const item of carried) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (!combinedIds.includes(identity)) combinedIds.push(identity);
|
||||
}
|
||||
if (combinedIds.length > limit) {
|
||||
throw new Error(`Tomorrow can hold ${limit} items. Keep ${combinedIds.length - limit} in Today or edit Tomorrow first.`);
|
||||
}
|
||||
const estimates = {...(loaded?.estimates || {})};
|
||||
const capacityMinutes = loaded?.capacity_minutes ?? null;
|
||||
const plannedMinutes = combinedIds.reduce((total, identity) => total + (Number(estimates[identity]) || 0), 0);
|
||||
if (capacityMinutes !== null && plannedMinutes > capacityMinutes) {
|
||||
throw new Error('Tomorrow is over capacity. Edit the plan before finishing wrap-up.');
|
||||
}
|
||||
const staged = tomorrowPlan.stage({ids:combinedIds, capacity_minutes:capacityMinutes, estimates});
|
||||
if (!staged) throw new Error('Tomorrow could not be saved on this device. Your Today plan is unchanged.');
|
||||
let scheduled = 0;
|
||||
for (const item of carried) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (!selected.has(identity) || !todayWork.contains(item)) continue;
|
||||
const deferred = laterWork.defer(item, wake, {handoff:'today'});
|
||||
if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.');
|
||||
if (!todaySync.enqueue('remove', identity)) {
|
||||
laterWork.restore?.(item);
|
||||
throw new Error('Today sync could not be queued. Your Today plan is unchanged.');
|
||||
throw new Error('Today sync could not be queued. The item remains in Today.');
|
||||
}
|
||||
if (todayWork.contains(item) && !todayWork.remove(item)) {
|
||||
throw new Error('Today could not be updated. Retry wrap-up.');
|
||||
@@ -45,7 +68,13 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
scheduled += 1;
|
||||
}
|
||||
await todaySync.flush();
|
||||
return { scheduled, left:items.length - scheduled, wake_at:wake.toISOString() };
|
||||
return {
|
||||
scheduled,
|
||||
left:items.length - scheduled,
|
||||
plan_count:combinedIds.length,
|
||||
plan_date:staged.plan_date,
|
||||
sync_pending:Boolean(staged.sync_pending),
|
||||
};
|
||||
}
|
||||
|
||||
return { open, choose, snapshot, finish };
|
||||
@@ -53,6 +82,7 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
|
||||
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
|
||||
let actualMinutes = {};
|
||||
let workedItems = [];
|
||||
|
||||
function close() {
|
||||
qs('#today-wrap-up-sheet').hidden = true;
|
||||
@@ -68,15 +98,16 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
|
||||
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
|
||||
'> Tomorrow 09:00</label></div>';
|
||||
'> Carry to Tomorrow</label></div>';
|
||||
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
|
||||
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
|
||||
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
|
||||
});
|
||||
}
|
||||
|
||||
function open(items, recapActualMinutes = {}) {
|
||||
function open(items, recapActualMinutes = {}, recapWorkedItems = []) {
|
||||
actualMinutes = { ...recapActualMinutes };
|
||||
workedItems = recapWorkedItems.map(item => ({...item}));
|
||||
controller.open(items);
|
||||
qs('#today-wrap-up-status').textContent = '';
|
||||
render();
|
||||
@@ -89,10 +120,16 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
|
||||
button.disabled = true;
|
||||
qs('#today-wrap-up-status').textContent = 'Saving tomorrow’s plan…';
|
||||
try {
|
||||
const tomorrowItems = controller.snapshot().filter(row => row.schedule).map(row => ({
|
||||
identity:row.identity,
|
||||
title:String(row.item.title || row.identity).slice(0, 180),
|
||||
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
|
||||
}));
|
||||
const result = await controller.finish();
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' carried to Tomorrow · ' + result.left +
|
||||
' left in Today' + (result.sync_pending ? ' · sync pending.' : '.');
|
||||
close();
|
||||
onComplete(result, actualMinutes);
|
||||
onComplete(result, actualMinutes, workedItems, tomorrowItems);
|
||||
} catch (error) {
|
||||
qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.';
|
||||
} finally {
|
||||
|
||||
@@ -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;
|
||||
@@ -1,21 +1,26 @@
|
||||
function createVoiceConversationCapture({
|
||||
Recognition, elements, transcriptStore = null, getLogin = () => '',
|
||||
createEvent = name => new Event(name, {bubbles:true}),
|
||||
maxLength = 0, draftLabel = '',
|
||||
}) {
|
||||
const supported = typeof Recognition === 'function';
|
||||
const label = String(draftLabel || elements.root.dataset?.draftLabel || 'reply').trim() || 'reply';
|
||||
let recognition = null;
|
||||
let target = '';
|
||||
let generation = 0;
|
||||
elements.root.hidden = !supported;
|
||||
if (!supported) {
|
||||
elements.status.textContent = 'Voice capture is unavailable; type the reply instead.';
|
||||
elements.status.textContent = 'Voice capture is unavailable; type the ' + label + ' instead.';
|
||||
}
|
||||
|
||||
const normalize = value => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
const login = () => String(getLogin() || '').trim();
|
||||
const composerLimit = Number(elements.draft.maxLength);
|
||||
const limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength :
|
||||
Number.isInteger(composerLimit) && composerLimit > 0 ? composerLimit : 10000;
|
||||
|
||||
function showReview(value, recovered = false) {
|
||||
const transcript = normalize(value).slice(0, 10000);
|
||||
const transcript = normalize(value).slice(0, limit);
|
||||
elements.transcript.value = transcript;
|
||||
elements.review.hidden = false;
|
||||
elements.append.textContent = elements.draft.value.trim() ? 'Append to draft' : 'Use transcript';
|
||||
@@ -36,7 +41,7 @@ function createVoiceConversationCapture({
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = '';
|
||||
if (!supported || !target || !transcriptStore || !login()) return;
|
||||
const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, 10000);
|
||||
const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, limit);
|
||||
if (opening === generation && recovered) showReview(recovered, true);
|
||||
}
|
||||
|
||||
@@ -45,13 +50,13 @@ function createVoiceConversationCapture({
|
||||
}
|
||||
|
||||
function commit(replace) {
|
||||
const transcript = normalize(elements.transcript.value).slice(0, 10000);
|
||||
const transcript = normalize(elements.transcript.value).slice(0, limit);
|
||||
elements.draft.value = replace ? transcript :
|
||||
[elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, 10000);
|
||||
[elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, limit);
|
||||
elements.draft.dispatchEvent(createEvent('input'));
|
||||
clearCheckpoint();
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = 'Transcript added. Review the reply before sending.';
|
||||
elements.status.textContent = 'Transcript added. Review the ' + label + ' before sending.';
|
||||
}
|
||||
|
||||
elements.start.addEventListener('click', () => {
|
||||
@@ -121,4 +126,20 @@ function createVoiceConversationCapture({
|
||||
return {supported, open, cancel};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture};
|
||||
function mountVoiceConversation({
|
||||
kind, draftSelector, qs, Recognition, transcriptStore, getLogin,
|
||||
createCapture = createVoiceConversationCapture,
|
||||
}) {
|
||||
return createCapture({
|
||||
Recognition, transcriptStore, getLogin,
|
||||
elements:{
|
||||
root:qs('#voice-' + kind), start:qs('#start-voice-' + kind),
|
||||
stop:qs('#stop-voice-' + kind), review:qs('#voice-' + kind + '-review'),
|
||||
transcript:qs('#voice-' + kind + '-transcript'), append:qs('#append-voice-' + kind),
|
||||
replace:qs('#replace-with-voice-' + kind), discard:qs('#discard-voice-' + kind),
|
||||
status:qs('#voice-' + kind + '-status'), draft:qs(draftSelector),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture, mountVoiceConversation};
|
||||
@@ -0,0 +1,298 @@
|
||||
function createWeekCalendarImport() {
|
||||
const maxBytes=1024*1024;
|
||||
const zonedInstantFormatters=new Map(),zonePartFormatters=new Map(),supportedTimeZones=new Map();
|
||||
function clock(value) {
|
||||
const match=/^(\d{2}):(\d{2})$/.exec(String(value||''));
|
||||
if(!match)return null;
|
||||
const minutes=Number(match[1])*60+Number(match[2]);
|
||||
return Number(match[1])<24&&Number(match[2])<60?minutes:null;
|
||||
}
|
||||
function instant(value) {
|
||||
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value||'');
|
||||
if(!match)return null;
|
||||
const parts=match.slice(1,7).map(Number);
|
||||
const milliseconds=match[7]?Date.UTC(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]):
|
||||
new Date(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]).getTime();
|
||||
return Number.isFinite(milliseconds)?milliseconds:null;
|
||||
}
|
||||
function zonedInstant(value,timeZone) {
|
||||
if(!timeZone)return instant(value);
|
||||
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/.exec(value||'');
|
||||
if(!match)return null;
|
||||
try {
|
||||
const wanted=match.slice(1).map(Number);
|
||||
if(!zonedInstantFormatters.has(timeZone))zonedInstantFormatters.set(timeZone,new Intl.DateTimeFormat('en-CA',{
|
||||
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23',
|
||||
}));
|
||||
const formatter=zonedInstantFormatters.get(timeZone);
|
||||
let result=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5]);
|
||||
for(let attempt=0;attempt<2;attempt+=1) {
|
||||
const shown=Object.fromEntries(formatter.formatToParts(new Date(result)).map(part=>[part.type,part.value]));
|
||||
const represented=Date.UTC(Number(shown.year),Number(shown.month)-1,Number(shown.day),Number(shown.hour),Number(shown.minute),Number(shown.second));
|
||||
result+=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5])-represented;
|
||||
}
|
||||
return result;
|
||||
} catch(error) { return null; }
|
||||
}
|
||||
function supportedTimeZone(timeZone) {
|
||||
if(!timeZone)return true;
|
||||
if(supportedTimeZones.has(timeZone))return supportedTimeZones.get(timeZone);
|
||||
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);supportedTimeZones.set(timeZone,true);return true;}
|
||||
catch(error) {supportedTimeZones.set(timeZone,false);return false;}
|
||||
}
|
||||
function zoneParts(value,timeZone) {
|
||||
const date=new Date(value);
|
||||
if(!timeZone)return {year:date.getFullYear(),month:date.getMonth()+1,day:date.getDate(),weekday:date.getDay(),
|
||||
hour:date.getHours(),minute:date.getMinutes(),second:date.getSeconds()};
|
||||
if(!zonePartFormatters.has(timeZone))zonePartFormatters.set(timeZone,new Intl.DateTimeFormat('en-US',{
|
||||
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23',
|
||||
}));
|
||||
const formatter=zonePartFormatters.get(timeZone);
|
||||
const parts=Object.fromEntries(formatter.formatToParts(date).map(part=>[part.type,part.value]));
|
||||
return {year:Number(parts.year),month:Number(parts.month),day:Number(parts.day),
|
||||
weekday:['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(parts.weekday),hour:Number(parts.hour),
|
||||
minute:Number(parts.minute),second:Number(parts.second)};
|
||||
}
|
||||
function addCalendarDays(value,days,timeZone) {
|
||||
if(!timeZone) {const date=new Date(value);date.setDate(date.getDate()+days);return date.getTime();}
|
||||
const parts=zoneParts(value,timeZone),day=new Date(Date.UTC(parts.year,parts.month-1,parts.day+days));
|
||||
const wall=[day.getUTCFullYear(),String(day.getUTCMonth()+1).padStart(2,'0'),String(day.getUTCDate()).padStart(2,'0')].join('')+
|
||||
'T'+[parts.hour,parts.minute,parts.second].map(part=>String(part).padStart(2,'0')).join('');
|
||||
return zonedInstant(wall,timeZone);
|
||||
}
|
||||
function dateInstant(value) {
|
||||
const match=/^(\d{4})(\d{2})(\d{2})$/.exec(value||'');
|
||||
return match?new Date(Number(match[1]),Number(match[2])-1,Number(match[3])).getTime():null;
|
||||
}
|
||||
function events(source) {
|
||||
if(typeof source!=='string'||!source.includes('BEGIN:VCALENDAR'))throw new Error('Choose a valid .ics calendar file.');
|
||||
if(new TextEncoder().encode(source).length>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
|
||||
const lines=source.replace(/\r\n[ \t]/g,'').split(/\r?\n/);
|
||||
const result=[];let current=null;
|
||||
lines.forEach(line=>{
|
||||
if(line==='BEGIN:VEVENT'){current={};return;}
|
||||
if(line==='END:VEVENT'){
|
||||
if((current?.unsupportedTimezone||(current?.start!=null&¤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,needsReflow=false,reflowPreview=null;
|
||||
const root=()=>qs('#week-capacity-import');
|
||||
function clear() {
|
||||
reviewed=null;needsReflow=false;reflowPreview=null;
|
||||
qs('#week-capacity-file').value='';
|
||||
qs('#week-capacity-review').hidden=true;
|
||||
qs('#week-capacity-days').innerHTML='';
|
||||
qs('#apply-week-capacities').disabled=false;
|
||||
qs('#apply-week-capacities').textContent='Apply seven capacities';
|
||||
}
|
||||
function open() {
|
||||
clear();root().hidden=false;qs('#week-capacity-status').textContent='';
|
||||
qs('#week-capacity-file').focus?.();return true;
|
||||
}
|
||||
function cancel() {clear();root().hidden=true;return true;}
|
||||
function reviewSource(source) {
|
||||
const display=controller.dates();
|
||||
reviewed=review(source,{dates:display.map(item=>item.date),workdayStart:qs('#week-capacity-start').value,
|
||||
workdayEnd:qs('#week-capacity-end').value});
|
||||
const current=controller.review?.().days||[];
|
||||
needsReflow=reviewed.some((day,index)=>Number(current[index]?.planned_minutes)>day.capacity_minutes);
|
||||
const previewValues=reviewed.map(day=>({plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
free_windows:day.free_windows.map(window=>({...window}))}));
|
||||
reflowPreview=needsReflow?controller.previewCapacityReflow?.(previewValues):null;
|
||||
const destinations=new Map();
|
||||
(reflowPreview?.days||[]).forEach(day=>(day.ids||[]).forEach(id=>destinations.set(id,day.plan_date)));
|
||||
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>{
|
||||
const previous=current[index]||{},planned=Number(previous.planned_minutes)||0,over=Math.max(0,planned-day.capacity_minutes);
|
||||
const moved=[...new Set((previous.ids||[]).map(id=>destinations.get(id)).filter(date=>date&&date!==day.plan_date))]
|
||||
.map(date=>display.find(item=>item.date===date)?.label||date);
|
||||
return '<article class="week-capacity-day'+(over?' is-overloaded':'')+'"><strong>'+display[index].label+'</strong><span>'+
|
||||
(Number.isInteger(previous.capacity_minutes)?previous.capacity_minutes+' → ':'')+day.capacity_minutes+' min available</span><small>'+
|
||||
planned+' min planned · '+day.busy_minutes+' min busy</small>'+(over?'<small class="week-capacity-over">'+over+
|
||||
' min over refreshed capacity</small>':'')+(moved.length?'<small>Moves to '+moved.join(', ')+'</small>':'')+'</article>';
|
||||
}).join('');
|
||||
qs('#week-capacity-review').hidden=false;
|
||||
const unsupported=reviewed.unsupported_count||0;
|
||||
const reflowBlocked=Boolean(reflowPreview?.blockers?.length);
|
||||
qs('#apply-week-capacities').disabled=unsupported>0||reflowBlocked;
|
||||
qs('#apply-week-capacities').textContent=needsReflow?'Apply capacities & reflow':'Apply seven capacities';
|
||||
const kind=reviewed.unsupported_timezone_count?'calendar event':'recurring event';
|
||||
qs('#week-capacity-status').textContent=unsupported?
|
||||
unsupported+' '+kind+(unsupported===1?'':'s')+' could not be counted. Apply is unavailable; export a simpler seven-day calendar and try again.':
|
||||
(reflowBlocked?'Reflow needs an estimate for every planned item before refreshed capacity can be applied.':
|
||||
(reflowPreview?.unscheduled?.length?reflowPreview.unscheduled.length+' planned item'+
|
||||
(reflowPreview.unscheduled.length===1?'':'s')+' will return to My Work. Review before applying.':
|
||||
'Review seven capacity totals. Calendar details stay on this device.'));
|
||||
return reviewed.map(day=>({...day}));
|
||||
}
|
||||
async function apply() {
|
||||
if(!reviewed||reviewed.unsupported_count)return false;
|
||||
const keep=Boolean(qs('#keep-week-free-times')?.checked);
|
||||
const values=reviewed.map(day=>({
|
||||
plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
...(keep?{free_windows:day.free_windows.map(window=>({...window}))}:{}),
|
||||
}));
|
||||
const staged=needsReflow?controller.applyCapacityReflow?.(values):controller.stageCapacities(values);
|
||||
if(!staged)return false;
|
||||
qs('#apply-week-capacities').disabled=true;
|
||||
try {await controller.flush();activeAvailability=reviewed.map(day=>({plan_date:day.plan_date,
|
||||
free_windows:day.free_windows.map(window=>({...window}))}));cancel();onApplied();return true;}
|
||||
finally {qs('#apply-week-capacities').disabled=false;}
|
||||
}
|
||||
qs('#week-capacity-file')?.addEventListener('change',async event=>{
|
||||
const file=event.currentTarget.files?.[0];if(!file)return;
|
||||
try {
|
||||
if(file.size>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
|
||||
reviewSource(await file.text());
|
||||
} catch(error) {clear();qs('#week-capacity-status').textContent=error.message||'Calendar could not be read.';}
|
||||
});
|
||||
qs('#cancel-week-capacity-import')?.addEventListener('click',cancel);
|
||||
qs('#apply-week-capacities')?.addEventListener('click',()=>apply().catch(error=>{
|
||||
qs('#week-capacity-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Capacities remain saved on this phone.';
|
||||
}));
|
||||
function availability() {
|
||||
const saved=(controller.state?.().days||[]).filter(day=>Array.isArray(day.free_windows));
|
||||
const source=activeAvailability||saved;
|
||||
return source.length?source.map(day=>({plan_date:day.plan_date,
|
||||
free_windows:day.free_windows.map(window=>({...window}))})):null;
|
||||
}
|
||||
return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null,
|
||||
availability};
|
||||
}
|
||||
function mount(controller,weekWorkflow,qs) {
|
||||
const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview});
|
||||
StackchainWeekCalendar.setAvailabilityProvider(workflow.availability);
|
||||
qs('#open-week-capacity-import').addEventListener('click',workflow.open);
|
||||
return workflow;
|
||||
}
|
||||
return {review,createWorkflow,mount};
|
||||
}
|
||||
const weekCalendarImport=createWeekCalendarImport();
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=weekCalendarImport;
|
||||
@@ -0,0 +1,161 @@
|
||||
(function(root){
|
||||
'use strict';
|
||||
let availabilityProvider=()=>null;
|
||||
function escapeText(value){
|
||||
return String(value||'').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/,/g,'\\,').replace(/;/g,'\\;');
|
||||
}
|
||||
function compact(value){return String(value||'').replace(/[-:]/g,'');}
|
||||
function addMinutes(value,minutes){
|
||||
const [hours,mins]=String(value).split(':').map(Number),total=hours*60+mins+Number(minutes);
|
||||
return String(Math.floor(total/60)%24).padStart(2,'0')+':'+String(total%60).padStart(2,'0');
|
||||
}
|
||||
function clockMinutes(value){const [hours,minutes]=String(value).split(':').map(Number);return hours*60+minutes;}
|
||||
function stableId(value){return String(value||'work').replace(/[^a-z0-9]+/gi,'-').replace(/^-|-$/g,'').toLowerCase();}
|
||||
function foldLine(line){
|
||||
const encoder=new TextEncoder(),chunks=[];let chunk='',bytes=0,limit=75;
|
||||
for(const character of String(line)){
|
||||
const width=encoder.encode(character).length;
|
||||
if(chunk&&bytes+width>limit){chunks.push(chunk);chunk=character;bytes=width;limit=74;}
|
||||
else {chunk+=character;bytes+=width;}
|
||||
}
|
||||
chunks.push(chunk);return chunks.join('\r\n ');
|
||||
}
|
||||
function buildWeekSchedule(plan,startTimes,getItem,selected,availability){
|
||||
const blocks=[],blockers=[],availableByDate=new Map((availability||[]).map(day=>[day.plan_date,day.free_windows||[]]));
|
||||
(plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{
|
||||
let cursor=startTimes?.[day.plan_date]||'09:00';
|
||||
const windows=availableByDate.has(day.plan_date)?availableByDate.get(day.plan_date):(day.free_windows||null);
|
||||
const candidates=[];
|
||||
(day.ids||[]).forEach(id=>{
|
||||
if(selected&&!selected.has(id))return;
|
||||
const minutes=Number(day.estimates?.[id]),item=getItem?.(id);
|
||||
if(!item||!Number.isFinite(minutes)||minutes<=0)return;
|
||||
const exact=startTimes?.[id]||day.start_times?.[id];
|
||||
let start=exact||cursor;
|
||||
if(!exact&&windows){
|
||||
const fit=windows.find(window=>{
|
||||
const candidate=Math.max(clockMinutes(cursor),clockMinutes(window.start_time));
|
||||
if(candidate+minutes>clockMinutes(window.end_time))return false;
|
||||
start=addMinutes('00:00',candidate);return true;
|
||||
});
|
||||
if(!fit){blockers.push({id,title:item.title||'Untitled work',plan_date:day.plan_date,minutes});return;}
|
||||
}
|
||||
const startMinute=clockMinutes(start),endMinute=startMinute+minutes;
|
||||
candidates.push({item,id,minutes,start,startMinute,endMinute,exact:Boolean(exact)});
|
||||
if(!exact)cursor=addMinutes('00:00',endMinute);
|
||||
});
|
||||
const invalid=new Set();
|
||||
candidates.forEach(candidate=>{
|
||||
if(candidate.endMinute>1440||windows&&!windows.some(window=>clockMinutes(window.start_time)<=candidate.startMinute&&candidate.endMinute<=clockMinutes(window.end_time)))invalid.add(candidate.id);
|
||||
});
|
||||
candidates.forEach((left,index)=>candidates.slice(index+1).forEach(right=>{
|
||||
if(left.startMinute<right.endMinute&&left.endMinute>right.startMinute){invalid.add(left.id);invalid.add(right.id);}
|
||||
}));
|
||||
candidates.forEach(candidate=>{
|
||||
if(invalid.has(candidate.id)){
|
||||
blockers.push({id:candidate.id,title:candidate.item.title||'Untitled work',plan_date:day.plan_date,minutes:candidate.minutes,reason:'invalid-exact-time'});return;
|
||||
}
|
||||
blocks.push({...candidate.item,id:candidate.id,plan_date:day.plan_date,start_time:candidate.start,
|
||||
end_time:addMinutes('00:00',candidate.endMinute),minutes:candidate.minutes});
|
||||
});
|
||||
});
|
||||
return {blocks,blockers};
|
||||
}
|
||||
function buildWeekBlocks(plan,startTimes,getItem,selected,availability){
|
||||
return buildWeekSchedule(plan,startTimes,getItem,selected,availability).blocks;
|
||||
}
|
||||
function serializeWeekCalendar(blocks,{timezone='UTC',revision=0,generatedAt}={}){
|
||||
const stamp=generatedAt||new Date().toISOString().replace(/[-:]/g,'').replace(/\.\d{3}/,'');
|
||||
const lines=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Stackchain//Week Ahead//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','X-WR-CALNAME:Stackchain Week Ahead',`X-WR-TIMEZONE:${timezone}`,'BEGIN:VTIMEZONE',`TZID:${timezone}`,'END:VTIMEZONE'];
|
||||
(blocks||[]).forEach(block=>{
|
||||
const day=compact(block.plan_date),reference=block.repository+(block.number!=null?' #'+block.number:'');
|
||||
lines.push('BEGIN:VEVENT',`UID:week-${stableId(block.id)}@stackchain`,`SEQUENCE:${Math.max(0,Math.floor(Number(revision)||0))}`,`DTSTAMP:${stamp}`,
|
||||
`DTSTART;TZID=${timezone}:${day}T${compact(block.start_time)}00`,`DTEND;TZID=${timezone}:${day}T${compact(block.end_time)}00`,
|
||||
`SUMMARY:${escapeText(block.title||'Untitled work')}`,`DESCRIPTION:${escapeText(reference+' · Stackchain Week Ahead')}`,
|
||||
`URL:${String(block.url||'')}`,'TRANSP:OPAQUE','END:VEVENT');
|
||||
});
|
||||
lines.push('END:VCALENDAR');return lines.map(foldLine).join('\r\n')+'\r\n';
|
||||
}
|
||||
async function deliverWeekCalendar({text,filename,navigator,document,urlApi,FileCtor}){
|
||||
const file=new FileCtor([text],filename,{type:'text/calendar;charset=utf-8'});
|
||||
const payload={files:[file],title:'Stackchain Week Ahead',text:'Timed Week Ahead calendar blocks'};
|
||||
if(typeof navigator?.share==='function'&&typeof navigator?.canShare==='function'&&navigator.canShare(payload)){
|
||||
await navigator.share(payload);return 'shared';
|
||||
}
|
||||
const href=urlApi.createObjectURL(file);
|
||||
try{const anchor=document.createElement('a');anchor.href=href;anchor.download=filename;anchor.click();}
|
||||
finally{urlApi.revokeObjectURL(href);}
|
||||
return 'downloaded';
|
||||
}
|
||||
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onSave,onDone,windowObject=root,navigatorObject=root.navigator,
|
||||
documentObject=root.document,urlApi=root.URL,FileCtor=root.File}={}){
|
||||
const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days');
|
||||
let plan=null,focusReturn=null;
|
||||
const selected=()=>new Set(Array.from(daysRoot.querySelectorAll('[data-week-calendar-item]')).filter(input=>input.checked).map(input=>input.value));
|
||||
const starts=()=>Object.fromEntries([
|
||||
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]),
|
||||
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-exact]')).filter(input=>input.value).map(input=>[input.dataset.weekCalendarExact,input.value]),
|
||||
]);
|
||||
const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.());
|
||||
const blocks=()=>schedule().blocks;
|
||||
function update(){
|
||||
const current=schedule(),byId=new Map(current.blocks.map(block=>[block.id,block])),blockedById=new Map(current.blockers.map(blocker=>[blocker.id,blocker]));
|
||||
daysRoot.querySelectorAll('[data-week-calendar-preview]').forEach(node=>{
|
||||
const id=node.dataset.weekCalendarPreview,block=byId.get(id),blocker=blockedById.get(id);
|
||||
node.textContent=block?block.start_time+'–'+block.end_time+' · '+block.minutes+' min':
|
||||
(blocker?'Does not fit imported free time':'Excluded from calendar');
|
||||
});
|
||||
qs('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0;
|
||||
qs('#save-week-calendar-times').disabled=!current.blocks.length||current.blockers.length>0;
|
||||
if(current.blockers.length){
|
||||
const blocker=current.blockers[0];
|
||||
qs('#week-calendar-status').textContent=blocker.title+' does not fit free time on '+blocker.plan_date+'. Adjust the start, estimate, or calendar import.';
|
||||
}else qs('#week-calendar-status').textContent=(getAvailability?.()?'Planning around imported busy time · ':'Manual timing · ')+
|
||||
current.blocks.length+' calendar block'+(current.blocks.length===1?'':'s')+' selected.';
|
||||
}
|
||||
function close({back=false}={}){
|
||||
handoff.hidden=true;
|
||||
if(back){review.hidden=false;const target=focusReturn||qs('#confirm-week-plan');focusReturn=null;target.focus?.();}
|
||||
}
|
||||
function open(value,{returnFocus=null}={}){
|
||||
plan=value;if(returnFocus)focusReturn=returnFocus;review.hidden=true;handoff.hidden=false;
|
||||
const labels=new Map((value.days||[]).map(day=>[day.plan_date,new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(new Date(day.plan_date+'T12:00:00Z'))]));
|
||||
daysRoot.innerHTML=(value.days||[]).filter(day=>day.ids?.length).map(day=>'<article class="week-calendar-day"><header><h3>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
|
||||
'</h3><label>Start <input type="time" value="09:00" data-week-calendar-start="'+escapeAttribute(day.plan_date)+'"></label></header>'+
|
||||
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96),saved=day.start_times?.[id]||'';
|
||||
return '<div class="week-calendar-item"><label class="week-calendar-choice"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
|
||||
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label><label class="week-calendar-time">Start<input type="time" value="'+escapeAttribute(saved)+'" data-week-calendar-exact="'+escapeAttribute(id)+'" aria-label="Exact start for '+escapeAttribute(item?.title||'work')+'"></label></div>';}).join('')+'</article>').join('');
|
||||
daysRoot.querySelectorAll('input').forEach(input=>input.addEventListener('change',update));
|
||||
update();qs('#back-to-week-review-from-calendar').focus?.();return true;
|
||||
}
|
||||
qs('#back-to-week-review-from-calendar').addEventListener('click',()=>close({back:true}));
|
||||
qs('#save-week-calendar-times').addEventListener('click',async()=>{
|
||||
const button=qs('#save-week-calendar-times'),current=schedule();if(!current.blocks.length||current.blockers.length)return;
|
||||
button.disabled=true;qs('#week-calendar-status').textContent='Saving exact task times…';
|
||||
const byDate={};current.blocks.forEach(block=>(byDate[block.plan_date]||(byDate[block.plan_date]={}))[block.id]=block.start_time);
|
||||
try{await onSave?.(byDate);plan={...plan,days:(plan.days||[]).map(day=>({...day,start_times:{...(byDate[day.plan_date]||{})}}))};
|
||||
qs('#week-calendar-status').textContent='Exact task times saved to Week Ahead.';}
|
||||
catch(error){qs('#week-calendar-status').textContent=(error?.message||'Exact times could not be saved.')+' Retry when connected.';}
|
||||
finally{button.disabled=false;}
|
||||
});
|
||||
qs('#export-saved-week-calendar')?.addEventListener('click',event=>{focusReturn=event.currentTarget;qs('#confirm-week-plan').click();});
|
||||
qs('#share-week-calendar').addEventListener('click',async()=>{
|
||||
const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;
|
||||
button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…';
|
||||
const day=new Date().toISOString().slice(0,10);
|
||||
try{
|
||||
const text=serializeWeekCalendar(current,{timezone:plan.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone,revision:plan.revision});
|
||||
const result=await deliverWeekCalendar({text,filename:'stackchain-week-ahead-'+day+'.ics',navigator:navigatorObject,
|
||||
document:documentObject,urlApi,FileCtor});
|
||||
close();onDone?.(result);
|
||||
}catch(error){
|
||||
qs('#week-calendar-status').textContent=error?.name==='AbortError'?'Share cancelled. Your Week Ahead is still ready.':'Calendar export failed. Retry without leaving Week Ahead.';
|
||||
button.disabled=false;
|
||||
}
|
||||
});
|
||||
return {open,close,blocks};
|
||||
}
|
||||
function setAvailabilityProvider(value){availabilityProvider=typeof value==='function'?value:()=>null;}
|
||||
const api={buildWeekBlocks,buildWeekSchedule,deliverWeekCalendar,escapeText,foldLine,mountWeekCalendarHandoff,serializeWeekCalendar,setAvailabilityProvider};
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.StackchainWeekCalendar=api;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,41 +1,77 @@
|
||||
async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader }) {
|
||||
async function loadWorkspace({
|
||||
document,
|
||||
window = null,
|
||||
createLoader = createFeatureLoader,
|
||||
schedule = callback => setTimeout(callback,750),
|
||||
}) {
|
||||
let cameOnline = false;
|
||||
let replayed = false;
|
||||
let retryInFlight = null;
|
||||
const captureOnline = () => { cameOnline = true; };
|
||||
window?.addEventListener('online', captureOnline);
|
||||
const status = document.querySelector('#my-work-action-status');
|
||||
const url = document.querySelector(
|
||||
'meta[name="stackchain-feature-today-timer"]'
|
||||
)?.content || '';
|
||||
const loader = createLoader({
|
||||
document,
|
||||
urls: { 'today-timer': url },
|
||||
});
|
||||
if (status) status.textContent = 'Starting workspace…';
|
||||
try {
|
||||
await loader.load('today-timer');
|
||||
if (status) status.textContent = '';
|
||||
return {
|
||||
replayOnline(callback) {
|
||||
if (replayed) return;
|
||||
replayed = true;
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (cameOnline) callback();
|
||||
},
|
||||
const retryButton = document.querySelector('#retry-workspace');
|
||||
const urls = Object.fromEntries(['today-timer', 'planning'].map(name => [name,
|
||||
document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || ''
|
||||
]));
|
||||
const originalUrls = {...urls};
|
||||
const loader = createLoader({document, urls});
|
||||
|
||||
let attempts = 0;
|
||||
const load = () => {
|
||||
if (attempts++) Object.keys(urls).forEach(name => {
|
||||
urls[name] = originalUrls[name] + '?retry=' + attempts;
|
||||
});
|
||||
return Promise.all(Object.keys(urls).map(name => loader.load(name)));
|
||||
};
|
||||
const waitForRecovery = () => new Promise(resolve => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) {
|
||||
retryButton.hidden = retryButton.disabled = false;
|
||||
}
|
||||
|
||||
const recover = () => {
|
||||
if (retryInFlight) return retryInFlight;
|
||||
if (retryButton) retryButton.disabled = true;
|
||||
retryInFlight = load().then(() => {
|
||||
window?.removeEventListener('online', recover);
|
||||
resolve();
|
||||
}).catch(() => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) retryButton.disabled = false;
|
||||
}).finally(() => {
|
||||
retryInFlight = null;
|
||||
});
|
||||
return retryInFlight;
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (window?.location?.reload) {
|
||||
if (cameOnline) window.location.reload();
|
||||
else window.addEventListener('online', () => window.location.reload(), { once: true });
|
||||
window?.addEventListener('online', recover);
|
||||
retryButton?.addEventListener('click', recover);
|
||||
});
|
||||
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await new Promise(resolve => schedule(resolve));
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await waitForRecovery();
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = window
|
||||
? 'Workspace could not load. Reconnect to retry automatically, or reload now.'
|
||||
: 'Workspace could not load. Check your connection, then reload to retry.';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (retryButton) retryButton.hidden = true;
|
||||
if (status) status.textContent = '';
|
||||
return {
|
||||
replayOnline(callback) {
|
||||
if (replayed) return;
|
||||
replayed = true;
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (cameOnline) callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
@@ -1,4 +1,5 @@
|
||||
fastapi==0.133.1
|
||||
cryptography==50.0.0
|
||||
httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
Pillow==12.3.0
|
||||
|
||||
+80
-29
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a reproducible, verifiable Stackchain Dashboard release bundle."""
|
||||
"""Build a reproducible release bundle from an exact Git commit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,37 +8,93 @@ import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
|
||||
RUNTIME_FILES = ("README.md", "requirements.txt")
|
||||
RUNTIME_FILES = (
|
||||
"README.md",
|
||||
"requirements.txt",
|
||||
"scripts/rotate_private_state.py",
|
||||
"scripts/rotate_unfiled_drafts.py",
|
||||
)
|
||||
EXCLUDED_PARTS = {"__pycache__", ".pytest_cache"}
|
||||
EXCLUDED_SUFFIXES = (".pyc", ".pyo")
|
||||
COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeFile:
|
||||
name: str
|
||||
data: bytes
|
||||
executable: bool
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def runtime_files(root: Path) -> list[Path]:
|
||||
files = [root / name for name in RUNTIME_FILES if (root / name).is_file()]
|
||||
for name in RUNTIME_DIRECTORIES:
|
||||
directory = root / name
|
||||
if directory.is_dir():
|
||||
files.extend(path for path in directory.rglob("*") if path.is_file())
|
||||
return sorted(files, key=lambda path: path.relative_to(root).as_posix())
|
||||
def _git(root: Path, *args: str, text: bool = False) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(root), *args],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=text,
|
||||
)
|
||||
|
||||
|
||||
def manifest_for(root: Path, files: list[Path], commit: str) -> dict:
|
||||
def runtime_files_at_commit(root: Path, commit: str) -> list[RuntimeFile]:
|
||||
"""Read the runtime allowlist directly from the declared Git tree."""
|
||||
root = root.resolve()
|
||||
if not COMMIT_PATTERN.fullmatch(commit):
|
||||
raise ValueError("declared commit must be a full 40-character Git object ID")
|
||||
resolved = _git(root, "rev-parse", "--verify", f"{commit}^{{commit}}", text=True)
|
||||
if resolved.returncode != 0 or resolved.stdout.strip() != commit:
|
||||
raise ValueError("declared commit is unavailable")
|
||||
|
||||
listing = _git(root, "ls-tree", "-rz", "--full-tree", commit)
|
||||
if listing.returncode != 0:
|
||||
raise ValueError("could not inspect the declared commit")
|
||||
|
||||
files: list[RuntimeFile] = []
|
||||
for record in listing.stdout.split(b"\0"):
|
||||
if not record:
|
||||
continue
|
||||
metadata, raw_name = record.split(b"\t", 1)
|
||||
mode, object_type, object_id = metadata.decode("ascii").split()
|
||||
name = raw_name.decode("utf-8")
|
||||
path = PurePosixPath(name)
|
||||
selected = name in RUNTIME_FILES or (path.parts and path.parts[0] in RUNTIME_DIRECTORIES)
|
||||
if not selected or object_type != "blob":
|
||||
continue
|
||||
if mode not in {"100644", "100755"}:
|
||||
raise ValueError(f"release member is not a regular file: {name}")
|
||||
if EXCLUDED_PARTS.intersection(path.parts) or name.endswith(EXCLUDED_SUFFIXES):
|
||||
raise ValueError(f"declared commit contains generated release member: {name}")
|
||||
blob = _git(root, "cat-file", "blob", object_id)
|
||||
if blob.returncode != 0:
|
||||
raise ValueError(f"could not read release member: {name}")
|
||||
files.append(RuntimeFile(name, blob.stdout, mode == "100755"))
|
||||
|
||||
names = {item.name for item in files}
|
||||
missing = [name for name in RUNTIME_FILES if name not in names]
|
||||
if missing or not any(name.startswith("src/") for name in names) or not any(
|
||||
name.startswith("frontend/") for name in names
|
||||
):
|
||||
raise ValueError("declared commit is missing required runtime files")
|
||||
return sorted(files, key=lambda item: item.name)
|
||||
|
||||
|
||||
def manifest_for(files: list[RuntimeFile], commit: str) -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"commit": commit,
|
||||
"files": {
|
||||
path.relative_to(root).as_posix(): {
|
||||
"sha256": sha256(path.read_bytes()),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
for path in files
|
||||
item.name: {"sha256": sha256(item.data), "size": len(item.data)} for item in files
|
||||
},
|
||||
}
|
||||
|
||||
@@ -58,19 +114,10 @@ def tar_info(name: str, size: int, epoch: int, executable: bool = False) -> tarf
|
||||
|
||||
|
||||
def build(root: Path, output_dir: Path, commit: str, epoch: int) -> tuple[Path, Path, Path]:
|
||||
root = root.resolve()
|
||||
files = runtime_files(root)
|
||||
missing = [name for name in RUNTIME_FILES if not (root / name).is_file()]
|
||||
if missing or not (root / "src").is_dir() or not (root / "frontend").is_dir():
|
||||
raise ValueError("release root is missing required runtime files")
|
||||
|
||||
embedded_manifest = manifest_for(root, files, commit)
|
||||
files = runtime_files_at_commit(root, commit)
|
||||
embedded_manifest = manifest_for(files, commit)
|
||||
archive_members: dict[str, tuple[bytes, bool]] = {
|
||||
path.relative_to(root).as_posix(): (
|
||||
path.read_bytes(),
|
||||
bool(path.stat().st_mode & 0o111),
|
||||
)
|
||||
for path in files
|
||||
item.name: (item.data, item.executable) for item in files
|
||||
}
|
||||
archive_members["release-manifest.json"] = (json_bytes(embedded_manifest), False)
|
||||
|
||||
@@ -112,7 +159,11 @@ def parse_args() -> argparse.Namespace:
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
for path in build(args.root, args.output_dir, args.commit, args.source_date_epoch):
|
||||
try:
|
||||
paths = build(args.root, args.output_dir, args.commit, args.source_date_epoch)
|
||||
except (OSError, ValueError) as error:
|
||||
raise SystemExit(f"release build failed: {error}") from error
|
||||
for path in paths:
|
||||
print(path)
|
||||
|
||||
|
||||
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rewrap shared private-state envelopes under the configured active key."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.private_state import connect_private_sqlite # noqa: E402
|
||||
from src.state_encryption import ( # noqa: E402
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Field:
|
||||
column: str
|
||||
binding: str
|
||||
alias: str | None = None
|
||||
plaintext_string: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Table:
|
||||
name: str
|
||||
context_columns: tuple[str, ...]
|
||||
fields: tuple[Field, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Store:
|
||||
name: str
|
||||
identity: str
|
||||
env: str
|
||||
filename: str
|
||||
tables: tuple[Table, ...]
|
||||
|
||||
|
||||
STORES = (
|
||||
Store("today", "today", "STACKCHAIN_TODAY_DB", "today.sqlite3", (
|
||||
Table("today_plans", ("login",), (Field("ids", "plan:{login}"),)),
|
||||
Table("tomorrow_plans", ("login",), (Field("payload", "tomorrow:{login}"),)),
|
||||
Table("tomorrow_promotions", ("login", "promotion_id"), (Field("result", "tomorrow-promotion:{login}:{promotion_id}"),)),
|
||||
Table("week_plans", ("login",), (Field("payload", "week:{login}"),)),
|
||||
Table("week_promotions", ("login", "promotion_id"), (Field("result", "week-promotion:{login}:{promotion_id}"),)),
|
||||
Table("week_reschedules", ("login", "operation_id"), (Field("result", "week-reschedule:{login}:{operation_id}"),)),
|
||||
Table("today_sessions", ("login",), (Field("device_id", "session:{login}"),)),
|
||||
Table("today_recaps", ("login",), (
|
||||
Field("session_id", "recap-id:{login}", "session_id", True),
|
||||
Field("items", "recap-items:{login}:{session_id}"),
|
||||
)),
|
||||
Table("today_time_logs", ("login",), (
|
||||
Field("session_id", "time-log-session:{login}", "session_id", True),
|
||||
Field("identity", "time-log-identity:{login}:{session_id}", "identity", True),
|
||||
Field("actual_minutes", "time-log-payload:{login}:{session_id}:{identity}"),
|
||||
)),
|
||||
)),
|
||||
Store("later", "later", "STACKCHAIN_LATER_DB", "later.sqlite3", (
|
||||
Table("later_plans", ("login",), (Field("records", "plan:{login}"),)),
|
||||
Table("later_item_revisions", ("login",), (Field("item_id", "item-revision:{login}", plaintext_string=True),)),
|
||||
)),
|
||||
Store("live-snapshot", "live-snapshot", "STACKCHAIN_LIVE_SNAPSHOT_DB", "live-snapshot.sqlite3", (
|
||||
Table("live_snapshot", ("generation",), (Field("value_json", "{generation}"),)),
|
||||
)),
|
||||
Store("available-issue-snapshot", "available-issue-snapshot", "STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB", "available-issue-snapshot.sqlite3", (
|
||||
Table("available_issue_snapshot", (), (Field("items_json", "singleton"),)),
|
||||
)),
|
||||
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
|
||||
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
|
||||
)),
|
||||
Store(
|
||||
"completed-filed-reviews",
|
||||
"completed-filed-reviews",
|
||||
"STACKCHAIN_COMPLETED_FILED_REVIEW_DB",
|
||||
"completed-filed-reviews.sqlite3",
|
||||
(
|
||||
Table(
|
||||
"completed_filed_review_collections",
|
||||
("login",),
|
||||
(Field("receipts", "receipts:{login}"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
Store("security-events", "security-events", "STACKCHAIN_SECURITY_EVENT_DB", "security-events.sqlite3", (
|
||||
Table("security_events", ("id",), (Field("payload", "event:{id}"),)),
|
||||
)),
|
||||
Store("idempotency-ledger", "idempotency-ledger", "STACKCHAIN_IDEMPOTENCY_DB", "idempotency.sqlite3", (
|
||||
Table("idempotency_operations", ("key",), (
|
||||
Field("fingerprint", "{key}:fingerprint"),
|
||||
Field("response_json", "{key}:response"),
|
||||
)),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
def _tables(connection: sqlite3.Connection) -> set[str]:
|
||||
return {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def rotate_store(path: Path, spec: Store, config) -> dict[str, int]:
|
||||
counts = {"current": 0, "failed": 0, "migrated": 0, "total": 0}
|
||||
if not path.exists():
|
||||
return counts
|
||||
cipher = PrivateStateCipher(config, store=spec.identity)
|
||||
with connect_private_sqlite(path, timeout=1.0) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
available = _tables(connection)
|
||||
for table in spec.tables:
|
||||
if table.name not in available:
|
||||
continue
|
||||
columns = (*table.context_columns, *(field.column for field in table.fields))
|
||||
query = f"SELECT rowid AS _rotation_rowid, {', '.join(columns)} FROM {table.name}"
|
||||
for row in connection.execute(query).fetchall():
|
||||
context = {name: row[name] for name in table.context_columns}
|
||||
updates: dict[str, str] = {}
|
||||
row_failed = False
|
||||
row_counts = {"current": 0, "migrated": 0, "total": 0}
|
||||
for field in table.fields:
|
||||
payload = row[field.column]
|
||||
if payload is None:
|
||||
continue
|
||||
row_counts["total"] += 1
|
||||
try:
|
||||
binding = field.binding.format(**context)
|
||||
if isinstance(payload, str) and not payload.startswith(("v1:", "v2:")) and field.plaintext_string:
|
||||
value, stale = payload, True
|
||||
else:
|
||||
value, stale = cipher.open(str(payload), binding=binding)
|
||||
if field.alias:
|
||||
context[field.alias] = value
|
||||
if stale:
|
||||
updates[field.column] = cipher.seal(value, binding=binding)
|
||||
row_counts["migrated"] += 1
|
||||
else:
|
||||
row_counts["current"] += 1
|
||||
except (KeyError, PrivateStateEncryptionError, ValueError):
|
||||
row_failed = True
|
||||
break
|
||||
counts["total"] += row_counts["total"]
|
||||
if row_failed:
|
||||
counts["failed"] += 1
|
||||
continue
|
||||
if updates:
|
||||
assignments = ", ".join(f"{name} = ?" for name in updates)
|
||||
connection.execute(
|
||||
f"UPDATE {table.name} SET {assignments} WHERE rowid = ?",
|
||||
(*updates.values(), row["_rotation_rowid"]),
|
||||
)
|
||||
counts["migrated"] += row_counts["migrated"]
|
||||
counts["current"] += row_counts["current"]
|
||||
return counts
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
config = private_state_encryption_config()
|
||||
if isinstance(config, bytes):
|
||||
raise PrivateStateEncryptionError("rotation requires a keyring")
|
||||
state = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
|
||||
report = {
|
||||
spec.name: rotate_store(
|
||||
Path(os.getenv(spec.env, str(state / spec.filename))), spec, config
|
||||
)
|
||||
for spec in STORES
|
||||
}
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
print(json.dumps({"error": "Private-state rotation configuration is unavailable"}, sort_keys=True))
|
||||
return 2
|
||||
print(json.dumps(report, sort_keys=True))
|
||||
return 1 if any(item["failed"] for item in report.values()) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rewrap synchronized Draft rows under the configured active encryption key."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.unfiled_draft_store import ( # noqa: E402
|
||||
UnfiledDraftEncryptionError,
|
||||
UnfiledDraftStore,
|
||||
decode_unfiled_draft_encryption_keyring,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
keys, active = decode_unfiled_draft_encryption_keyring(
|
||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS", ""),
|
||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID", ""),
|
||||
)
|
||||
store = UnfiledDraftStore(
|
||||
os.getenv(
|
||||
"STACKCHAIN_UNFILED_DRAFT_DB",
|
||||
str(Path(os.getenv("STACKCHAIN_STATE_DIR", ".")) / "unfiled-drafts.sqlite3"),
|
||||
),
|
||||
encryption_keys=keys,
|
||||
active_key_id=active,
|
||||
)
|
||||
result = store.rewrap_all()
|
||||
except (OSError, UnfiledDraftEncryptionError):
|
||||
print(json.dumps({"error": "Draft rotation configuration is unavailable"}, sort_keys=True))
|
||||
return 2
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 1 if result["failed"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify that a deployed dashboard is usable, not merely alive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class DeploymentVerificationError(RuntimeError):
|
||||
"""A public deployment failed one observable user-flow check."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Response:
|
||||
status: int
|
||||
url: str
|
||||
content_type: str
|
||||
body: bytes
|
||||
|
||||
|
||||
def _request(opener: urllib.request.OpenerDirector, request: str | urllib.request.Request, label: str) -> Response:
|
||||
try:
|
||||
with opener.open(request, timeout=10) as response:
|
||||
return Response(
|
||||
status=response.status,
|
||||
url=response.geturl(),
|
||||
content_type=response.headers.get_content_type(),
|
||||
body=response.read(),
|
||||
)
|
||||
except urllib.error.HTTPError as error:
|
||||
raise DeploymentVerificationError(f"{label} returned HTTP {error.code}") from None
|
||||
except (OSError, urllib.error.URLError, TimeoutError):
|
||||
raise DeploymentVerificationError(f"{label} could not be reached") from None
|
||||
|
||||
|
||||
def verify_deployment(base_url: str, access_token: str) -> dict[str, str]:
|
||||
"""Exercise liveness, readiness, sign-in, PWA scope, and mobile Home."""
|
||||
parsed = urllib.parse.urlsplit(base_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise DeploymentVerificationError("deployment URL must be absolute HTTP(S)")
|
||||
if not access_token:
|
||||
raise DeploymentVerificationError("operator access token is required")
|
||||
base_url = base_url.rstrip("/") + "/"
|
||||
base_path = urllib.parse.urlsplit(base_url).path
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
||||
|
||||
health = _request(opener, urllib.parse.urljoin(base_url, "healthz"), "liveness")
|
||||
if health.status != 200:
|
||||
raise DeploymentVerificationError(f"liveness returned HTTP {health.status}")
|
||||
|
||||
readiness = _request(opener, urllib.parse.urljoin(base_url, "readyz"), "readiness")
|
||||
if readiness.status != 200:
|
||||
raise DeploymentVerificationError(f"readiness returned HTTP {readiness.status}")
|
||||
|
||||
public_entry = _request(opener, base_url, "public entry")
|
||||
public_text = public_entry.body.decode("utf-8", errors="replace")
|
||||
if public_entry.content_type != "text/html" or 'name="access_token"' not in public_text:
|
||||
raise DeploymentVerificationError("public entry did not render the operator sign-in form")
|
||||
|
||||
manifest_response = _request(
|
||||
opener, urllib.parse.urljoin(base_url, "manifest.webmanifest"), "manifest"
|
||||
)
|
||||
try:
|
||||
manifest = json.loads(manifest_response.body)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise DeploymentVerificationError("manifest did not return valid JSON") from None
|
||||
manifest_scope = urllib.parse.urljoin(manifest_response.url, str(manifest.get("scope", "")))
|
||||
manifest_start = urllib.parse.urljoin(manifest_response.url, str(manifest.get("start_url", "")))
|
||||
expected = urllib.parse.urlsplit(base_url)
|
||||
resolved_scope = urllib.parse.urlsplit(manifest_scope)
|
||||
resolved_start = urllib.parse.urlsplit(manifest_start)
|
||||
if (
|
||||
(resolved_scope.scheme, resolved_scope.netloc, resolved_scope.path)
|
||||
!= (expected.scheme, expected.netloc, expected.path)
|
||||
or (resolved_start.scheme, resolved_start.netloc, resolved_start.path)
|
||||
!= (expected.scheme, expected.netloc, expected.path)
|
||||
):
|
||||
raise DeploymentVerificationError("manifest scope or start URL escaped the deployment subpath")
|
||||
|
||||
encoded = json.dumps({
|
||||
"access_token": access_token,
|
||||
"device_label": "Deployment smoke verifier",
|
||||
}).encode()
|
||||
login_request = urllib.request.Request(
|
||||
urllib.parse.urljoin(base_url, "api/v1/session"),
|
||||
data=encoded,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
authenticated = _request(opener, login_request, "operator sign-in")
|
||||
try:
|
||||
authenticated_payload = json.loads(authenticated.body)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise DeploymentVerificationError("operator sign-in did not return valid JSON") from None
|
||||
if authenticated_payload.get("authenticated") is not True:
|
||||
raise DeploymentVerificationError("operator sign-in was not accepted")
|
||||
|
||||
home = _request(opener, base_url, "authenticated Home")
|
||||
home_text = home.body.decode("utf-8", errors="replace")
|
||||
if home.content_type != "text/html" or (
|
||||
'id="mobile-task-dock"' not in home_text or 'id="new-issue"' not in home_text
|
||||
):
|
||||
raise DeploymentVerificationError("operator sign-in did not reach the mobile dashboard Home")
|
||||
|
||||
return {
|
||||
"health": "ok",
|
||||
"readiness": "ready",
|
||||
"public_entry": "login",
|
||||
"manifest_scope": base_path,
|
||||
"authenticated_home": "mobile",
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("url", help="Dashboard URL including its public subpath")
|
||||
args = parser.parse_args(argv)
|
||||
token = os.environ.get("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
|
||||
try:
|
||||
result = verify_deployment(args.url, token)
|
||||
except DeploymentVerificationError as error:
|
||||
print(f"deployment verification failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -10,6 +10,8 @@ import sys
|
||||
import tarfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from build_release import manifest_for, runtime_files_at_commit
|
||||
|
||||
|
||||
def one(directory: Path, pattern: str) -> Path:
|
||||
matches = sorted(directory.glob(pattern))
|
||||
@@ -22,7 +24,7 @@ def digest(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def verify(input_dir: Path, commit: str) -> None:
|
||||
def verify(input_dir: Path, commit: str, repository: Path) -> None:
|
||||
archive_path = one(input_dir, "*.tar.gz")
|
||||
manifest_path = one(input_dir, "*.manifest.json")
|
||||
checksum_path = one(input_dir, "*.sha256")
|
||||
@@ -45,6 +47,11 @@ def verify(input_dir: Path, commit: str) -> None:
|
||||
expected_files = manifest.get("files")
|
||||
if not isinstance(expected_files, dict):
|
||||
raise ValueError("manifest files must be an object")
|
||||
source_files = runtime_files_at_commit(repository, commit)
|
||||
source_manifest = manifest_for(source_files, commit)["files"]
|
||||
if expected_files != source_manifest:
|
||||
raise ValueError("bundle manifest does not match the declared Git commit")
|
||||
source_by_name = {item.name: item for item in source_files}
|
||||
with tarfile.open(archive_path, "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
for member in members:
|
||||
@@ -67,19 +74,22 @@ def verify(input_dir: Path, commit: str) -> None:
|
||||
data = bundled.read()
|
||||
if metadata != {"sha256": digest(data), "size": len(data)}:
|
||||
raise ValueError(f"bundle member failed verification: {name}")
|
||||
if data != source_by_name[name].data:
|
||||
raise ValueError(f"bundle member differs from the declared Git commit: {name}")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input-dir", type=Path, required=True)
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--repository", type=Path, default=Path.cwd())
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
try:
|
||||
verify(args.input_dir, args.commit)
|
||||
verify(args.input_dir, args.commit, args.repository)
|
||||
except (OSError, ValueError, json.JSONDecodeError, tarfile.TarError) as error:
|
||||
print(f"release verification failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
@@ -10,6 +9,11 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
|
||||
class RefreshLeaseLost(RuntimeError):
|
||||
@@ -26,9 +30,13 @@ class AvailableIssueSnapshotState:
|
||||
|
||||
|
||||
class AvailableIssueSnapshotStore:
|
||||
def __init__(self, path, *, clock=None):
|
||||
def __init__(self, path, *, clock=None, encryption_key=None):
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="available-issue-snapshot",
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
@@ -91,8 +99,21 @@ class AvailableIssueSnapshotStore:
|
||||
"SELECT expires_at FROM available_issue_refresh_lease "
|
||||
"WHERE singleton = 1 AND expires_at > ?", (now,)
|
||||
).fetchone()
|
||||
items = None
|
||||
if row["items_json"]:
|
||||
items, legacy = self._cipher.open(row["items_json"])
|
||||
if not isinstance(items, list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
if legacy:
|
||||
migrated = self._cipher.seal(items)
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = ? "
|
||||
"WHERE singleton = 1 AND items_json = ?",
|
||||
(migrated, row["items_json"]),
|
||||
)
|
||||
return AvailableIssueSnapshotState(
|
||||
items=json.loads(row["items_json"]) if row["items_json"] else None,
|
||||
items=items,
|
||||
created_at=row["created_at"],
|
||||
retry_at=row["retry_at"],
|
||||
refreshing=lease is not None,
|
||||
@@ -127,7 +148,7 @@ class AvailableIssueSnapshotStore:
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, "
|
||||
"retry_at = NULL WHERE singleton = 1",
|
||||
(json.dumps(items, separators=(",", ":")), now),
|
||||
(self._cipher.seal(items), now),
|
||||
)
|
||||
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
|
||||
connection.commit()
|
||||
@@ -143,7 +164,7 @@ class AvailableIssueSnapshotStore:
|
||||
row = connection.execute(
|
||||
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
items = json.loads(row["items_json"]) if row["items_json"] else None
|
||||
items = self._cipher.open(row["items_json"])[0] if row["items_json"] else None
|
||||
if items is not None:
|
||||
items = [
|
||||
item for item in items
|
||||
@@ -151,7 +172,7 @@ class AvailableIssueSnapshotStore:
|
||||
]
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = ? WHERE singleton = 1",
|
||||
(json.dumps(items, separators=(",", ":")),),
|
||||
(self._cipher.seal(items),),
|
||||
)
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Durable, account-scoped completed Filed review receipts."""
|
||||
"""Durable, encrypted, account-scoped completed Filed review receipts."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -6,33 +6,68 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
|
||||
|
||||
class CompletedFiledReviewStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0):
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 200,
|
||||
timeout: float = 1.0,
|
||||
encryption_key: bytes | None = None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="completed-filed-reviews",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA secure_delete=ON")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS completed_filed_reviews (
|
||||
login TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
touched_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (login, repository, issue_number)
|
||||
CREATE TABLE IF NOT EXISTS completed_filed_review_collections (
|
||||
login TEXT PRIMARY KEY,
|
||||
receipts TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
legacy = connection.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'completed_filed_reviews'"
|
||||
).fetchone()
|
||||
if legacy:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT login, repository, issue_number, updated_at "
|
||||
"FROM completed_filed_reviews ORDER BY login, touched_at"
|
||||
).fetchall()
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
for login, repository, number, updated_at in rows:
|
||||
grouped.setdefault(login, []).append({
|
||||
"repository": repository,
|
||||
"number": int(number),
|
||||
"updated_at": updated_at,
|
||||
})
|
||||
for login, receipts in grouped.items():
|
||||
normalized = [self._receipt(item) for item in receipts][-self.limit:]
|
||||
connection.execute(
|
||||
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
|
||||
(login, self._seal(login, normalized)),
|
||||
)
|
||||
connection.execute("DROP TABLE completed_filed_reviews")
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
@@ -45,7 +80,7 @@ class CompletedFiledReviewStore:
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _receipt(raw: dict) -> tuple[str, int, str]:
|
||||
def _receipt(raw: dict) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("receipt must be an object")
|
||||
repository = raw.get("repository")
|
||||
@@ -63,23 +98,41 @@ class CompletedFiledReviewStore:
|
||||
raise ValueError("updated_at is invalid") from error
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("updated_at is invalid")
|
||||
return repository, number, updated_at
|
||||
return {"repository": repository, "number": number, "updated_at": updated_at}
|
||||
|
||||
def _open(self, login: str, payload: str | None) -> tuple[list[dict], bool]:
|
||||
if payload is None:
|
||||
return [], False
|
||||
value, stale = self._cipher.open(payload, binding=f"receipts:{login}")
|
||||
if not isinstance(value, list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
try:
|
||||
return [self._receipt(item) for item in value], stale
|
||||
except ValueError as error:
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||
|
||||
def _seal(self, login: str, receipts: list[dict]) -> str:
|
||||
return self._cipher.seal(receipts, binding=f"receipts:{login}")
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(rows) -> dict:
|
||||
return {"receipts": [
|
||||
{"repository": row[0], "number": int(row[1]), "updated_at": row[2]}
|
||||
for row in rows
|
||||
]}
|
||||
def _snapshot(receipts: list[dict]) -> dict:
|
||||
return {"receipts": receipts}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? ORDER BY touched_at",
|
||||
(self._login(login),),
|
||||
).fetchall()
|
||||
return self._snapshot(rows)
|
||||
row = connection.execute(
|
||||
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
|
||||
(login,),
|
||||
).fetchone()
|
||||
receipts, stale = self._open(login, row[0] if row else None)
|
||||
if row is not None and stale:
|
||||
connection.execute(
|
||||
"UPDATE completed_filed_review_collections SET receipts = ? "
|
||||
"WHERE login = ? AND receipts = ?",
|
||||
(self._seal(login, receipts), login, row[0]),
|
||||
)
|
||||
return self._snapshot(receipts)
|
||||
|
||||
def merge(self, login: str, receipts: list[dict]) -> dict:
|
||||
login = self._login(login)
|
||||
@@ -88,35 +141,25 @@ class CompletedFiledReviewStore:
|
||||
normalized = [self._receipt(receipt) for receipt in receipts]
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
touched = int(connection.execute(
|
||||
"SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?",
|
||||
row = connection.execute(
|
||||
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
|
||||
(login,),
|
||||
).fetchone()[0])
|
||||
for repository, number, updated_at in normalized:
|
||||
current = connection.execute(
|
||||
"SELECT updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? AND repository = ? AND issue_number = ?",
|
||||
(login, repository, number),
|
||||
).fetchone()
|
||||
if current is not None and current[0] >= updated_at:
|
||||
).fetchone()
|
||||
current, _stale = self._open(login, row[0] if row else None)
|
||||
for incoming in normalized:
|
||||
match = next((
|
||||
item for item in current
|
||||
if item["repository"] == incoming["repository"] and item["number"] == incoming["number"]
|
||||
), None)
|
||||
if match is not None and match["updated_at"] >= incoming["updated_at"]:
|
||||
continue
|
||||
touched += 1
|
||||
connection.execute(
|
||||
"INSERT INTO completed_filed_reviews "
|
||||
"(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(login, repository, issue_number) DO UPDATE SET "
|
||||
"updated_at=excluded.updated_at, touched_at=excluded.touched_at",
|
||||
(login, repository, number, updated_at, touched),
|
||||
)
|
||||
if match is not None:
|
||||
current.remove(match)
|
||||
current.append(incoming)
|
||||
current = current[-self.limit:]
|
||||
connection.execute(
|
||||
"DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN ("
|
||||
"SELECT rowid FROM completed_filed_reviews WHERE login = ? "
|
||||
"ORDER BY touched_at DESC LIMIT ?)",
|
||||
(login, login, self.limit),
|
||||
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
|
||||
(login, self._seal(login, current)),
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? ORDER BY touched_at",
|
||||
(login,),
|
||||
).fetchall()
|
||||
return self._snapshot(rows)
|
||||
return self._snapshot(current)
|
||||
+20
-8
@@ -25,21 +25,27 @@ FEATURE_SOURCES = {
|
||||
"issue-capture": (
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"sign-out": ("static/private-data-inventory.js", "static/sign-out-review.js"),
|
||||
"device-setup": (
|
||||
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
|
||||
"static/device-storage.js", "static/mobile-device-setup.js",
|
||||
),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"planning": (
|
||||
"static/plan-today.js", "static/plan-today-readiness.js",
|
||||
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
|
||||
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
||||
),
|
||||
"today-timer": (
|
||||
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
"static/search-batch-plan.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
"static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
),
|
||||
}
|
||||
@@ -89,7 +95,7 @@ def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> Runti
|
||||
digest = hashlib.sha256(content).hexdigest()[:16]
|
||||
return RuntimeBundle(
|
||||
runtime_bytes=content,
|
||||
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
|
||||
runtime_gzip_bytes=gzip.compress(content, compresslevel=9, mtime=0),
|
||||
runtime_digest=digest,
|
||||
runtime_name=f"{prefix}-{digest}.js",
|
||||
)
|
||||
@@ -114,7 +120,13 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
f'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
|
||||
for name, bundle in feature_bundles.items()
|
||||
)
|
||||
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||
workspace_preload = (
|
||||
f'<link rel="preload" as="script" '
|
||||
f'href="{feature_bundles["today-timer"].runtime_name}">'
|
||||
)
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</head>", feature_metadata + "\n" + workspace_preload + "\n</head>"
|
||||
)
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>",
|
||||
f'<script src="{core.runtime_name}"></script>\n</body>',
|
||||
|
||||
+74
-30
@@ -445,7 +445,7 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||
return None
|
||||
repository = item.get("repository")
|
||||
repository = repository if isinstance(repository, dict) else {}
|
||||
url = _safe_web_url(item.get("html_url"))
|
||||
url = _safe_gitea_web_url(item.get("html_url"))
|
||||
if not (
|
||||
isinstance(item.get("number"), int)
|
||||
and isinstance(item.get("title"), str)
|
||||
@@ -614,7 +614,7 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||
],
|
||||
"assignees": assignee_names,
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
"url": _safe_gitea_web_url(issue.get("html_url")),
|
||||
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
|
||||
"reopenable": actual_kind == "issue" and state == "closed",
|
||||
"assigned_to_me": bool(login and login in assignee_names),
|
||||
@@ -650,7 +650,7 @@ def _normalize_available_issue(item: Any) -> dict | None:
|
||||
"assignees": [],
|
||||
"updated_at": item.get("updated_at", "")
|
||||
if isinstance(item.get("updated_at"), str) else "",
|
||||
"url": _safe_web_url(item.get("html_url")),
|
||||
"url": _safe_gitea_web_url(item.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
@@ -756,13 +756,6 @@ async def issues() -> WorkItems:
|
||||
)
|
||||
|
||||
|
||||
def _safe_web_url(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
parsed = urlsplit(value)
|
||||
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
|
||||
|
||||
|
||||
def _safe_gitea_web_url(value: Any) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return ""
|
||||
@@ -819,8 +812,8 @@ def _normalize_notifications(threads: Any) -> list[dict]:
|
||||
subject = thread.get("subject")
|
||||
repository = repository if isinstance(repository, dict) else {}
|
||||
subject = subject if isinstance(subject, dict) else {}
|
||||
subject_url = _safe_web_url(subject.get("html_url"))
|
||||
latest_url = _safe_web_url(subject.get("latest_comment_html_url"))
|
||||
subject_url = _safe_gitea_web_url(subject.get("html_url"))
|
||||
latest_url = _safe_gitea_web_url(subject.get("latest_comment_html_url"))
|
||||
number_text = (
|
||||
urlsplit(subject_url).path.rstrip("/").rsplit("/", 1)[-1]
|
||||
if subject_url
|
||||
@@ -1053,8 +1046,8 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
comment = comment if isinstance(comment, dict) else {}
|
||||
user_value = comment.get("user")
|
||||
user = user_value if isinstance(user_value, dict) else {}
|
||||
subject_url = _safe_web_url(subject.get("html_url"))
|
||||
latest_url = _safe_web_url(comment.get("html_url")) or _safe_web_url(
|
||||
subject_url = _safe_gitea_web_url(subject.get("html_url"))
|
||||
latest_url = _safe_gitea_web_url(comment.get("html_url")) or _safe_gitea_web_url(
|
||||
subject.get("latest_comment_html_url")
|
||||
)
|
||||
assignee_values = subject_detail.get("assignees")
|
||||
@@ -1236,7 +1229,7 @@ async def upload_notification_attachment(
|
||||
if not isinstance(attachment, dict):
|
||||
raise ValueError("Gitea attachment response was not an object")
|
||||
name = attachment.get("name")
|
||||
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||
url = _safe_gitea_web_url(attachment.get("browser_download_url"))
|
||||
size = attachment.get("size")
|
||||
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||
raise ValueError("Gitea did not confirm the attachment")
|
||||
@@ -1275,7 +1268,7 @@ def _normalize_issue_comment(comment: dict) -> dict:
|
||||
"created_at": comment.get("created_at", "")
|
||||
if isinstance(comment.get("created_at"), str)
|
||||
else "",
|
||||
"url": _safe_web_url(comment.get("html_url")),
|
||||
"url": _safe_gitea_web_url(comment.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
@@ -1357,7 +1350,7 @@ async def upload_assigned_issue_attachment(
|
||||
if not isinstance(attachment, dict):
|
||||
raise ValueError("Gitea attachment response was not an object")
|
||||
name = attachment.get("name")
|
||||
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||
url = _safe_gitea_web_url(attachment.get("browser_download_url"))
|
||||
size = attachment.get("size")
|
||||
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||
raise ValueError("Gitea did not confirm the attachment")
|
||||
@@ -1383,7 +1376,7 @@ async def upload_preview_attachment(
|
||||
if not isinstance(attachment, dict):
|
||||
raise ValueError("Gitea attachment response was not an object")
|
||||
name = attachment.get("name")
|
||||
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||
url = _safe_gitea_web_url(attachment.get("browser_download_url"))
|
||||
size = attachment.get("size")
|
||||
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||
raise ValueError("Gitea did not confirm the attachment")
|
||||
@@ -1410,7 +1403,7 @@ async def upload_assigned_pull_attachment(
|
||||
if not isinstance(attachment, dict):
|
||||
raise ValueError("Gitea attachment response was not an object")
|
||||
name = attachment.get("name")
|
||||
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||
url = _safe_gitea_web_url(attachment.get("browser_download_url"))
|
||||
size = attachment.get("size")
|
||||
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||
raise ValueError("Gitea did not confirm the attachment")
|
||||
@@ -1586,7 +1579,7 @@ async def create_issue(
|
||||
"updated_at": issue.get("updated_at", "")
|
||||
if isinstance(issue.get("updated_at"), str)
|
||||
else "",
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
"url": _safe_gitea_web_url(issue.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
@@ -1640,7 +1633,7 @@ async def claim_available_issue(repository: str, number: int) -> dict:
|
||||
"assignees": logins,
|
||||
"updated_at": confirmed.get("updated_at", "")
|
||||
if isinstance(confirmed.get("updated_at"), str) else "",
|
||||
"url": _safe_web_url(confirmed.get("html_url")),
|
||||
"url": _safe_gitea_web_url(confirmed.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
@@ -1700,7 +1693,7 @@ async def reopen_issue(repository: str, number: int) -> dict:
|
||||
"assignees": logins,
|
||||
"updated_at": confirmed.get("updated_at", "")
|
||||
if isinstance(confirmed.get("updated_at"), str) else "",
|
||||
"url": _safe_web_url(confirmed.get("html_url")),
|
||||
"url": _safe_gitea_web_url(confirmed.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
@@ -2256,7 +2249,7 @@ async def issue_detail(repository: str, number: int) -> dict:
|
||||
and isinstance(issue["milestone"].get("title"), str)
|
||||
else None
|
||||
),
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
"url": _safe_gitea_web_url(issue.get("html_url")),
|
||||
"labels": [
|
||||
label["name"] for label in labels
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||
@@ -2297,7 +2290,7 @@ async def issue_dependencies(repository: str, number: int, limit: int = 20) -> l
|
||||
"number": dependency_number,
|
||||
"title": item.get("title", "") if isinstance(item.get("title"), str) else "",
|
||||
"state": "open",
|
||||
"url": _safe_web_url(item.get("html_url")),
|
||||
"url": _safe_gitea_web_url(item.get("html_url")),
|
||||
})
|
||||
return dependencies
|
||||
|
||||
@@ -2425,7 +2418,7 @@ async def _update_issue_content(
|
||||
"body": body,
|
||||
"state": issue.get("state", "open"),
|
||||
"updated_at": issue.get("updated_at", ""),
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
"url": _safe_gitea_web_url(issue.get("html_url")),
|
||||
}
|
||||
raise IssueEditConflictError("issue changed upstream")
|
||||
|
||||
@@ -2450,7 +2443,7 @@ async def _update_issue_content(
|
||||
"body": body,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"updated_at": confirmed.get("updated_at", ""),
|
||||
"url": _safe_web_url(confirmed.get("html_url")),
|
||||
"url": _safe_gitea_web_url(confirmed.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
@@ -2518,7 +2511,7 @@ async def resolve_work_route(
|
||||
"number": number,
|
||||
"title": target.get("title", "") if isinstance(target.get("title"), str) else "",
|
||||
"state": state,
|
||||
"url": _safe_web_url(target.get("html_url")),
|
||||
"url": _safe_gitea_web_url(target.get("html_url")),
|
||||
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
|
||||
**({
|
||||
"is_filed": True,
|
||||
@@ -2623,7 +2616,7 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
|
||||
"number": number,
|
||||
"title": pull.get("title") if isinstance(pull.get("title"), str) else "",
|
||||
"body": pull.get("body") if isinstance(pull.get("body"), str) else "",
|
||||
"url": _safe_web_url(pull.get("html_url")),
|
||||
"url": _safe_gitea_web_url(pull.get("html_url")),
|
||||
"author": user.get("login") if isinstance(user.get("login"), str) else "",
|
||||
"head_sha": sha,
|
||||
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
|
||||
@@ -2681,6 +2674,48 @@ async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def release_receipt_status(repository: str, commit_sha: str) -> dict:
|
||||
"""Return CI and release evidence for one exact merge commit."""
|
||||
status, releases = await asyncio.gather(
|
||||
fetch(f"repos/{repository}/commits/{commit_sha}/status"),
|
||||
fetch(f"repos/{repository}/releases?limit=20"),
|
||||
)
|
||||
normalized_checks = _normalize_commit_checks(status)
|
||||
checks = [
|
||||
{"name": check["name"], "state": check["state"], "url": check["url"]}
|
||||
for check in normalized_checks
|
||||
]
|
||||
matching = next(
|
||||
(
|
||||
release
|
||||
for release in (releases if isinstance(releases, list) else [])
|
||||
if isinstance(release, dict) and release.get("target_commitish") == commit_sha
|
||||
),
|
||||
None,
|
||||
)
|
||||
normalized_release = None
|
||||
if matching is not None:
|
||||
assets = matching.get("assets")
|
||||
normalized_release = {
|
||||
"tag": matching.get("tag_name") if isinstance(matching.get("tag_name"), str) else "",
|
||||
"url": _safe_gitea_web_url(matching.get("html_url")),
|
||||
"assets": [
|
||||
{
|
||||
"name": asset.get("name") if isinstance(asset.get("name"), str) else "",
|
||||
"url": _safe_gitea_web_url(asset.get("browser_download_url")),
|
||||
}
|
||||
for asset in (assets if isinstance(assets, list) else [])[:20]
|
||||
if isinstance(asset, dict)
|
||||
],
|
||||
}
|
||||
return {
|
||||
"commit_sha": commit_sha,
|
||||
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
|
||||
"checks": checks,
|
||||
"release": normalized_release,
|
||||
}
|
||||
|
||||
|
||||
async def pull_check_status(repository: str, number: int) -> dict:
|
||||
"""Load only mutable pull and CI state, without immutable review data."""
|
||||
base = f"repos/{repository}/pulls/{number}"
|
||||
@@ -2748,7 +2783,16 @@ async def merge_assigned_pull(
|
||||
json={"Do": "merge", "head_commit_id": current_sha},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"number": number, "merged": True, "state": "closed"}
|
||||
payload = response.json()
|
||||
merge_commit_sha = payload.get("sha") if isinstance(payload, dict) else None
|
||||
if not isinstance(merge_commit_sha, str) or not merge_commit_sha:
|
||||
raise ValueError("Gitea merge response did not include the merge commit")
|
||||
return {
|
||||
"number": number,
|
||||
"merged": True,
|
||||
"state": "closed",
|
||||
"merge_commit_sha": merge_commit_sha,
|
||||
}
|
||||
|
||||
|
||||
async def pull_review_detail(repository: str, number: int) -> dict:
|
||||
@@ -2865,7 +2909,7 @@ async def submit_pull_review(
|
||||
return {
|
||||
"id": review.get("id"),
|
||||
"state": review.get("state") or "COMMENT",
|
||||
"url": _safe_web_url(review.get("html_url")),
|
||||
"url": _safe_gitea_web_url(review.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+51
-4
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, private_state_encryption_config
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -29,12 +30,17 @@ class IdempotencyLedger:
|
||||
max_entries: int,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
clock: Callable[[], float] = time.time,
|
||||
encryption_key: bytes | None = None,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.max_entries = max_entries
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="idempotency-ledger",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
@@ -67,6 +73,17 @@ class IdempotencyLedger:
|
||||
def _fingerprint(value: tuple[Any, ...]) -> str:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
def _seal(self, value: str, *, key: str, field: str) -> str:
|
||||
return self._cipher.seal(value, binding=f"{key}:{field}")
|
||||
|
||||
def _open(self, payload: str, *, key: str, field: str) -> tuple[str, bool]:
|
||||
value, legacy = self._cipher.open(payload, binding=f"{key}:{field}")
|
||||
if legacy:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True), True
|
||||
if not isinstance(value, str):
|
||||
raise RuntimeError("idempotency ledger payload is invalid")
|
||||
return value, legacy
|
||||
|
||||
def reserve(self, key: str, fingerprint: tuple[Any, ...]) -> Reservation:
|
||||
encoded = self._fingerprint(fingerprint)
|
||||
now = self.clock()
|
||||
@@ -84,10 +101,40 @@ class IdempotencyLedger:
|
||||
(key,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row[0] != encoded:
|
||||
stored_fingerprint, legacy_fingerprint = self._open(
|
||||
row[0], key=key, field="fingerprint"
|
||||
)
|
||||
if legacy_fingerprint:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET fingerprint = ? "
|
||||
"WHERE key = ? AND fingerprint = ?",
|
||||
(
|
||||
self._seal(
|
||||
stored_fingerprint, key=key, field="fingerprint"
|
||||
),
|
||||
key,
|
||||
row[0],
|
||||
),
|
||||
)
|
||||
if stored_fingerprint != encoded:
|
||||
return Reservation("conflict")
|
||||
if row[1] == "completed":
|
||||
return Reservation("completed", json.loads(row[2]))
|
||||
stored_response, legacy_response = self._open(
|
||||
row[2], key=key, field="response"
|
||||
)
|
||||
if legacy_response:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET response_json = ? "
|
||||
"WHERE key = ? AND response_json = ?",
|
||||
(
|
||||
self._seal(
|
||||
stored_response, key=key, field="response"
|
||||
),
|
||||
key,
|
||||
row[2],
|
||||
),
|
||||
)
|
||||
return Reservation("completed", json.loads(stored_response))
|
||||
if row[3] <= now - self.ttl_seconds:
|
||||
return Reservation("uncertain")
|
||||
return Reservation("pending")
|
||||
@@ -110,7 +157,7 @@ class IdempotencyLedger:
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency_operations "
|
||||
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
|
||||
(key, encoded, now),
|
||||
(key, self._seal(encoded, key=key, field="fingerprint"), now),
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
||||
@@ -129,7 +176,7 @@ class IdempotencyLedger:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET status = 'completed', "
|
||||
"response_json = ?, completed_at = ? WHERE key = ?",
|
||||
(encoded, self.clock(), key),
|
||||
(self._seal(encoded, key=key, field="response"), self.clock(), key),
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
||||
|
||||
+96
-23
@@ -1,12 +1,12 @@
|
||||
"""Durable, account-scoped Later deferrals."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
class LaterStore:
|
||||
@@ -17,6 +17,7 @@ class LaterStore:
|
||||
timeout: float = 1.0,
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
encryption_key: bytes | None = None,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
@@ -24,6 +25,10 @@ class LaterStore:
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="later",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
@@ -99,11 +104,55 @@ class LaterStore:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "records": {}}
|
||||
return {"revision": int(row[0]), "records": json.loads(row[1])}
|
||||
return {"revision": 0, "records": {}}, False
|
||||
records, legacy = self._cipher.open(row[1], binding=f"plan:{login}")
|
||||
if not isinstance(records, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {"revision": int(row[0]), "records": records}, legacy
|
||||
|
||||
def _sealed_records(self, login: str, records: dict) -> str:
|
||||
return self._cipher.seal(records, binding=f"plan:{login}")
|
||||
|
||||
def _item_revisions(
|
||||
self, connection: sqlite3.Connection, login: str
|
||||
) -> tuple[dict[str, int], dict[str, str]]:
|
||||
revisions: dict[str, int] = {}
|
||||
stored_ids: dict[str, str] = {}
|
||||
for stored_item_id, revision in connection.execute(
|
||||
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
|
||||
(login,),
|
||||
):
|
||||
if stored_item_id.startswith(("v1:", "v2:")):
|
||||
item_id, _legacy = self._cipher.open(
|
||||
stored_item_id, binding=f"item-revision:{login}"
|
||||
)
|
||||
else:
|
||||
item_id = stored_item_id
|
||||
if not isinstance(item_id, str) or not item_id:
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
revisions[item_id] = int(revision)
|
||||
stored_ids[item_id] = stored_item_id
|
||||
return revisions, stored_ids
|
||||
|
||||
def _migrate_item_ids(
|
||||
self, connection: sqlite3.Connection, login: str, stored_ids: dict[str, str]
|
||||
) -> dict[str, str]:
|
||||
migrated = dict(stored_ids)
|
||||
for item_id, stored_item_id in stored_ids.items():
|
||||
if stored_item_id.startswith(("v1:", "v2:")):
|
||||
continue
|
||||
sealed_item_id = self._cipher.seal(
|
||||
item_id, binding=f"item-revision:{login}"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE later_item_revisions SET item_id = ? "
|
||||
"WHERE login = ? AND item_id = ?",
|
||||
(sealed_item_id, login, stored_item_id),
|
||||
)
|
||||
migrated[item_id] = sealed_item_id
|
||||
return migrated
|
||||
|
||||
@staticmethod
|
||||
def _validate_wake_at(wake_at: str | None) -> str:
|
||||
@@ -116,12 +165,21 @@ class LaterStore:
|
||||
return wake_at
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
(login,),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
snapshot, legacy_plan = self._snapshot(row, login)
|
||||
_revisions, stored_item_ids = self._item_revisions(connection, login)
|
||||
if row is not None and legacy_plan:
|
||||
connection.execute(
|
||||
"UPDATE later_plans SET records = ? WHERE login = ? AND records = ?",
|
||||
(self._sealed_records(login, snapshot["records"]), login, row[1]),
|
||||
)
|
||||
self._migrate_item_ids(connection, login, stored_item_ids)
|
||||
return snapshot
|
||||
|
||||
def apply(
|
||||
self,
|
||||
@@ -152,7 +210,7 @@ class LaterStore:
|
||||
row = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
snapshot, legacy_plan = self._snapshot(row, login)
|
||||
records = dict(snapshot["records"])
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
@@ -161,15 +219,19 @@ class LaterStore:
|
||||
|
||||
# Existing databases predate per-item revisions. Conservatively mark
|
||||
# active deferrals as changed at the latest known plan revision.
|
||||
item_revisions, stored_item_ids = self._item_revisions(connection, login)
|
||||
stored_item_ids = self._migrate_item_ids(connection, login, stored_item_ids)
|
||||
for item_id in records:
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
|
||||
(login, item_id, revision),
|
||||
)
|
||||
item_revisions = dict(connection.execute(
|
||||
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
|
||||
(login,),
|
||||
).fetchall())
|
||||
if item_id not in item_revisions:
|
||||
stored_item_id = self._cipher.seal(
|
||||
item_id, binding=f"item-revision:{login}"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
|
||||
(login, stored_item_id, revision),
|
||||
)
|
||||
item_revisions[item_id] = revision
|
||||
stored_item_ids[item_id] = stored_item_id
|
||||
batch_start_item_revisions = dict(item_revisions)
|
||||
|
||||
for operation in operations:
|
||||
@@ -218,21 +280,32 @@ class LaterStore:
|
||||
revision += 1 if changed else 0
|
||||
if changed:
|
||||
item_revisions[item_id] = revision
|
||||
connection.execute(
|
||||
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision",
|
||||
(login, item_id, revision),
|
||||
)
|
||||
stored_item_id = stored_item_ids.get(item_id)
|
||||
if stored_item_id is None:
|
||||
stored_item_id = self._cipher.seal(
|
||||
item_id, binding=f"item-revision:{login}"
|
||||
)
|
||||
stored_item_ids[item_id] = stored_item_id
|
||||
connection.execute(
|
||||
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
|
||||
(login, stored_item_id, revision),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE later_item_revisions SET revision = ? "
|
||||
"WHERE login = ? AND item_id = ?",
|
||||
(revision, login, stored_item_id),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
|
||||
serialized = self._sealed_records(login, records)
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
elif accepted:
|
||||
elif accepted or legacy_plan:
|
||||
connection.execute(
|
||||
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
|
||||
@@ -12,6 +12,11 @@ from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
SECTIONS = ("context", "events", "notifications")
|
||||
|
||||
@@ -53,9 +58,14 @@ class LiveSnapshotStore:
|
||||
path: str | os.PathLike[str],
|
||||
*,
|
||||
clock: Callable[[], float] | None = None,
|
||||
encryption_key: bytes | None = None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="live-snapshot",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
@@ -138,8 +148,23 @@ class LiveSnapshotStore:
|
||||
(now,),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
value = None
|
||||
if row["value_json"] is not None:
|
||||
value, legacy = self._cipher.open(
|
||||
row["value_json"], binding=row["generation"]
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
if legacy:
|
||||
migrated = self._cipher.seal(value, binding=row["generation"])
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE live_snapshot SET value_json = ? "
|
||||
"WHERE singleton = 1 AND value_json = ?",
|
||||
(migrated, row["value_json"]),
|
||||
)
|
||||
return LiveSnapshotState(
|
||||
value=json.loads(row["value_json"]) if row["value_json"] is not None else None,
|
||||
value=value,
|
||||
created_at=json.loads(row["created_at_json"]),
|
||||
failure_count=json.loads(row["failure_count_json"]),
|
||||
retry_at=json.loads(row["retry_at_json"]),
|
||||
@@ -198,7 +223,7 @@ class LiveSnapshotStore:
|
||||
connection.rollback()
|
||||
raise RefreshLeaseLost("live refresh lease expired or changed owner")
|
||||
row = connection.execute(
|
||||
"SELECT revisions_json FROM live_snapshot WHERE singleton = 1"
|
||||
"SELECT revisions_json, generation FROM live_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
revisions = json.loads(row["revisions_json"])
|
||||
notifications = value.get("notifications")
|
||||
@@ -232,7 +257,7 @@ class LiveSnapshotStore:
|
||||
failure_count_json = ?, retry_at_json = ?, revisions_json = ?
|
||||
WHERE singleton = 1""",
|
||||
(
|
||||
json.dumps(value, separators=(",", ":")),
|
||||
self._cipher.seal(value, binding=row["generation"]),
|
||||
json.dumps(created_at, separators=(",", ":")),
|
||||
json.dumps(failure_count, separators=(",", ":")),
|
||||
json.dumps(retry_at, separators=(",", ":")),
|
||||
@@ -257,9 +282,11 @@ class LiveSnapshotStore:
|
||||
((notification_id,) for notification_id in read_ids),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT value_json, revisions_json FROM live_snapshot WHERE singleton = 1"
|
||||
"SELECT value_json, revisions_json, generation FROM live_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
value = json.loads(row["value_json"]) if row["value_json"] else None
|
||||
value = self._cipher.open(
|
||||
row["value_json"], binding=row["generation"]
|
||||
)[0] if row["value_json"] else None
|
||||
revisions = json.loads(row["revisions_json"])
|
||||
if value is not None and isinstance(value.get("notifications"), list):
|
||||
previous = value["notifications"]
|
||||
@@ -274,7 +301,7 @@ class LiveSnapshotStore:
|
||||
connection.execute(
|
||||
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
|
||||
(
|
||||
json.dumps(value, separators=(",", ":")),
|
||||
self._cipher.seal(value, binding=row["generation"]),
|
||||
json.dumps(revisions, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
|
||||
+659
-38
@@ -3,6 +3,7 @@ import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
@@ -54,17 +55,29 @@ from src.passkey_store import PasskeyStore
|
||||
from src.push_notifications import (
|
||||
PushConfiguration,
|
||||
dispatch_deadline_reminders,
|
||||
dispatch_start_day_reminders,
|
||||
dispatch_unread_updates,
|
||||
send_web_push,
|
||||
)
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.push_subscription_store import build_push_subscription_store
|
||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
|
||||
from src.unfiled_draft_store import (
|
||||
UnfiledDraftConflict,
|
||||
UnfiledDraftEncryptionError,
|
||||
UnfiledDraftStore,
|
||||
decode_unfiled_draft_encryption_key,
|
||||
decode_unfiled_draft_encryption_keyring,
|
||||
)
|
||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||
from src.suggestion_engine import compute
|
||||
from src.later_store import LaterStore
|
||||
from src.today_store import TodayPlanFull, TodayStore
|
||||
from src.today_store import (
|
||||
TodayPlanFull, TodayPromotionConflict, TodaySessionConflict, TodayStore,
|
||||
TomorrowPlanConflict, TomorrowPlanNotDue, WeekPlanConflict,
|
||||
)
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
from src.views import FRONTEND_BUILD, router as frontend_router
|
||||
|
||||
|
||||
@@ -109,6 +122,14 @@ async def _push_channel_loop(dispatch, *, interval: float) -> None:
|
||||
next_tick += elapsed_intervals * interval
|
||||
|
||||
|
||||
async def _start_day_plan_snapshot() -> dict:
|
||||
user = await current_user()
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
return {}
|
||||
return await asyncio.to_thread(_today_store().get_start_day_plan, login)
|
||||
|
||||
|
||||
async def _push_poll_loop() -> None:
|
||||
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
|
||||
deadline_interval = max(
|
||||
@@ -151,11 +172,25 @@ async def _push_poll_loop() -> None:
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
|
||||
async def dispatch_start_day() -> None:
|
||||
await dispatch_start_day_reminders(
|
||||
_push_subscription_store,
|
||||
_push_configuration(),
|
||||
_start_day_plan_snapshot,
|
||||
session_statuses=dashboard_auth.managed_session_statuses,
|
||||
send_timeout_seconds=send_timeout,
|
||||
lease_seconds=lease_seconds,
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
|
||||
channel_tasks = (
|
||||
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
|
||||
asyncio.create_task(
|
||||
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
|
||||
),
|
||||
asyncio.create_task(
|
||||
_push_channel_loop(dispatch_start_day, interval=deadline_interval)
|
||||
),
|
||||
)
|
||||
try:
|
||||
done, _pending = await asyncio.wait(
|
||||
@@ -347,8 +382,16 @@ _available_issue_snapshot_store = AvailableIssueSnapshotStore(
|
||||
str(_state_dir / "available-issue-snapshot.sqlite3"),
|
||||
)
|
||||
)
|
||||
_push_subscription_store = PushSubscriptionStore(
|
||||
os.getenv("STACKCHAIN_PUSH_DB", str(_state_dir / "push-subscriptions.sqlite3"))
|
||||
_push_subscription_store = build_push_subscription_store(
|
||||
os.getenv("STACKCHAIN_PUSH_DB", str(_state_dir / "push-subscriptions.sqlite3")),
|
||||
push_enabled=all(
|
||||
os.getenv(name, "").strip()
|
||||
for name in (
|
||||
"STACKCHAIN_VAPID_PUBLIC_KEY",
|
||||
"STACKCHAIN_VAPID_PRIVATE_KEY",
|
||||
"STACKCHAIN_VAPID_SUBJECT",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -413,6 +456,23 @@ class DeadlineReminderPayload(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class StartDayReminderPayload(BaseModel):
|
||||
enabled: bool
|
||||
timezone: str = Field(min_length=1, max_length=64)
|
||||
reminder_hour: int = Field(default=9, ge=0, le=23)
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except ZoneInfoNotFoundError as error:
|
||||
raise ValueError("Valid IANA timezone required") from error
|
||||
return value
|
||||
|
||||
|
||||
StepUpAction = Literal[
|
||||
"merge_pull",
|
||||
"submit_pull_review",
|
||||
@@ -536,7 +596,7 @@ class NotificationReadBatch(BaseModel):
|
||||
|
||||
class TodayOperation(BaseModel):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
action: Literal["add", "remove", "move", "configure", "rollover"]
|
||||
action: Literal["add", "remove", "move", "configure", "rollover", "activate"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
direction: Literal["up", "down"] | None = None
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
@@ -545,6 +605,7 @@ class TodayOperation(BaseModel):
|
||||
plan_date: str | None = Field(default=None, min_length=10, max_length=10)
|
||||
timezone: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
ids: list[str] = Field(default_factory=list, max_length=5)
|
||||
activation_state: Literal["coaching", "complete"] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_action_fields(self):
|
||||
@@ -563,6 +624,11 @@ class TodayOperation(BaseModel):
|
||||
raise ValueError("rollover IDs must be unique")
|
||||
elif self.plan_date is not None or self.timezone is not None or self.ids:
|
||||
raise ValueError("date, timezone, and IDs are only valid for rollover")
|
||||
if self.action == "activate":
|
||||
if self.item_id != "first-task" or self.activation_state is None:
|
||||
raise ValueError("activate requires a first-task activation state")
|
||||
elif self.activation_state is not None:
|
||||
raise ValueError("activation state is only valid for activate")
|
||||
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
|
||||
for item_id, minutes in self.estimates.items()):
|
||||
raise ValueError("estimates must use bounded item IDs and minutes")
|
||||
@@ -574,6 +640,8 @@ class TodayOperation(BaseModel):
|
||||
data.pop("plan_date", None)
|
||||
data.pop("timezone", None)
|
||||
data.pop("ids", None)
|
||||
if self.action != "activate":
|
||||
data.pop("activation_state", None)
|
||||
return data
|
||||
|
||||
|
||||
@@ -588,6 +656,36 @@ class TodayRecap(BaseModel):
|
||||
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class TodaySessionEntry(BaseModel):
|
||||
identity: str = Field(min_length=1, max_length=500)
|
||||
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
|
||||
class TodaySessionUpdate(BaseModel):
|
||||
base_revision: int = Field(ge=0)
|
||||
device_id: str = Field(min_length=1, max_length=100)
|
||||
identity: str = Field(max_length=500)
|
||||
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
|
||||
entries: list[TodaySessionEntry] | None = Field(default=None, max_length=20)
|
||||
running: bool
|
||||
break_deadline_at: int | None = Field(default=None, ge=0, le=10_000_000_000_000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_session_state(self):
|
||||
if self.running and self.break_deadline_at is not None:
|
||||
raise ValueError("break must remain paused")
|
||||
if self.entries is not None:
|
||||
identities = [entry.identity for entry in self.entries]
|
||||
if len(set(identities)) != len(identities):
|
||||
raise ValueError("Today session entry identities must be unique")
|
||||
active = [entry for entry in self.entries if entry.identity == self.identity]
|
||||
if self.identity and (len(active) != 1 or active[0].elapsed_ms != self.elapsed_ms):
|
||||
raise ValueError("active Today session must match its ledger entry")
|
||||
if not self.identity and self.entries:
|
||||
raise ValueError("inactive Today session ledger must be empty")
|
||||
return self
|
||||
|
||||
|
||||
class TodayRecapTimeLog(TodayRecap):
|
||||
log_identities: list[str] = Field(min_length=1, max_length=20)
|
||||
|
||||
@@ -618,6 +716,97 @@ class TodayOperationBatch(BaseModel):
|
||||
operations: list[TodayOperation] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
class TomorrowPlanUpdate(BaseModel):
|
||||
base_revision: int = Field(ge=0)
|
||||
ids: list[str] = Field(max_length=5)
|
||||
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
||||
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
|
||||
plan_date: str = Field(min_length=10, max_length=10)
|
||||
timezone: str = Field(min_length=1, max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_plan(self):
|
||||
if len(set(self.ids)) != len(self.ids):
|
||||
raise ValueError("Tomorrow IDs must be unique")
|
||||
if any(not item_id or len(item_id) > 500 for item_id in self.ids):
|
||||
raise ValueError("Tomorrow IDs must be bounded")
|
||||
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
|
||||
for item_id, minutes in self.estimates.items()):
|
||||
raise ValueError("estimates must use bounded item IDs and minutes")
|
||||
return self
|
||||
|
||||
|
||||
class TomorrowPromotion(BaseModel):
|
||||
promotion_id: str = Field(min_length=1, max_length=100)
|
||||
tomorrow_revision: int = Field(ge=0)
|
||||
today_revision: int = Field(ge=0)
|
||||
|
||||
|
||||
class WeekFreeWindow(BaseModel):
|
||||
start_time: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||
end_time: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||
|
||||
|
||||
class WeekPlanDay(BaseModel):
|
||||
plan_date: str = Field(min_length=10, max_length=10)
|
||||
ids: list[str] = Field(max_length=5)
|
||||
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
||||
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
|
||||
free_windows: list[WeekFreeWindow] | None = Field(default=None, max_length=16)
|
||||
start_times: dict[str, str] = Field(default_factory=dict, max_length=5)
|
||||
|
||||
|
||||
class WeekPlanUpdate(BaseModel):
|
||||
base_revision: int = Field(ge=0)
|
||||
days: list[WeekPlanDay] = Field(max_length=7)
|
||||
timezone: str = Field(min_length=1, max_length=100)
|
||||
availability_defaults: list[int] | None = Field(
|
||||
default=None, min_length=7, max_length=7,
|
||||
)
|
||||
|
||||
|
||||
class WeekPromotion(BaseModel):
|
||||
promotion_id: str = Field(min_length=1, max_length=100)
|
||||
week_revision: int = Field(ge=0)
|
||||
plan_date: str = Field(min_length=10, max_length=10)
|
||||
today_revision: int = Field(ge=0)
|
||||
|
||||
|
||||
class WeekReschedule(BaseModel):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
identity: str = Field(min_length=1, max_length=500)
|
||||
estimate_minutes: int = Field(ge=5, le=1440)
|
||||
plan_date: str = Field(min_length=10, max_length=10)
|
||||
today_revision: int = Field(ge=0)
|
||||
week_revision: int = Field(ge=0)
|
||||
allow_over_capacity: bool = False
|
||||
|
||||
|
||||
class WeekItemPull(BaseModel):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
identity: str = Field(min_length=1, max_length=500)
|
||||
today_revision: int = Field(ge=0)
|
||||
week_revision: int = Field(ge=0)
|
||||
allow_over_capacity: bool = False
|
||||
|
||||
|
||||
class WeekReconciliation(WeekPromotion):
|
||||
ids: list[str] = Field(max_length=5)
|
||||
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
||||
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_selection(self):
|
||||
if len(set(self.ids)) != len(self.ids):
|
||||
raise ValueError("reconciliation IDs must be unique")
|
||||
if any(not item_id or len(item_id) > 500 for item_id in self.ids):
|
||||
raise ValueError("reconciliation IDs must be bounded")
|
||||
if any(item_id not in self.ids or minutes < 5 or minutes > 1440
|
||||
for item_id, minutes in self.estimates.items()):
|
||||
raise ValueError("estimates must match selected IDs and be bounded")
|
||||
return self
|
||||
|
||||
|
||||
class LaterOperationBatch(BaseModel):
|
||||
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
|
||||
|
||||
@@ -1322,7 +1511,7 @@ async def require_operator_session(request: Request, call_next):
|
||||
async def prevent_live_api_caching(request, call_next):
|
||||
response = await call_next(request)
|
||||
path = dashboard_auth.application_path(request)
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||
path.startswith("/api/v1/repos/")
|
||||
and path.endswith("/review")
|
||||
) or path.startswith("/api/v1/notifications") or (
|
||||
@@ -2127,7 +2316,13 @@ async def push_status(request: Request):
|
||||
device_id,
|
||||
)
|
||||
preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.deadline_preferences, device_id
|
||||
_push_subscription_store.deadline_preferences, device_id, now=time.time()
|
||||
)
|
||||
start_day_preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.start_day_preferences, device_id
|
||||
)
|
||||
delivery_health = await asyncio.to_thread(
|
||||
_push_subscription_store.delivery_health, device_id
|
||||
)
|
||||
return {
|
||||
"available": configuration.enabled,
|
||||
@@ -2137,6 +2332,11 @@ async def push_status(request: Request):
|
||||
"timezone": preferences["timezone"],
|
||||
"reminder_hour": preferences["reminder_hour"],
|
||||
"reminder_days": preferences["reminder_days"],
|
||||
"snoozed_until": preferences["snoozed_until"],
|
||||
"start_day_enabled": start_day_preferences["enabled"],
|
||||
"start_day_timezone": start_day_preferences["timezone"],
|
||||
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
|
||||
"delivery_health": delivery_health,
|
||||
}
|
||||
|
||||
|
||||
@@ -2190,6 +2390,52 @@ async def unsubscribe_push(request: Request):
|
||||
return {"subscribed": False}
|
||||
|
||||
|
||||
@app.post("/api/v1/push-subscription/test")
|
||||
async def test_push_notification(request: Request):
|
||||
configuration = _push_configuration()
|
||||
if not configuration.enabled:
|
||||
raise HTTPException(status_code=503, detail="Push notifications are not configured")
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
subscription = await asyncio.to_thread(
|
||||
_push_subscription_store.subscription_for_session, device_id
|
||||
)
|
||||
if subscription is None:
|
||||
raise HTTPException(status_code=409, detail="Enable device notifications first")
|
||||
payload = json.dumps({
|
||||
"title": "Stackchain notifications are working",
|
||||
"body": "This device can receive private work alerts.",
|
||||
"route": "#/device-setup",
|
||||
"tag": "stackchain-push-test",
|
||||
}, separators=(",", ":"))
|
||||
try:
|
||||
await send_web_push(subscription, payload, configuration)
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(_push_subscription_store.delete_session, device_id)
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Re-enable notifications for this device"
|
||||
) from error
|
||||
reason = "timeout" if isinstance(error, (asyncio.TimeoutError, TimeoutError)) else "provider"
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.mark_delivery_failed,
|
||||
device_id,
|
||||
"unread",
|
||||
reason,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Test notification delivery failed",
|
||||
headers={"Retry-After": "1"},
|
||||
) from error
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.mark_delivery_succeeded, device_id, "unread"
|
||||
)
|
||||
return {"delivery_state": "healthy", "delivered": True}
|
||||
|
||||
|
||||
@app.put("/api/v1/push-subscription/deadlines")
|
||||
async def update_deadline_reminders(payload: DeadlineReminderPayload, request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
@@ -2215,6 +2461,58 @@ async def update_deadline_reminders(payload: DeadlineReminderPayload, request: R
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/v1/push-subscription/start-day")
|
||||
async def update_start_day_reminders(payload: StartDayReminderPayload, request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
if payload.enabled and not await asyncio.to_thread(
|
||||
_push_subscription_store.is_subscribed, device_id
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="Enable device notifications first")
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.set_start_day_preferences,
|
||||
device_id,
|
||||
enabled=payload.enabled,
|
||||
timezone=payload.timezone,
|
||||
reminder_hour=payload.reminder_hour,
|
||||
)
|
||||
return {
|
||||
"start_day_enabled": payload.enabled,
|
||||
"start_day_timezone": payload.timezone,
|
||||
"start_day_reminder_hour": payload.reminder_hour,
|
||||
}
|
||||
|
||||
|
||||
@app.patch("/api/v1/push-subscription/deadlines/snooze")
|
||||
async def snooze_deadline_reminder(request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
now = int(time.time())
|
||||
snoozed = await asyncio.to_thread(
|
||||
_push_subscription_store.snooze_deadline_reminder,
|
||||
device_id,
|
||||
now=now,
|
||||
delay_seconds=3_600,
|
||||
)
|
||||
if not snoozed:
|
||||
raise HTTPException(status_code=409, detail="Enable deadline reminders first")
|
||||
return {"snoozed": True, "snoozed_until": now + 3_600}
|
||||
|
||||
|
||||
@app.delete("/api/v1/push-subscription/deadlines/snooze")
|
||||
async def resume_deadline_reminders(request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.clear_deadline_snooze,
|
||||
device_id,
|
||||
)
|
||||
return {"snoozed": False}
|
||||
|
||||
|
||||
@app.post("/api/v1/session/activity")
|
||||
async def record_session_activity(request: Request):
|
||||
session = request.state.dashboard_session
|
||||
@@ -2269,8 +2567,22 @@ def _completed_filed_review_store() -> CompletedFiledReviewStore:
|
||||
|
||||
|
||||
def _unfiled_draft_store() -> UnfiledDraftStore:
|
||||
encoded_keyring = os.getenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS")
|
||||
if encoded_keyring is not None:
|
||||
keys, active = decode_unfiled_draft_encryption_keyring(
|
||||
encoded_keyring,
|
||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID", ""),
|
||||
)
|
||||
return UnfiledDraftStore(
|
||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3")),
|
||||
encryption_keys=keys,
|
||||
active_key_id=active,
|
||||
)
|
||||
return UnfiledDraftStore(
|
||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3"))
|
||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3")),
|
||||
encryption_key=decode_unfiled_draft_encryption_key(
|
||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY", "")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -2294,7 +2606,7 @@ async def get_completed_filed_reviews(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_completed_filed_review_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Completed Filed review synchronization is unavailable",
|
||||
@@ -2315,7 +2627,7 @@ async def merge_completed_filed_reviews(payload: CompletedFiledReviewBatch):
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Completed Filed review synchronization is unavailable",
|
||||
@@ -2328,7 +2640,7 @@ async def get_saved_searches(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_saved_search_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Saved Search synchronization is unavailable",
|
||||
@@ -2358,7 +2670,7 @@ async def replace_saved_searches(payload: SavedSearchCollection):
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Saved Search synchronization is unavailable",
|
||||
@@ -2371,7 +2683,7 @@ async def get_unfiled_drafts(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, UnfiledDraftEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Draft synchronization is unavailable",
|
||||
@@ -2401,7 +2713,7 @@ async def replace_unfiled_drafts(payload: UnfiledDraftCollection):
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, UnfiledDraftEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Draft synchronization is unavailable",
|
||||
@@ -2414,7 +2726,7 @@ async def get_today_plan():
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(_today_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Today synchronization is unavailable",
|
||||
@@ -2448,7 +2760,7 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
|
||||
)
|
||||
except TodayPlanFull:
|
||||
raise HTTPException(status_code=409, detail="Today is limited to 5 items")
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Today synchronization is unavailable",
|
||||
@@ -2456,12 +2768,282 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/tomorrow")
|
||||
async def get_tomorrow_plan():
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(_today_store().get_tomorrow, login)
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Tomorrow synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/v1/tomorrow")
|
||||
async def replace_tomorrow_plan(payload: TomorrowPlanUpdate):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().replace_tomorrow, login, **payload.model_dump()
|
||||
)
|
||||
except TomorrowPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "tomorrow_changed", "snapshot": error.snapshot},
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Tomorrow synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/tomorrow/promote")
|
||||
async def promote_tomorrow_plan(payload: TomorrowPromotion):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().promote_tomorrow, login, **payload.model_dump()
|
||||
)
|
||||
except TomorrowPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "tomorrow_changed", "snapshot": error.snapshot},
|
||||
)
|
||||
except TodayPromotionConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "today_changed", "today": error.today},
|
||||
)
|
||||
except TomorrowPlanNotDue as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "tomorrow_not_due", "plan_date": error.plan_date},
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Tomorrow promotion is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/week")
|
||||
async def get_week_plan():
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(_today_store().get_week, login)
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Week Ahead synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/v1/week")
|
||||
async def replace_week_plan(payload: WeekPlanUpdate):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().replace_week, login,
|
||||
base_revision=payload.base_revision,
|
||||
days=[day.model_dump(exclude_none=True) for day in payload.days],
|
||||
timezone=payload.timezone,
|
||||
availability_defaults=payload.availability_defaults,
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Week Ahead synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/week/promote")
|
||||
async def promote_week_plan(payload: WeekPromotion):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().promote_week, login, **payload.model_dump()
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
|
||||
)
|
||||
except TodayPromotionConflict as error:
|
||||
if error.week is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "week_today_in_progress", "today": error.today, "week": error.week},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "today_changed", "today": error.today}
|
||||
)
|
||||
except TomorrowPlanNotDue as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_not_due", "plan_date": error.plan_date}
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Week Ahead promotion is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/week/reschedule")
|
||||
async def reschedule_today_to_week(payload: WeekReschedule):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().reschedule_today_to_week, login, **payload.model_dump()
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_changed", "week": error.snapshot}
|
||||
)
|
||||
except TodayPromotionConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "today_changed", "today": error.today, "week": error.week},
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Week Ahead rescheduling is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/week/pull-item")
|
||||
async def pull_week_item_into_today(payload: WeekItemPull):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().pull_week_item, login, **payload.model_dump()
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_changed", "week": error.snapshot}
|
||||
)
|
||||
except TodayPromotionConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "today_changed", "today": error.today, "week": error.week},
|
||||
)
|
||||
except TodayPlanFull:
|
||||
raise HTTPException(status_code=409, detail={"code": "today_full"})
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Adding Week Ahead work to Today is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/week/start-early")
|
||||
async def start_week_day_early(payload: WeekPromotion):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().promote_week, login, **payload.model_dump(), allow_future=True
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
|
||||
)
|
||||
except TodayPromotionConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "week_today_in_progress", "today": error.today, "week": error.week},
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Starting Week Ahead early is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/week/reconcile")
|
||||
async def reconcile_week_plan(payload: WeekReconciliation):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().reconcile_week, login, **payload.model_dump()
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
|
||||
)
|
||||
except TodayPromotionConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "week_today_changed", "today": error.today, "week": error.week},
|
||||
)
|
||||
except TomorrowPlanNotDue as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "week_not_due", "plan_date": error.plan_date}
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Week Ahead reconciliation is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/today/session")
|
||||
async def get_today_session():
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(_today_store().get_session, login)
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Today session synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/today/session")
|
||||
async def update_today_session(payload: TodaySessionUpdate):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_today_store().update_session, login, **payload.model_dump()
|
||||
)
|
||||
except TodaySessionConflict as error:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "session_changed", "session": error.session},
|
||||
)
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Today session synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/today/recaps")
|
||||
async def get_today_recaps(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
recaps = await asyncio.to_thread(_today_store().list_recaps, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Today recap history is unavailable",
|
||||
@@ -2483,7 +3065,7 @@ async def save_today_recap(payload: TodayRecap, response: Response):
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Today recap could not be saved",
|
||||
@@ -2535,7 +3117,7 @@ async def save_today_recap_and_log_time(
|
||||
raise ValueError("time log target must match the saved recap")
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(status_code=503, detail="Today recap could not be saved", headers={"Retry-After": "1"})
|
||||
|
||||
semaphore = asyncio.Semaphore(3)
|
||||
@@ -2604,7 +3186,7 @@ async def get_later_plan():
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(_later_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Later synchronization is unavailable",
|
||||
@@ -2634,7 +3216,7 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Later synchronization is unavailable",
|
||||
@@ -3782,7 +4364,7 @@ async def _remove_notifications_from_live_snapshot(thread_ids: list[int]) -> Non
|
||||
_live_snapshot_value = updated
|
||||
try:
|
||||
await asyncio.to_thread(_live_snapshot_store.remove_notifications, read_ids)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
# The upstream mutation already succeeded; keep process-local filtering.
|
||||
pass
|
||||
|
||||
@@ -3962,7 +4544,7 @@ async def live_snapshot(
|
||||
events_revision=events_revision,
|
||||
notifications_revision=notifications_revision,
|
||||
)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
return JSONResponse(
|
||||
{"error": "Gitea live snapshot state is temporarily unavailable"},
|
||||
status_code=503,
|
||||
@@ -4257,7 +4839,7 @@ async def defer_notification(
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
return JSONResponse(
|
||||
{"error": "Later synchronization is unavailable. Please retry."},
|
||||
status_code=503,
|
||||
@@ -5879,31 +6461,60 @@ async def merge_assigned_pull(
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
|
||||
async def merge_pull():
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
return await gitea_proxy.merge_assigned_pull(
|
||||
repository, number, submission.expected_head_sha
|
||||
try:
|
||||
assigned = await asyncio.wait_for(
|
||||
gitea_proxy.is_assigned_pull(repository, number),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The pull request assignment could not be verified. Nothing was merged."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
if not assigned:
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"pull_merged",
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
return JSONResponse(
|
||||
{"error": "Security activity is temporarily unavailable. Nothing was merged."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
merge_pull(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"pull_merged",
|
||||
target=f"{repository}#{number}",
|
||||
gitea_proxy.merge_assigned_pull(
|
||||
repository, number, submission.expected_head_sha
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except gitea_proxy.StalePullError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "New commits were pushed. Refresh before merging."},
|
||||
status_code=409,
|
||||
)
|
||||
except gitea_proxy.PullNotMergeableError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "This pull request is not currently safe to merge. Refresh its status."},
|
||||
status_code=409,
|
||||
@@ -5919,6 +6530,10 @@ async def merge_assigned_pull(
|
||||
except Exception:
|
||||
merged = False
|
||||
if merged:
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return {"number": number, "merged": True, "state": "closed"}
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -5933,6 +6548,12 @@ async def merge_assigned_pull(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/release-receipt/{commit_sha}")
|
||||
async def release_receipt(owner: str, repo: str, commit_sha: str):
|
||||
result = await gitea_proxy.release_receipt_status(f"{owner}/{repo}", commit_sha)
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
|
||||
async def submit_review(
|
||||
submission: PullReviewSubmission,
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
@@ -12,6 +13,14 @@ class UnsafePushEndpoint(ValueError):
|
||||
Resolver = Callable[[str, int], Awaitable[list[str]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedPushEndpoint:
|
||||
endpoint: str
|
||||
hostname: str
|
||||
port: int
|
||||
addresses: tuple[str, ...]
|
||||
|
||||
|
||||
async def _resolve(host: str, port: int) -> list[str]:
|
||||
loop = asyncio.get_running_loop()
|
||||
records = await loop.getaddrinfo(
|
||||
@@ -23,13 +32,13 @@ async def _resolve(host: str, port: int) -> list[str]:
|
||||
return sorted({record[4][0] for record in records})
|
||||
|
||||
|
||||
async def validate_public_push_endpoint(
|
||||
async def resolve_public_push_endpoint(
|
||||
endpoint: str,
|
||||
*,
|
||||
resolver: Resolver = _resolve,
|
||||
timeout_seconds: float = 2.0,
|
||||
) -> str:
|
||||
"""Return a canonical public HTTPS push endpoint or fail closed."""
|
||||
) -> ResolvedPushEndpoint:
|
||||
"""Resolve a canonical endpoint to public addresses for a pinned connection."""
|
||||
parsed = urlsplit(endpoint)
|
||||
try:
|
||||
port = parsed.port
|
||||
@@ -59,4 +68,24 @@ async def validate_public_push_endpoint(
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
|
||||
if not public:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
|
||||
return endpoint
|
||||
return ResolvedPushEndpoint(
|
||||
endpoint=endpoint,
|
||||
hostname=parsed.hostname,
|
||||
port=port or 443,
|
||||
addresses=tuple(sorted(set(addresses))),
|
||||
)
|
||||
|
||||
|
||||
async def validate_public_push_endpoint(
|
||||
endpoint: str,
|
||||
*,
|
||||
resolver: Resolver = _resolve,
|
||||
timeout_seconds: float = 2.0,
|
||||
) -> str:
|
||||
"""Return a canonical public HTTPS push endpoint or fail closed."""
|
||||
resolved = await resolve_public_push_endpoint(
|
||||
endpoint,
|
||||
resolver=resolver,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
return resolved.endpoint
|
||||
+257
-16
@@ -5,10 +5,18 @@ import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Awaitable, Callable
|
||||
from urllib.parse import urlsplit
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import requests
|
||||
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
from src.push_endpoint_policy import (
|
||||
ResolvedPushEndpoint,
|
||||
UnsafePushEndpoint,
|
||||
resolve_public_push_endpoint,
|
||||
validate_public_push_endpoint,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -22,17 +30,65 @@ class PushConfiguration:
|
||||
return bool(self.public_key and self.private_key and self.subject)
|
||||
|
||||
|
||||
class _PinnedHTTPSAdapter(requests.adapters.HTTPAdapter):
|
||||
"""Dial one approved IP while authenticating the endpoint's original host."""
|
||||
|
||||
def __init__(self, resolved: ResolvedPushEndpoint):
|
||||
self.resolved = resolved
|
||||
super().__init__()
|
||||
|
||||
def add_headers(self, request, **kwargs):
|
||||
super().add_headers(request, **kwargs)
|
||||
request.headers["Host"] = self.resolved.hostname
|
||||
|
||||
def build_connection_pool_key_attributes(self, request, verify, cert=None):
|
||||
parsed = urlsplit(request.url)
|
||||
if parsed.scheme != "https" or parsed.hostname != self.resolved.hostname:
|
||||
raise UnsafePushEndpoint("Push transport attempted an unvalidated destination")
|
||||
host, tls = super().build_connection_pool_key_attributes(request, verify, cert)
|
||||
host.update(
|
||||
host=self.resolved.addresses[0],
|
||||
port=self.resolved.port,
|
||||
)
|
||||
tls.update(
|
||||
assert_hostname=self.resolved.hostname,
|
||||
server_hostname=self.resolved.hostname,
|
||||
)
|
||||
return host, tls
|
||||
|
||||
|
||||
def _delivery_failure_reason(error: Exception) -> str:
|
||||
if isinstance(error, (asyncio.TimeoutError, TimeoutError)):
|
||||
return "timeout"
|
||||
return "provider"
|
||||
|
||||
|
||||
async def send_web_push(
|
||||
subscription: dict, payload: str, configuration: PushConfiguration
|
||||
subscription: dict,
|
||||
payload: str,
|
||||
configuration: PushConfiguration,
|
||||
*,
|
||||
endpoint_resolver: Callable[[str], Awaitable[ResolvedPushEndpoint]] | None = None,
|
||||
webpush_sender: Callable[..., object] | None = None,
|
||||
) -> None:
|
||||
import requests
|
||||
from pywebpush import webpush
|
||||
if endpoint_resolver is None:
|
||||
endpoint_resolver = resolve_public_push_endpoint
|
||||
resolved = await endpoint_resolver(subscription["endpoint"])
|
||||
if not resolved.addresses:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
|
||||
if webpush_sender is None:
|
||||
from pywebpush import webpush
|
||||
|
||||
webpush_sender = webpush
|
||||
|
||||
session = requests.Session()
|
||||
session.trust_env = False
|
||||
session.max_redirects = 0
|
||||
origin = f"https://{resolved.hostname}"
|
||||
session.mount(origin, _PinnedHTTPSAdapter(resolved))
|
||||
|
||||
await asyncio.to_thread(
|
||||
webpush,
|
||||
webpush_sender,
|
||||
subscription_info=subscription,
|
||||
data=payload,
|
||||
vapid_private_key=configuration.private_key,
|
||||
@@ -77,6 +133,7 @@ async def dispatch_unread_updates(
|
||||
for item in page.get("items", [])
|
||||
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
|
||||
}
|
||||
unread_count = min(len(thread_revisions), 9999)
|
||||
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
|
||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
|
||||
if session_statuses is not None:
|
||||
@@ -145,6 +202,7 @@ async def dispatch_unread_updates(
|
||||
"route": f"#/my-work/update/{thread_id}",
|
||||
"tag": f"stackchain-update-{thread_id}",
|
||||
"notification_id": thread_id,
|
||||
"unread_count": unread_count,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
@@ -160,14 +218,24 @@ async def dispatch_unread_updates(
|
||||
status = getattr(
|
||||
getattr(error, "response", None), "status_code", None
|
||||
)
|
||||
if status in {404, 410}:
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(
|
||||
store.delete_session, delivery.session_id
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
delivery.session_id,
|
||||
"unread",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
can_send_digest = False
|
||||
# Leave this device's transient failures unseen for a later
|
||||
# poll instead of paying the endpoint deadline repeatedly.
|
||||
break
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, delivery.session_id, "unread"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivered,
|
||||
delivery.session_id,
|
||||
@@ -192,6 +260,7 @@ async def dispatch_unread_updates(
|
||||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-digest",
|
||||
"update_count": len(overflow_revisions),
|
||||
"unread_count": unread_count,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
@@ -207,17 +276,26 @@ async def dispatch_unread_updates(
|
||||
status = getattr(
|
||||
getattr(error, "response", None), "status_code", None
|
||||
)
|
||||
if status in {404, 410}:
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(
|
||||
store.delete_session, delivery.session_id
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
delivery.session_id,
|
||||
"unread",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_digest_pending,
|
||||
delivery.session_id,
|
||||
overflow_revisions,
|
||||
)
|
||||
return count
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, delivery.session_id, "unread"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivered,
|
||||
delivery.session_id,
|
||||
@@ -229,10 +307,19 @@ async def dispatch_unread_updates(
|
||||
async def dispatch_device_safely(delivery) -> int:
|
||||
try:
|
||||
return await dispatch_device(delivery)
|
||||
except Exception:
|
||||
except Exception as error:
|
||||
# Device-specific validation, persistence, or provider failures
|
||||
# must not cancel healthy siblings. Leave any uncheckpointed
|
||||
# revisions unseen so a later poll can retry them.
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
delivery.session_id,
|
||||
"unread",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
counts = await asyncio.gather(
|
||||
@@ -272,6 +359,125 @@ async def dispatch_deadline_reminders(
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="deadline")
|
||||
|
||||
|
||||
async def dispatch_start_day_reminders(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
tomorrow: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
lease_seconds: float = 60.0,
|
||||
max_concurrency: int = 8,
|
||||
) -> int:
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="start-day",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
devices = await asyncio.to_thread(store.start_day_reminder_devices)
|
||||
if not devices:
|
||||
return 0
|
||||
current = now or datetime.now(timezone.utc)
|
||||
due_devices = []
|
||||
for device in devices:
|
||||
try:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
except ZoneInfoNotFoundError:
|
||||
continue
|
||||
if local_now.hour >= device.reminder_hour:
|
||||
due_devices.append((device, local_now.date().isoformat()))
|
||||
if not due_devices:
|
||||
return 0
|
||||
plan = await tomorrow()
|
||||
ids = plan.get("ids") if isinstance(plan, dict) else None
|
||||
plan_date = plan.get("plan_date") if isinstance(plan, dict) else None
|
||||
if not isinstance(ids, list) or not ids or not isinstance(plan_date, str):
|
||||
return 0
|
||||
due_devices = [
|
||||
(device, local_day) for device, local_day in due_devices
|
||||
if local_day >= plan_date and device.delivered_plan_date != plan_date
|
||||
]
|
||||
if not due_devices:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses(
|
||||
[device.session_id for device, _local_day in due_devices]
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
for device, _local_day in due_devices:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
due_devices = [
|
||||
pair for pair in due_devices
|
||||
if statuses.get(pair[0].session_id) == "active"
|
||||
]
|
||||
payload = json.dumps({
|
||||
"title": "Your planned day is ready",
|
||||
"body": "Open Stackchain to prepare Today.",
|
||||
"route": "#/my-work/start-day",
|
||||
"tag": f"stackchain-start-day-{plan_date}",
|
||||
"plan_date": plan_date,
|
||||
}, separators=(",", ":"))
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="start-day",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not still_owner:
|
||||
return 0
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload) if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
device.session_id,
|
||||
"start-day",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, device.session_id, "start-day"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_start_day_reminder_delivered, device.session_id, plan_date
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device, _local_day in due_devices),
|
||||
return_exceptions=True,
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="start-day")
|
||||
|
||||
|
||||
async def _dispatch_deadline_reminders_unlocked(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
@@ -298,10 +504,16 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
except ZoneInfoNotFoundError:
|
||||
continue
|
||||
if (
|
||||
local_now.hour >= device.reminder_hour
|
||||
snooze_due = (
|
||||
device.snoozed_until is not None
|
||||
and device.snoozed_until <= current.timestamp()
|
||||
)
|
||||
daily_due = (
|
||||
device.snoozed_until is None
|
||||
and local_now.hour >= device.reminder_hour
|
||||
and device.delivered_local_day != local_now.date().isoformat()
|
||||
):
|
||||
)
|
||||
if snooze_due or daily_due:
|
||||
eligible_devices.append(device)
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
@@ -319,6 +531,11 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||
continue
|
||||
due_days.append(due_day)
|
||||
if not due_days:
|
||||
await asyncio.gather(*(
|
||||
asyncio.to_thread(store.clear_deadline_snooze, device.session_id)
|
||||
for device in eligible_devices
|
||||
if device.snoozed_until is not None
|
||||
))
|
||||
return 0
|
||||
due_counts = {}
|
||||
for device in eligible_devices:
|
||||
@@ -327,6 +544,11 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||
due_count = sum(due_day <= local_cutoff for due_day in due_days)
|
||||
if due_count:
|
||||
due_counts[device.session_id] = due_count
|
||||
await asyncio.gather(*(
|
||||
asyncio.to_thread(store.clear_deadline_snooze, device.session_id)
|
||||
for device in eligible_devices
|
||||
if device.snoozed_until is not None and device.session_id not in due_counts
|
||||
))
|
||||
eligible_devices = [
|
||||
device for device in eligible_devices if device.session_id in due_counts
|
||||
]
|
||||
@@ -358,10 +580,16 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||
except ZoneInfoNotFoundError:
|
||||
return 0
|
||||
local_day = local_now.date().isoformat()
|
||||
if (
|
||||
local_now.hour < device.reminder_hour
|
||||
or device.delivered_local_day == local_day
|
||||
):
|
||||
snooze_due = (
|
||||
device.snoozed_until is not None
|
||||
and device.snoozed_until <= current.timestamp()
|
||||
)
|
||||
daily_due = (
|
||||
device.snoozed_until is None
|
||||
and local_now.hour >= device.reminder_hour
|
||||
and device.delivered_local_day != local_day
|
||||
)
|
||||
if not (snooze_due or daily_due):
|
||||
return 0
|
||||
due_count = due_counts[device.session_id]
|
||||
still_owner = await asyncio.to_thread(
|
||||
@@ -388,8 +616,21 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception:
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
device.session_id,
|
||||
"deadline",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, device.session_id, "deadline"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_deadline_reminder_delivered, device.session_id, local_day
|
||||
)
|
||||
|
||||
+335
-11
@@ -1,10 +1,19 @@
|
||||
import json
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
PrivateStateCipher,
|
||||
decode_private_state_encryption_key,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -31,6 +40,108 @@ class DeadlineReminderDevice:
|
||||
reminder_hour: int
|
||||
reminder_days: int
|
||||
delivered_local_day: str | None
|
||||
snoozed_until: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StartDayReminderDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
timezone: str
|
||||
reminder_hour: int
|
||||
delivered_plan_date: str | None
|
||||
|
||||
|
||||
class DisabledPushSubscriptionStore:
|
||||
"""No-persistence store used when Web Push is not configured."""
|
||||
|
||||
def is_subscribed(self, session_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def subscription_for_session(self, session_id: str) -> dict | None:
|
||||
return None
|
||||
|
||||
def deadline_preferences(
|
||||
self, session_id: str, *, now: float | None = None
|
||||
) -> dict:
|
||||
return {
|
||||
"enabled": False,
|
||||
"timezone": "UTC",
|
||||
"reminder_hour": 9,
|
||||
"reminder_days": 2,
|
||||
"snoozed_until": None,
|
||||
}
|
||||
|
||||
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
||||
return []
|
||||
|
||||
def start_day_preferences(self, session_id: str) -> dict:
|
||||
return {"enabled": False, "timezone": "UTC", "reminder_hour": 9}
|
||||
|
||||
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
|
||||
return []
|
||||
|
||||
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
|
||||
return []
|
||||
|
||||
def acquire_dispatch_lease(self, *args, **kwargs) -> bool:
|
||||
return False
|
||||
|
||||
def release_dispatch_lease(self, *args, **kwargs) -> bool:
|
||||
return False
|
||||
|
||||
def snooze_deadline_reminder(self, *args, **kwargs) -> bool:
|
||||
return False
|
||||
|
||||
def upsert(self, *args, **kwargs) -> None:
|
||||
raise RuntimeError("push notifications are not configured")
|
||||
|
||||
def delete_session(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def delete_all(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_deadline_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def clear_deadline_snooze(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_deadline_reminder_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_start_day_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def reconcile_unread(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_digest_pending(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def delivery_health(self, *args, **kwargs) -> dict:
|
||||
return {}
|
||||
|
||||
def mark_delivery_failed(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_delivery_succeeded(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def build_push_subscription_store(
|
||||
path: str | Path, *, push_enabled: bool
|
||||
) -> PushSubscriptionStore | DisabledPushSubscriptionStore:
|
||||
if not push_enabled:
|
||||
return DisabledPushSubscriptionStore()
|
||||
return PushSubscriptionStore(path)
|
||||
|
||||
|
||||
def _revisions(
|
||||
@@ -56,8 +167,15 @@ def _revisions(
|
||||
class PushSubscriptionStore:
|
||||
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
|
||||
|
||||
def __init__(self, path: str | Path):
|
||||
def __init__(self, path: str | Path, *, encryption_key: bytes | None = None):
|
||||
self.path = Path(path)
|
||||
key = encryption_key
|
||||
if key is None:
|
||||
key = decode_private_state_encryption_key(
|
||||
os.getenv("STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY", "")
|
||||
)
|
||||
self._encryption_key = key
|
||||
self._cipher = PrivateStateCipher(key, store="push-subscriptions")
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
@@ -94,6 +212,27 @@ class PushSubscriptionStore:
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
reminder_days INTEGER NOT NULL DEFAULT 2,
|
||||
delivered_local_day TEXT,
|
||||
snoozed_until REAL,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_delivery_health (
|
||||
session_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempted_at REAL NOT NULL,
|
||||
last_succeeded_at REAL,
|
||||
reason TEXT,
|
||||
PRIMARY KEY (session_id, channel),
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_start_day_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
delivered_plan_date TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
@@ -140,6 +279,35 @@ class PushSubscriptionStore:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
|
||||
)
|
||||
if "snoozed_until" not in preference_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_deadline_preferences ADD COLUMN snoozed_until REAL"
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, endpoint, subscription_json FROM push_subscriptions"
|
||||
).fetchall()
|
||||
for session_id, endpoint, payload in rows:
|
||||
subscription, plaintext = self._cipher.open(payload, binding=session_id)
|
||||
if not isinstance(subscription, dict) or not subscription.get("endpoint"):
|
||||
raise ValueError("push subscription payload is invalid")
|
||||
endpoint_index = self._endpoint_index(subscription["endpoint"])
|
||||
if plaintext or endpoint != endpoint_index:
|
||||
connection.execute(
|
||||
"UPDATE push_subscriptions SET endpoint = ?, subscription_json = ? WHERE session_id = ?",
|
||||
(
|
||||
endpoint_index,
|
||||
self._cipher.seal(subscription, binding=session_id),
|
||||
session_id,
|
||||
),
|
||||
)
|
||||
|
||||
def _endpoint_index(self, endpoint: str) -> str:
|
||||
return hmac.new(
|
||||
self._encryption_key,
|
||||
b"stackchain:push-endpoint:v1\0" + endpoint.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
def _connect(self):
|
||||
connection = connect_private_sqlite(self.path, timeout=2)
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
@@ -175,13 +343,16 @@ class PushSubscriptionStore:
|
||||
|
||||
def upsert(self, session_id: str, subscription: dict) -> None:
|
||||
endpoint = subscription["endpoint"]
|
||||
encoded = json.dumps(subscription, separators=(",", ":"), sort_keys=True)
|
||||
endpoint_index = self._endpoint_index(endpoint)
|
||||
encoded = self._cipher.seal(subscription, binding=session_id)
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,))
|
||||
connection.execute(
|
||||
"DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint_index,)
|
||||
)
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
||||
connection.execute(
|
||||
"INSERT INTO push_subscriptions(session_id, endpoint, subscription_json) VALUES (?, ?, ?)",
|
||||
(session_id, endpoint, encoded),
|
||||
(session_id, endpoint_index, encoded),
|
||||
)
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
@@ -198,6 +369,67 @@ class PushSubscriptionStore:
|
||||
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
||||
).fetchone() is not None
|
||||
|
||||
def subscription_for_session(self, session_id: str) -> dict | None:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT subscription_json FROM push_subscriptions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return self._open_subscription(session_id, row[0]) if row else None
|
||||
|
||||
def delivery_health(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT channel, consecutive_failures, last_attempted_at,
|
||||
last_succeeded_at, reason
|
||||
FROM push_delivery_health WHERE session_id = ? ORDER BY channel""",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
return {
|
||||
row[0]: {
|
||||
"state": "degraded" if row[1] else "healthy",
|
||||
"consecutive_failures": row[1],
|
||||
"last_attempted_at": row[2],
|
||||
"last_succeeded_at": row[3],
|
||||
"reason": row[4],
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
def mark_delivery_failed(
|
||||
self, session_id: str, channel: str, reason: str, *, now: float | None = None
|
||||
) -> None:
|
||||
attempted_at = time.time() if now is None else now
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_delivery_health(
|
||||
session_id, channel, consecutive_failures, last_attempted_at, reason
|
||||
) VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(session_id, channel) DO UPDATE SET
|
||||
consecutive_failures = consecutive_failures + 1,
|
||||
last_attempted_at = excluded.last_attempted_at,
|
||||
reason = excluded.reason""",
|
||||
(session_id, channel, attempted_at, reason),
|
||||
)
|
||||
|
||||
def mark_delivery_succeeded(
|
||||
self, session_id: str, channel: str, *, now: float | None = None
|
||||
) -> None:
|
||||
attempted_at = time.time() if now is None else now
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_delivery_health(
|
||||
session_id, channel, consecutive_failures, last_attempted_at,
|
||||
last_succeeded_at, reason
|
||||
) VALUES (?, ?, 0, ?, ?, NULL)
|
||||
ON CONFLICT(session_id, channel) DO UPDATE SET
|
||||
consecutive_failures = 0,
|
||||
last_attempted_at = excluded.last_attempted_at,
|
||||
last_succeeded_at = excluded.last_succeeded_at,
|
||||
reason = NULL""",
|
||||
(session_id, channel, attempted_at, attempted_at),
|
||||
)
|
||||
|
||||
def set_deadline_preferences(
|
||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
|
||||
reminder_days: int = 2,
|
||||
@@ -215,42 +447,128 @@ class PushSubscriptionStore:
|
||||
(session_id, int(enabled), timezone, reminder_hour, reminder_days),
|
||||
)
|
||||
|
||||
def deadline_preferences(self, session_id: str) -> dict:
|
||||
def deadline_preferences(self, session_id: str, *, now: float | None = None) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT enabled, timezone, reminder_hour, reminder_days
|
||||
"""SELECT enabled, timezone, reminder_hour, reminder_days, snoozed_until
|
||||
FROM push_deadline_preferences WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
current_time = time.time() if now is None else now
|
||||
snoozed_until = row[4] if row and row[4] and row[4] > current_time else None
|
||||
return {
|
||||
"enabled": bool(row[0]) if row else False,
|
||||
"timezone": row[1] if row else "UTC",
|
||||
"reminder_hour": row[2] if row else 9,
|
||||
"reminder_days": row[3] if row else 2,
|
||||
"snoozed_until": snoozed_until,
|
||||
}
|
||||
|
||||
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
||||
p.reminder_hour, p.reminder_days, p.delivered_local_day
|
||||
p.reminder_hour, p.reminder_days, p.delivered_local_day,
|
||||
p.snoozed_until
|
||||
FROM push_subscriptions s
|
||||
JOIN push_deadline_preferences p ON p.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
return [
|
||||
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4], row[5])
|
||||
DeadlineReminderDevice(
|
||||
row[0],
|
||||
self._open_subscription(row[0], row[1]),
|
||||
row[2],
|
||||
row[3],
|
||||
row[4],
|
||||
row[5],
|
||||
row[6],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def snooze_deadline_reminder(
|
||||
self, session_id: str, *, now: float, delay_seconds: int = 3_600
|
||||
) -> bool:
|
||||
with self._connect() as connection:
|
||||
result = connection.execute(
|
||||
"""UPDATE push_deadline_preferences SET snoozed_until = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(now + delay_seconds, session_id),
|
||||
)
|
||||
return result.rowcount == 1
|
||||
|
||||
def clear_deadline_snooze(self, session_id: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_deadline_preferences SET snoozed_until = NULL
|
||||
WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
)
|
||||
|
||||
def mark_deadline_reminder_delivered(self, session_id: str, local_day: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_deadline_preferences SET delivered_local_day = ?
|
||||
"""UPDATE push_deadline_preferences
|
||||
SET delivered_local_day = ?, snoozed_until = NULL
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(local_day, session_id),
|
||||
)
|
||||
|
||||
def set_start_day_preferences(
|
||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_start_day_preferences(
|
||||
session_id, enabled, timezone, reminder_hour
|
||||
) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
timezone = excluded.timezone,
|
||||
reminder_hour = excluded.reminder_hour""",
|
||||
(session_id, int(enabled), timezone, reminder_hour),
|
||||
)
|
||||
|
||||
def start_day_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT enabled, timezone, reminder_hour
|
||||
FROM push_start_day_preferences WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"enabled": bool(row[0]) if row else False,
|
||||
"timezone": row[1] if row else "UTC",
|
||||
"reminder_hour": row[2] if row else 9,
|
||||
}
|
||||
|
||||
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
||||
p.reminder_hour, p.delivered_plan_date
|
||||
FROM push_subscriptions s
|
||||
JOIN push_start_day_preferences p ON p.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
return [
|
||||
StartDayReminderDevice(
|
||||
row[0], self._open_subscription(row[0], row[1]), row[2], row[3], row[4]
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def mark_start_day_reminder_delivered(
|
||||
self, session_id: str, plan_date: str
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_start_day_preferences SET delivered_plan_date = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(plan_date, session_id),
|
||||
)
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||
) -> list[PushDelivery]:
|
||||
@@ -313,13 +631,19 @@ class PushSubscriptionStore:
|
||||
deliveries.append(
|
||||
PushDelivery(
|
||||
session_id,
|
||||
json.loads(encoded),
|
||||
self._open_subscription(session_id, encoded),
|
||||
unseen,
|
||||
tuple(digest_revisions),
|
||||
)
|
||||
)
|
||||
return deliveries
|
||||
|
||||
def _open_subscription(self, session_id: str, payload: str) -> dict:
|
||||
subscription, _plaintext = self._cipher.open(payload, binding=session_id)
|
||||
if not isinstance(subscription, dict):
|
||||
raise ValueError("push subscription payload is invalid")
|
||||
return subscription
|
||||
|
||||
def reconcile_unread(self, thread_ids: Iterable[int]) -> None:
|
||||
"""Prune per-device checkpoints that are absent from a complete snapshot."""
|
||||
unread_ids = tuple(sorted({int(value) for value in thread_ids if int(value) > 0}))
|
||||
|
||||
+34
-11
@@ -1,11 +1,11 @@
|
||||
"""Durable, account-scoped saved Search views."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
@@ -21,10 +21,21 @@ class SavedSearchConflict(ValueError):
|
||||
|
||||
|
||||
class SavedSearchStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0):
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 20,
|
||||
timeout: float = 1.0,
|
||||
encryption_key: bytes | None = None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="saved-searches",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
@@ -50,19 +61,31 @@ class SavedSearchStore:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
return {"revision": 0, "views": []} if row is None else {
|
||||
"revision": int(row[0]), "views": json.loads(row[1])
|
||||
}
|
||||
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "views": []}, False
|
||||
views, legacy = self._cipher.open(row[1], binding=f"views:{login}")
|
||||
if not isinstance(views, list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {"revision": int(row[0]), "views": views}, legacy
|
||||
|
||||
def _sealed_views(self, login: str, views: list[dict]) -> str:
|
||||
return self._cipher.seal(views, binding=f"views:{login}")
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = ?",
|
||||
(self._login(login),),
|
||||
(login,),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
snapshot, legacy = self._snapshot(row, login)
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE saved_searches SET views = ? WHERE login = ? AND views = ?",
|
||||
(self._sealed_views(login, snapshot["views"]), login, row[1]),
|
||||
)
|
||||
return snapshot
|
||||
|
||||
def _normalize(self, views: list[dict]) -> list[dict]:
|
||||
if not isinstance(views, list):
|
||||
@@ -113,13 +136,13 @@ class SavedSearchStore:
|
||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||
raise ValueError("revision is invalid")
|
||||
normalized = self._normalize(views)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
serialized = self._sealed_views(login, normalized)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current = self._snapshot(row)
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
if current["revision"] != expected_revision:
|
||||
raise SavedSearchConflict(current)
|
||||
revision = expected_revision + 1
|
||||
|
||||
+137
-23
@@ -7,6 +7,11 @@ from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
|
||||
class SecurityEventStoreError(RuntimeError):
|
||||
@@ -39,12 +44,22 @@ class SecurityEventStore:
|
||||
max_events: int = 10_000,
|
||||
retention_seconds: int = 90 * 24 * 60 * 60,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
encryption_key: bytes | None = None,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.max_events = max(1, max_events)
|
||||
self.retention_seconds = max(1, retention_seconds)
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
try:
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="security-events",
|
||||
)
|
||||
except PrivateStateEncryptionError as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _bounded(value: str | None, limit: int) -> str | None:
|
||||
@@ -60,10 +75,7 @@ class SecurityEventStore:
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS security_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
method TEXT,
|
||||
device_label TEXT,
|
||||
target TEXT,
|
||||
payload TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
@@ -81,6 +93,11 @@ class SecurityEventStore:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
|
||||
)
|
||||
plaintext_columns = {"kind", "method", "device_label", "target"}
|
||||
if "payload" not in columns or plaintext_columns.intersection(columns):
|
||||
self._migrate_plaintext(
|
||||
connection, has_payload="payload" in columns
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS security_events_created "
|
||||
"ON security_events(created_at DESC, id DESC)"
|
||||
@@ -95,6 +112,56 @@ class SecurityEventStore:
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def _migrate_plaintext(
|
||||
self, connection: sqlite3.Connection, *, has_payload: bool
|
||||
) -> None:
|
||||
connection.execute("PRAGMA secure_delete = ON")
|
||||
payload_column = "payload," if has_payload else "NULL AS payload,"
|
||||
rows = connection.execute(
|
||||
f"SELECT id, {payload_column} kind, method, device_label, target, "
|
||||
"created_at, status, operation_id FROM security_events ORDER BY id"
|
||||
).fetchall()
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE security_events_encrypted (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
payload TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
for (
|
||||
event_id,
|
||||
payload,
|
||||
kind,
|
||||
method,
|
||||
device_label,
|
||||
target,
|
||||
created_at,
|
||||
status,
|
||||
operation_id,
|
||||
) in rows:
|
||||
connection.execute(
|
||||
"INSERT INTO security_events_encrypted "
|
||||
"(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
event_id,
|
||||
payload
|
||||
or self._seal_event(
|
||||
event_id, kind, method, device_label, target
|
||||
),
|
||||
created_at,
|
||||
status,
|
||||
operation_id,
|
||||
),
|
||||
)
|
||||
connection.execute("DROP TABLE security_events")
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events_encrypted RENAME TO security_events"
|
||||
)
|
||||
|
||||
def _prune(self, connection: sqlite3.Connection, now: int) -> None:
|
||||
connection.execute(
|
||||
"DELETE FROM security_events WHERE created_at < ?",
|
||||
@@ -106,6 +173,24 @@ class SecurityEventStore:
|
||||
(self.max_events,),
|
||||
)
|
||||
|
||||
def _seal_event(
|
||||
self,
|
||||
event_id: int,
|
||||
kind: str,
|
||||
method: str | None,
|
||||
device_label: str | None,
|
||||
target: str | None,
|
||||
) -> str:
|
||||
return self._cipher.seal(
|
||||
{
|
||||
"kind": self._bounded(kind, 48) or "security_event",
|
||||
"method": self._bounded(method, 32),
|
||||
"device_label": self._bounded(device_label, 64),
|
||||
"target": self._bounded(target, 255),
|
||||
},
|
||||
binding=f"event:{event_id}",
|
||||
)
|
||||
|
||||
def record(
|
||||
self,
|
||||
kind: str,
|
||||
@@ -117,16 +202,17 @@ class SecurityEventStore:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO security_events(created_at, status) VALUES (?, 'completed')",
|
||||
(now,),
|
||||
)
|
||||
event_id = cursor.lastrowid
|
||||
payload = self._seal_event(
|
||||
event_id, kind, method, device_label, target
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO security_events(kind, method, device_label, target, created_at, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'completed')",
|
||||
(
|
||||
self._bounded(kind, 48) or "security_event",
|
||||
self._bounded(method, 32),
|
||||
self._bounded(device_label, 64),
|
||||
self._bounded(target, 255),
|
||||
now,
|
||||
),
|
||||
"UPDATE security_events SET payload = ? WHERE id = ?",
|
||||
(payload, event_id),
|
||||
)
|
||||
self._prune(connection, now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
@@ -146,16 +232,19 @@ class SecurityEventStore:
|
||||
operation_id = secrets.token_urlsafe(24)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO security_events(created_at, status, operation_id) "
|
||||
"VALUES (?, 'pending', ?)",
|
||||
(now, operation_id),
|
||||
)
|
||||
event_id = cursor.lastrowid
|
||||
connection.execute(
|
||||
"INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
|
||||
"UPDATE security_events SET payload = ? WHERE id = ?",
|
||||
(
|
||||
self._bounded(kind, 48) or "security_event",
|
||||
self._bounded(method, 32),
|
||||
self._bounded(device_label, 64),
|
||||
self._bounded(target, 255),
|
||||
now,
|
||||
operation_id,
|
||||
self._seal_event(
|
||||
event_id, kind, method, device_label, target
|
||||
),
|
||||
event_id,
|
||||
),
|
||||
)
|
||||
self._prune(connection, now)
|
||||
@@ -204,7 +293,7 @@ class SecurityEventStore:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, kind, method, device_label, target, created_at, status "
|
||||
"SELECT id, payload, created_at, status "
|
||||
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
|
||||
parameters,
|
||||
).fetchall()
|
||||
@@ -214,7 +303,32 @@ class SecurityEventStore:
|
||||
) from exc
|
||||
has_more = len(rows) > bounded_limit
|
||||
visible = rows[:bounded_limit]
|
||||
events = []
|
||||
try:
|
||||
for event_id, payload, created_at, status in visible:
|
||||
value, _legacy = self._cipher.open(
|
||||
payload, binding=f"event:{event_id}"
|
||||
)
|
||||
if not isinstance(value, dict) or not isinstance(value.get("kind"), str):
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state could not be decrypted"
|
||||
)
|
||||
events.append(
|
||||
SecurityEvent(
|
||||
event_id,
|
||||
value["kind"],
|
||||
value.get("method"),
|
||||
value.get("device_label"),
|
||||
value.get("target"),
|
||||
created_at,
|
||||
status,
|
||||
)
|
||||
)
|
||||
except PrivateStateEncryptionError as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
return SecurityEventPage(
|
||||
events=[SecurityEvent(*row) for row in visible],
|
||||
events=events,
|
||||
next_cursor=visible[-1][0] if has_more else None,
|
||||
)
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Authenticated envelopes for retained private dashboard state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
|
||||
class PrivateStateEncryptionError(RuntimeError):
|
||||
"""Private retained state could not be authenticated or decrypted."""
|
||||
|
||||
|
||||
def decode_private_state_encryption_key(encoded: str) -> bytes:
|
||||
"""Decode an independently injected 256-bit private-state key."""
|
||||
try:
|
||||
key = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError) as error:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption key is invalid"
|
||||
) from error
|
||||
if len(key) != 32:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption key must decode to exactly 32 bytes"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def private_state_encryption_key() -> bytes:
|
||||
"""Load the legacy single key setting."""
|
||||
return decode_private_state_encryption_key(
|
||||
os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", "")
|
||||
)
|
||||
|
||||
|
||||
_KEY_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
|
||||
_MAX_KEYS = 4
|
||||
|
||||
|
||||
def decode_private_state_encryption_keyring(
|
||||
encoded: str, active_key_id: str
|
||||
) -> tuple[dict[str, bytes], str]:
|
||||
"""Decode a bounded key-ID-to-key JSON object and validate its active key."""
|
||||
try:
|
||||
raw = json.loads(encoded)
|
||||
except (TypeError, json.JSONDecodeError) as error:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption keyring is invalid"
|
||||
) from error
|
||||
if not isinstance(raw, dict) or not raw:
|
||||
raise PrivateStateEncryptionError("private state encryption keyring is invalid")
|
||||
if len(raw) > _MAX_KEYS:
|
||||
raise PrivateStateEncryptionError(
|
||||
f"private state encryption keyring supports at most {_MAX_KEYS} keys"
|
||||
)
|
||||
keys: dict[str, bytes] = {}
|
||||
for key_id, value in raw.items():
|
||||
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
|
||||
raise PrivateStateEncryptionError("private state encryption key ID is invalid")
|
||||
if not isinstance(value, str):
|
||||
raise PrivateStateEncryptionError("private state encryption keyring is invalid")
|
||||
keys[key_id] = decode_private_state_encryption_key(value)
|
||||
if active_key_id not in keys:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state active key is missing from the keyring"
|
||||
)
|
||||
return keys, active_key_id
|
||||
|
||||
|
||||
def private_state_encryption_config() -> bytes | tuple[dict[str, bytes], str]:
|
||||
"""Load a rotation keyring when configured, otherwise the legacy single key."""
|
||||
encoded = os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS", "")
|
||||
active = os.getenv("STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID", "")
|
||||
if encoded or active:
|
||||
return decode_private_state_encryption_keyring(encoded, active)
|
||||
return private_state_encryption_key()
|
||||
|
||||
|
||||
class PrivateStateCipher:
|
||||
"""Seal JSON values with store-specific authenticated context."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key: bytes | Mapping[str, bytes] | tuple[dict[str, bytes], str],
|
||||
*,
|
||||
store: str,
|
||||
active_key_id: str | None = None,
|
||||
):
|
||||
if isinstance(key, tuple):
|
||||
key, active_key_id = key
|
||||
if isinstance(key, Mapping):
|
||||
if not key or len(key) > _MAX_KEYS or active_key_id not in key:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption keyring configuration is invalid"
|
||||
)
|
||||
ciphers: dict[str, AESGCM] = {}
|
||||
for key_id, raw_key in key.items():
|
||||
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption key ID is invalid"
|
||||
)
|
||||
if not isinstance(raw_key, bytes) or len(raw_key) != 32:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption requires exactly 32 key bytes"
|
||||
)
|
||||
ciphers[key_id] = AESGCM(raw_key)
|
||||
self._ciphers = ciphers
|
||||
self._active_key_id = active_key_id
|
||||
self._legacy_cipher = None
|
||||
else:
|
||||
if not isinstance(key, bytes) or len(key) != 32:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption requires exactly 32 key bytes"
|
||||
)
|
||||
self._ciphers = {}
|
||||
self._active_key_id = None
|
||||
self._legacy_cipher = AESGCM(key)
|
||||
if not store or "\0" in store:
|
||||
raise ValueError("private state store identity is invalid")
|
||||
self._store = store
|
||||
|
||||
def _aad(self, binding: str, *, version: str = "v1", key_id: str = "") -> bytes:
|
||||
prefix = f"stackchain:private-state:{version}\0{self._store}\0{binding}"
|
||||
if version == "v2":
|
||||
prefix += f"\0{key_id}"
|
||||
return prefix.encode()
|
||||
|
||||
def seal(self, value: object, *, binding: str = "singleton") -> str:
|
||||
plaintext = json.dumps(value, separators=(",", ":")).encode()
|
||||
nonce = os.urandom(12)
|
||||
if self._active_key_id is not None:
|
||||
key_id = self._active_key_id
|
||||
sealed = nonce + self._ciphers[key_id].encrypt(
|
||||
nonce, plaintext, self._aad(binding, version="v2", key_id=key_id)
|
||||
)
|
||||
return f"v2:{key_id}:" + base64.urlsafe_b64encode(sealed).decode()
|
||||
sealed = nonce + self._legacy_cipher.encrypt(
|
||||
nonce, plaintext, self._aad(binding)
|
||||
)
|
||||
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
|
||||
|
||||
@staticmethod
|
||||
def _decode_sealed(payload: str) -> bytes:
|
||||
sealed = base64.b64decode(payload, altchars=b"-_", validate=True)
|
||||
if len(sealed) < 28:
|
||||
raise ValueError("encrypted payload is too short")
|
||||
return sealed
|
||||
|
||||
def open(self, payload: str, *, binding: str = "singleton") -> tuple[object, bool]:
|
||||
"""Return the decoded value and whether active-key rewrapping is required."""
|
||||
if not isinstance(payload, str):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
if not payload.startswith(("v1:", "v2:")):
|
||||
try:
|
||||
return json.loads(payload), True
|
||||
except (TypeError, json.JSONDecodeError) as error:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state could not be decrypted"
|
||||
) from error
|
||||
try:
|
||||
if payload.startswith("v2:"):
|
||||
_, key_id, encoded = payload.split(":", 2)
|
||||
cipher = self._ciphers.get(key_id)
|
||||
if cipher is None:
|
||||
raise ValueError("encrypted payload uses an unavailable key")
|
||||
sealed = self._decode_sealed(encoded)
|
||||
plaintext = cipher.decrypt(
|
||||
sealed[:12],
|
||||
sealed[12:],
|
||||
self._aad(binding, version="v2", key_id=key_id),
|
||||
)
|
||||
return json.loads(plaintext), key_id != self._active_key_id
|
||||
|
||||
sealed = self._decode_sealed(payload[3:])
|
||||
candidates = (
|
||||
[self._legacy_cipher]
|
||||
if self._legacy_cipher is not None
|
||||
else list(self._ciphers.values())
|
||||
)
|
||||
plaintext = None
|
||||
for cipher in candidates:
|
||||
try:
|
||||
plaintext = cipher.decrypt(
|
||||
sealed[:12], sealed[12:], self._aad(binding)
|
||||
)
|
||||
break
|
||||
except InvalidTag:
|
||||
continue
|
||||
if plaintext is None:
|
||||
raise InvalidTag
|
||||
return json.loads(plaintext), self._active_key_id is not None
|
||||
except (
|
||||
binascii.Error,
|
||||
InvalidTag,
|
||||
UnicodeDecodeError,
|
||||
ValueError,
|
||||
json.JSONDecodeError,
|
||||
) as error:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state could not be decrypted"
|
||||
) from error
|
||||
+1149
-62
File diff suppressed because it is too large.
Load diff
+190
-8
@@ -3,17 +3,22 @@
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
_KEY_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
|
||||
|
||||
|
||||
class UnfiledDraftConflict(ValueError):
|
||||
@@ -24,16 +29,97 @@ class UnfiledDraftConflict(ValueError):
|
||||
self.snapshot = snapshot
|
||||
|
||||
|
||||
class UnfiledDraftEncryptionError(RuntimeError):
|
||||
"""Raised when private draft state cannot be authenticated and decrypted."""
|
||||
|
||||
|
||||
def decode_unfiled_draft_encryption_key(encoded: str) -> bytes:
|
||||
"""Decode the independently injected 256-bit draft-encryption key."""
|
||||
try:
|
||||
key = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError) as error:
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"unfiled draft encryption key is invalid"
|
||||
) from error
|
||||
if len(key) != 32:
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"unfiled draft encryption key must decode to exactly 32 bytes"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def decode_unfiled_draft_encryption_keyring(
|
||||
encoded: str, active_key_id: str
|
||||
) -> tuple[dict[str, bytes], str]:
|
||||
"""Decode a bounded JSON object of named keys without exposing key material."""
|
||||
def unique_object(pairs):
|
||||
value = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("duplicate key id")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
try:
|
||||
raw = json.loads(encoded, object_pairs_hook=unique_object)
|
||||
if not isinstance(raw, dict) or not raw or len(raw) > 8:
|
||||
raise ValueError("invalid keyring size")
|
||||
if active_key_id not in raw:
|
||||
raise ValueError("active key is unavailable")
|
||||
keys = {}
|
||||
for key_id, value in raw.items():
|
||||
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
|
||||
raise ValueError("invalid key id")
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("invalid key value")
|
||||
keys[key_id] = decode_unfiled_draft_encryption_key(value)
|
||||
if len(set(keys.values())) != len(keys):
|
||||
raise ValueError("duplicate encryption key")
|
||||
return keys, active_key_id
|
||||
except (json.JSONDecodeError, TypeError, ValueError, UnfiledDraftEncryptionError) as error:
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"unfiled draft encryption keyring is invalid"
|
||||
) from error
|
||||
|
||||
|
||||
class UnfiledDraftStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
encryption_key: bytes | None = None,
|
||||
encryption_keys: dict[str, bytes] | None = None,
|
||||
active_key_id: str | None = None,
|
||||
limit: int = 20,
|
||||
max_total_bytes: int = 12 * 1024 * 1024,
|
||||
timeout: float = 1.0,
|
||||
):
|
||||
self.path = Path(path)
|
||||
if encryption_keys is None:
|
||||
if not isinstance(encryption_key, bytes) or len(encryption_key) != 32:
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"unfiled draft encryption requires exactly 32 key bytes"
|
||||
)
|
||||
self._ciphers = {"legacy": AESGCM(encryption_key)}
|
||||
self._active_key_id = None
|
||||
else:
|
||||
if (
|
||||
not encryption_keys
|
||||
or len(encryption_keys) > 8
|
||||
or active_key_id not in encryption_keys
|
||||
or any(
|
||||
not isinstance(key_id, str)
|
||||
or not _KEY_ID.fullmatch(key_id)
|
||||
or not isinstance(key, bytes)
|
||||
or len(key) != 32
|
||||
for key_id, key in encryption_keys.items()
|
||||
)
|
||||
):
|
||||
raise UnfiledDraftEncryptionError("unfiled draft encryption keyring is invalid")
|
||||
self._ciphers = {
|
||||
key_id: AESGCM(key) for key_id, key in encryption_keys.items()
|
||||
}
|
||||
self._active_key_id = active_key_id
|
||||
self.limit = limit
|
||||
self.max_total_bytes = max_total_bytes
|
||||
self.timeout = timeout
|
||||
@@ -61,18 +147,114 @@ class UnfiledDraftStore:
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
return {"revision": 0, "drafts": []} if row is None else {
|
||||
"revision": int(row[0]), "drafts": json.loads(row[1])
|
||||
}
|
||||
def _aad(login: str, revision: int) -> bytes:
|
||||
return f"stackchain:unfiled-drafts:v1\0{login}\0{revision}".encode()
|
||||
|
||||
@staticmethod
|
||||
def _v2_aad(login: str, revision: int, key_id: str) -> bytes:
|
||||
return f"stackchain:unfiled-drafts:v2\0{key_id}\0{login}\0{revision}".encode()
|
||||
|
||||
def _encrypt(self, login: str, revision: int, drafts: list[dict]) -> str:
|
||||
plaintext = json.dumps(drafts, separators=(",", ":")).encode()
|
||||
nonce = os.urandom(12)
|
||||
if self._active_key_id is None:
|
||||
sealed = nonce + self._ciphers["legacy"].encrypt(
|
||||
nonce, plaintext, self._aad(login, revision)
|
||||
)
|
||||
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
|
||||
key_id = self._active_key_id
|
||||
sealed = nonce + self._ciphers[key_id].encrypt(
|
||||
nonce, plaintext, self._v2_aad(login, revision, key_id)
|
||||
)
|
||||
return f"v2:{key_id}:" + base64.urlsafe_b64encode(sealed).decode()
|
||||
|
||||
def _decrypt(self, login: str, revision: int, payload: str) -> list[dict]:
|
||||
try:
|
||||
if payload.startswith("v1:"):
|
||||
encoded = payload[3:]
|
||||
cipher = self._ciphers.get("legacy")
|
||||
aad = self._aad(login, revision)
|
||||
elif payload.startswith("v2:"):
|
||||
_version, key_id, encoded = payload.split(":", 2)
|
||||
cipher = self._ciphers.get(key_id)
|
||||
aad = self._v2_aad(login, revision, key_id)
|
||||
else:
|
||||
raise ValueError("encrypted payload version is invalid")
|
||||
if cipher is None:
|
||||
raise ValueError("encrypted payload key is unavailable")
|
||||
sealed = base64.b64decode(encoded, altchars=b"-_", validate=True)
|
||||
if len(sealed) < 12 + 16:
|
||||
raise ValueError("encrypted payload is too short")
|
||||
plaintext = cipher.decrypt(sealed[:12], sealed[12:], aad)
|
||||
drafts = json.loads(plaintext)
|
||||
if not isinstance(drafts, list):
|
||||
raise ValueError("decrypted payload is not a collection")
|
||||
return drafts
|
||||
except (binascii.Error, InvalidTag, UnicodeDecodeError, ValueError, json.JSONDecodeError) as error:
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"unfiled drafts could not be decrypted"
|
||||
) from error
|
||||
|
||||
def _snapshot(self, login: str, row) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "drafts": []}, False
|
||||
revision, payload = int(row[0]), row[1]
|
||||
if payload.startswith(("v1:", "v2:")):
|
||||
drafts = self._decrypt(login, revision, payload)
|
||||
active_prefix = (
|
||||
"v1:" if self._active_key_id is None else f"v2:{self._active_key_id}:"
|
||||
)
|
||||
return {"revision": revision, "drafts": drafts}, not payload.startswith(active_prefix)
|
||||
try:
|
||||
drafts = json.loads(payload)
|
||||
except (TypeError, json.JSONDecodeError) as error:
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"legacy unfiled drafts could not be decoded"
|
||||
) from error
|
||||
if not isinstance(drafts, list):
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"legacy unfiled drafts could not be decoded"
|
||||
)
|
||||
return {"revision": revision, "drafts": drafts}, True
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
|
||||
(self._login(login),),
|
||||
(login,),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
snapshot, legacy = self._snapshot(login, row)
|
||||
if legacy:
|
||||
connection.execute(
|
||||
"UPDATE unfiled_drafts SET drafts = ? WHERE login = ?",
|
||||
(
|
||||
self._encrypt(login, snapshot["revision"], snapshot["drafts"]),
|
||||
login,
|
||||
),
|
||||
)
|
||||
return snapshot
|
||||
|
||||
def rewrap_all(self) -> dict[str, int]:
|
||||
"""Rewrap every readable row and return aggregate counts only."""
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT login, drafts FROM unfiled_drafts ORDER BY login"
|
||||
).fetchall()
|
||||
result = {"total": len(rows), "migrated": 0, "current": 0, "failed": 0}
|
||||
active_prefix = (
|
||||
"v1:" if self._active_key_id is None else f"v2:{self._active_key_id}:"
|
||||
)
|
||||
for login, payload in rows:
|
||||
was_current = payload.startswith(active_prefix)
|
||||
try:
|
||||
self.get(login)
|
||||
except UnfiledDraftEncryptionError:
|
||||
result["failed"] += 1
|
||||
else:
|
||||
result["current" if was_current else "migrated"] += 1
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _filing_plan(raw: object) -> dict | None:
|
||||
@@ -233,16 +415,16 @@ class UnfiledDraftStore:
|
||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||
raise ValueError("revision is invalid")
|
||||
normalized = self._normalize(drafts)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current = self._snapshot(row)
|
||||
current, _legacy = self._snapshot(login, row)
|
||||
if current["revision"] != expected_revision:
|
||||
raise UnfiledDraftConflict(current)
|
||||
revision = expected_revision + 1
|
||||
serialized = self._encrypt(login, revision, normalized)
|
||||
connection.execute(
|
||||
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Test-only secret injection for encrypted private state stores."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
os.environ.setdefault(
|
||||
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
|
||||
"c3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3M=",
|
||||
)
|
||||
os.environ.setdefault(
|
||||
"STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY",
|
||||
"cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHA=",
|
||||
)
|
||||
+56
-1
@@ -55,6 +55,8 @@ class FakeGiteaServer(ThreadingHTTPServer):
|
||||
self.issue_creation_ready = Event()
|
||||
self.assigned_issue_numbers = [issue["number"] for issue in AVAILABLE_ISSUES]
|
||||
self.comments: list[tuple[int, str]] = []
|
||||
self.activity_comments: dict[int, list[dict]] = {}
|
||||
self.edited_comments: list[tuple[int, int, str]] = []
|
||||
self.requests: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
@@ -87,6 +89,11 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
self._json(200, REPOSITORY)
|
||||
elif path == "/api/v1/repos/search":
|
||||
self._json(200, {"data": [REPOSITORY], "ok": True})
|
||||
elif path == "/api/v1/repos/acme/mobile/assignees":
|
||||
self._json(200, [
|
||||
USER,
|
||||
{"id": 2, "login": "alex", "full_name": "Alexander", "active": True},
|
||||
])
|
||||
elif path == "/api/v1/repos/issues/search":
|
||||
is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"]
|
||||
is_available_scan = not any(
|
||||
@@ -103,8 +110,21 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
]
|
||||
issues = assigned if is_assigned_scan else available if is_available_scan else []
|
||||
self._json(200, issues, **{"X-Total-Count": str(len(issues))})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/comments/"):
|
||||
try:
|
||||
comment_id = int(path.rsplit("/", 1)[-1])
|
||||
except ValueError:
|
||||
comment_id = 0
|
||||
comment = next((item for comments in self.server.activity_comments.values()
|
||||
for item in comments if item.get("id") == comment_id), None)
|
||||
self._json(200, {**comment, "user": USER}) if comment else self._json(404, {"message": "not found"})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"):
|
||||
self._json(200, [], **{"X-Total-Count": "0"})
|
||||
try:
|
||||
number = int(path.split("/")[-2])
|
||||
except ValueError:
|
||||
number = 0
|
||||
comments = self.server.activity_comments.get(number, [])
|
||||
self._json(200, comments, **{"X-Total-Count": str(len(comments))})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
|
||||
self._json(200, [])
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):
|
||||
@@ -175,8 +195,43 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_PATCH(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = urlsplit(self.path).path
|
||||
self.server.requests.append(("PATCH", self.path))
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) == 8 and parts[:7] == ["api", "v1", "repos", "acme", "mobile", "issues", "comments"]:
|
||||
try:
|
||||
comment_id = int(parts[7])
|
||||
except ValueError:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
match = next(((number, item) for number, comments in self.server.activity_comments.items()
|
||||
for item in comments if item.get("id") == comment_id), None)
|
||||
if match is None:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
number, comment = match
|
||||
body = str(payload.get("body", ""))
|
||||
comment["body"] = body
|
||||
self.server.edited_comments.append((number, comment_id, body))
|
||||
self._json(200, {**comment, "user": USER, "updated_at": "2026-08-18T01:00:00Z"})
|
||||
return
|
||||
if len(parts) == 9 and parts[:5] == ["api", "v1", "repos", "acme", "mobile"] and parts[5] == "issues" and parts[7] == "comments":
|
||||
try:
|
||||
number, comment_id = int(parts[6]), int(parts[8])
|
||||
except ValueError:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
comments = self.server.activity_comments.get(number, [])
|
||||
comment = next((item for item in comments if item.get("id") == comment_id), None)
|
||||
if comment is None:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
body = str(payload.get("body", ""))
|
||||
comment["body"] = body
|
||||
self.server.edited_comments.append((number, comment_id, body))
|
||||
self._json(200, {**comment, "user": USER, "updated_at": "2026-08-18T01:00:00Z"})
|
||||
return
|
||||
if not path.startswith("/api/v1/repos/acme/mobile/issues/"):
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
@@ -11,10 +12,108 @@ if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from fake_gitea import AVAILABLE_ISSUES, FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(390, 844)])
|
||||
def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake.assigned_issue_numbers = []
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("First task release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
expect(page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
sheet = page.locator("#mobile-first-task")
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator("#mobile-first-task-find")).to_be_focused()
|
||||
for selector in ("#mobile-first-task-find", "#mobile-first-task-create", "#mobile-first-task-setup"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("#mobile-first-task-find").click()
|
||||
expect(sheet).to_be_hidden()
|
||||
expect(page.locator("#find-work-sheet")).to_be_visible()
|
||||
page.locator("#close-find-work").click()
|
||||
|
||||
context.set_offline(True)
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator("#mobile-first-task-find")).to_be_disabled()
|
||||
expect(page.locator("#mobile-first-task-status")).to_contain_text("Create a task now")
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_enabled()
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_focused()
|
||||
|
||||
page.locator("#close-mobile-first-task").click()
|
||||
context.set_offline(False)
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
document.querySelector('[data-mobile-today-hud]').hidden = false;
|
||||
localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching');
|
||||
const productionCoach = document.querySelector('[data-mobile-first-task-coach]');
|
||||
const isolatedCoach = productionCoach.cloneNode(true);
|
||||
productionCoach.replaceWith(isolatedCoach);
|
||||
window.firstTaskOutcomeProbe = createMobileFirstTask({
|
||||
getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true,
|
||||
coach: isolatedCoach,
|
||||
});
|
||||
return window.firstTaskOutcomeProbe.refresh();
|
||||
}"""
|
||||
)
|
||||
coach = page.locator("[data-mobile-first-task-coach]")
|
||||
expect(coach).to_be_visible()
|
||||
expect(coach).to_contain_text("Complete your first task")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True
|
||||
expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
|
||||
expect(coach).to_be_hidden()
|
||||
expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
fresh_context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
fresh_page = fresh_context.new_page()
|
||||
fresh_page.goto(origin + "/", wait_until="networkidle")
|
||||
fresh_page.locator('input[name="device_label"]').fill("Replacement release phone")
|
||||
fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
fresh_page.locator("#submit-sign-in").click()
|
||||
fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||
assert fresh_page.evaluate(
|
||||
"localStorage.getItem('stackchain.first-task.v1:timmy')"
|
||||
) == "complete"
|
||||
fresh_page.locator('[data-mobile-task="work"]').click()
|
||||
expect(fresh_page.locator("#mobile-first-task")).to_be_hidden()
|
||||
fresh_context.close()
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
tmp_path: Path, width: int, height: int
|
||||
@@ -29,6 +128,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
workspace_requests: list[str] = []
|
||||
launch_transfer_events: list[str] = []
|
||||
live_requests: list[str] = []
|
||||
|
||||
try:
|
||||
@@ -38,6 +138,18 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
cdp = context.new_cdp_session(page)
|
||||
cdp.send("Network.enable")
|
||||
cdp.send(
|
||||
"Network.emulateNetworkConditions",
|
||||
{
|
||||
"offline": False,
|
||||
"latency": 100,
|
||||
"downloadThroughput": 48 * 1024,
|
||||
"uploadThroughput": 48 * 1024,
|
||||
"connectionType": "cellular3g",
|
||||
},
|
||||
)
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on(
|
||||
"console",
|
||||
@@ -53,9 +165,16 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
)
|
||||
page.on(
|
||||
"request",
|
||||
lambda request: workspace_requests.append(request.url)
|
||||
if "feature-today-timer-" in request.url
|
||||
else None,
|
||||
lambda request: (
|
||||
workspace_requests.append(request.url),
|
||||
launch_transfer_events.append("workspace-requested"),
|
||||
)
|
||||
if "feature-today-timer-" in request.url else None,
|
||||
)
|
||||
page.on(
|
||||
"requestfinished",
|
||||
lambda request: launch_transfer_events.append("core-finished")
|
||||
if "/runtime-" in request.url else None,
|
||||
)
|
||||
page.on(
|
||||
"request",
|
||||
@@ -69,6 +188,9 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
assert launch_transfer_events.index("workspace-requested") < (
|
||||
launch_transfer_events.index("core-finished")
|
||||
), launch_transfer_events
|
||||
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
initial_live_requests = len(live_requests)
|
||||
@@ -101,6 +223,18 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator("#work-settings-toggle").click()
|
||||
tomorrow = page.locator("#plan-tomorrow")
|
||||
expect(tomorrow).to_be_visible()
|
||||
tomorrow_bounds = tomorrow.bounding_box()
|
||||
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
|
||||
tomorrow.click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
|
||||
expect(page.locator("#save-and-start-today")).to_be_hidden()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
|
||||
expect(delivery_queue).to_be_visible()
|
||||
@@ -124,16 +258,47 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
expect(page.locator("#device-setup-sheet")).to_be_visible()
|
||||
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
|
||||
expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
|
||||
expect(page.locator("body > header")).to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).to_have_attribute("inert", "")
|
||||
expect(dock).to_have_attribute("inert", "")
|
||||
expect(page.locator("#close-device-setup")).to_be_focused()
|
||||
page.keyboard.press("Shift+Tab")
|
||||
expect(page.locator("#clear-private-device-data")).to_be_focused()
|
||||
page.keyboard.press("Tab")
|
||||
expect(page.locator("#close-device-setup")).to_be_focused()
|
||||
for selector in ("#clear-device-caches", "#clear-private-device-data"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#close-device-setup").click()
|
||||
page.go_back()
|
||||
expect(page.locator("#device-setup-sheet")).to_be_hidden()
|
||||
expect(page.locator("#open-device-setup")).to_be_focused()
|
||||
expect(page.locator("body > header")).not_to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).not_to_have_attribute("inert", "")
|
||||
expect(dock).not_to_have_attribute("inert", "")
|
||||
|
||||
page.locator("#active-devices").click()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_visible()
|
||||
expect(page.locator("#security-activity-section")).to_be_visible()
|
||||
expect(page.locator("body > header")).to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).to_have_attribute("inert", "")
|
||||
expect(dock).to_have_attribute("inert", "")
|
||||
expect(page.locator("#close-active-devices")).to_be_focused()
|
||||
expect(page.locator("#active-devices-status")).to_contain_text("active device")
|
||||
expect(page.locator("#enrolled-passkeys-status")).not_to_contain_text("Loading")
|
||||
page.keyboard.press("Shift+Tab")
|
||||
assert page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const controls = [...document.querySelector('#active-devices-sheet').querySelectorAll(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
|
||||
)].filter(control => !control.hidden);
|
||||
return document.activeElement === controls.at(-1);
|
||||
}
|
||||
"""
|
||||
)
|
||||
page.keyboard.press("Tab")
|
||||
expect(page.locator("#close-active-devices")).to_be_focused()
|
||||
security_nav = page.locator(".security-section-nav")
|
||||
expect(security_nav).to_be_visible()
|
||||
for control in security_nav.locator("button").all():
|
||||
@@ -143,9 +308,15 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
|
||||
page.go_back()
|
||||
expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
|
||||
expect(page.locator("body > header")).to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).to_have_attribute("inert", "")
|
||||
expect(dock).to_have_attribute("inert", "")
|
||||
page.go_back()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
||||
expect(page.locator("#active-devices")).to_be_focused()
|
||||
expect(page.locator("body > header")).not_to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).not_to_have_attribute("inert", "")
|
||||
expect(dock).not_to_have_attribute("inert", "")
|
||||
|
||||
assert len(workspace_requests) == 1, workspace_requests
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
@@ -158,6 +329,59 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
attempts: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
|
||||
def interrupt_once(route):
|
||||
attempts.append(route.request.url)
|
||||
if len(attempts) == 1:
|
||||
route.abort("failed")
|
||||
else:
|
||||
route.continue_()
|
||||
|
||||
page.route(re.compile(r"/feature-today-timer-[^/?]+\.js(?:\?.*)?$"), interrupt_once)
|
||||
page.locator('input[name="device_label"]').fill("Workspace recovery release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.wait_for_timeout(1500)
|
||||
resources = page.evaluate(
|
||||
"performance.getEntriesByType('resource').map(entry => entry.name)"
|
||||
)
|
||||
state = {
|
||||
"attempts": attempts,
|
||||
"resources": resources,
|
||||
"action": page.locator("#my-work-action-status").text_content(),
|
||||
"retry_hidden": page.locator("#retry-workspace").is_hidden(),
|
||||
"work": page.locator("#my-work-status").text_content(),
|
||||
}
|
||||
assert len(attempts) == 2, state
|
||||
assert "2" in (state["work"] or ""), state
|
||||
expect(page.locator("#retry-workspace")).to_be_hidden()
|
||||
expect(page.locator("#my-work-action-status")).not_to_contain_text("Workspace unavailable")
|
||||
assert any("feature-today-timer-" in url and "retry=" in url for url in resources), state
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
@@ -250,3 +474,72 @@ def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reviews_and_downloads_mobile_agenda_snapshot(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
original_due_dates = [item.get("due_date") for item in AVAILABLE_ISSUES]
|
||||
AVAILABLE_ISSUES[0]["due_date"] = "2026-08-19"
|
||||
AVAILABLE_ISSUES[1]["due_date"] = "2026-08-20"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
|
||||
page.on("response", lambda response: failed_responses.append(f"{response.status} {response.url}") if response.status >= 400 else None)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Agenda export release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="agenda"]').click()
|
||||
expect(page.locator("#issue-sheet")).to_be_visible()
|
||||
page.locator("#close-issue-sheet").click()
|
||||
expect(page.locator("#issue-sheet")).to_be_hidden()
|
||||
expect(page.locator("#agenda-export")).to_be_visible()
|
||||
trigger = page.locator("#open-agenda-export")
|
||||
expect(trigger).to_be_enabled()
|
||||
bounds = trigger.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
trigger.click()
|
||||
expect(page.locator("#agenda-export-sheet")).to_be_visible()
|
||||
expect(page.locator("#agenda-export-items input:checked")).to_have_count(2)
|
||||
page.locator("#agenda-export-items input").nth(1).uncheck()
|
||||
expect(page.locator("#agenda-export-status")).to_have_text("1 of 2 deadlines selected.")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
with page.expect_download() as pending:
|
||||
page.locator("#share-agenda-export").click()
|
||||
download = pending.value
|
||||
text = Path(download.path()).read_text()
|
||||
assert download.suggested_filename.startswith("stackchain-agenda-")
|
||||
assert text.count("BEGIN:VEVENT") == 1
|
||||
assert "SUMMARY:Ship mobile capture" in text
|
||||
assert "Polish desktop filters" not in text
|
||||
expect(trigger).to_be_focused()
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
browser.close()
|
||||
finally:
|
||||
for item, due_date in zip(AVAILABLE_ISSUES, original_due_dates):
|
||||
if due_date is None:
|
||||
item.pop("due_date", None)
|
||||
else:
|
||||
item["due_date"] = due_date
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -84,6 +84,9 @@ def release_server(archive: Path, tmp_path: Path, gitea_url: str):
|
||||
"STACKCHAIN_DASHBOARD_AUTH_MODE": "operator",
|
||||
"STACKCHAIN_DASHBOARD_ACCESS_TOKEN": ACCESS_TOKEN,
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET": "artifact-browser-independent-session-secret-863",
|
||||
"STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY": (
|
||||
"ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQ="
|
||||
),
|
||||
"STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN": origin,
|
||||
"STACKCHAIN_STATE_DIR": str(tmp_path / "state"),
|
||||
}
|
||||
@@ -187,7 +190,17 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
|
||||
new_action.click()
|
||||
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
|
||||
assert page.evaluate("document.activeElement?.id") != "create-issue-title"
|
||||
assert page.evaluate("document.activeElement?.id") == "cancel-new-issue"
|
||||
assert page.evaluate("document.querySelector('main').inert") is True
|
||||
page.keyboard.press("Shift+Tab")
|
||||
assert page.evaluate("document.activeElement.closest('#create-issue-sheet')?.id") == "create-issue-sheet"
|
||||
page.keyboard.press("Escape")
|
||||
expect(page.locator("#create-issue-sheet")).not_to_have_class("create-issue-sheet open")
|
||||
assert page.evaluate("document.activeElement?.dataset.mobileTask") == "new"
|
||||
assert page.evaluate("document.querySelector('main').inert") is False
|
||||
new_action.click()
|
||||
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
|
||||
assert page.evaluate("document.activeElement?.id") == "cancel-new-issue"
|
||||
create_navigation = page.locator(".mobile-create-issue-nav")
|
||||
expect(create_navigation).to_be_visible()
|
||||
create_navigation_buttons = create_navigation.locator("button")
|
||||
|
||||
@@ -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,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile sign-out journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_reviews_private_work_before_mobile_sign_out(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
delete_requests: list[str] = []
|
||||
browser_errors: list[str] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": 390, "height": 844}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on(
|
||||
"request",
|
||||
lambda request: delete_requests.append(request.url)
|
||||
if request.method == "DELETE" and "/api/v1/session" in request.url
|
||||
else None,
|
||||
)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Sign-out review phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.evaluate("localStorage.setItem('stackchain.release-test-draft', 'unsynced')")
|
||||
|
||||
launcher = page.locator("#sign-out")
|
||||
page.locator("#app-menu-toggle").click()
|
||||
launcher.click()
|
||||
sheet = page.locator("#sign-out-review-sheet")
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator("#sign-out-review-summary")).to_contain_text("private browser item")
|
||||
expect(page.locator("#sign-out-review-warning")).to_contain_text("may not be synced")
|
||||
expect(page.locator("#confirm-sign-out")).to_have_text("Sign out and erase private work")
|
||||
assert page.evaluate("document.querySelector('main').inert") is True
|
||||
panel = page.locator(".sign-out-review-panel").bounding_box()
|
||||
assert panel and panel["height"] <= 844
|
||||
for selector in ("#cancel-sign-out", "#confirm-sign-out"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert delete_requests == []
|
||||
|
||||
page.keyboard.press("Escape")
|
||||
expect(sheet).to_be_hidden()
|
||||
expect(launcher).to_be_focused()
|
||||
assert page.evaluate("document.querySelector('main').inert") is False
|
||||
assert page.evaluate("localStorage.getItem('stackchain.release-test-draft')") == "unsynced"
|
||||
assert delete_requests == []
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
@@ -15,6 +15,13 @@ from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def open_today_action(page, selector: str):
|
||||
page.locator("[data-mobile-today-more]").click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_visible()
|
||||
page.locator(selector).click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_hidden()
|
||||
|
||||
|
||||
def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
@@ -46,7 +53,10 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
try:
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
except AssertionError as error:
|
||||
raise AssertionError({"browser_errors": browser_errors, "failed_responses": failed_responses}) from error
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#plan-today-candidates .plan-today-item")).to_have_count(2)
|
||||
@@ -78,7 +88,231 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
expect(page.locator("#send-issue-comment-next")).to_be_visible()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
try:
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
except AssertionError as error:
|
||||
raise AssertionError(page.evaluate("""() => ({
|
||||
hidden:document.querySelector('#plan-today-sheet').hidden,
|
||||
state:history.state,
|
||||
bodyClass:document.body.className,
|
||||
status:document.querySelector('#my-work-action-status').textContent,
|
||||
error:document.querySelector('#plan-today-error').textContent,
|
||||
})""")) from error
|
||||
|
||||
page.locator("#close-issue-sheet").click()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||
|
||||
page.locator('[data-mobile-task="new"]').click()
|
||||
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
|
||||
expect(page.locator("#today-capture-interruption")).to_be_visible()
|
||||
expect(page.locator("#today-capture-interruption-label")).to_contain_text("Ship mobile capture")
|
||||
expect(page.locator("#save-unfiled-issue")).to_have_text("Save & return to Today")
|
||||
paused_timer = page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const state = JSON.parse(localStorage.getItem(key));
|
||||
return state.entries[state.active_identity];
|
||||
}""")
|
||||
assert paused_timer["running"] is False
|
||||
page.locator("#create-issue-title").fill("Remember release note")
|
||||
page.locator("#save-unfiled-issue").click()
|
||||
expect(page.locator("#create-issue-sheet")).not_to_have_class("create-issue-sheet open")
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||
resumed = page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
const captures = JSON.parse(localStorage.getItem('stackchain.unfiled-issues.v1') || 'null');
|
||||
return {running:timer.entries[timer.active_identity].running,
|
||||
saved:JSON.stringify(captures).includes('Remember release note')};
|
||||
}""")
|
||||
assert resumed == {"running": True, "saved": True}
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator('[data-mobile-task="search"]').click()
|
||||
search_pause = page.locator('#cmd-palette [data-search-today-interruption]')
|
||||
expect(search_pause).to_be_visible()
|
||||
expect(search_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
return_button = search_pause.locator('[data-return-from-search]')
|
||||
bounds = return_button.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === false &&
|
||||
timer.search_interruption?.identity === timer.active_identity;
|
||||
}""")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
return_button.click()
|
||||
expect(page.locator("#cmd-palette")).not_to_have_class("open")
|
||||
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === true && !timer.search_interruption;
|
||||
}""")
|
||||
page.locator("#close-issue-sheet").click()
|
||||
|
||||
page.locator("[data-mobile-today-more]").click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_visible()
|
||||
blocked = page.locator("[data-mobile-today-blocked]")
|
||||
expect(blocked).to_be_visible()
|
||||
blocked_bounds = blocked.bounding_box()
|
||||
assert blocked_bounds and blocked_bounds["height"] >= 44
|
||||
blocked.click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_hidden()
|
||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||
expect(page.locator("#today-progress-title")).to_have_text("Report blocker")
|
||||
expect(page.locator("#today-progress-target")).to_contain_text("Ship mobile capture")
|
||||
expect(page.locator("#today-progress-body")).to_be_focused()
|
||||
expect(page.locator("#today-progress-activity")).to_be_hidden()
|
||||
expect(page.locator(".today-progress-evidence")).to_be_hidden()
|
||||
for control in page.locator(".today-blocker-presets button, #post-today-blocker").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator('[data-today-blocker-preset="later-today"]').click()
|
||||
assert page.locator("#today-blocker-return-at").input_value()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#cancel-today-progress").click()
|
||||
expect(page.locator("#today-progress-sheet")).to_be_hidden()
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Ship mobile capture")
|
||||
fake.activity_comments = {
|
||||
41: [{
|
||||
"id": 701,
|
||||
"user": {"login": "timmy"},
|
||||
"body": "Wrong voice transcript",
|
||||
"issue_url": f"{fake_url}/api/v1/repos/acme/mobile/issues/41",
|
||||
"created_at": "2026-08-18T00:00:00Z",
|
||||
}]
|
||||
}
|
||||
open_today_action(page, "[data-mobile-today-update]")
|
||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||
progress = page.locator("#today-progress-body")
|
||||
progress.fill("Pairing with @al")
|
||||
mention = page.locator('#today-progress-body-mentions [data-login="alex"]')
|
||||
try:
|
||||
expect(mention).to_be_visible()
|
||||
except AssertionError as error:
|
||||
raise AssertionError({
|
||||
"status": page.locator("#today-progress-body-mention-status").text_content(),
|
||||
"requests": fake.requests[-20:],
|
||||
"browser_errors": browser_errors,
|
||||
"failed_responses": failed_responses,
|
||||
}) from error
|
||||
mention_bounds = mention.bounding_box()
|
||||
assert mention_bounds and mention_bounds["height"] >= 44
|
||||
mention.click()
|
||||
expect(progress).to_have_value("Pairing with @alex ")
|
||||
page.locator("#post-today-progress").click()
|
||||
expect(page.locator("#today-progress-sheet")).to_be_hidden()
|
||||
admitted = page.evaluate("""() => {
|
||||
const record = JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') || 'null');
|
||||
return record?.items?.find(item => item.kind === 'issue-comment' && item.number === 41);
|
||||
}""")
|
||||
assert admitted and admitted["repository"] == "acme/mobile"
|
||||
assert admitted["body"] == "Pairing with @alex"
|
||||
|
||||
open_today_action(page, "[data-mobile-today-update]")
|
||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||
owned = page.locator('[data-comment-id="701"]')
|
||||
try:
|
||||
expect(owned.locator('[data-comment-action="edit"]')).to_be_visible()
|
||||
except AssertionError as error:
|
||||
direct = page.evaluate("""async () => {
|
||||
const response = await fetch('api/v1/repos/acme/mobile/issues/41/comments?limit=20');
|
||||
return [response.status, await response.text()];
|
||||
}""")
|
||||
raise AssertionError({
|
||||
"activity": page.locator("#today-progress-activity").inner_html(),
|
||||
"direct": direct,
|
||||
"requests": fake.requests[-20:],
|
||||
"browser_errors": browser_errors,
|
||||
}) from error
|
||||
owned.locator('[data-comment-action="edit"]').click()
|
||||
correction = owned.locator(".comment-edit-textarea")
|
||||
correction.fill("Corrected voice transcript")
|
||||
owned.locator("[data-comment-edit-save]").click()
|
||||
try:
|
||||
expect(owned.locator(".markdown-content")).to_have_text("Corrected voice transcript")
|
||||
except AssertionError as error:
|
||||
raise AssertionError({
|
||||
"activity": page.locator("#today-progress-activity").inner_html(),
|
||||
"edited": fake.edited_comments,
|
||||
"requests": fake.requests[-10:],
|
||||
"failed_responses": failed_responses,
|
||||
}) from error
|
||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Ship mobile capture")
|
||||
assert fake.edited_comments == [(41, 701, "Corrected voice transcript")]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#cancel-today-progress").click()
|
||||
expect(page.locator("#today-progress-sheet")).to_be_hidden()
|
||||
page.locator("[data-mobile-today-more]").click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_visible()
|
||||
take_break = page.locator("[data-today-break-open]")
|
||||
expect(take_break).to_be_visible()
|
||||
take_break.click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_hidden()
|
||||
expect(page.locator("#today-break-sheet")).to_be_visible()
|
||||
for control in page.locator(".today-break-actions button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.go_back()
|
||||
expect(page.locator("#today-break-sheet")).to_be_hidden()
|
||||
|
||||
open_today_action(page, "[data-today-break-open]")
|
||||
page.locator('[data-today-break-minutes="5"]').click()
|
||||
expect(page.locator("#today-break-status")).to_have_text("On break · resume in 5:00")
|
||||
expect(page.locator("[data-mobile-today-toggle]")).to_have_text("Resume timer")
|
||||
page.evaluate("""() => {
|
||||
const prefix = 'stackchain.today-sync.v1.timmy.operation.';
|
||||
localStorage.setItem(prefix + 'broken', '{not-json');
|
||||
}""")
|
||||
page.reload(wait_until="networkidle")
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
expect(page.locator("#today-sync-status")).to_have_text(
|
||||
"Today queue recovered · discarded 1 unreadable device record."
|
||||
)
|
||||
assert page.evaluate("""() => !Object.keys(localStorage).some(
|
||||
key => key.startsWith('stackchain.today-sync.v1.timmy.operation.')
|
||||
)""")
|
||||
expect(page.locator("#today-break-status")).to_contain_text("On break · resume in")
|
||||
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
||||
page.locator("#close-issue-sheet").click()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
expect(page.locator("#resume-today-break")).to_be_visible()
|
||||
page.locator("#resume-today-break").click()
|
||||
expect(page.locator("#today-break-status")).to_be_hidden()
|
||||
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
|
||||
page.locator("#close-issue-sheet").click()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
page.set_viewport_size({"width": 320, "height": 568})
|
||||
layout = page.evaluate("""() => {
|
||||
const hud = document.querySelector('[data-mobile-today-hud]').getBoundingClientRect();
|
||||
const dock = document.querySelector('.mobile-task-dock').getBoundingClientRect();
|
||||
const padding = parseFloat(getComputedStyle(document.body).paddingBottom);
|
||||
return {hudHeight:hud.height, hudTop:hud.top, dockTop:dock.top, padding, viewport:window.innerHeight};
|
||||
}""")
|
||||
assert layout["hudHeight"] <= 116
|
||||
assert layout["padding"] >= layout["viewport"] - min(layout["hudTop"], layout["dockTop"])
|
||||
for control in page.locator("[data-mobile-today-hud] button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
open_today_action(page, "[data-today-break-open]")
|
||||
expect(page.locator("#today-break-sheet")).to_be_visible()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
custom = page.locator("#today-break-custom-minutes")
|
||||
bounds = custom.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator("#cancel-today-break").click()
|
||||
expect(page.locator("#today-break-sheet")).to_be_hidden()
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
page.locator("[data-mobile-today-open]").click()
|
||||
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
|
||||
|
||||
page.locator('[data-issue-section="reply"]').click()
|
||||
page.locator("#issue-comment").fill("Handoff complete; continuing with the next Today item.")
|
||||
@@ -88,9 +322,34 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
expect(page.locator("#issue-comment")).to_have_value("")
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true")
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
|
||||
assert fake.comments == [(41, "Handoff complete; continuing with the next Today item.")]
|
||||
assert fake.comments == [
|
||||
(41, "Pairing with @alex"),
|
||||
(41, "Handoff complete; continuing with the next Today item."),
|
||||
]
|
||||
|
||||
receipt = page.locator("#today-completion-undo")
|
||||
expect(receipt).to_be_visible()
|
||||
expect(receipt).to_contain_text("Removed from Today. Undo?")
|
||||
undo = page.locator("#undo-today-completion")
|
||||
bounds = undo.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator("#close-issue-sheet").click()
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||
expect(page.locator(".mobile-task-dock")).to_be_visible()
|
||||
layout = page.evaluate("""() => {
|
||||
const receipt = document.querySelector('#today-completion-undo').getBoundingClientRect();
|
||||
const hud = document.querySelector('[data-mobile-today-hud]').getBoundingClientRect();
|
||||
const dock = document.querySelector('.mobile-task-dock').getBoundingClientRect();
|
||||
return {receiptBottom:receipt.bottom, hudTop:hud.top, dockTop:dock.top};
|
||||
}""")
|
||||
assert layout["receiptBottom"] <= min(layout["hudTop"], layout["dockTop"])
|
||||
undo.click()
|
||||
expect(receipt).to_be_hidden()
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
|
||||
|
||||
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
|
||||
assert today == ["issue:acme/mobile:42:"]
|
||||
assert today == ["issue:acme/mobile:41:", "issue:acme/mobile:42:"]
|
||||
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
@@ -99,3 +358,261 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Today detour phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
page.locator("#plan-today-available").fill("90")
|
||||
page.locator("#plan-today-available").press("Tab")
|
||||
page.locator("#build-today-plan").click()
|
||||
missing = page.locator("[data-plan-missing-estimate]")
|
||||
expect(missing).to_have_count(2)
|
||||
for _ in range(2):
|
||||
missing.nth(0).fill("30")
|
||||
missing.nth(0).press("Tab")
|
||||
page.locator("#save-and-start-today").click()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
page.locator("#close-issue-sheet").click(force=True)
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
page.locator('[data-mobile-task="find"]').click()
|
||||
find_pause = page.locator("#find-work-sheet [data-today-detour]")
|
||||
expect(find_pause).to_be_visible()
|
||||
expect(find_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
bounds = find_pause.locator("[data-return-from-detour]").bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'find';
|
||||
}""")
|
||||
page.locator("#close-find-work").click()
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
|
||||
}""")
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
queue_pause = page.locator("#mobile-queue-sheet [data-today-detour]")
|
||||
expect(queue_pause).to_be_visible()
|
||||
expect(queue_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
page.locator('[data-mobile-queue="later"]').click()
|
||||
persistent_pause = page.locator("#my-work > [data-today-detour]")
|
||||
expect(persistent_pause).to_be_visible()
|
||||
return_button = persistent_pause.locator("[data-return-from-detour]")
|
||||
bounds = return_button.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
return_button.click()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
|
||||
}""")
|
||||
|
||||
page.locator("#close-issue-sheet").click()
|
||||
page.locator("#app-menu-toggle").click()
|
||||
page.locator("#open-insights").click()
|
||||
insights_pause = page.locator("#insights-sheet > [data-today-detour]")
|
||||
expect(insights_pause).to_be_visible()
|
||||
expect(insights_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
bounds = insights_pause.locator("[data-return-from-detour]").bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'insights';
|
||||
}""")
|
||||
page.reload(wait_until="networkidle")
|
||||
restored_pause = page.locator("#insights-sheet > [data-today-detour]")
|
||||
expect(restored_pause).to_be_visible()
|
||||
expect(restored_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
restored_pause.locator("[data-return-from-detour]").click()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
|
||||
}""")
|
||||
|
||||
page.locator("#close-issue-sheet").click()
|
||||
page.locator("#app-menu-toggle").click()
|
||||
page.locator("#active-devices").click()
|
||||
security_pause = page.locator("#security-center-today-detour")
|
||||
expect(security_pause).to_be_visible()
|
||||
expect(security_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
bounds = page.locator("#return-from-security-center").bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'security-center';
|
||||
}""")
|
||||
page.locator("#security-section-devices").click()
|
||||
page.go_back()
|
||||
expect(security_pause).to_be_visible()
|
||||
page.go_back()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
|
||||
}""")
|
||||
|
||||
page.locator("#active-devices").click()
|
||||
expect(security_pause).to_be_visible()
|
||||
page.locator("#return-from-security-center").click()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_mobile_issue(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Blocker flow phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
page.locator("#plan-today-available").fill("90")
|
||||
page.locator("#plan-today-available").press("Tab")
|
||||
page.locator("#build-today-plan").click()
|
||||
missing = page.locator("[data-plan-missing-estimate]")
|
||||
expect(missing).to_have_count(2)
|
||||
for _ in range(2):
|
||||
missing.nth(0).fill("30")
|
||||
missing.nth(0).press("Tab")
|
||||
page.locator("#save-and-start-today").click()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
page.locator("#close-issue-sheet").click()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||
|
||||
open_today_action(page, "[data-mobile-today-update]")
|
||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||
page.locator("#today-progress-body").fill("Blocked waiting for the design owner")
|
||||
page.locator("#post-today-progress").click()
|
||||
expect(page.locator("#today-progress-sheet")).to_be_hidden()
|
||||
open_today_action(page, "[data-today-break-open]")
|
||||
page.locator('[data-today-break-minutes="5"]').click()
|
||||
expect(page.locator("#resume-today-break")).to_be_visible()
|
||||
|
||||
page.evaluate("""() => {
|
||||
const outbox = JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') ||
|
||||
'{"version":2,"items":[]}');
|
||||
outbox.items.push({
|
||||
id: 'already-admitted-blocker', operationId: 'already-admitted-blocker',
|
||||
kind: 'issue-comment', repository: 'acme/mobile', number: 41,
|
||||
body: 'Blocked after reload waiting for the design owner', ownerLogin: 'timmy',
|
||||
status: 'queued', queuedAt: Date.now()
|
||||
});
|
||||
localStorage.setItem('stackchain.authored-outbox.v1', JSON.stringify(outbox));
|
||||
localStorage.setItem('stackchain.today-progress.v1.timmy', JSON.stringify({
|
||||
version: 1,
|
||||
drafts: {
|
||||
'issue:acme/mobile:41:': {
|
||||
body: 'Blocked after reload waiting for the design owner',
|
||||
operation_id: 'already-admitted-blocker',
|
||||
blocker_pending: true,
|
||||
blocker_until: '2000-01-02T03:04:00.000Z'
|
||||
}
|
||||
}
|
||||
}));
|
||||
}""")
|
||||
page.reload(wait_until="networkidle")
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
||||
page.locator("#close-issue-sheet").click()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
expect(page.locator("#resume-today-break")).to_be_visible()
|
||||
page.locator("#resume-today-break").click()
|
||||
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
|
||||
page.locator("#close-issue-sheet").click()
|
||||
open_today_action(page, "[data-mobile-today-update]")
|
||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||
expect(page.locator("#today-progress-blocker-recovery")).to_be_visible()
|
||||
expect(page.locator("#today-progress-blocker-recovery")).to_contain_text("Blocker queued")
|
||||
expect(page.locator("#today-progress-body")).to_be_disabled()
|
||||
expect(page.locator("#today-blocker-return-at")).to_have_value("2000-01-02T03:04")
|
||||
page.locator("#today-blocker-return-at").fill("2099-08-19T09:00")
|
||||
blocker = page.locator("#post-today-blocker")
|
||||
expect(blocker).to_have_text("Finish moving on")
|
||||
bounds = blocker.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
blocker.click()
|
||||
|
||||
expect(page.locator("#today-progress-sheet")).to_be_hidden()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
|
||||
state = page.evaluate("""() => ({
|
||||
later: JSON.parse(localStorage.getItem('stackchain.later-work.v1.timmy') || '{}'),
|
||||
today: JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]'),
|
||||
outbox: JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') || 'null'),
|
||||
})""")
|
||||
assert state["today"] == ["issue:acme/mobile:42:"]
|
||||
assert state["later"]["issue:acme/mobile:41:"].startswith("2099-08-19T09:00")
|
||||
queued_comments = [
|
||||
item for item in state["outbox"]["items"]
|
||||
if item["kind"] == "issue-comment"
|
||||
and item["number"] == 41
|
||||
and item["body"] == "Blocked after reload waiting for the design owner"
|
||||
]
|
||||
delivered_comments = [
|
||||
{"number": number, "body": body}
|
||||
for number, body in fake.comments
|
||||
if number == 41 and body == "Blocked after reload waiting for the design owner"
|
||||
]
|
||||
assert len(queued_comments) <= 1
|
||||
assert len(delivered_comments) <= 1
|
||||
assert queued_comments or delivered_comments
|
||||
receipt = (delivered_comments or queued_comments)[0]
|
||||
assert receipt["number"] == 41
|
||||
assert receipt["body"] == "Blocked after reload waiting for the design owner"
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Today summary journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.add_init_script(
|
||||
"Object.defineProperty(navigator, 'share', {configurable:true, value:async payload => { window.__sharedSummary = payload; }});"
|
||||
)
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on(
|
||||
"console",
|
||||
lambda message: browser_errors.append(message.text)
|
||||
if message.type == "error"
|
||||
else None,
|
||||
)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Today summary release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const summary=createTodaySummary({
|
||||
storage:localStorage,getLogin:()=> 'timmy',
|
||||
share:payload=>navigator.share(payload),
|
||||
copy:text=>navigator.clipboard.writeText(text),
|
||||
createOperationId:()=> 'release-summary-op',
|
||||
resolveTarget:async target=>({...target,kind:'issue',title:'Daily status',state:'open'}),
|
||||
enqueueDurably:async message=>{ window.__queuedSummary=message; return {item:message,durability:'background'}; },
|
||||
});
|
||||
const view=createTodaySummaryView({summary,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
||||
window.__summaryTest={summary,view};
|
||||
view.open(
|
||||
[{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}],
|
||||
[{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}],
|
||||
);
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
sheet = page.locator("#today-summary-sheet")
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator("#today-summary-preview")).to_have_text(
|
||||
"Today\n- Ship mobile capture\n\nTomorrow\n- Review release"
|
||||
)
|
||||
assert "42m" not in page.locator("#today-summary-preview").inner_text()
|
||||
for control in page.locator(".today-summary-actions button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("#today-summary-destination").fill("acme/mobile#41")
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
window.__summaryTest.summary.setDestination(document.querySelector('#today-summary-destination').value);
|
||||
return window.__summaryTest.view.validateDestination(document.querySelector('#validate-today-summary-destination'));
|
||||
}
|
||||
"""
|
||||
)
|
||||
expect(page.locator("#today-summary-target")).to_contain_text(
|
||||
"acme/mobile#41 · issue · Daily status"
|
||||
)
|
||||
expect(page.locator("#post-today-summary")).to_be_enabled()
|
||||
page.evaluate(
|
||||
"window.__summaryTest.view.postDraft(document.querySelector('#post-today-summary'))"
|
||||
)
|
||||
expect(sheet).to_be_hidden()
|
||||
assert page.evaluate("window.__queuedSummary") == {
|
||||
"kind": "search-reply",
|
||||
"repository": "acme/mobile",
|
||||
"number": 41,
|
||||
"targetKind": "issue",
|
||||
"body": "Today\n- Ship mobile capture\n\nTomorrow\n- Review release",
|
||||
"operationId": "release-summary-op",
|
||||
}
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
window.__summaryTest.view.open(
|
||||
[{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}],
|
||||
[{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}]
|
||||
)
|
||||
"""
|
||||
)
|
||||
expect(sheet).to_be_visible()
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
window.__summaryTest.summary.includeActuals(true);
|
||||
window.__summaryTest.summary.setNote('Waiting on review');
|
||||
window.__summaryTest.view.render();
|
||||
}
|
||||
"""
|
||||
)
|
||||
expect(page.locator("#today-summary-preview")).to_contain_text(
|
||||
"Ship mobile capture (42m)"
|
||||
)
|
||||
page.evaluate(
|
||||
"window.__summaryTest.view.shareDraft(document.querySelector('#share-today-summary'))"
|
||||
)
|
||||
expect(sheet).to_be_hidden()
|
||||
assert page.evaluate("window.__sharedSummary") == {
|
||||
"title": "Today summary",
|
||||
"text": "Today\n- Ship mobile capture (42m)\n\nTomorrow\n- Review release\n\nNote\nWaiting on review",
|
||||
}
|
||||
assert page.evaluate(
|
||||
"localStorage.getItem('stackchain.today-summary-draft.v1.timmy')"
|
||||
) is None
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Today to Week Ahead journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reschedules_active_today_into_week_ahead(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with release_server(
|
||||
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
|
||||
) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(
|
||||
viewport={"width": width, "height": height}, timezone_id="UTC"
|
||||
)
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Reschedule release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
plan_date = (date.today() + timedelta(days=2)).isoformat()
|
||||
saved_week = page.evaluate(
|
||||
"""async ({planDate}) => {
|
||||
const response=await fetch('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({base_revision:0,timezone:'UTC',days:[{
|
||||
plan_date:planDate,ids:[],capacity_minutes:90,estimates:{}
|
||||
}]})});
|
||||
return {status:response.status,body:await response.json()};
|
||||
}""",
|
||||
{"planDate": plan_date},
|
||||
)
|
||||
assert saved_week["status"] == 200
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
page.locator("#plan-today-available").fill("90")
|
||||
page.locator("#plan-today-available").press("Tab")
|
||||
page.locator("#build-today-plan").click()
|
||||
missing = page.locator("[data-plan-missing-estimate]")
|
||||
for _ in range(2):
|
||||
missing.nth(0).fill("30")
|
||||
missing.nth(0).press("Tab")
|
||||
save_and_start = page.locator("#save-and-start-today")
|
||||
expect(save_and_start).to_be_enabled()
|
||||
save_and_start.click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden(timeout=10_000)
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
issue_sheet = page.locator("#issue-sheet")
|
||||
if issue_sheet.is_visible():
|
||||
page.locator("#close-issue-sheet").click()
|
||||
expect(issue_sheet).to_be_hidden()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
launcher = page.locator("[data-work-session-reschedule-week]")
|
||||
page.locator("[data-mobile-today-more]").click()
|
||||
expect(launcher).to_be_visible()
|
||||
launcher_bounds = launcher.bounding_box()
|
||||
assert launcher_bounds and launcher_bounds["height"] >= 44
|
||||
launcher.click()
|
||||
dialog = page.locator("#today-week-reschedule")
|
||||
expect(dialog).to_be_visible()
|
||||
days = page.locator("#today-week-reschedule-days button")
|
||||
try:
|
||||
expect(days).to_have_count(7)
|
||||
except AssertionError as error:
|
||||
raise AssertionError({
|
||||
"status": page.locator("#today-week-reschedule-status").text_content(),
|
||||
"page_errors": page_errors,
|
||||
"today": page.evaluate("async()=>await (await fetch('api/v1/today')).json()"),
|
||||
"week": page.evaluate("async()=>await (await fetch('api/v1/week')).json()"),
|
||||
}) from error
|
||||
for control in [*days.all(), page.locator("#cancel-today-week-reschedule"), page.locator("#confirm-today-week-reschedule")]:
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("#cancel-today-week-reschedule").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
expect(launcher).to_be_focused()
|
||||
|
||||
launcher.click()
|
||||
destination = page.locator(
|
||||
f'#today-week-reschedule-days button[data-plan-date="{plan_date}"]'
|
||||
)
|
||||
destination.click()
|
||||
page.locator("#confirm-today-week-reschedule").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
|
||||
week = page.evaluate("async()=>await (await fetch('api/v1/week')).json()")
|
||||
today = page.evaluate("async()=>await (await fetch('api/v1/today')).json()")
|
||||
assert week["days"][0]["plan_date"] == plan_date
|
||||
assert len(week["days"][0]["ids"]) == 1
|
||||
assert len(today["ids"]) == 1
|
||||
assert not page_errors
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=2)
|
||||
@@ -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,546 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Week Ahead journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
saved: list[dict] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC")
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
pulled: list[dict] = []
|
||||
|
||||
page.route("**/api/v1/today", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 3, "ids": ["issue:acme/mobile:99:"],
|
||||
"capacity_minutes": 60, "estimates": {"issue:acme/mobile:99:": 30},
|
||||
}),
|
||||
))
|
||||
|
||||
def pull_item_route(route):
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
pulled.append(body)
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"today": {"revision": 4, "ids": ["issue:acme/mobile:99:", body["identity"]],
|
||||
"capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:99:": 30, body["identity"]: 45}},
|
||||
"week": {"revision": 99, "timezone": "UTC", "days": [{
|
||||
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
|
||||
"ids": [], "capacity_minutes": 60, "estimates": {},
|
||||
}]},
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week/pull-item", pull_item_route)
|
||||
|
||||
def week_route(route):
|
||||
if route.request.method == "PUT":
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
saved.append(body)
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": body["base_revision"] + 1,
|
||||
"timezone": body["timezone"], "days": body["days"],
|
||||
**({"availability_defaults": body["availability_defaults"]}
|
||||
if "availability_defaults" in body else {}),
|
||||
}))
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 0, "timezone": None, "days": [{
|
||||
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
|
||||
"ids": ["issue:acme/mobile:41:", "issue:acme/mobile:42:"], "capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:41:": 45, "issue:acme/mobile:42:": 45},
|
||||
}],
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week", week_route)
|
||||
page.route(
|
||||
"**/api/v1/repos/acme/mobile/issues/41/close",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"number":41,"state":"closed","updated_at":"2026-08-21T12:00:00Z"}',
|
||||
),
|
||||
)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Week Ahead release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
assert not page_errors, f"Week Ahead launch raised: {page_errors}"
|
||||
expect(page.locator("#plan-today-title")).to_have_text("Week Ahead")
|
||||
overview = page.locator("#week-review")
|
||||
expect(overview).to_be_visible()
|
||||
cards = page.locator("#week-review-days .week-review-day")
|
||||
expect(cards).to_have_count(7)
|
||||
expect(cards.first).to_contain_text("Next up")
|
||||
expect(cards.first).to_contain_text("Ship mobile capture")
|
||||
expect(page.locator("#week-review-status")).to_contain_text("30 min over capacity")
|
||||
expect(page.locator("#confirm-week-plan")).to_be_hidden()
|
||||
blocked_export = page.locator("#export-saved-week-calendar")
|
||||
expect(blocked_export).to_be_visible()
|
||||
expect(blocked_export).to_be_disabled()
|
||||
availability = page.locator("#open-week-availability")
|
||||
expect(availability).to_be_visible()
|
||||
availability_bounds = availability.bounding_box()
|
||||
assert availability_bounds and availability_bounds["height"] >= 44
|
||||
availability.click()
|
||||
editor = page.locator("#week-availability-editor")
|
||||
expect(editor).to_be_visible()
|
||||
capacities = editor.locator("[data-weekday-capacity]")
|
||||
expect(capacities).to_have_count(7)
|
||||
for index, minutes in enumerate((480, 480, 480, 480, 360, 0, 0)):
|
||||
capacities.nth(index).fill(str(minutes))
|
||||
bounds = capacities.nth(index).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#save-week-availability").click()
|
||||
expect(editor).to_be_hidden()
|
||||
expect(page.locator("#week-review-status")).to_have_text("Weekly availability saved.")
|
||||
assert saved[-1]["availability_defaults"] == [480, 480, 480, 480, 360, 0, 0]
|
||||
availability.click()
|
||||
page.locator("#disable-week-availability").click()
|
||||
expect(editor).to_be_hidden()
|
||||
expect(page.locator("#week-review-status")).to_have_text("Weekly availability disabled.")
|
||||
assert "availability_defaults" not in saved[-1]
|
||||
edit_week = page.locator("#edit-week-plan")
|
||||
expect(edit_week).to_be_visible()
|
||||
remove_actions = cards.first.locator("[data-week-unplan]")
|
||||
expect(remove_actions).to_have_count(2)
|
||||
remove_bounds = remove_actions.nth(1).bounding_box()
|
||||
assert remove_bounds and remove_bounds["height"] >= 44
|
||||
remove_actions.nth(1).click()
|
||||
expect(cards.first).not_to_contain_text("Polish desktop filters")
|
||||
expect(page.locator("#week-unplan-receipt")).to_have_text(
|
||||
"Polish desktop filters removed from Week Ahead."
|
||||
)
|
||||
undo = page.locator("#undo-week-unplan")
|
||||
expect(undo).to_be_visible()
|
||||
undo_bounds = undo.bounding_box()
|
||||
assert undo_bounds and undo_bounds["height"] >= 44
|
||||
undo.click()
|
||||
expect(cards.first).to_contain_text("Polish desktop filters")
|
||||
expect(page.locator("#week-unplan-receipt")).to_be_hidden()
|
||||
assert saved[-2]["days"][0]["ids"] == ["issue:acme/mobile:41:"]
|
||||
assert saved[-1]["days"][0]["ids"] == [
|
||||
"issue:acme/mobile:41:", "issue:acme/mobile:42:"
|
||||
]
|
||||
writes_before_reflow = len(saved)
|
||||
reflow = page.locator("#open-week-reflow")
|
||||
expect(reflow).to_be_visible()
|
||||
bounds = reflow.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
reflow.click()
|
||||
preview = page.locator("#week-reflow-review")
|
||||
expect(preview).to_be_visible()
|
||||
expect(page.locator("#week-reflow-days")).to_contain_text("1 item · 45 of 60 min")
|
||||
expect(page.locator("#week-reflow-unscheduled")).to_have_text(
|
||||
"1 item cannot fit: Polish desktop filters. It will remain in My Work."
|
||||
)
|
||||
for control in (page.locator("#cancel-week-reflow"), page.locator("#apply-week-reflow")):
|
||||
control_bounds = control.bounding_box()
|
||||
assert control_bounds and control_bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#cancel-week-reflow").click()
|
||||
expect(preview).to_be_hidden()
|
||||
assert len(saved) == writes_before_reflow, "cancelling reflow must not write"
|
||||
reflow.click()
|
||||
page.locator("#apply-week-reflow").click()
|
||||
expect(page.locator("#week-review-status")).to_have_text("Week Ahead reflowed and saved.")
|
||||
expect(preview).to_be_hidden()
|
||||
assert len(saved) == writes_before_reflow + 1
|
||||
assert [day["ids"] for day in saved[-1]["days"]][:2] == [["issue:acme/mobile:41:"], []]
|
||||
planned_item = cards.first.locator("[data-week-open-item]")
|
||||
expect(planned_item).to_have_count(1)
|
||||
planned_bounds = planned_item.bounding_box()
|
||||
assert planned_bounds and planned_bounds["height"] >= 44
|
||||
planned_item.click()
|
||||
expect(page.locator("#issue-sheet")).to_have_class(re.compile(r"\bopen\b"))
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
expect(overview).to_be_visible()
|
||||
page.locator("#close-issue-sheet").click()
|
||||
expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b"))
|
||||
expect(overview).to_be_visible()
|
||||
expect(planned_item).to_be_focused()
|
||||
assert len(saved) == writes_before_reflow + 1, "opening and inspecting Week Ahead must not add a write"
|
||||
export_saved = page.locator("#export-saved-week-calendar")
|
||||
expect(export_saved).to_be_visible()
|
||||
expect(export_saved).to_be_enabled()
|
||||
export_bounds = export_saved.bounding_box()
|
||||
assert export_bounds and export_bounds["height"] >= 44
|
||||
writes_before_export = len(saved)
|
||||
export_saved.click()
|
||||
expect(page.locator("#week-calendar-handoff")).to_be_visible()
|
||||
expect(overview).to_be_hidden()
|
||||
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text(
|
||||
"09:00–09:45 · 45 min"
|
||||
)
|
||||
assert len(saved) == writes_before_export, "exporting a saved week must not add a write"
|
||||
page.locator("#back-to-week-review-from-calendar").click()
|
||||
expect(overview).to_be_visible()
|
||||
expect(export_saved).to_be_focused()
|
||||
for control in (cards.first.locator("[data-week-edit-day]"), edit_week):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
edit_week.click()
|
||||
expect(overview).to_be_hidden()
|
||||
expect(page.locator("#plan-today-title")).to_have_text("Plan Week Ahead")
|
||||
dates = page.locator("#week-plan-dates button")
|
||||
expect(dates).to_have_count(7)
|
||||
expect(dates.first).to_have_attribute("aria-current", "date")
|
||||
for index in range(7):
|
||||
bounds = dates.nth(index).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
dates.nth(2).click()
|
||||
expect(dates.nth(2)).to_have_attribute("aria-current", "date")
|
||||
expect(page.locator("#week-plan-progress")).to_have_text("Day 3 of 7 · 1 planned")
|
||||
expect(page.locator("#save-today-plan")).to_have_text("Save & next")
|
||||
page.locator("#save-today-plan").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(dates.nth(3)).to_have_attribute("aria-current", "date")
|
||||
expect(page.locator("#week-plan-progress")).to_have_text("Day 4 of 7 · 1 planned")
|
||||
dates.nth(6).click()
|
||||
expect(page.locator("#save-today-plan")).to_have_text("Save week")
|
||||
page.locator("#save-today-plan").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
expect(page.locator("#week-review-days .week-review-day")).to_have_count(7)
|
||||
first_day = page.locator("#week-review-days .week-review-day").first
|
||||
expect(first_day).to_contain_text("Ship mobile capture")
|
||||
expect(first_day).to_contain_text("acme/mobile #41 · Issue · 45 min")
|
||||
expect(first_day.locator("code")).to_have_count(0)
|
||||
edit = first_day.locator("[data-week-edit-day]")
|
||||
bounds = edit.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
edit.click()
|
||||
expect(page.locator("#week-review")).to_be_hidden()
|
||||
expect(page.locator("#back-to-week-review")).to_be_visible()
|
||||
expect(page.locator("#save-today-plan")).to_have_text("Save & review")
|
||||
page.locator("#back-to-week-review").click()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
expect(first_day).to_contain_text("Ship mobile capture")
|
||||
|
||||
page.locator("#open-week-capacity-import").click()
|
||||
capacity_import = page.locator("#week-capacity-import")
|
||||
expect(capacity_import).to_be_visible()
|
||||
private_title = "Private customer planning"
|
||||
tomorrow = (date.today() + timedelta(days=1)).strftime("%Y%m%d")
|
||||
excluded = (date.today() + timedelta(days=2)).strftime("%Y%m%d")
|
||||
added = (date.today() + timedelta(days=4)).strftime("%Y%m%d")
|
||||
calendar = (
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
|
||||
f"SUMMARY:{private_title}\r\nDTSTART;TZID=America/New_York:{tomorrow}T050000\r\n"
|
||||
f"DTEND;TZID=America/New_York:{tomorrow}T060000\r\nRRULE:FREQ=DAILY;COUNT=3\r\n"
|
||||
f"EXDATE;TZID=America/New_York:{excluded}T050000\r\n"
|
||||
f"RDATE;TZID=America/New_York:{added}T050000\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
||||
)
|
||||
unsupported_title = "Private monthly board review"
|
||||
unsupported_calendar = (
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
|
||||
f"SUMMARY:{unsupported_title}\r\nDTSTART:{tomorrow}T100000\r\n"
|
||||
f"DTEND:{tomorrow}T110000\r\nRRULE:FREQ=MONTHLY;BYDAY=1FR\r\n"
|
||||
"END:VEVENT\r\nEND:VCALENDAR"
|
||||
)
|
||||
page.locator("#week-capacity-file").set_input_files({
|
||||
"name": "unsupported.ics", "mimeType": "text/calendar", "buffer": unsupported_calendar.encode()
|
||||
})
|
||||
expect(page.locator("#week-capacity-status")).to_contain_text(
|
||||
"1 recurring event could not be counted"
|
||||
)
|
||||
expect(page.locator("#apply-week-capacities")).to_be_disabled()
|
||||
expect(capacity_import).not_to_contain_text(unsupported_title)
|
||||
page.locator("#week-capacity-file").set_input_files({
|
||||
"name": "availability.ics", "mimeType": "text/calendar", "buffer": calendar.encode()
|
||||
})
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day")).to_have_count(7)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").first).to_contain_text(
|
||||
"420 min available"
|
||||
)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").nth(1)).to_contain_text(
|
||||
"480 min available"
|
||||
)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").nth(2)).to_contain_text(
|
||||
"420 min available"
|
||||
)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").nth(3)).to_contain_text(
|
||||
"420 min available"
|
||||
)
|
||||
expect(capacity_import).not_to_contain_text(private_title)
|
||||
for control in (
|
||||
page.locator("#cancel-week-capacity-import"),
|
||||
page.locator("#week-capacity-file"),
|
||||
page.locator("#week-capacity-start"),
|
||||
page.locator("#week-capacity-end"),
|
||||
page.locator("#keep-week-free-times"),
|
||||
page.locator("#apply-week-capacities"),
|
||||
):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#keep-week-free-times").check()
|
||||
page.locator("#apply-week-capacities").click()
|
||||
expect(capacity_import).to_be_hidden()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
expect(first_day).to_contain_text("45 of 420 min")
|
||||
assert saved[-1]["days"][0]["free_windows"] == [
|
||||
{"start_time": "10:00", "end_time": "17:00"}
|
||||
]
|
||||
|
||||
page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled")
|
||||
confirm = page.locator("#confirm-week-plan")
|
||||
bounds = confirm.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
confirm.click()
|
||||
expect(page.locator("#week-calendar-handoff")).to_be_visible()
|
||||
expect(page.locator("#week-review")).to_be_hidden()
|
||||
start = page.locator("[data-week-calendar-start]").first
|
||||
expect(start).to_have_value("09:00")
|
||||
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:00–10:45 · 45 min")
|
||||
expect(page.locator("#week-calendar-status")).to_contain_text("Planning around imported busy time")
|
||||
exact = page.locator('[data-week-calendar-exact="issue:acme/mobile:41:"]')
|
||||
expect(exact).to_have_value("")
|
||||
exact.fill("14:00")
|
||||
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("14:00–14:45 · 45 min")
|
||||
save_times = page.locator("#save-week-calendar-times")
|
||||
for control in (start, exact, page.locator("#back-to-week-review-from-calendar"), save_times, page.locator("#share-week-calendar")):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
save_times.click()
|
||||
expect(page.locator("#week-calendar-status")).to_have_text("Exact task times saved to Week Ahead.")
|
||||
assert saved[-1]["days"][0]["start_times"] == {"issue:acme/mobile:41:": "14:00"}
|
||||
page.locator("#back-to-week-review-from-calendar").click()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
confirm.click()
|
||||
with page.expect_download() as download_info:
|
||||
page.locator("#share-week-calendar").click()
|
||||
assert download_info.value.suggested_filename.startswith("stackchain-week-ahead-")
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
|
||||
assert saved and len(saved[-1]["days"]) == 7
|
||||
assert [day["plan_date"] for day in saved[-1]["days"]] == sorted(
|
||||
day["plan_date"] for day in saved[-1]["days"]
|
||||
)
|
||||
|
||||
before_retirement = len(saved)
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
expect(overview).to_be_visible()
|
||||
planned_item = cards.first.locator(
|
||||
'[data-week-open-item="issue:acme/mobile:41:"]'
|
||||
)
|
||||
planned_item.click()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
page.once("dialog", lambda dialog: dialog.accept())
|
||||
page.locator("#close-issue").click()
|
||||
expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b"))
|
||||
expect(cards.first).not_to_contain_text("Ship mobile capture")
|
||||
expect(cards.first).to_contain_text("Polish desktop filters")
|
||||
expect(cards.first).to_contain_text("45 of 60 min")
|
||||
expect(page.locator("#week-review-status")).to_have_text(
|
||||
"Ship mobile capture removed and Week Ahead saved."
|
||||
)
|
||||
expect(edit_week).to_be_focused()
|
||||
assert len(saved) == before_retirement + 1
|
||||
assert saved[-1]["days"][0]["ids"] == ["issue:acme/mobile:42:"]
|
||||
assert saved[-1]["days"][0]["capacity_minutes"] == 60
|
||||
pull = cards.first.locator('[data-week-pull-item="issue:acme/mobile:42:"]')
|
||||
expect(pull).to_have_text("Review Today · 75 of 60 min")
|
||||
pull_bounds = pull.bounding_box()
|
||||
assert pull_bounds and pull_bounds["height"] >= 44
|
||||
page.once("dialog", lambda dialog: dialog.dismiss())
|
||||
pull.click()
|
||||
expect(cards.first).to_contain_text("Polish desktop filters")
|
||||
assert pulled == [], "cancelling the overload review must not submit"
|
||||
expect(pull).to_have_text("Review Today · 75 of 60 min")
|
||||
page.once("dialog", lambda dialog: dialog.accept())
|
||||
pull.click()
|
||||
expect(cards.first).not_to_contain_text("Polish desktop filters")
|
||||
expect(page.locator("#week-review-status")).to_have_text(
|
||||
"Polish desktop filters added to Today."
|
||||
)
|
||||
assert pulled and pulled[-1]["identity"] == "issue:acme/mobile:42:"
|
||||
assert pulled[-1]["today_revision"] == 3
|
||||
assert pulled[-1]["allow_over_capacity"] is True
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reconciles_only_the_week_day_changed_on_both_devices(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
puts: list[dict] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
|
||||
def week_route(route):
|
||||
if route.request.method == "PUT":
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
puts.append(body)
|
||||
if len(puts) == 1:
|
||||
route.fulfill(status=409, content_type="application/json", body='{"message":"changed"}')
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 6, "timezone": body["timezone"], "days": body["days"],
|
||||
}))
|
||||
return
|
||||
if puts:
|
||||
changed_date = puts[0]["days"][0]["plan_date"]
|
||||
following_date = (date.fromisoformat(changed_date) + timedelta(days=1)).isoformat()
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 5, "timezone": puts[0]["timezone"], "days": [
|
||||
{"plan_date": changed_date, "ids": [], "capacity_minutes": 90, "estimates": {}},
|
||||
{"plan_date": following_date, "ids": [], "capacity_minutes": 30, "estimates": {}},
|
||||
],
|
||||
}))
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 4, "timezone": None, "days": [],
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week", week_route)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Week conflict release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
page.locator("#edit-week-plan").click()
|
||||
page.locator("#plan-today-available").fill("60")
|
||||
page.locator("#save-today-plan").click()
|
||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent.includes('Conflict')")
|
||||
|
||||
choices = page.locator("[data-week-conflict-choice]")
|
||||
expect(choices).to_have_count(2)
|
||||
for index in range(2):
|
||||
bounds = choices.nth(index).locator("xpath=..").bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator('[data-week-conflict-choice="phone"]').check()
|
||||
expect(page.locator("#save-merged-week")).to_be_enabled()
|
||||
page.locator("#save-merged-week").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
|
||||
assert len(puts) == 2
|
||||
assert [day["capacity_minutes"] for day in puts[-1]["days"]] == [60, 30]
|
||||
assert not page_errors, f"Week conflict recovery raised: {page_errors}"
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reads_the_confirmed_week_offline_then_retries_live(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
online = True
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC")
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
|
||||
def week_route(route):
|
||||
if not online:
|
||||
route.fulfill(status=503, content_type="application/json", body='{"message":"offline"}')
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 7, "timezone": "UTC", "days": [{
|
||||
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
|
||||
"ids": ["issue:acme/mobile:41:"], "capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:41:": 30},
|
||||
}],
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week", week_route)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Offline Week Ahead phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture")
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
online = False
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
notice = page.locator("#week-offline-snapshot")
|
||||
expect(notice).to_be_visible()
|
||||
expect(notice).to_contain_text("Offline snapshot")
|
||||
expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture")
|
||||
expect(page.locator("[data-week-edit-day]")).to_have_count(0)
|
||||
expect(page.locator("#edit-week-plan")).to_be_hidden()
|
||||
expect(page.locator("#open-week-capacity-import")).to_be_hidden()
|
||||
retry = page.locator("#retry-week-live")
|
||||
expect(retry).to_be_visible()
|
||||
bounds = retry.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
online = True
|
||||
retry.click()
|
||||
expect(notice).to_be_hidden()
|
||||
expect(page.locator("#edit-week-plan")).to_be_visible()
|
||||
expect(page.locator("[data-week-edit-day]")).to_have_count(7)
|
||||
assert not page_errors, f"offline Week Ahead recovery raised: {page_errors}"
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
@@ -0,0 +1,118 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
AGENDA_CALENDAR = Path(__file__).parents[1] / "frontend" / "agenda-calendar.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
|
||||
|
||||
def run_node(source: str):
|
||||
completed = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_calendar_snapshot_serializes_ordered_all_day_events_with_stable_identity():
|
||||
items = [
|
||||
{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 12,
|
||||
"title": "Plan, ship; verify\\path\nnext",
|
||||
"due_date": "2026-08-19T23:00:00-07:00",
|
||||
"url": "https://forge.example/git/stackchain/dashboard/issues/12",
|
||||
},
|
||||
{
|
||||
"repository": "stackchain/api",
|
||||
"number": 7,
|
||||
"title": "API release",
|
||||
"due_date": "2026-08-20",
|
||||
"url": "https://forge.example/git/stackchain/api/issues/7",
|
||||
},
|
||||
]
|
||||
source = f"""
|
||||
const calendar = require({json.dumps(str(AGENDA_CALENDAR))});
|
||||
const text = calendar.serializeAgendaCalendar({json.dumps(items)}, {{generatedOn:'20260818'}});
|
||||
process.stdout.write(JSON.stringify({{text}}));
|
||||
"""
|
||||
|
||||
text = run_node(source)["text"]
|
||||
assert text.startswith("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n")
|
||||
assert text.endswith("END:VCALENDAR\r\n")
|
||||
assert text.count("BEGIN:VEVENT") == 2
|
||||
assert text.index("stackchain/dashboard#12") < text.index("stackchain/api#7")
|
||||
assert "UID:issue-12@stackchain-dashboard" in text
|
||||
assert "DTSTART;VALUE=DATE:20260819" in text
|
||||
assert "SUMMARY:Plan\\, ship\\; verify\\\\path\\nnext" in text
|
||||
assert "URL:https://forge.example/git/stackchain/dashboard/issues/12" in text
|
||||
assert "DTSTAMP:20260818T000000Z" in text
|
||||
assert "\n" not in text.replace("\r\n", "")
|
||||
|
||||
|
||||
def test_calendar_snapshot_folds_utf8_lines_to_rfc_octet_limit_without_splitting_characters():
|
||||
item = {
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 99,
|
||||
"title": "Launch 🚀 " + "é" * 60,
|
||||
"due_date": "2026-08-21",
|
||||
"url": "https://forge.example/git/stackchain/dashboard/issues/99",
|
||||
}
|
||||
source = f"""
|
||||
const calendar = require({json.dumps(str(AGENDA_CALENDAR))});
|
||||
const text = calendar.serializeAgendaCalendar([{json.dumps(item)}], {{generatedOn:'20260818'}});
|
||||
process.stdout.write(JSON.stringify({{text}}));
|
||||
"""
|
||||
|
||||
text = run_node(source)["text"]
|
||||
physical_lines = text.split("\r\n")
|
||||
assert all(len(line.encode("utf-8")) <= 75 for line in physical_lines)
|
||||
assert any(line.startswith(" ") for line in physical_lines)
|
||||
unfolded = text.replace("\r\n ", "")
|
||||
assert "SUMMARY:Launch 🚀 " + "é" * 60 in unfolded
|
||||
|
||||
|
||||
def test_calendar_snapshot_prefers_native_file_share_and_falls_back_to_one_download():
|
||||
source = f"""
|
||||
const calendar = require({json.dumps(str(AGENDA_CALENDAR))});
|
||||
class FakeFile {{ constructor(parts, name, options) {{ this.parts=parts; this.name=name; this.type=options.type; }} }}
|
||||
const shared=[];
|
||||
const nativeNavigator={{canShare:payload => payload.files[0].name.endsWith('.ics'), share:async payload => shared.push(payload)}};
|
||||
const clicks=[]; const revoked=[];
|
||||
const document={{createElement:() => ({{click(){{clicks.push({{download:this.download,href:this.href}})}}}})}};
|
||||
const urlApi={{createObjectURL:()=>'blob:agenda', revokeObjectURL:value=>revoked.push(value)}};
|
||||
(async () => {{
|
||||
const native = await calendar.deliverCalendarSnapshot({{text:'BEGIN:VCALENDAR', filename:'stackchain-agenda.ics', navigator:nativeNavigator, document, urlApi, FileCtor:FakeFile}});
|
||||
const fallback = await calendar.deliverCalendarSnapshot({{text:'BEGIN:VCALENDAR', filename:'stackchain-agenda.ics', navigator:{{}}, document, urlApi, FileCtor:FakeFile}});
|
||||
process.stdout.write(JSON.stringify({{native,fallback,shared:shared.length,clicks,revoked}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(source) == {
|
||||
"native": "shared",
|
||||
"fallback": "downloaded",
|
||||
"shared": 1,
|
||||
"clicks": [{"download": "stackchain-agenda.ics", "href": "blob:agenda"}],
|
||||
"revoked": ["blob:agenda"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_agenda_export_review_requires_selection_and_exposes_snapshot_privacy_boundary():
|
||||
markup = INDEX.read_text()
|
||||
source = await dashboard()
|
||||
feature_source = source + AGENDA_CALENDAR.read_text()
|
||||
|
||||
assert 'id="open-agenda-export"' in markup
|
||||
assert 'id="agenda-export-sheet"' in markup
|
||||
assert 'id="agenda-export-items"' in markup
|
||||
assert 'id="share-agenda-export"' in markup
|
||||
assert "This exports a snapshot" in markup
|
||||
assert "agendaMyWork(activeMyWork)" in feature_source
|
||||
assert "checkbox.checked" in feature_source
|
||||
assert "serializeAgendaCalendar(selected" in feature_source
|
||||
assert "deliverCalendarSnapshot" in feature_source
|
||||
assert "agenda-export-empty" in feature_source
|
||||
@@ -15,6 +15,44 @@ def run_node(script: str):
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_repeated_operation_id_rejects_a_different_comment_payload():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy'}});
|
||||
const original={{kind:'issue-comment',repository:'o/r',number:7,body:'Done',operationId:'stable',attachments:[{{filename:'before.jpg',contentType:'image/jpeg',note:'Before',operationId:'photo-1'}}]}};
|
||||
const identical=outbox.enqueue(original);
|
||||
let error='';
|
||||
try {{ outbox.enqueue({{...original,attachments:[{{filename:'after.jpg',contentType:'image/jpeg',note:'After',operationId:'photo-2'}}]}}); }}
|
||||
catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{identical,error,items:outbox.list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["identical"]["operationId"] == "stable"
|
||||
assert output["error"] == "This operation ID is already bound to a different queued action."
|
||||
assert len(output["items"]) == 1
|
||||
assert output["items"][0]["attachment"]["operationId"] == "photo-1"
|
||||
|
||||
|
||||
def test_repeated_pull_review_operation_uses_the_default_comment_decision():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy'}});
|
||||
const review={{kind:'pull-review',repository:'o/r',number:8,body:'Notes',expectedHeadSha:'abc',operationId:'review-op'}};
|
||||
outbox.enqueue(review);
|
||||
let error='';
|
||||
try {{ outbox.enqueue(review); }} catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["error"] == ""
|
||||
assert len(output["items"]) == 1
|
||||
assert output["items"][0]["decision"] == "comment"
|
||||
|
||||
|
||||
def test_authored_outbox_quarantines_legacy_and_cross_account_messages():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -1,7 +1,78 @@
|
||||
import threading
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
|
||||
|
||||
PRIVATE_KEY = b"a" * 32
|
||||
|
||||
|
||||
def test_published_find_work_catalog_is_encrypted_at_rest_and_survives_restart(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
store = AvailableIssueSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
|
||||
owner = store.try_acquire_refresh(lease_seconds=5)
|
||||
canary = "private-find-work-body-canary"
|
||||
|
||||
published = store.publish(
|
||||
owner,
|
||||
items=[{"repository": "stackchain/api", "number": 7, "body": canary}],
|
||||
)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v1:")
|
||||
assert canary not in payload
|
||||
assert AvailableIssueSnapshotStore(
|
||||
path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY
|
||||
).load().items == published.items
|
||||
|
||||
|
||||
def test_find_work_catalog_lazily_migrates_plaintext_without_changing_freshness(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
store = AvailableIssueSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, retry_at = ?",
|
||||
('[{"repository":"stackchain/api","number":7}]', 91.0, 105.0),
|
||||
)
|
||||
|
||||
state = store.load()
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
migrated = connection.execute(
|
||||
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()[0]
|
||||
assert state.items == [{"repository": "stackchain/api", "number": 7}]
|
||||
assert (state.created_at, state.retry_at) == (91.0, 105.0)
|
||||
assert migrated.startswith("v1:")
|
||||
|
||||
|
||||
def test_find_work_ciphertext_cannot_be_substituted_into_live_snapshot(tmp_path):
|
||||
available_path = tmp_path / "available.sqlite3"
|
||||
available = AvailableIssueSnapshotStore(
|
||||
available_path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY
|
||||
)
|
||||
owner = available.try_acquire_refresh(lease_seconds=5)
|
||||
available.publish(owner, items=[{"body": "secret"}])
|
||||
with sqlite3.connect(available_path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()[0]
|
||||
|
||||
live_path = tmp_path / "live.sqlite3"
|
||||
from src.live_snapshot_store import LiveSnapshotStore
|
||||
live = LiveSnapshotStore(live_path, encryption_key=PRIVATE_KEY)
|
||||
with sqlite3.connect(live_path) as connection:
|
||||
connection.execute("UPDATE live_snapshot SET value_json = ?", (payload,))
|
||||
|
||||
with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
|
||||
live.load()
|
||||
|
||||
|
||||
def test_independent_workers_allow_only_one_catalog_refresh(tmp_path):
|
||||
|
||||
@@ -41,11 +41,14 @@ def test_release_promotion_waits_for_tests_and_bundle():
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
assert "github.event_name == 'push'" in release
|
||||
assert "actions/download-artifact@v3" in release
|
||||
assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release
|
||||
assert (
|
||||
'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .'
|
||||
in release
|
||||
)
|
||||
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
|
||||
|
||||
|
||||
def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
def test_release_promotion_waits_for_packaged_mobile_journeys():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
@@ -58,10 +61,23 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
assert (
|
||||
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
|
||||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||
"tests/e2e/test_mobile_search_week_plan.py "
|
||||
"tests/e2e/test_mobile_find_work_release.py "
|
||||
"tests/e2e/test_mobile_home_bootstrap_release.py "
|
||||
"tests/e2e/test_mobile_sign_out_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||
"tests/e2e/test_mobile_today_summary_release.py "
|
||||
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
|
||||
"tests/e2e/test_mobile_week_ahead_release.py "
|
||||
"tests/e2e/test_mobile_today_week_reschedule_release.py "
|
||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
||||
) in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
|
||||
|
||||
def test_browser_job_runs_packaged_today_week_reschedule_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
|
||||
assert "tests/e2e/test_mobile_today_week_reschedule_release.py" in browser
|
||||
@@ -951,6 +951,17 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
|
||||
|
||||
def test_mobile_search_pauses_today_and_offers_a_lossless_return():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert html.count('<aside class="search-today-interruption" data-search-today-interruption') == 2
|
||||
assert html.count('<button data-return-from-search type="button">') == 2
|
||||
assert "taskOverlayHistory.leave();" in html
|
||||
assert ".search-today-interruption" in css
|
||||
assert ".search-today-interruption button { min-height:44px;" in css
|
||||
|
||||
|
||||
def test_search_preview_queues_eligible_issue_and_continues_preserved_search():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
assert "stackchain-dashboard-shell-v130" in worker
|
||||
@@ -1,3 +1,5 @@
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -21,6 +23,30 @@ def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path)
|
||||
assert store.get("alexander") == {"receipts": []}
|
||||
|
||||
|
||||
def test_receipt_history_is_encrypted_and_bound_to_its_account(tmp_path):
|
||||
database = tmp_path / "completed-filed.sqlite3"
|
||||
store = CompletedFiledReviewStore(database, encryption_key=b"f" * 32)
|
||||
timmy = receipt("private-timmy/repository", 912)
|
||||
alexander = receipt("private-alexander/repository", 427)
|
||||
|
||||
store.merge("timmy-private-login", [timmy])
|
||||
store.merge("alexander-private-login", [alexander])
|
||||
|
||||
raw = database.read_bytes()
|
||||
assert b"private-timmy/repository" not in raw
|
||||
assert b"private-alexander/repository" not in raw
|
||||
with sqlite3.connect(database) as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT login, receipts FROM completed_filed_review_collections ORDER BY login"
|
||||
).fetchall()
|
||||
connection.execute(
|
||||
"UPDATE completed_filed_review_collections SET receipts = ? WHERE login = ?",
|
||||
(rows[1][1], rows[0][0]),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="private state could not be decrypted"):
|
||||
store.get("alexander-private-login")
|
||||
|
||||
|
||||
def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
|
||||
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2)
|
||||
newer = receipt(updated_at="2026-08-16T12:00:00Z")
|
||||
@@ -32,6 +58,42 @@ def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
|
||||
assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]}
|
||||
|
||||
|
||||
def test_legacy_plaintext_receipts_migrate_for_every_account_without_loss(tmp_path):
|
||||
database = tmp_path / "completed-filed.sqlite3"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE completed_filed_reviews ("
|
||||
"login TEXT NOT NULL, repository TEXT NOT NULL, issue_number INTEGER NOT NULL, "
|
||||
"updated_at TEXT NOT NULL, touched_at INTEGER NOT NULL, "
|
||||
"PRIMARY KEY (login, repository, issue_number))"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO completed_filed_reviews VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
("timmy", "legacy-private/first", 1, "2026-08-14T12:00:00Z", 1),
|
||||
("timmy", "legacy-private/second", 2, "2026-08-15T12:00:00Z", 2),
|
||||
("alexander", "legacy-private/other", 3, "2026-08-16T12:00:00Z", 1),
|
||||
],
|
||||
)
|
||||
|
||||
store = CompletedFiledReviewStore(database, encryption_key=b"m" * 32)
|
||||
|
||||
assert store.get("timmy") == {"receipts": [
|
||||
receipt("legacy-private/first", 1, "2026-08-14T12:00:00Z"),
|
||||
receipt("legacy-private/second", 2, "2026-08-15T12:00:00Z"),
|
||||
]}
|
||||
assert store.get("alexander") == {"receipts": [
|
||||
receipt("legacy-private/other", 3, "2026-08-16T12:00:00Z")
|
||||
]}
|
||||
raw = database.read_bytes()
|
||||
assert b"legacy-private/first" not in raw
|
||||
assert b"legacy-private/other" not in raw
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT count(*) FROM sqlite_master WHERE name = 'completed_filed_reviews'"
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("candidate", [
|
||||
{"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"},
|
||||
{"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"},
|
||||
|
||||
@@ -96,6 +96,21 @@ const options = {{
|
||||
}
|
||||
|
||||
|
||||
def test_other_focused_surfaces_can_request_the_same_lazy_action_controller():
|
||||
script = f"""
|
||||
const createConversationActionHydrator = require({json.dumps(str(HYDRATOR))});
|
||||
let loads=0; const controller={{name:'shared-actions'}};
|
||||
const hydrator=createConversationActionHydrator({{
|
||||
load:async()=>{{loads++;}}, activate:()=>controller,
|
||||
}});
|
||||
(async()=>{{
|
||||
const [first,second]=await Promise.all([hydrator.get(),hydrator.get()]);
|
||||
process.stdout.write(JSON.stringify({{loads,same:first===second,name:first.name}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {"loads": 1, "same": True, "name": "shared-actions"}
|
||||
|
||||
|
||||
def test_issue_pull_and_update_conversations_trigger_optional_actions_not_startup():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
javascript = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
@@ -79,6 +79,36 @@ const photos = [
|
||||
}
|
||||
|
||||
|
||||
def test_store_isolates_today_progress_photos_from_conversation_photos():
|
||||
script = f"""
|
||||
const createStore = require({json.dumps(str(STORE))});
|
||||
const records = new Map();
|
||||
const transaction = async (operation, key, value) => {{
|
||||
if (operation === 'put') records.set(key, structuredClone(value));
|
||||
if (operation === 'get') return records.has(key) ? structuredClone(records.get(key)) : null;
|
||||
if (operation === 'delete') records.delete(key);
|
||||
}};
|
||||
const conversation = createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
|
||||
const progress = createStore({{transaction,getOwnerLogin:()=> 'timmy',scope:'today-progress'}});
|
||||
const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}};
|
||||
(async()=>{{
|
||||
await conversation.save(target,[{{filename:'reply.jpg',contentType:'image/jpeg',blob:new Blob(['reply'])}}]);
|
||||
await progress.save(target,[{{filename:'progress.jpg',contentType:'image/jpeg',blob:new Blob(['progress'])}}]);
|
||||
const reply=await conversation.load(target); const today=await progress.load(target);
|
||||
await progress.remove(target);
|
||||
process.stdout.write(JSON.stringify({{reply:reply[0].filename,today:today[0].filename,
|
||||
replyAfter:(await conversation.load(target))[0].filename,progressAfter:await progress.load(target),count:records.size}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"reply": "reply.jpg",
|
||||
"today": "progress.jpg",
|
||||
"replyAfter": "reply.jpg",
|
||||
"progressAfter": None,
|
||||
"count": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_coordinator_ignores_stale_restores_and_clears_only_after_durable_completion():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
@@ -138,6 +168,35 @@ const drafts = createCoordinator({{store, lanes:{{issue:{{controller}}}}}});
|
||||
assert json.loads(run_node(script)) == ["save:1:first.webp", "restore:second.webp"]
|
||||
|
||||
|
||||
def test_coordinator_clear_does_not_delete_the_durable_draft_during_reopen():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
const records=new Map(); let current=[]; let drafts; const restored=[];
|
||||
const key=target=>target.kind+':'+target.number;
|
||||
const store={{
|
||||
save:async(target,attachments)=>attachments?.length?records.set(key(target),attachments):records.delete(key(target)),
|
||||
load:async target=>records.get(key(target))||null,
|
||||
remove:async target=>records.delete(key(target)),
|
||||
}};
|
||||
const controller={{
|
||||
serialize:async()=>current,
|
||||
restore:value=>{{current=value;restored.push(value[0].filename);}},
|
||||
clear:()=>{{current=[];drafts.checkpoint('today').catch(()=>{{}});}},
|
||||
}};
|
||||
drafts=createCoordinator({{store,lanes:{{today:{{controller}}}}}});
|
||||
const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}};
|
||||
(async()=>{{
|
||||
await drafts.open('today',target);
|
||||
current=[{{filename:'proof.webp',contentType:'image/webp',blob:new Blob(['proof'])}}];
|
||||
await drafts.checkpoint('today');
|
||||
await drafts.switchTo('today',target);
|
||||
await Promise.resolve();
|
||||
process.stdout.write(JSON.stringify({{restored,saved:(await store.load(target))?.[0]?.filename||null}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {"restored": ["proof.webp"], "saved": "proof.webp"}
|
||||
|
||||
|
||||
def test_my_work_wires_durable_photo_drafts_into_all_conversation_boundaries():
|
||||
dashboard = DASHBOARD.read_text()
|
||||
html = INDEX.read_text()
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_issue_capture_modal_inerts_background_focuses_cancel_and_restores_exact_launcher():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createIssueCapture = require({json.dumps(str(MODULE))});
|
||||
function element(id) {{
|
||||
return {{id, inert:false, hidden:false, disabled:false, isConnected:true,
|
||||
focus() {{ document.activeElement = this; }},
|
||||
getClientRects() {{ return [{{}}]; }} }};
|
||||
}}
|
||||
const launcher = element('mobile-new');
|
||||
const cancel = element('cancel-new-issue');
|
||||
const title = element('create-issue-title');
|
||||
const panel = {{...element('create-issue-panel'), scrollTop:420}};
|
||||
const background = [element('header'), element('main'), element('mobile-task-dock')];
|
||||
const root = {{
|
||||
querySelector: selector => selector === '#cancel-new-issue' ? cancel :
|
||||
(selector === '.create-issue-panel' ? panel : null),
|
||||
querySelectorAll: () => [cancel, title],
|
||||
addEventListener() {{}}, removeEventListener() {{}},
|
||||
}};
|
||||
const document = {{activeElement: launcher}};
|
||||
const lifecycle = createIssueCapture.createModalLifecycle({{
|
||||
root, background, document, requestClose() {{}},
|
||||
}});
|
||||
lifecycle.open(launcher);
|
||||
const opened = {{active:document.activeElement.id, inert:background.map(item => item.inert)}};
|
||||
lifecycle.close();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened, closed:{{active:document.activeElement.id, inert:background.map(item => item.inert), scrollTop:panel.scrollTop}}
|
||||
}}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"opened": {"active": "cancel-new-issue", "inert": [True, True, True]},
|
||||
"closed": {"active": "mobile-new", "inert": [False, False, False], "scrollTop": 0},
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_modal_wraps_visible_focus_and_escape_uses_close_request():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createIssueCapture = require({json.dumps(str(MODULE))});
|
||||
function element(id, visible=true) {{
|
||||
return {{id, inert:false, hidden:!visible, disabled:false, isConnected:true,
|
||||
focus() {{ document.activeElement = this; }},
|
||||
getClientRects() {{ return visible ? [{{}}] : []; }} }};
|
||||
}}
|
||||
const launcher = element('empty-work-create');
|
||||
const cancel = element('cancel-new-issue');
|
||||
const hidden = element('hidden-control', false);
|
||||
const submit = element('submit-new-issue');
|
||||
const listeners = {{}};
|
||||
const root = {{
|
||||
querySelector: selector => selector === '#cancel-new-issue' ? cancel : null,
|
||||
querySelectorAll: () => [cancel, hidden, submit],
|
||||
addEventListener: (name, callback) => {{ listeners[name] = callback; }},
|
||||
removeEventListener() {{}},
|
||||
}};
|
||||
const document = {{activeElement: launcher}};
|
||||
let closes = 0;
|
||||
const lifecycle = createIssueCapture.createModalLifecycle({{
|
||||
root, background:[], document, requestClose() {{ closes += 1; }},
|
||||
}});
|
||||
lifecycle.open(launcher);
|
||||
document.activeElement = submit;
|
||||
let prevented = 0;
|
||||
listeners.keydown({{key:'Tab', shiftKey:false, preventDefault() {{ prevented += 1; }}}});
|
||||
const wrappedForward = document.activeElement.id;
|
||||
document.activeElement = cancel;
|
||||
listeners.keydown({{key:'Tab', shiftKey:true, preventDefault() {{ prevented += 1; }}}});
|
||||
const wrappedBackward = document.activeElement.id;
|
||||
listeners.keydown({{key:'Escape', shiftKey:false, preventDefault() {{ prevented += 1; }}}});
|
||||
process.stdout.write(JSON.stringify({{wrappedForward, wrappedBackward, closes, prevented}}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"wrappedForward": "cancel-new-issue",
|
||||
"wrappedBackward": "submit-new-issue",
|
||||
"closes": 1,
|
||||
"prevented": 3,
|
||||
}
|
||||
@@ -12,6 +12,19 @@ SESSION_JS = ROOT / "frontend" / "session.js"
|
||||
SECURITY_CENTER_JS = ROOT / "frontend" / "security-center.js"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_center_wires_mobile_today_pause_before_lazy_loading_and_visible_return():
|
||||
html = await dashboard()
|
||||
session = SESSION_JS.read_text()
|
||||
dashboard_js = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="security-center-today-detour" data-today-detour' in html
|
||||
assert 'id="return-from-security-center" data-return-from-detour type="button">Return to Today</button>' in html
|
||||
assert session.index("beginDetour?.('security-center')") < session.index("devicesSheet.hidden = false;")
|
||||
assert "onClose: () => root.stackchainTodayTimerView?.finishDetour?.('security-center')" in session
|
||||
assert "window.stackchainTodayTimerView = timerView;" in dashboard_js
|
||||
|
||||
|
||||
def run_session_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
@@ -351,6 +364,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
@@ -378,6 +392,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
@@ -510,6 +525,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -534,6 +550,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -631,6 +648,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
@@ -55,6 +55,110 @@ async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplic
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_snoozed_deadline_revalidates_and_sends_once_after_expiry(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
store.mark_deadline_reminder_delivered("device-a", "2026-08-13")
|
||||
snoozed_at = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc)
|
||||
store.snooze_deadline_reminder(
|
||||
"device-a", now=snoozed_at.timestamp(), delay_seconds=3_600
|
||||
)
|
||||
snapshots = 0
|
||||
sent = []
|
||||
|
||||
async def assigned():
|
||||
nonlocal snapshots
|
||||
snapshots += 1
|
||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14"}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_deadline_reminders(
|
||||
store, config, assigned, send,
|
||||
now=datetime(2026, 8, 13, 10, 59, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert snapshots == 0
|
||||
|
||||
assert await dispatch_deadline_reminders(
|
||||
store, config, assigned, send,
|
||||
now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc),
|
||||
) == 1
|
||||
assert snapshots == 1
|
||||
assert sent[0]["deadline_count"] == 1
|
||||
assert sent[0]["tag"] == "stackchain-deadline-digest-2026-08-13"
|
||||
assert store.deadline_reminder_devices()[0].snoozed_until is None
|
||||
assert await dispatch_deadline_reminders(
|
||||
store, config, assigned, send,
|
||||
now=datetime(2026, 8, 13, 11, 1, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert snapshots == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_expired_deadline_snooze_clears_when_no_deadlines_remain(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
store.mark_deadline_reminder_delivered("device-a", "2026-08-13")
|
||||
store.snooze_deadline_reminder(
|
||||
"device-a",
|
||||
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
|
||||
async def resolved():
|
||||
return {"complete": True, "items": []}
|
||||
|
||||
assert await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
resolved,
|
||||
now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert store.deadline_reminder_devices()[0].snoozed_until is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_expired_deadline_snooze_clears_when_deadlines_move_beyond_horizon(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="UTC", reminder_hour=9, reminder_days=2
|
||||
)
|
||||
store.mark_deadline_reminder_delivered("device-a", "2026-08-13")
|
||||
store.snooze_deadline_reminder(
|
||||
"device-a",
|
||||
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
|
||||
async def replanned():
|
||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-20"}]}
|
||||
|
||||
assert await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
replanned,
|
||||
now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert store.deadline_reminder_devices()[0].snoozed_until is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -237,9 +341,39 @@ def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_pa
|
||||
"timezone": "Europe/London",
|
||||
"reminder_hour": 8,
|
||||
"reminder_days": 2,
|
||||
"snoozed_until": None,
|
||||
}
|
||||
|
||||
|
||||
def test_deadline_snooze_is_device_bound_and_requires_enabled_reminders(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("enabled", "disabled"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
"enabled", enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
store.set_deadline_preferences(
|
||||
"disabled", enabled=False, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
|
||||
assert store.snooze_deadline_reminder(
|
||||
"enabled", now=1_765_000_000, delay_seconds=3_600
|
||||
) is True
|
||||
assert store.snooze_deadline_reminder(
|
||||
"disabled", now=1_765_000_000, delay_seconds=3_600
|
||||
) is False
|
||||
assert store.snooze_deadline_reminder(
|
||||
"missing", now=1_765_000_000, delay_seconds=3_600
|
||||
) is False
|
||||
|
||||
devices = {device.session_id: device for device in store.deadline_reminder_devices()}
|
||||
assert devices["enabled"].snoozed_until == 1_765_003_600
|
||||
assert "disabled" not in devices
|
||||
|
||||
|
||||
def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
|
||||
database = tmp_path / "push.sqlite3"
|
||||
import sqlite3
|
||||
@@ -258,7 +392,11 @@ def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
delivered_local_day TEXT
|
||||
);
|
||||
INSERT INTO push_subscriptions VALUES ('device-a', 'https://push.example/a', '{}');
|
||||
INSERT INTO push_subscriptions VALUES (
|
||||
'device-a',
|
||||
'https://push.example/a',
|
||||
'{"endpoint":"https://push.example/a","keys":{"p256dh":"key","auth":"secret"}}'
|
||||
);
|
||||
INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL);
|
||||
""")
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ class Target {
|
||||
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
|
||||
let purgeCalls=0, purgeError='';
|
||||
let inventory={recordCount:0, unavailable:false};
|
||||
let persistence={persisted:false, persistResult:false, persistedCalls:0, persistCalls:0, error:''};
|
||||
const localStorage={
|
||||
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
|
||||
get length(){return this.values.size;},
|
||||
@@ -27,11 +28,16 @@ const caches={
|
||||
async keys(){return Array.from(this.values);},
|
||||
async delete(key){return this.values.delete(key);},
|
||||
};
|
||||
const storageManager={
|
||||
estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024}),
|
||||
persisted:async()=>{persistence.persistedCalls++;if(persistence.error)throw new Error(persistence.error);return persistence.persisted;},
|
||||
persist:async()=>{persistence.persistCalls++;persistence.persisted=persistence.persistResult;return persistence.persistResult;},
|
||||
};
|
||||
const controller=createDeviceStorage({
|
||||
summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
|
||||
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
|
||||
inspectPrivateDatabases:async()=>inventory,
|
||||
storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})},
|
||||
storageManager,
|
||||
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
|
||||
});
|
||||
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);});
|
||||
@@ -49,7 +55,104 @@ process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.t
|
||||
|
||||
assert result == {
|
||||
"summary": "2 MB of 10 MB browser storage used.",
|
||||
"detail": "1 private browser item · 0 private work records · 1 cached app copy",
|
||||
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_refresh_checks_storage_protection_without_requesting_permission():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
process.stdout.write(JSON.stringify({
|
||||
readiness:await controller.persistenceReadiness(),
|
||||
persistedCalls:persistence.persistedCalls,
|
||||
persistCalls:persistence.persistCalls,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"readiness": {
|
||||
"state": "incomplete",
|
||||
"detail": "Offline work uses best-effort browser storage and may be removed under storage pressure.",
|
||||
},
|
||||
"persistedCalls": 1,
|
||||
"persistCalls": 0,
|
||||
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_explicit_request_confirms_storage_is_protected_before_claiming_success():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
persistence.persistResult=true;
|
||||
const readiness=await controller.requestPersistence();
|
||||
process.stdout.write(JSON.stringify({
|
||||
readiness,
|
||||
persistedCalls:persistence.persistedCalls,
|
||||
persistCalls:persistence.persistCalls,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"readiness": {
|
||||
"state": "complete",
|
||||
"detail": "Offline work is protected from automatic browser storage cleanup.",
|
||||
},
|
||||
"persistedCalls": 2,
|
||||
"persistCalls": 1,
|
||||
"detail": "Storage protection: Protected · 1 private browser item · 0 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_denied_request_remains_retryable_and_does_not_claim_protection():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
const readiness=await controller.requestPersistence();
|
||||
process.stdout.write(JSON.stringify({readiness,persistCalls:persistence.persistCalls}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"readiness": {
|
||||
"state": "incomplete",
|
||||
"detail": "The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.",
|
||||
},
|
||||
"persistCalls": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_denied_request_keeps_setup_and_storage_panel_in_the_same_state_when_recheck_fails():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
storageManager.persist=async()=>{persistence.persistCalls++;persistence.error='check blocked';return false;};
|
||||
const readiness=await controller.requestPersistence();
|
||||
process.stdout.write(JSON.stringify({readiness,detail:detail.textContent}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"readiness": {
|
||||
"state": "incomplete",
|
||||
"detail": "The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.",
|
||||
},
|
||||
"detail": "Storage protection: Denied · 1 private browser item · 0 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_request_is_retryable_and_updates_storage_status_without_data_loss():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
storageManager.persist=async()=>{persistence.persistCalls++;throw new Error('blocked');};
|
||||
const readiness=await controller.requestPersistence();
|
||||
process.stdout.write(JSON.stringify({readiness,detail:detail.textContent,persistCalls:persistence.persistCalls}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"readiness": {
|
||||
"state": "incomplete",
|
||||
"detail": "Storage protection request failed. Offline work still uses best-effort storage; retry when ready.",
|
||||
},
|
||||
"detail": "Storage protection: Request failed · 1 private browser item · 0 private work records · 1 cached app copy",
|
||||
"persistCalls": 1,
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +172,7 @@ process.stdout.write(JSON.stringify({
|
||||
"caches": ["other-app"],
|
||||
"storage": ["stackchain.draft.1", "other.preference"],
|
||||
"status": "Cached app copies cleared. Private work was kept.",
|
||||
"detail": "1 private browser item · 0 private work records · 0 cached app copies",
|
||||
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 0 cached app copies",
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +193,7 @@ process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.tex
|
||||
},
|
||||
"calls": 1,
|
||||
"status": "All Stackchain private data was cleared from this device.",
|
||||
"detail": "0 private browser items · 0 private work records · 0 cached app copies",
|
||||
"detail": "Storage protection: Best effort · 0 private browser items · 0 private work records · 0 cached app copies",
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +215,7 @@ process.stdout.write(JSON.stringify({
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
|
||||
"detail": "0 private browser items · 3 private work records · 1 cached app copy",
|
||||
"detail": "Storage protection: Best effort · 0 private browser items · 3 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +235,7 @@ process.stdout.write(JSON.stringify({
|
||||
assert result == {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"detail": "0 private browser items · private work status unknown · 1 cached app copy",
|
||||
"detail": "Storage protection: Best effort · 0 private browser items · private work status unknown · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from src.views import dashboard, feature_bundle, runtime_bundle, service_worker
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
PAGE_SCRIPT = re.compile(r'<script src="(static/[^"]+\.js)"></script>')
|
||||
SCRIPT_PRELOAD = re.compile(r'<link rel="preload" as="script" href="([^"]+)">')
|
||||
|
||||
|
||||
def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
@@ -35,8 +36,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
(changed_frontend / "widgets.js").write_text(
|
||||
(changed_frontend / "widgets.js").read_text() + "\n// changed\n"
|
||||
(changed_frontend / "session.js").write_text(
|
||||
(changed_frontend / "session.js").read_text() + "\n// changed\n"
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
@@ -48,8 +49,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
|
||||
assert set(first.feature_bundles) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
||||
"today-timer", "security-center",
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
|
||||
"today-timer", "security-center", "planning",
|
||||
}
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
@@ -70,14 +71,22 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createQueueToday" not in first.runtime_bytes
|
||||
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createPlanToday" in first.feature_bundles["planning"].runtime_bytes
|
||||
assert b"function createTomorrowPlan" in first.feature_bundles["planning"].runtime_bytes
|
||||
assert b"function createPlanToday" not in first.runtime_bytes
|
||||
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createSignOutReview" not in first.runtime_bytes
|
||||
assert b"function createSignOutReview" in first.feature_bundles["sign-out"].runtime_bytes
|
||||
assert b"function createDetailDefer" not in first.runtime_bytes
|
||||
assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
|
||||
assert len(first.runtime_gzip_bytes) <= 99 * 1024
|
||||
# Core mobile workflows stay below 100 KiB, including synced Search, Update decisions,
|
||||
# and interruption-safe Security Center navigation.
|
||||
assert len(first.runtime_gzip_bytes) <= 100 * 1024
|
||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||
@@ -116,6 +125,19 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
|
||||
|
||||
|
||||
def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch():
|
||||
build = build_frontend(FRONTEND)
|
||||
workspace = build.feature_bundles["today-timer"]
|
||||
|
||||
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name]
|
||||
assert build.dashboard_html.count(f'<script src="{workspace.runtime_name}"></script>') == 0
|
||||
assert len(build.runtime_gzip_bytes) <= 100 * 1024
|
||||
assert len(workspace.runtime_gzip_bytes) <= 110 * 1024
|
||||
|
||||
shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0]
|
||||
assert f"BASE + '{workspace.runtime_name}'" not in shell_block
|
||||
|
||||
|
||||
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
|
||||
build = build_frontend(FRONTEND)
|
||||
bundles = {"core.js": build.runtime_bytes}
|
||||
|
||||
@@ -20,7 +20,7 @@ async def test_notification_page_preserves_upstream_total_without_loading_other_
|
||||
"subject": {
|
||||
"title": "Retry failed deploy",
|
||||
"type": "Issue",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
|
||||
},
|
||||
}],
|
||||
)
|
||||
@@ -170,8 +170,8 @@ async def test_unread_notifications_are_normalized_for_mobile_handoff():
|
||||
"title": "Retry failed deploy",
|
||||
"type": "Issue",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
|
||||
"latest_comment_html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
},
|
||||
{"id": 43, "repository": None, "subject": None},
|
||||
@@ -193,8 +193,8 @@ async def test_unread_notifications_are_normalized_for_mobile_handoff():
|
||||
"title": "Retry failed deploy",
|
||||
"subject_type": "Issue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"subject_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
|
||||
"subject_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
@@ -245,8 +245,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"state": "open",
|
||||
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"latest_comment_url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
|
||||
"latest_comment_html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
})
|
||||
if request.url.path.endswith("/issues/7"):
|
||||
@@ -262,7 +262,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"user": {"login": "alexander"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
|
||||
})
|
||||
if request.url.path.endswith("/issues/7/comments"):
|
||||
return httpx.Response(200, json=[{
|
||||
@@ -270,7 +270,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"user": {"login": "alexander"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
|
||||
}], headers={"X-Total-Count": "1"})
|
||||
return httpx.Response(404)
|
||||
|
||||
@@ -292,13 +292,13 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"title": "Retry failed deploy",
|
||||
"subject_type": "Issue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
|
||||
"subject_body": "Deploy fails after **three** retries.",
|
||||
"latest_comment": {
|
||||
"author": "alexander",
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
"issue": {"number": 7, "assignees": [], "claimable": True},
|
||||
"acknowledge_supported": True,
|
||||
@@ -362,7 +362,7 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
|
||||
"user": {"login": "timmy"},
|
||||
"body": "Please retry the worker.",
|
||||
"created_at": "2026-08-07T19:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-91",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
@@ -388,7 +388,7 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
|
||||
"author": "timmy",
|
||||
"body": "Please retry the worker.",
|
||||
"created_at": "2026-08-07T19:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user