Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
51f4223f4b | ||
|
|
241f0f3a2a | ||
|
|
34d5f86d53 | ||
|
|
3fde7cf3ad | ||
|
|
6913fc8257 | ||
|
|
437da72f3d | ||
|
|
9a2b8d97bd | ||
|
|
1e4f101219 | ||
|
|
c0a02a05d8 | ||
|
|
246f995b56 | ||
|
|
db28cc5690 | ||
|
|
26aa12019e | ||
|
|
1baa9c1f91 | ||
|
|
d64c646b68 | ||
|
|
021af60cf3 | ||
|
|
09dbdb9961 | ||
|
|
484dcc6b9c | ||
|
|
7571a4aa2d | ||
|
|
dc68305dcb | ||
|
|
386acda1dc | ||
|
|
a2606f1dc2 | ||
|
|
a2c91e0296 | ||
|
|
8148545295 | ||
|
|
70c8ea6584 | ||
|
|
f30143b5ea | ||
|
|
cf76bc3636 | ||
|
|
0fc152f623 | ||
|
|
377fd889c5 | ||
|
|
0a4c9a0ae8 | ||
|
|
1a4f930b50 | ||
|
|
49bbeadc48 | ||
|
|
5470e48e26 | ||
|
|
0fcb7f5152 | ||
|
|
0f30ca94ab | ||
|
|
468676d2f4 | ||
|
|
40623f939c | ||
|
|
110fd860b3 | ||
|
|
edf5d3fda4 | ||
|
|
e395878f92 | ||
|
|
f48b9b5a6c | ||
|
|
971656b76c | ||
|
|
48ec66cc30 |
No files matched your search
@@ -15,6 +15,8 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.11" }
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pip install -r requirements-audit.txt
|
||||
- run: python3 -m pip_audit -r requirements.txt --strict
|
||||
- run: python3 -m pytest tests/ -q
|
||||
|
||||
build-release:
|
||||
@@ -55,7 +57,7 @@ jobs:
|
||||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py -q
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -80,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
|
||||
@@ -117,8 +117,14 @@ 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. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
After wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it.
|
||||
|
||||
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
|
||||
@@ -185,8 +191,24 @@ 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 use the private-state key and
|
||||
authenticate each envelope to its normalized account, preventing rows from being substituted between
|
||||
operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their
|
||||
revision; missing, wrong, or modified key material returns no saved-view content. Other private stores
|
||||
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 +274,33 @@ 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 and Saved Search state, 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.
|
||||
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||
# 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 +316,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 +339,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 +394,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 +468,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
|
||||
|
||||
@@ -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;
|
||||
+255
-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; }
|
||||
@@ -26,10 +48,13 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
#open-insights { display:none; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
.notification-undo[hidden] { display:none; }
|
||||
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
|
||||
.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; }
|
||||
@@ -45,6 +70,11 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
|
||||
.security-section-nav { position:sticky; top:-18px; z-index:2; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:0 -2px; padding:10px 2px; background:#0b1526; }
|
||||
.security-section-nav button { min-height:44px; min-width:0; }
|
||||
.security-section-nav button[aria-current="page"] { border-color:#55d6be; color:#55d6be; }
|
||||
.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:64px; }
|
||||
.security-devices { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.device-setup-sheet[hidden] { display:none; }
|
||||
@@ -59,6 +89,10 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
|
||||
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
|
||||
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
||||
.device-storage { display:grid; gap:12px; margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0d1d31; }
|
||||
.device-storage h3, .device-storage p { margin:0 0 5px; }
|
||||
.device-storage-actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.device-storage-actions button { min-height:44px; flex:1 1 180px; }
|
||||
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
|
||||
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
|
||||
.device-readiness-summary { color:#bfdbfe; font-weight:700; }
|
||||
@@ -76,6 +110,7 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
|
||||
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
|
||||
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
|
||||
.security-event button { min-height:44px; margin-top:10px; }
|
||||
.authentication-alert { border-color:#d69e2e; background:#241b09; }
|
||||
.authentication-alert strong { color:#f6c453; }
|
||||
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
||||
@@ -91,6 +126,8 @@ button:hover { filter: brightness(1.15); }
|
||||
.panel[open] > summary { margin-bottom: 8px; }
|
||||
aside, section { position: relative; z-index: 1; }
|
||||
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
|
||||
.insights-shell { display:contents; }
|
||||
.insights-header { display:none; }
|
||||
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
|
||||
.sidebar { display:flex; flex-direction:column; gap: 12px; }
|
||||
.stack { display:flex; flex-direction:column; gap:10px; }
|
||||
@@ -122,6 +159,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; }
|
||||
@@ -239,6 +279,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; }
|
||||
@@ -258,12 +351,60 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
|
||||
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-recap-actions button { min-height:44px; width:100%; }
|
||||
.today-wrap-up-sheet { position:fixed; inset:0; z-index:89; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-wrap-up-sheet[hidden] { display:none; }
|
||||
.today-wrap-up-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-wrap-up-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-wrap-up-header h2 { margin:.2rem 0; }
|
||||
.today-wrap-up-header button { min-height:44px; min-width:44px; }
|
||||
.today-wrap-up-items { display:grid; gap:8px; margin:14px 0; }
|
||||
.today-wrap-up-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; min-width:0; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233d; overflow-wrap:anywhere; }
|
||||
.today-wrap-up-item span { display:block; }
|
||||
.today-wrap-up-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; }
|
||||
.today-wrap-up-item input { width:24px; min-height:24px; }
|
||||
.today-wrap-up-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-wrap-up-actions button { min-height:44px; width:100%; }
|
||||
.today-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)); }
|
||||
.today-handoff-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-handoff-panel h2 { margin:.2rem 0; }
|
||||
.today-handoff-panel header button { min-height:44px; min-width:44px; }
|
||||
.today-handoff-items { display:grid; gap:8px; margin:14px 0; }
|
||||
.today-handoff-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; padding:12px; border:1px solid #29486c; border-radius:12px; background:#101f35; }
|
||||
.today-handoff-item span { display:block; }
|
||||
.today-handoff-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; }
|
||||
.today-handoff-item input { width:24px; min-height:24px; }
|
||||
.today-handoff-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-handoff-actions button { min-height:44px; width:100%; }
|
||||
@media (max-width:420px) {
|
||||
.today-interruption-actions { grid-template-columns:1fr; }
|
||||
.today-recap-actions { grid-template-columns:1fr; }
|
||||
.today-recap-row { grid-template-columns:1fr; }
|
||||
.today-recap-row input { width:min(100%,8rem); }
|
||||
.today-recap-variance { grid-column:1; }
|
||||
.today-wrap-up-item { grid-template-columns:1fr; }
|
||||
}
|
||||
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-readiness-sheet[hidden] { display:none; }
|
||||
@@ -315,17 +456,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); }
|
||||
@@ -746,6 +906,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; }
|
||||
@@ -886,6 +1048,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; }
|
||||
@@ -893,9 +1067,11 @@ 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)); }
|
||||
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; }
|
||||
@@ -905,6 +1081,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.app-menu:not([open]) > .app-menu-panel { display:none; }
|
||||
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
|
||||
.app-menu-panel button { min-height:44px; width:100%; }
|
||||
#open-insights { display:block; }
|
||||
.insights-shell { display:none; }
|
||||
.insights-shell[data-mobile-open="true"] { position:fixed; inset:0; z-index:90; display:grid; align-content:start; gap:12px; overflow:auto; overflow-x:hidden; padding:max(12px,env(safe-area-inset-top)) max(12px,env(safe-area-inset-right)) max(12px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left)); background:#050c15; }
|
||||
.insights-header { position:sticky; top:0; z-index:2; display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; }
|
||||
.insights-header h2, .insights-header p { margin:0 0 4px; }
|
||||
.insights-header button { min-height:44px; flex:none; }
|
||||
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.device-setup-step { grid-template-columns:1fr; }
|
||||
.device-setup-action { width:100%; }
|
||||
@@ -987,10 +1169,27 @@ 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; }
|
||||
.mobile-queue-panel h2 { margin:0; }
|
||||
.mobile-delivery-recovery { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-delivery-recovery::backdrop { background:rgba(3,9,18,.78); }
|
||||
.mobile-delivery-recovery-panel { box-sizing:border-box; display:grid; gap:12px; width:100%; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||
.mobile-delivery-recovery-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.mobile-delivery-recovery-panel h2, .mobile-delivery-recovery-panel p { margin:0; }
|
||||
.mobile-delivery-recovery-panel button { min-height:48px; }
|
||||
.mobile-delivery-recovery-panel > #mobile-delivery-recovery-action { width:100%; font-weight:800; }
|
||||
.delivery-recovery-reason { padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1c30; }
|
||||
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
|
||||
.mobile-start-day h3, .mobile-start-day p { margin:0; }
|
||||
.mobile-start-day p + p { margin-top:4px; }
|
||||
@@ -1006,13 +1205,14 @@ 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-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) {
|
||||
@@ -1021,3 +1221,47 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
/* One-handed live-data recovery on the mobile Home surface. */
|
||||
.mobile-pull-refresh {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.mobile-pull-refresh {
|
||||
position: sticky;
|
||||
top: calc(3.25rem + env(safe-area-inset-top));
|
||||
z-index: 8;
|
||||
display: block;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 2rem);
|
||||
margin: 0 auto -2rem;
|
||||
padding: .45rem .8rem;
|
||||
border: 1px solid rgba(125, 211, 252, .45);
|
||||
border-radius: 999px;
|
||||
background: rgba(11, 18, 32, .96);
|
||||
color: #dbeafe;
|
||||
font-size: .78rem;
|
||||
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .28);
|
||||
transform: translateY(.5rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-pull-refresh[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-pull-refresh[data-state="ready"],
|
||||
.mobile-pull-refresh[data-state="success"] {
|
||||
border-color: rgba(52, 211, 153, .7);
|
||||
}
|
||||
|
||||
.mobile-pull-refresh[data-state="error"] {
|
||||
border-color: rgba(251, 113, 133, .75);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) and (prefers-reduced-motion: no-preference) {
|
||||
.mobile-pull-refresh {
|
||||
transition: transform 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
}
|
||||
+402
-94
@@ -1,4 +1,5 @@
|
||||
(async function(){
|
||||
const workspaceLifecycle = await loadWorkspace({ document, window });
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const fmt = (d) => new Date(d).toLocaleString();
|
||||
const cardPlanning = createCardPlanning(document);
|
||||
@@ -26,6 +27,7 @@
|
||||
actions:qs('#issue-planning'),
|
||||
},
|
||||
planning:qs('#issue-planning'),
|
||||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobileIssueDetailNavigation.start();
|
||||
@@ -40,6 +42,7 @@
|
||||
review:qs('#pull-review'),
|
||||
},
|
||||
beforeNavigate:{review(target) { target.open = true; }},
|
||||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobilePullDetailNavigation.start();
|
||||
@@ -87,7 +90,24 @@
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
async function recoverMobileDelivery(item) {
|
||||
if (!item?.outbox_id || !activeFlushLogin) throw new Error('Sign in again to retry.');
|
||||
const authored = item.kind === 'authored-outbox';
|
||||
const result = await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin);
|
||||
(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result);
|
||||
}
|
||||
let mobileQueueCounts = {};
|
||||
let mobilePreparationItems = {};
|
||||
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => draftInbox.partition(lastDrafts).deliveries,
|
||||
getIndex: item => lastDrafts.indexOf(item),
|
||||
activate: recoverMobileDelivery,
|
||||
beforeOpen: () => selectMobileQueue('draft'),
|
||||
onComplete: () => {
|
||||
if (!mobileStartDay.completePhase('delivery')) showMobileQueueCompletion('Delivery', true);
|
||||
},
|
||||
});
|
||||
mobileDeliveryRecovery.start();
|
||||
function openFiledFollowUp() {
|
||||
selectMobileQueue('filed');
|
||||
const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
|
||||
@@ -101,6 +121,7 @@
|
||||
return target.kind === 'update' ? 'opened-update' : 'opened-issue';
|
||||
}
|
||||
const mobileQueueLauncher = createMobileQueueLauncher({
|
||||
openDelivery: () => mobileDeliveryRecovery.open(),
|
||||
openToday: () => mobileWorkEntry.open(),
|
||||
openAgenda: openAgendaSession,
|
||||
openUpdates: openUpdateTriage,
|
||||
@@ -111,21 +132,39 @@
|
||||
getCounts: () => mobileQueueCounts,
|
||||
openFindWork: () => qs('#find-work').click(),
|
||||
});
|
||||
function openMobileStartDay() {
|
||||
const state = mobileStartDay.state();
|
||||
mobileStartDay.render();
|
||||
qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';
|
||||
const sheet = qs('#mobile-queue-sheet');
|
||||
if (!sheet.open) qs('#mobile-queue-sheet').showModal();
|
||||
qs('#mobile-start-day-action').focus();
|
||||
}
|
||||
const mobileFirstTask = createMobileFirstTask({
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
hasWork: () => todayMyWork.length > 0 || activeMyWork.length > 0,
|
||||
isTodayActive: () => workSession.checkpointed(),
|
||||
});
|
||||
mobileFirstTask.start();
|
||||
const mobileWorkEntry = createMobileWorkEntry({
|
||||
isTodayActive: () => workSession.checkpointed(),
|
||||
isTodayResumable: () => workSession.resumable(),
|
||||
getTodayCount: () => todayMyWork.length,
|
||||
getEligibleCount: () => activeMyWork.length,
|
||||
getPreparationState: () => mobileStartDay.state(),
|
||||
queueLauncher: mobileQueueLauncher,
|
||||
continueToday: continueTodaySession,
|
||||
resumeToday: resumeTodaySession,
|
||||
startToday: startTodaySession,
|
||||
planToday: () => openPlanToday(mobileTaskButtons.work),
|
||||
|
||||
prepareToday: openMobileStartDay,
|
||||
shouldActivate: () => mobileFirstTask.required(),
|
||||
openActivation: () => mobileFirstTask.open(),
|
||||
findWork: () => qs('#find-work').click(),
|
||||
});
|
||||
const mobileStartDay = createMobileStartDay({
|
||||
getCounts: () => mobileQueueCounts,
|
||||
getPhaseItems: () => mobilePreparationItems,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
openQueue: name => {
|
||||
const sheet = qs('#mobile-queue-sheet');
|
||||
@@ -189,13 +228,13 @@
|
||||
deadlineBadge: qs('#mobile-deadline-count'),
|
||||
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() :
|
||||
name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name),
|
||||
detour:() => timerView,
|
||||
overlays: mobileTaskOverlays,
|
||||
actions: {
|
||||
work: () => mobileWorkEntry.open(),
|
||||
find: () => qs('#find-work').click(),
|
||||
new: () => qs('#new-issue').click(),
|
||||
search: () => qs('#open-palette').click(),
|
||||
queues: () => {},
|
||||
},
|
||||
observe(callback, overlays) {
|
||||
const observer = new MutationObserver(callback);
|
||||
@@ -204,6 +243,33 @@
|
||||
},
|
||||
});
|
||||
mobileTaskDock.start();
|
||||
const mobileTodayActions = qs('#mobile-today-actions');
|
||||
createMobileTodayCommandBar({
|
||||
more:qs('[data-mobile-today-more]'),
|
||||
sheet:mobileTodayActions,
|
||||
close:qs('[data-mobile-today-actions-close]'),
|
||||
firstAction:qs('[data-work-session-previous]', mobileTodayActions),
|
||||
actionButtons:Array.from(mobileTodayActions.querySelectorAll(
|
||||
'[data-work-session-previous], [data-work-session-next], [data-mobile-today-update], [data-mobile-today-blocked], [data-today-break-open], [data-work-session-adjust-plan]'
|
||||
)),
|
||||
endAction:qs('[data-mobile-today-end]', mobileTodayActions),
|
||||
existingEndControl:qs('#end-today-session'),
|
||||
hud:qs('[data-mobile-today-hud]'),
|
||||
dock:qs('#mobile-task-dock'),
|
||||
style:document.documentElement.style,
|
||||
});
|
||||
const mobileInsights = createMobileInsights({
|
||||
root: qs('#insights-sheet'),
|
||||
launcher: qs('#open-insights'),
|
||||
closeButton: qs('#close-insights'),
|
||||
|
||||
dock: qs('#mobile-task-dock'),
|
||||
hud: qs('[data-mobile-today-hud]'),
|
||||
backgrounds: [qs('header'), qs('#my-work')],
|
||||
history: window.history,
|
||||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||||
detour:() => timerView,
|
||||
});
|
||||
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
|
||||
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
|
||||
let liveMode = true;
|
||||
@@ -276,6 +342,24 @@
|
||||
let confirmedOwnerLogin = '';
|
||||
let planningOwnerLogin = '';
|
||||
let activeFlushLogin = '';
|
||||
let releaseReceipt = null;
|
||||
function attachReleaseReceipt() {
|
||||
if (releaseReceipt) return releaseReceipt;
|
||||
releaseReceipt = createReleaseReceipt({
|
||||
storage:localStorage, getLogin:()=>confirmedOwnerLogin, fetchJson:fetchReviewJson,
|
||||
launcher:qs('#release-receipt-launcher'), dialog:qs('#release-receipt-sheet'),
|
||||
statusNode:qs('#release-receipt-status'), checksNode:qs('#release-receipt-checks'),
|
||||
releaseNode:qs('#release-receipt-link'),
|
||||
listNode:qs('#release-watchlist'),
|
||||
});
|
||||
releaseReceipt.bind();
|
||||
qs('#close-release-receipt').addEventListener('click', () => qs('#release-receipt-sheet').close());
|
||||
qs('#refresh-release-receipt').addEventListener('click', () => releaseReceipt.refresh().catch(error => {
|
||||
qs('#release-receipt-status').textContent = error.message + ' Retry when connected.';
|
||||
}));
|
||||
qs('#dismiss-release-receipt').addEventListener('click', () => releaseReceipt.dismiss());
|
||||
return releaseReceipt;
|
||||
}
|
||||
const completedFiledReview = createCompletedFiledReview({
|
||||
storage: localStorage,
|
||||
getLogin() { return planningOwnerLogin; },
|
||||
@@ -323,33 +407,53 @@
|
||||
const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`;
|
||||
if (!localStorage.getItem(marker)) {
|
||||
localStorage.setItem(marker, '1');
|
||||
setTimeout(() => openPlanToday(qs('#plan-today')), 0);
|
||||
setTimeout(() => {
|
||||
if (!workSession.checkpointed()) openPlanToday(qs('#plan-today'));
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
onStatus: (state, detail = {}) => {
|
||||
const status = qs('#today-sync-status');
|
||||
status.textContent = state === 'saved' ? 'Today saved to account.' :
|
||||
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
|
||||
(state === 'pending' ? 'Today saved on this device · sync pending.' :
|
||||
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
|
||||
(state === 'expired' ? `Today edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
|
||||
'Today sync unavailable · changes stay on this device.'))));
|
||||
(state === 'recovered' ? `Today queue recovered · discarded ${detail.discarded} unreadable device record${detail.discarded === 1 ? '' : 's'}.` :
|
||||
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
|
||||
(state === 'pending' ? 'Today saved on this device · sync pending.' :
|
||||
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
|
||||
(state === 'expired' ? `Today edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
|
||||
'Today sync unavailable · changes stay on this device.')))));
|
||||
},
|
||||
});
|
||||
todaySync.startLifecycle({ window, document });
|
||||
const todayHandoff = createTodayHandoff({
|
||||
storage:localStorage,
|
||||
getLogin:() => planningOwnerLogin,
|
||||
identity:item => todayWork.identity(item),
|
||||
items:() => [...todayMyWork, ...activeMyWork],
|
||||
todayWork,
|
||||
todaySync,
|
||||
});
|
||||
const todayHandoffView = todayHandoff.mount({ qs, escapeHtml,
|
||||
onComplete:() => refreshMyWorkView(),
|
||||
});
|
||||
const promptTodayHandoff = () => todayHandoffView.open();
|
||||
const laterWork = createLaterWork({
|
||||
storage: localStorage,
|
||||
getLogin: () => planningOwnerLogin,
|
||||
onChange: (action, itemId, wakeAt) => {
|
||||
if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush();
|
||||
onChange: (action, itemId, wakeAt, handoff) => {
|
||||
if (laterSync.enqueue(action, itemId, wakeAt, handoff)) laterSync.flush();
|
||||
},
|
||||
onExpire: ids => {
|
||||
onExpire: (ids, handoffs) => {
|
||||
if (handoffs.length) {
|
||||
todayHandoff.capture(handoffs);
|
||||
todayHandoffView.reset();
|
||||
}
|
||||
const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
|
||||
if (queued) laterSync.flush();
|
||||
},
|
||||
onWake: () => {
|
||||
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
|
||||
refreshMyWorkView();
|
||||
setTimeout(promptTodayHandoff, 0);
|
||||
},
|
||||
});
|
||||
const laterSync = createLaterSync({
|
||||
@@ -432,22 +536,19 @@
|
||||
{headers:{Accept:'application/json'}},
|
||||
);
|
||||
}
|
||||
const issueMentions = createMentionComposer({
|
||||
textarea:qs('#issue-comment'), listbox:qs('#issue-comment-mentions'),
|
||||
status:qs('#issue-comment-mention-status'), getRepository:()=>selectedIssue?.repository,
|
||||
loadCandidates:loadMentionCandidates,
|
||||
const [issueMentions, pullMentions, updateMentions, todayProgressMentions] = [
|
||||
['issue-comment',()=>selectedIssue?.repository],
|
||||
['pull-comment',()=>selectedPull?.repository],
|
||||
['update-reply',()=>selectedUpdate?.repository],
|
||||
['today-progress-body',()=>currentTodayProgressTarget()?.repository],
|
||||
].map(([id,getRepository]) => {
|
||||
const controller = createMentionComposer({
|
||||
textarea:qs('#'+id), listbox:qs('#'+id+'-mentions'),
|
||||
status:qs('#'+id+'-mention-status'), getRepository, loadCandidates:loadMentionCandidates,
|
||||
});
|
||||
controller.start();
|
||||
return controller;
|
||||
});
|
||||
const pullMentions = createMentionComposer({
|
||||
textarea:qs('#pull-comment'), listbox:qs('#pull-comment-mentions'),
|
||||
status:qs('#pull-comment-mention-status'), getRepository:()=>selectedPull?.repository,
|
||||
loadCandidates:loadMentionCandidates,
|
||||
});
|
||||
const updateMentions = createMentionComposer({
|
||||
textarea:qs('#update-reply'), listbox:qs('#update-reply-mentions'),
|
||||
status:qs('#update-reply-mention-status'), getRepository:()=>selectedUpdate?.repository,
|
||||
loadCandidates:loadMentionCandidates,
|
||||
});
|
||||
[issueMentions, pullMentions, updateMentions].forEach(controller => controller.start());
|
||||
|
||||
async function api(url) {
|
||||
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
@@ -522,6 +623,16 @@
|
||||
window.location.hash = '#/my-work/agenda';
|
||||
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
|
||||
});
|
||||
|
||||
StackchainAgendaCalendar.mountAgendaCalendarExport({
|
||||
qs,
|
||||
getItems:() => agendaMyWork(activeMyWork),
|
||||
escapeHtml,
|
||||
onDone:result => {
|
||||
qs('#my-work-action-status').textContent = result === 'shared' ?
|
||||
'Agenda calendar snapshot shared.' : 'Agenda calendar snapshot downloaded.';
|
||||
},
|
||||
});
|
||||
let searchReplyAttachmentTarget = null;
|
||||
let searchReplyRestoreGeneration = 0;
|
||||
let restoringSearchReplyPhotos = false;
|
||||
@@ -768,25 +879,21 @@
|
||||
update:photoDraftLane(updateReplyAttachmentController, '#update-reply-status'),
|
||||
},
|
||||
});
|
||||
const todayProgressPhotos = createTodayProgressPhotos({
|
||||
qs, document, issueAttachment, fetchJson:fetchReviewJson,
|
||||
createStore:createConversationReplyDraftStore, createDrafts:createConversationPhotoDrafts,
|
||||
getLogin:() => confirmedOwnerLogin,
|
||||
});
|
||||
const voiceTranscriptStore = createVoiceTranscriptStore();
|
||||
function mountConversationVoice(kind, draftSelector) {
|
||||
return createVoiceConversationCapture({
|
||||
Recognition: window.SpeechRecognition || window.webkitSpeechRecognition,
|
||||
transcriptStore: voiceTranscriptStore,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
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),
|
||||
},
|
||||
});
|
||||
}
|
||||
const issueVoiceReply = mountConversationVoice('issue-comment', '#issue-comment');
|
||||
const pullVoiceReply = mountConversationVoice('pull-comment', '#pull-comment');
|
||||
const updateVoiceReply = mountConversationVoice('update-reply', '#update-reply');
|
||||
const searchVoiceReply = mountConversationVoice('search-reply', '#search-preview-reply');
|
||||
const voiceOptions = {
|
||||
qs, Recognition:window.SpeechRecognition || window.webkitSpeechRecognition,
|
||||
transcriptStore:voiceTranscriptStore, getLogin:() => confirmedOwnerLogin,
|
||||
};
|
||||
const [issueVoiceReply, pullVoiceReply, updateVoiceReply, searchVoiceReply, todayProgressVoice] = [
|
||||
['issue-comment', '#issue-comment'], ['pull-comment', '#pull-comment'],
|
||||
['update-reply', '#update-reply'], ['search-reply', '#search-preview-reply'],
|
||||
['today-progress', '#today-progress-body'],
|
||||
].map(([kind, draftSelector]) => mountVoiceConversation({...voiceOptions, kind, draftSelector}));
|
||||
function conversationVoiceTarget(kind, item) {
|
||||
return kind + ':' + String(item?.repository || '').trim().toLowerCase() + '#' + Number(item?.number);
|
||||
}
|
||||
@@ -911,6 +1018,11 @@
|
||||
getOwnerLogin: () => confirmedOwnerLogin,
|
||||
mergeChecklistConflict: mergeChecklistConflict,
|
||||
});
|
||||
const todayProgress = createTodayProgress({
|
||||
storage:localStorage,
|
||||
getLogin:() => confirmedOwnerLogin,
|
||||
admit:message => authoredOutbox.enqueueDurably(message),
|
||||
});
|
||||
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
|
||||
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||||
});
|
||||
@@ -924,7 +1036,7 @@
|
||||
issueOutbox.reconcileBackground(records);
|
||||
authoredOutbox.reconcileBackground(records);
|
||||
notificationReadOutbox.reconcileBackground(records);
|
||||
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
|
||||
}).catch(() => {});
|
||||
}
|
||||
const shareParams = new URLSearchParams(location.search);
|
||||
const appShortcut = mobileAppShortcuts.createController({
|
||||
@@ -946,7 +1058,7 @@
|
||||
'comment-actions': document.querySelector('meta[name="stackchain-feature-comment-actions"]')?.content || '',
|
||||
},
|
||||
});
|
||||
const commentActionHydrator = createConversationActionHydrator({
|
||||
const actionHydrator = createConversationActionHydrator({
|
||||
load: () => commentActionFeatures.load('comment-actions'),
|
||||
activate: () => {
|
||||
commentActions = createCommentActions({
|
||||
@@ -1050,6 +1162,7 @@
|
||||
return false;
|
||||
});
|
||||
}
|
||||
attachReleaseReceipt();
|
||||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
if (!wrapPreference) {
|
||||
wrapPreference = createReviewController.createWrapPreference({
|
||||
@@ -1059,6 +1172,14 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
function restoreReleaseReceipt() {
|
||||
const account = String(confirmedOwnerLogin || '').trim().toLowerCase();
|
||||
if (!account) return;
|
||||
try {
|
||||
if (!localStorage.getItem('stackchain.release-receipt.v1:' + account)) return;
|
||||
} catch (_error) { return; }
|
||||
ensurePullWorkflow().then(() => releaseReceipt.restore()).catch(() => {});
|
||||
}
|
||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
|
||||
@@ -1375,6 +1496,7 @@
|
||||
},
|
||||
replyComposer:qs('#update-reply'),
|
||||
jumpToNewActivity:() => updateReadPosition.jump(),
|
||||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobileUpdateDetailNavigation.start();
|
||||
@@ -1388,6 +1510,7 @@
|
||||
history:qs('#review-history-workspace'),
|
||||
},
|
||||
summaryComposer:qs('#review-summary'),
|
||||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobileReviewDetailNavigation.start();
|
||||
@@ -1631,6 +1754,25 @@
|
||||
workRoute.open(routedWorkItem(item), options);
|
||||
}
|
||||
|
||||
function navigateWorkSection(kind, section) {
|
||||
if (['issue', 'filed'].includes(kind)) {
|
||||
mobileIssueDetailNavigation.navigate(section, { focus:false });
|
||||
} else if (kind === 'pull') {
|
||||
mobilePullDetailNavigation.navigate(section, { focus:false });
|
||||
} else if (kind === 'update') {
|
||||
mobileUpdateDetailNavigation.navigate(section, { focus:false });
|
||||
} else if (kind === 'review') {
|
||||
mobileReviewDetailNavigation.navigate(section, { focus:false });
|
||||
}
|
||||
}
|
||||
|
||||
async function openRoutedWorkSection(item) {
|
||||
if (item.kind === 'update') await notificationReader.open(item, lastMyWork);
|
||||
else if (item.kind === 'review') await openReviewSheet(item, reviewTrigger);
|
||||
else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);
|
||||
else if (item.kind === 'pull') await openPullSheet(item, pullTrigger);
|
||||
}
|
||||
|
||||
const workRoute = createWorkRoute.createController({
|
||||
location: window.location,
|
||||
history: window.history,
|
||||
@@ -1648,14 +1790,17 @@
|
||||
qs('#retry-work-route').hidden = true;
|
||||
qs('#my-work-action-status').textContent = 'Loading shared work item…';
|
||||
},
|
||||
onOpen: item => {
|
||||
onOpen: async item => {
|
||||
qs('#retry-work-route').hidden = true;
|
||||
qs('#my-work-action-status').textContent = '';
|
||||
closeOpenWorkSheets();
|
||||
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
|
||||
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
|
||||
else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);
|
||||
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
|
||||
await openRoutedWorkSection(item);
|
||||
const route = createWorkRoute.parse(window.location.hash);
|
||||
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
|
||||
},
|
||||
onSection: (section, options) => {
|
||||
const route = createWorkRoute.parse(window.location.hash);
|
||||
if (options.restore) navigateWorkSection(route?.kind, section);
|
||||
},
|
||||
onQueue: openWorkQueueRoute,
|
||||
onClose: () => {
|
||||
@@ -1718,9 +1863,17 @@
|
||||
'Agenda progress could not be saved on this device. You can keep working.';
|
||||
},
|
||||
});
|
||||
let todaySessionSync = null;
|
||||
let todayLockScreen = null;
|
||||
const timer = createTodayTimer({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
onChange: snapshot => {
|
||||
todaySessionSync?.publish(snapshot);
|
||||
queueMicrotask(() => {
|
||||
if (todayLockScreen) todayLockScreen.sync(snapshot, workSession.checkpointed());
|
||||
});
|
||||
},
|
||||
});
|
||||
const timerView = createTodayTimerView({
|
||||
timer,
|
||||
@@ -1729,13 +1882,32 @@
|
||||
formatEstimate: formatPlanMinutes,
|
||||
getItem: identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity),
|
||||
onReopen: identity => {
|
||||
taskOverlayHistory.leave();
|
||||
selectTodayWork();
|
||||
workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity));
|
||||
const item = [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity);
|
||||
if (!workSession.reopen(item)) workSession.resume(item);
|
||||
},
|
||||
onResume: identity => {
|
||||
selectTodayWork();
|
||||
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
|
||||
if (!workSession.resume(item)) resumeTodaySession();
|
||||
},
|
||||
onComplete: identity => {
|
||||
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
|
||||
return completeTodayItem(item);
|
||||
},
|
||||
onCapture:() => openCreateIssueSheet(),
|
||||
});
|
||||
window.stackchainTodayTimerView = timerView;
|
||||
mobileInsights.start();
|
||||
todaySessionSync = attachTodaySessionHandoff({
|
||||
fetchJson:fetchReviewJson, storage:localStorage, timer, qs,
|
||||
items:() => [...todayMyWork, ...activeMyWork],
|
||||
identity:item => todayWork.identity(item),
|
||||
selectToday:() => selectTodayWork(),
|
||||
startItem:item => workSession.start(item),
|
||||
announce:message => { qs('#my-work-action-status').textContent = message; },
|
||||
renderTimer:() => timerView.render(),
|
||||
});
|
||||
renderAttentionInterruption();
|
||||
qs('#return-to-today').addEventListener('click', () => {
|
||||
@@ -1765,11 +1937,24 @@
|
||||
interruptionPrompt.resolve(button.dataset.todayInterruption)
|
||||
);
|
||||
});
|
||||
const todaySummaryView = setupTodaySummary({
|
||||
qs, escapeHtml, getLogin:() => planningOwnerLogin, fetchJson:api,
|
||||
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
|
||||
});
|
||||
todaySummaryView.resume();
|
||||
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
|
||||
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
|
||||
todayRecapView['completeReplan']();
|
||||
refreshMyWorkView();
|
||||
warmTodayOffline();
|
||||
todaySummaryView.open(workedItems, tomorrowItems);
|
||||
},
|
||||
});
|
||||
const todayRecapView = setupTodayRecap(
|
||||
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
|
||||
() => planningOwnerLogin,
|
||||
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
|
||||
actualMinutes => openPlanToday(qs('#plan-today'), true, actualMinutes)
|
||||
(actualMinutes, workedItems) => todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)
|
||||
);
|
||||
function updateDetailDeferLabels(active) {
|
||||
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
||||
@@ -1791,6 +1976,7 @@
|
||||
qs('#resume-today-session').hidden = active || selectedWorkFilter !== 'today' ||
|
||||
!todayMyWork.length || !workSession.resumable();
|
||||
qs('#end-today-session').hidden = !active;
|
||||
todayProgressView.update();
|
||||
updateDetailDeferLabels(active);
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||||
@@ -1820,6 +2006,7 @@
|
||||
document.querySelectorAll('[data-work-session-complete]').forEach(button => {
|
||||
button.hidden = !workSession.checkpointed();
|
||||
});
|
||||
todayProgressView.update();
|
||||
},
|
||||
onFinish: () => {
|
||||
updateWorkSessionActions();
|
||||
@@ -1833,6 +2020,25 @@
|
||||
todayRecapView.finish(selectedWorkFilter);
|
||||
},
|
||||
});
|
||||
function currentTodayProgressTarget() {
|
||||
if (!workSession.checkpointed()) return null;
|
||||
const item = workSession.target('continue');
|
||||
if (!item || item.is_review || !['issue', 'pull'].includes(item.kind) ||
|
||||
!item.repository || !Number.isInteger(item.number)) return null;
|
||||
return {
|
||||
identity:todayWork.identity(item), kind:item.kind, repository:item.repository,
|
||||
number:item.number, label:item.key || item.repository + '#' + item.number,
|
||||
title:item.title || '', item,
|
||||
};
|
||||
}
|
||||
const todayProgressView = createTodayProgressView({
|
||||
progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos,
|
||||
voice:todayProgressVoice, mentions:todayProgressMentions,
|
||||
activity:mountTodayProgressActivity(qs,fetchReviewJson,actionHydrator,renderMarkdown),
|
||||
announce:message => { qs('#my-work-action-status').textContent = message; },
|
||||
onAdmitted:() => refreshMyWorkView(),
|
||||
moveOn:(target,until) => detailDefer.deferUntil(target.item,until),
|
||||
});
|
||||
function selectTodayWork() {
|
||||
qs('[data-work-filter="today"]').click();
|
||||
}
|
||||
@@ -2031,6 +2237,47 @@
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
advance: () => runTodayTransition('complete'),
|
||||
});
|
||||
todayLockScreen = createTodayLockScreen({
|
||||
storage:localStorage,
|
||||
getLogin:() => confirmedOwnerLogin,
|
||||
serviceWorker:navigator.serviceWorker,
|
||||
NotificationRef:window.Notification,
|
||||
control:qs('#today-lock-screen'),
|
||||
status:qs('#today-lock-screen-status'),
|
||||
locationRef:window.location,
|
||||
historyRef:window.history,
|
||||
onAction:(action, expectedIdentity) => {
|
||||
const state = timer.snapshot();
|
||||
if (action === 'complete') {
|
||||
selectTodayWork();
|
||||
if (!state.identity || state.identity !== expectedIdentity) {
|
||||
qs('#my-work-action-status').textContent =
|
||||
'That lock-screen action is stale. The current Today item was not changed.';
|
||||
return;
|
||||
}
|
||||
const item = todayMyWork.find(entry => todayWork.identity(entry) === expectedIdentity);
|
||||
if (!item) {
|
||||
qs('#my-work-action-status').textContent =
|
||||
'That Today item is no longer available. Nothing was changed.';
|
||||
return;
|
||||
}
|
||||
workSession.reopen(item);
|
||||
completeTodayItem(item);
|
||||
timerView.render();
|
||||
return;
|
||||
}
|
||||
if (!state.identity || (action === 'pause' && !state.running) ||
|
||||
(action === 'resume' && state.running)) return;
|
||||
const changed = action === 'pause' ? timer.pause() : timer.resume();
|
||||
if (changed === false) return;
|
||||
selectTodayWork();
|
||||
const item = todayMyWork.find(entry => todayWork.identity(entry) === state.identity);
|
||||
if (item) workSession.reopen(item);
|
||||
timerView.render();
|
||||
},
|
||||
});
|
||||
await todayLockScreen.sync(timer.snapshot(), workSession.checkpointed());
|
||||
await todayLockScreen.consumeLaunchAction();
|
||||
async function completeOwnershipExitToday(item) {
|
||||
if (!item || !todayWork.remove(item)) return null;
|
||||
todaySync.enqueue('remove', todayWork.identity(item));
|
||||
@@ -2477,7 +2724,7 @@
|
||||
|
||||
function renderPlanIssueDependencies(detail, loading = false) {
|
||||
const preview = planTodayPreview.snapshot();
|
||||
const assignedIssue = Boolean(selectedIssue && selectedIssueDetail === detail);
|
||||
const assignedIssue = Boolean(!preview.open && selectedIssue && selectedIssueDetail === detail);
|
||||
if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return;
|
||||
const panel = qs('#issue-blockers');
|
||||
const list = qs('#issue-blocker-list');
|
||||
@@ -2957,6 +3204,14 @@
|
||||
counts.today = todayMyWork.length;
|
||||
counts.later = laterMyWork.length;
|
||||
counts.draft = lastDrafts.length;
|
||||
counts.delivery = draftInbox.partition(lastDrafts).actionable;
|
||||
mobilePreparationItems = {
|
||||
delivery:draftInbox.partition(lastDrafts).deliveries,
|
||||
agenda:agendaMyWork(activeMyWork),
|
||||
attention:activeMyWork.filter(item => item.needs_attention),
|
||||
update:activeMyWork.filter(item => item.has_update),
|
||||
filed:activeMyWork.filter(item => item.is_filed),
|
||||
};
|
||||
if (!launchFilterResolved) {
|
||||
selectedWorkFilter = mobileLaunch.chooseFilter({
|
||||
saved: savedWorkFilter, today: counts.today, attention: counts.attention,
|
||||
@@ -2972,9 +3227,13 @@
|
||||
if (element) element.textContent = count;
|
||||
});
|
||||
mobileQueueCounts = counts;
|
||||
mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh});
|
||||
mobileStartDay.reconcile({
|
||||
authoritative:authoritativeMyWorkRefresh,
|
||||
authoritativePhases:['delivery'],
|
||||
});
|
||||
mobileStartDay.render();
|
||||
mobileTaskDock.updateQueues(counts);
|
||||
mobileFirstTask.refresh();
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||||
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
|
||||
@@ -3005,6 +3264,7 @@
|
||||
}
|
||||
if (reconcileSession && workSession.active()) workSession.reconcile();
|
||||
updateWorkSessionActions();
|
||||
if (todayHandoff.pending().length) setTimeout(promptTodayHandoff, 0);
|
||||
}
|
||||
|
||||
function activeWorkStreams() {
|
||||
@@ -3097,7 +3357,7 @@
|
||||
'<div class="draft-actions">' + outboxActions + '</div></article>';
|
||||
};
|
||||
const deliverySummary = '<section class="delivery-center" aria-labelledby="delivery-center-title">' +
|
||||
'<div><h3 id="delivery-center-title">Delivery center</h3>' +
|
||||
'<div><h3 id="delivery-center-title" tabindex="-1">Delivery center</h3>' +
|
||||
'<p class="small">Waiting <strong data-delivery-count="waiting">' + deliveryCenter.counts.waiting + '</strong> · ' +
|
||||
'Sending <strong data-delivery-count="sending">' + deliveryCenter.counts.sending + '</strong> · ' +
|
||||
'Needs attention <strong data-delivery-count="attention">' + deliveryCenter.counts.attention + '</strong> · ' +
|
||||
@@ -3116,14 +3376,14 @@
|
||||
const button = event.currentTarget;
|
||||
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
|
||||
button.disabled = true;
|
||||
qs('#my-work-action-status').textContent = 'Retrying safe waiting deliveries…';
|
||||
qs('#my-work-action-status').textContent = 'Retrying waiting deliveries…';
|
||||
const [issueResult, authoredResult] = await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)]);
|
||||
applyOutboxResult(issueResult);
|
||||
applyAuthoredOutboxResult(authoredResult);
|
||||
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
|
||||
qs('#my-work-action-status').textContent = 'Waiting deliveries retried; attention stayed queued.';
|
||||
});
|
||||
list.querySelectorAll('.draft-resume').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.onclick=async()=>{
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item) return;
|
||||
if (item.kind === 'unfiled-issue') {
|
||||
@@ -3132,7 +3392,7 @@
|
||||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||
else if (item.route) workRoute.open(item.route);
|
||||
});
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('.draft-continue').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
@@ -3179,7 +3439,7 @@
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-review-checklist').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.onclick=async()=>{
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
const queued = authoredOutbox.list().find(candidate => candidate.id === item?.outbox_id);
|
||||
if (!item?.checklist_conflict || !queued || !activeFlushLogin) return;
|
||||
@@ -3212,7 +3472,7 @@
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('.draft-authorize').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
@@ -3233,12 +3493,12 @@
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-copy').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.onclick=async()=>{
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item?.copy_text) return;
|
||||
await navigator.clipboard.writeText(item.copy_text);
|
||||
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
|
||||
});
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
@@ -3257,6 +3517,7 @@
|
||||
qs('#my-work-action-status').textContent = 'Draft discarded.';
|
||||
});
|
||||
});
|
||||
mobileDeliveryRecovery.render();
|
||||
}
|
||||
|
||||
function updateWorkPaginationControls() {
|
||||
@@ -3304,6 +3565,14 @@
|
||||
qs('#start-agenda-replan').textContent = 'Replan overdue (' + overdue.length + ')';
|
||||
qs('#agenda-replan-controls').hidden = true;
|
||||
}
|
||||
const agendaExport = qs('#agenda-export');
|
||||
const agendaExportButton = qs('#open-agenda-export');
|
||||
agendaExport.hidden = selectedWorkFilter !== 'agenda';
|
||||
agendaExportButton.disabled = queueItems.length === 0;
|
||||
qs('#agenda-export-empty').textContent = queueItems.length ?
|
||||
'Review ' + queueItems.length + (queueItems.length === 1 ? ' deadline' : ' deadlines') +
|
||||
' before sharing a calendar snapshot.' :
|
||||
'No deadlines are available to export from this Agenda.';
|
||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||
const visible = findQueueItems(queueItems, queueFindQuery);
|
||||
const emptyWorkStart = qs('#empty-work-start');
|
||||
@@ -3678,7 +3947,7 @@
|
||||
function showConversationWithActions(kind, state) {
|
||||
const surface = conversationActionSurfaces[kind];
|
||||
latestConversationStates[kind] = state;
|
||||
return commentActionHydrator.show({
|
||||
return actionHydrator.show({
|
||||
root:qs(surface.selector), state, paint:surface.paint,
|
||||
retry:qs('#retry-' + kind + '-comment-actions'),
|
||||
wire:controller => wireConversationActions(kind, controller),
|
||||
@@ -4520,7 +4789,25 @@
|
||||
sharedLaunchState = null;
|
||||
}
|
||||
|
||||
let createIssueModalLifecycle = null;
|
||||
let createIssueLauncher = null;
|
||||
function issueCaptureModal() {
|
||||
if (createIssueModalLifecycle) return createIssueModalLifecycle;
|
||||
const root = qs('#create-issue-sheet');
|
||||
const background = Array.from(document.body.children).filter(element =>
|
||||
element !== root && element.tagName !== 'SCRIPT' && element.getAttribute('role') !== 'dialog'
|
||||
);
|
||||
createIssueModalLifecycle = createIssueCapture.createModalLifecycle({
|
||||
root, background, document, requestClose:() => closeCreateIssueSheet(),
|
||||
});
|
||||
return createIssueModalLifecycle;
|
||||
}
|
||||
|
||||
async function openCreateIssueSheet(navigate = true) {
|
||||
const root = qs('#create-issue-sheet');
|
||||
if (!root.classList.contains('open') && document.activeElement && !root.contains(document.activeElement)) {
|
||||
createIssueLauncher = document.activeElement;
|
||||
}
|
||||
if (!issueCapture && !await ensureIssueCapture()) return;
|
||||
if (navigate) {
|
||||
taskOverlayHistory.open('new');
|
||||
@@ -4579,6 +4866,7 @@
|
||||
updateIssueCreateActions();
|
||||
qs('#create-issue-sheet').classList.add('open');
|
||||
creatingIssue = true;
|
||||
issueCaptureModal().open(createIssueLauncher);
|
||||
if (!sharedImageHandled && sharedImageMarker) {
|
||||
sharedImageHandled = true;
|
||||
if (createIssueAttachmentController.state()) {
|
||||
@@ -4635,7 +4923,7 @@
|
||||
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
|
||||
if (navigate && taskOverlayHistory.current() === 'new') {
|
||||
suppressCreateDraftOnHistoryClose = !preserveDraft;
|
||||
taskOverlayHistory.close();
|
||||
taskOverlayHistory.leave();
|
||||
return;
|
||||
}
|
||||
qs('#create-issue-sheet').classList.remove('open');
|
||||
@@ -4644,13 +4932,16 @@
|
||||
clearTimeout(duplicateCheckTimer);
|
||||
qs('#create-issue-duplicates').hidden = true;
|
||||
creatingIssue = false;
|
||||
issueCaptureModal().close({restore:!followUpSourceUpdate});
|
||||
createIssueLauncher = null;
|
||||
if (createAndStartRequested) timerView.transferCapture();
|
||||
else timerView.finishCapture();
|
||||
if (followUpSourceUpdate) {
|
||||
const source = followUpSourceUpdate;
|
||||
followUpSourceUpdate = null;
|
||||
notificationReader.open(source.item, source.detail);
|
||||
return;
|
||||
}
|
||||
qs('#new-issue').focus();
|
||||
}
|
||||
|
||||
function applyOutboxResult(result, openCreated = false, startCreated = false) {
|
||||
@@ -4994,6 +5285,7 @@
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
restoreReleaseReceipt();
|
||||
updateDeliveryReceiptControls();
|
||||
interruptionPrompt.restore();
|
||||
}
|
||||
@@ -5204,9 +5496,9 @@
|
||||
if (shareStatus[state.status]) status.textContent = shareStatus[state.status];
|
||||
else if (state.status === 'reopening') status.textContent = 'Reopening…';
|
||||
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
|
||||
else if (state.status === 'claimed') status.textContent = 'Assignment confirmed. Opening My Work…';
|
||||
else if (detail.claimable) status.textContent = 'This issue is open and unassigned.';
|
||||
else if (detail.assigned_to_me) status.textContent = 'This item is already in My Work.';
|
||||
else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…';
|
||||
else if (detail.claimable) status.textContent = 'Open and unassigned.';
|
||||
else if (detail.assigned_to_me) status.textContent = 'Already in My Work.';
|
||||
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
|
||||
else status.textContent = 'Ready.';
|
||||
}
|
||||
@@ -5715,7 +6007,6 @@
|
||||
event.currentTarget.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||||
qs('#create-update-follow-up').addEventListener('click', async () => {
|
||||
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
|
||||
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
|
||||
@@ -5756,16 +6047,19 @@
|
||||
await unfiledCaptures.completeResume(rUC);
|
||||
rUC = '';
|
||||
}
|
||||
const capture = timerView.finishCapture();
|
||||
issueCapture.clearDraft();
|
||||
qs('#create-issue-title').value = '';
|
||||
qs('#create-issue-body').value = '';
|
||||
createIssueAttachmentController.clear();
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('queues');
|
||||
if (!capture) {
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('queues');
|
||||
}
|
||||
refreshMyWorkView();
|
||||
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
|
||||
requestAnimationFrame(() => {
|
||||
if (!capture) requestAnimationFrame(() => {
|
||||
savedCard?.scrollIntoView({block:'nearest'});
|
||||
savedCard?.focus({preventScroll:true});
|
||||
});
|
||||
@@ -6765,7 +7059,8 @@
|
||||
button.disabled = true;
|
||||
qs('#pull-sheet-status').textContent = 'Merging pull request…';
|
||||
try {
|
||||
await pullController.merge(selectedPull, selectedPullDetail.head_sha);
|
||||
const mergeResult = await pullController.merge(selectedPull, selectedPullDetail.head_sha);
|
||||
releaseReceipt.capture(merging, mergeResult);
|
||||
closePullSheet();
|
||||
lastMyWork = lastMyWork.filter(item =>
|
||||
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
|
||||
@@ -7141,6 +7436,7 @@
|
||||
intervalMs: 8000,
|
||||
});
|
||||
function load() { return contextPoller.refresh({ force: true }); }
|
||||
createMobilePullRefresh({refresh:load}).start();
|
||||
const liveDataStatusSheet = qs('#live-data-status-sheet');
|
||||
const liveDataStatusTrigger = qs('#open-live-data-status');
|
||||
let latestLiveFreshness = {};
|
||||
@@ -7164,24 +7460,7 @@
|
||||
'Next automatic retry in ' + retrySeconds + 's.' :
|
||||
(pollState.lastSuccessAt ? 'Last successful refresh ' + fmt(new Date(pollState.lastSuccessAt)) + '.' : 'Waiting for the first successful refresh.');
|
||||
}
|
||||
function closeLiveDataStatus() {
|
||||
liveDataStatusSheet.hidden = true;
|
||||
liveDataStatusTrigger.setAttribute('aria-expanded', 'false');
|
||||
liveDataStatusTrigger.focus();
|
||||
}
|
||||
liveDataStatusTrigger.addEventListener('click', () => {
|
||||
renderLiveDataStatus();
|
||||
liveDataStatusSheet.hidden = false;
|
||||
liveDataStatusTrigger.setAttribute('aria-expanded', 'true');
|
||||
qs('#close-live-data-status').focus();
|
||||
});
|
||||
qs('#close-live-data-status').addEventListener('click', closeLiveDataStatus);
|
||||
liveDataStatusSheet.addEventListener('click', event => {
|
||||
if (event.target === liveDataStatusSheet) closeLiveDataStatus();
|
||||
});
|
||||
liveDataStatusSheet.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); closeLiveDataStatus(); }
|
||||
});
|
||||
liveDataStatus.mount({document, window, timerView, onOpen:renderLiveDataStatus}).start();
|
||||
const liveDataRefresh = liveDataStatus.createRefreshController({
|
||||
button: qs('#refresh-live-data'),
|
||||
output: qs('#live-data-status-result'),
|
||||
@@ -7238,6 +7517,7 @@
|
||||
if (!saved) return false;
|
||||
const outage = mode === 'outage';
|
||||
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
||||
restoreReleaseReceipt();
|
||||
planningOwnerLogin = confirmedOwnerLogin;
|
||||
interruptionPrompt.restore();
|
||||
updatePlanningAvailability();
|
||||
@@ -7289,6 +7569,9 @@
|
||||
flushAuthored: flushAuthoredOutbox,
|
||||
flushNotificationReads: flushNotificationReadOutbox,
|
||||
});
|
||||
const reconnectAfterOnline = () => setTimeout(reconnectOutboxes, 500);
|
||||
window.addEventListener('online', reconnectAfterOnline);
|
||||
workspaceLifecycle.replayOnline(reconnectAfterOnline);
|
||||
async function setOfflineWorkEnabled(enabled) {
|
||||
keepWorkOffline.checked = enabled;
|
||||
offlineWorkStore.setEnabled(enabled);
|
||||
@@ -7346,7 +7629,6 @@
|
||||
updateDeliveryReceiptControls();
|
||||
if (!navigator.onLine) await showOfflineStatus();
|
||||
window.addEventListener('offline', showOfflineStatus);
|
||||
window.addEventListener('online', reconnectOutboxes);
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
|
||||
@@ -7637,10 +7919,21 @@
|
||||
const controller = createPushNotifications({
|
||||
control:qs('#push-updates'),
|
||||
status:qs('#push-update-status'),
|
||||
testControl:qs('#push-test'),
|
||||
deadlineControl:qs('#push-deadlines'),
|
||||
deadlineStatus:qs('#push-deadline-status'),
|
||||
deadlineHour:qs('#push-deadline-hour'),
|
||||
deadlineDays:qs('#push-deadline-days'),
|
||||
deadlineSnooze:qs('#deadline-snooze'),
|
||||
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
|
||||
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
|
||||
onReviewDeadlines:() => {
|
||||
window.location.hash = '#/my-work/agenda';
|
||||
setTimeout(() => {
|
||||
const protect = qs('#protect-today');
|
||||
if (!protect.closest('[hidden]')) protect.click();
|
||||
}, 0);
|
||||
},
|
||||
notification:window.Notification,
|
||||
serviceWorker:navigator.serviceWorker,
|
||||
fetchJson:fetchReviewJson,
|
||||
@@ -7679,14 +7972,25 @@
|
||||
isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent),
|
||||
});
|
||||
installApp.start();
|
||||
const deviceStorage = createDeviceStorage.mount(document);
|
||||
await deviceStorage.start();
|
||||
deviceSetup = createMobileDeviceSetup.mount({
|
||||
document, installApp, promptStorage:localStorage,
|
||||
timerView,
|
||||
offlineAvailable:() => offlineStorageReady,
|
||||
offlineEnabled:() => offlineWorkStore.enabled(),
|
||||
enableOffline:() => setOfflineWorkEnabled(true),
|
||||
storageProtectionReadiness:() => deviceStorage.persistenceReadiness(),
|
||||
protectStorage:() => deviceStorage.requestPersistence(),
|
||||
notificationReadiness:() => pushController?.notificationReadiness()
|
||||
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
|
||||
enablePush:async () => {
|
||||
const controller = await pushControllerReady;
|
||||
if (!controller) return;
|
||||
if (controller.notificationReadiness().state === 'blocked') {
|
||||
await controller.recoverPermission('updates');
|
||||
return;
|
||||
}
|
||||
qs('#push-updates').checked = true;
|
||||
await controller.change();
|
||||
},
|
||||
@@ -7697,6 +8001,10 @@
|
||||
if (!controller) return;
|
||||
qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value;
|
||||
qs('#push-deadline-days').value = qs('#device-setup-deadline-days').value;
|
||||
if (controller.deadlineReadiness().state === 'blocked') {
|
||||
await controller.recoverPermission('deadline');
|
||||
return;
|
||||
}
|
||||
await controller.enableDeadline();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else {
|
||||
root.createDeviceStorage = factory;
|
||||
root.createDeviceStorage.mount = document => factory({
|
||||
summary:document.querySelector('#device-storage-summary'),
|
||||
detail:document.querySelector('#device-storage-detail'),
|
||||
clearCachesButton:document.querySelector('#clear-device-caches'),
|
||||
clearAllButton:document.querySelector('#clear-private-device-data'),
|
||||
localStorage:root.localStorage,
|
||||
sessionStorage:root.sessionStorage,
|
||||
caches:root.caches,
|
||||
storageManager:root.navigator?.storage,
|
||||
privateDatabases:root.stackchainPrivateDatabases,
|
||||
inspectPrivateDatabases:root.inspectStackchainPrivateDatabases,
|
||||
clearPrivateDeviceData:root.stackchainPrivateDeviceData,
|
||||
});
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function createDeviceStorage(options) {
|
||||
let privateItemCount = 0;
|
||||
let privateRecordCount = 0;
|
||||
let inventoryUnavailable = false;
|
||||
let fullClearArmed = false;
|
||||
let persistence = { state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable' };
|
||||
const ownedCaches = async () => (await options.caches?.keys?.() || [])
|
||||
.filter(name => name.startsWith('stackchain-dashboard-'));
|
||||
|
||||
function ownedStorageCount(storage) {
|
||||
if (!storage) return 0;
|
||||
let count = 0;
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
if (storage.key(index)?.startsWith('stackchain.')) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function megabytes(value) {
|
||||
return Math.round((Number(value) || 0) / (1024 * 1024));
|
||||
}
|
||||
|
||||
async function inspectPersistence() {
|
||||
if (typeof options.storageManager?.persisted !== 'function' || typeof options.storageManager?.persist !== 'function') {
|
||||
persistence = {state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable'};
|
||||
return persistence;
|
||||
}
|
||||
try {
|
||||
const protectedStorage = await options.storageManager.persisted();
|
||||
persistence = protectedStorage
|
||||
? {state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.', label:'Protected'}
|
||||
: {state:'incomplete', detail:'Offline work uses best-effort browser storage and may be removed under storage pressure.', label:'Best effort'};
|
||||
} catch (_error) {
|
||||
persistence = {state:'incomplete', detail:'Storage protection could not be checked. Retry to protect offline work.', label:'Check failed'};
|
||||
}
|
||||
return persistence;
|
||||
}
|
||||
|
||||
function persistenceReadiness() {
|
||||
return {state:persistence.state, detail:persistence.detail};
|
||||
}
|
||||
|
||||
async function requestPersistence() {
|
||||
if (typeof options.storageManager?.persist !== 'function') return persistenceReadiness();
|
||||
let granted = false;
|
||||
try { granted = await options.storageManager.persist(); }
|
||||
catch (_error) {
|
||||
persistence = {state:'incomplete', detail:'Storage protection request failed. Offline work still uses best-effort storage; retry when ready.', label:'Request failed'};
|
||||
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Request failed ');
|
||||
return persistenceReadiness();
|
||||
}
|
||||
await refresh();
|
||||
if (!granted && persistence.state !== 'complete') {
|
||||
persistence = {state:'incomplete', detail:'The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.', label:'Denied'};
|
||||
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Denied ');
|
||||
}
|
||||
return persistenceReadiness();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let estimate = null;
|
||||
try { estimate = await options.storageManager?.estimate?.(); }
|
||||
catch (_error) { /* Storage estimates are optional. */ }
|
||||
await inspectPersistence();
|
||||
options.summary.textContent = estimate?.quota
|
||||
? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.`
|
||||
: 'Browser storage usage is unavailable.';
|
||||
const itemCount = ownedStorageCount(options.localStorage)
|
||||
+ (options.sessionStorage === options.localStorage ? 0 : ownedStorageCount(options.sessionStorage));
|
||||
privateItemCount = itemCount;
|
||||
const inventory = await options.inspectPrivateDatabases?.(options.privateDatabases)
|
||||
|| { recordCount: 0, unavailable: true };
|
||||
privateRecordCount = inventory.recordCount;
|
||||
inventoryUnavailable = inventory.unavailable;
|
||||
const cacheCount = (await ownedCaches()).length;
|
||||
options.detail.textContent = `Storage protection: ${persistence.label} · ${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
|
||||
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
|
||||
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
|
||||
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
|
||||
}
|
||||
|
||||
async function clearCaches() {
|
||||
options.clearCachesButton.disabled = true;
|
||||
try {
|
||||
await Promise.all((await ownedCaches()).map(name => options.caches.delete(name)));
|
||||
await refresh();
|
||||
options.summary.textContent = 'Cached app copies cleared. Private work was kept.';
|
||||
} catch (error) {
|
||||
options.summary.textContent = `Cached copies could not be cleared: ${error.message}`;
|
||||
} finally {
|
||||
options.clearCachesButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
if ((privateItemCount > 0 || privateRecordCount > 0 || inventoryUnavailable) && !fullClearArmed) {
|
||||
fullClearArmed = true;
|
||||
options.clearAllButton.textContent = 'Confirm: clear private work';
|
||||
options.summary.textContent = 'Private drafts or queued work may not be synced. Press confirm to clear them from this device.';
|
||||
return;
|
||||
}
|
||||
options.clearAllButton.disabled = true;
|
||||
try {
|
||||
await options.clearPrivateDeviceData();
|
||||
privateItemCount = 0;
|
||||
fullClearArmed = false;
|
||||
options.clearAllButton.textContent = 'Clear all private data';
|
||||
options.detail.textContent = `Storage protection: ${persistence.label} · 0 private browser items · 0 private work records · 0 cached app copies`;
|
||||
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
|
||||
} catch (error) {
|
||||
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
|
||||
} finally {
|
||||
options.clearAllButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
options.clearCachesButton.addEventListener('click', clearCaches);
|
||||
options.clearAllButton.addEventListener('click', clearAll);
|
||||
return refresh();
|
||||
}
|
||||
|
||||
return { refresh, start, persistenceReadiness, requestPersistence };
|
||||
});
|
||||
+9
-2
@@ -240,7 +240,14 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
}
|
||||
|
||||
function partition(items = list()) {
|
||||
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
|
||||
const isDelivery = item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||
const needsAction = item => isDelivery(item) && (
|
||||
item.status === 'attention' || item.status === 'authorization' || item.status === 'completion' ||
|
||||
item.delivery_state === 'uncertain' || item.quarantined
|
||||
);
|
||||
const deliveries = items.filter(isDelivery).sort((left, right) =>
|
||||
Number(needsAction(right)) - Number(needsAction(left))
|
||||
);
|
||||
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
|
||||
const counts = deliveries.reduce((summary, item) => {
|
||||
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
|
||||
@@ -251,7 +258,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
const retryable = deliveries.filter(item =>
|
||||
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
|
||||
);
|
||||
return { drafts, deliveries, counts, retryable };
|
||||
return { drafts, deliveries, counts, retryable, actionable:deliveries.filter(needsAction).length };
|
||||
}
|
||||
|
||||
function deliveryLabel(item) {
|
||||
|
||||
+383
-17
@@ -16,6 +16,7 @@
|
||||
<details class="app-menu">
|
||||
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
|
||||
<div class="app-menu-panel">
|
||||
<button id="open-insights" type="button" aria-controls="insights-sheet">Insights</button>
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="open-palette">Command</button>
|
||||
<button id="open-device-setup" type="button">Device setup</button>
|
||||
@@ -26,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>
|
||||
@@ -47,22 +61,19 @@
|
||||
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
|
||||
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices and passkeys">
|
||||
<div class="active-devices-header">
|
||||
<div><h2>Active devices</h2><p class="small muted">Review signed-in devices and remove access you no longer trust.</p></div>
|
||||
<div><h2>Security Center</h2><p class="small muted">Investigate activity, remove untrusted access, and verify the result.</p></div>
|
||||
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||
</div>
|
||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
||||
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
|
||||
<div id="active-devices-list" class="active-devices-list"></div>
|
||||
<section class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
|
||||
<div class="enrolled-passkeys-header">
|
||||
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
|
||||
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
|
||||
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
|
||||
</div>
|
||||
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
|
||||
</section>
|
||||
<section class="security-activity" aria-labelledby="security-activity-title">
|
||||
<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>
|
||||
<button id="security-section-passkeys" type="button">Passkeys</button>
|
||||
</nav>
|
||||
<section id="security-activity-section" class="security-activity" aria-labelledby="security-activity-title">
|
||||
<div class="security-activity-header">
|
||||
<h3 id="security-activity-title">Security activity</h3>
|
||||
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
|
||||
@@ -71,6 +82,24 @@
|
||||
<div id="security-activity-list" class="security-activity-list"></div>
|
||||
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
|
||||
</section>
|
||||
<section id="security-devices-section" class="security-devices" aria-labelledby="security-devices-title">
|
||||
<div class="security-activity-header">
|
||||
<h3 id="security-devices-title">Active devices</h3>
|
||||
<p class="small muted">Review signed-in devices and remove access you no longer trust.</p>
|
||||
</div>
|
||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
||||
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
|
||||
<div id="active-devices-list" class="active-devices-list"></div>
|
||||
</section>
|
||||
<section id="security-passkeys-section" class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
|
||||
<div class="enrolled-passkeys-header">
|
||||
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
|
||||
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
|
||||
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
|
||||
</div>
|
||||
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -83,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>
|
||||
@@ -92,6 +125,10 @@
|
||||
<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>
|
||||
@@ -107,11 +144,23 @@
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<section class="device-storage" aria-labelledby="device-storage-heading">
|
||||
<div>
|
||||
<h3 id="device-storage-heading">Private device storage</h3>
|
||||
<p class="small" id="device-storage-summary" role="status" aria-live="polite">Checking private storage…</p>
|
||||
<p class="small muted" id="device-storage-detail"></p>
|
||||
</div>
|
||||
<div class="device-storage-actions">
|
||||
<button id="clear-device-caches" type="button">Clear cached copies</button>
|
||||
<button id="clear-private-device-data" type="button">Clear all private data</button>
|
||||
</div>
|
||||
</section>
|
||||
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div id="mobile-pull-refresh" class="mobile-pull-refresh" role="status" aria-live="polite" hidden></div>
|
||||
<section class="panel my-work" id="my-work" tabindex="-1">
|
||||
<div class="my-work-header">
|
||||
<div>
|
||||
@@ -154,10 +203,13 @@
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<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="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>
|
||||
@@ -181,6 +233,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>
|
||||
@@ -222,6 +278,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>
|
||||
@@ -239,6 +302,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>
|
||||
@@ -257,8 +327,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>
|
||||
@@ -273,6 +346,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>
|
||||
@@ -334,6 +439,7 @@
|
||||
<div id="md-preview" style="margin-top:8px;"></div>
|
||||
</details>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="plan-today-sheet" id="plan-today-sheet" role="dialog" aria-modal="true" aria-labelledby="plan-today-title" hidden>
|
||||
@@ -406,13 +512,72 @@
|
||||
<div id="today-recap-history" class="today-recap-history"></div>
|
||||
</section>
|
||||
<div class="today-recap-actions">
|
||||
<button id="save-today-recap" type="button">Save recap & adjust plan</button>
|
||||
<button id="save-today-recap" type="button">Save recap & wrap up</button>
|
||||
<button id="save-log-today-recap" type="button">Log selected time to Gitea</button>
|
||||
<button id="discard-today-recap" type="button">Keep timer & close</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<dialog class="today-handoff-dialog" id="today-handoff-dialog" aria-labelledby="today-handoff-title">
|
||||
<section class="today-handoff-panel">
|
||||
<header><div><div class="small">Yesterday’s commitments</div><h2 id="today-handoff-title">Review tomorrow’s plan</h2></div><button id="close-today-handoff" type="button">Not now</button></header>
|
||||
<p class="small muted">Choose the work you still intend to do Today. Unchecked work stays safely in My Work.</p>
|
||||
<div id="today-handoff-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-handoff-items" class="today-handoff-items"></div>
|
||||
<div class="today-handoff-actions"><button id="confirm-today-handoff" type="button">Add selected to Today</button></div>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<div class="today-wrap-up-sheet" id="today-wrap-up-sheet" role="dialog" aria-modal="true" aria-labelledby="today-wrap-up-title" hidden>
|
||||
<section class="today-wrap-up-panel">
|
||||
<div class="today-wrap-up-header">
|
||||
<div><div class="small">End your day intentionally</div><h2 id="today-wrap-up-title">Wrap up Today</h2></div>
|
||||
<button id="close-today-wrap-up" type="button">Not now</button>
|
||||
</div>
|
||||
<p class="small muted">Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.</p>
|
||||
<div id="today-wrap-up-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-wrap-up-items" class="today-wrap-up-items"></div>
|
||||
<div class="today-wrap-up-actions">
|
||||
<button id="finish-today-wrap-up" type="button">Finish wrap-up</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-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">
|
||||
@@ -463,6 +628,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>
|
||||
@@ -540,6 +709,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>
|
||||
@@ -843,6 +1016,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>
|
||||
@@ -906,6 +1083,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>
|
||||
@@ -1250,6 +1431,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">
|
||||
@@ -1460,16 +1646,146 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<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>
|
||||
<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-complete type="button" hidden>Done & next</button>
|
||||
<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>
|
||||
<button data-mobile-today-more type="button" aria-controls="mobile-today-actions" aria-haspopup="dialog">More</button>
|
||||
</section>
|
||||
<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-mobile-today-end type="button">End session</button>
|
||||
</div>
|
||||
</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>
|
||||
@@ -1487,6 +1803,7 @@
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||
@@ -1498,6 +1815,36 @@
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title">
|
||||
<section class="mobile-delivery-recovery-panel">
|
||||
<header>
|
||||
<div><p class="small muted" id="mobile-delivery-recovery-progress"></p><h2 id="mobile-delivery-recovery-title">Recover delivery</h2></div>
|
||||
<button id="close-mobile-delivery-recovery" type="button">Close</button>
|
||||
</header>
|
||||
<p id="mobile-delivery-recovery-destination" class="small"></p>
|
||||
<p id="mobile-delivery-recovery-reason" class="delivery-recovery-reason"></p>
|
||||
<p id="mobile-delivery-recovery-status" class="small" role="status" aria-live="polite"></p>
|
||||
<button id="mobile-delivery-recovery-action" type="button">Recover delivery</button>
|
||||
<details><summary>Other recovery options</summary><p class="small muted">Close this guide to edit, copy, or discard the saved delivery from Drafts.</p></details>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<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>
|
||||
@@ -1509,6 +1856,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>
|
||||
@@ -1540,13 +1889,21 @@
|
||||
<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>
|
||||
<script src="static/comment-next.js"></script>
|
||||
@@ -1584,13 +1941,18 @@
|
||||
<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>
|
||||
<script src="static/mobile-start-day.js"></script>
|
||||
<script src="static/update-triage-session.js"></script>
|
||||
<script src="static/update-review-handoff.js"></script>
|
||||
@@ -1601,8 +1963,11 @@
|
||||
<script src="static/update-decision-transaction.js"></script>
|
||||
<script src="static/agenda-session-launcher.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-insights.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/private-device-data.js"></script>
|
||||
<script src="static/device-storage.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
<script src="static/mobile-composer-viewport.js"></script>
|
||||
@@ -1615,6 +1980,7 @@
|
||||
<script src="static/mobile-search-preview-nav.js"></script>
|
||||
<script src="static/mobile-plan-today-nav.js"></script>
|
||||
<script src="static/mobile-find-work-nav.js"></script>
|
||||
<script src="static/workspace-bootstrap.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -160,15 +160,17 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, wakeAt = null) {
|
||||
function enqueue(action, itemId, wakeAt = null, handoff = null) {
|
||||
if (!['defer', 'restore'].includes(action) || !itemId ||
|
||||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
|
||||
(action === 'defer' && typeof wakeAt !== 'string') ||
|
||||
![null, 'today'].includes(handoff)) return false;
|
||||
pending().filter(operation => operation.item_id === itemId)
|
||||
.forEach(operation => removeOperation(operation.operation_id));
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
if (handoff) operation.handoff = handoff;
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
@@ -189,9 +191,11 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||
try {
|
||||
if (storage.getItem(marker)) return false;
|
||||
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
|
||||
enqueue('defer', itemId, wakeAt)
|
||||
);
|
||||
Object.entries(records || {}).forEach(([itemId, record]) => {
|
||||
const wakeAt = record && typeof record === 'object' ? record.wake_at : record;
|
||||
const handoff = record?.handoff === 'today' ? 'today' : null;
|
||||
enqueue('defer', itemId, wakeAt, handoff);
|
||||
});
|
||||
storage.setItem(marker, '1');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
|
||||
+22
-10
@@ -38,7 +38,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
}
|
||||
}
|
||||
|
||||
function defer(item, until) {
|
||||
function defer(item, until, options = {}) {
|
||||
const key = storageKey();
|
||||
const id = identity(item);
|
||||
const wake = new Date(until);
|
||||
@@ -46,9 +46,10 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
|
||||
const records = read();
|
||||
const wakeAt = wake.toISOString();
|
||||
records[id] = wakeAt;
|
||||
const handoff = options.handoff === 'today' ? 'today' : null;
|
||||
records[id] = handoff ? { wake_at:wakeAt, handoff } : wakeAt;
|
||||
if (!write(records)) return 'unavailable';
|
||||
onChange('defer', id, wakeAt);
|
||||
onChange('defer', id, wakeAt, handoff);
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
@@ -93,9 +94,13 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
function adopt(records) {
|
||||
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
|
||||
const normalized = {};
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
Object.entries(records).forEach(([id, record]) => {
|
||||
const wake = record && typeof record === 'object' ? record.wake_at : record;
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
|
||||
if (id && Number.isFinite(wakeTime)) {
|
||||
const wakeAt = new Date(wakeTime).toISOString();
|
||||
normalized[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
|
||||
}
|
||||
});
|
||||
return write(normalized);
|
||||
}
|
||||
@@ -119,25 +124,32 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
const wakeTimes = [];
|
||||
const expired = [];
|
||||
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
const handoffs = [];
|
||||
Object.entries(records).forEach(([id, record]) => {
|
||||
const wake = record && typeof record === 'object' ? record.wake_at : record;
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
|
||||
if (Number.isFinite(wakeTime)) expired.push(id);
|
||||
if (Number.isFinite(wakeTime)) {
|
||||
expired.push(id);
|
||||
if (record?.handoff === 'today') handoffs.push(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pruneMissing && !available.has(id)) return;
|
||||
retained[id] = new Date(wakeTime).toISOString();
|
||||
const wakeAt = new Date(wakeTime).toISOString();
|
||||
retained[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
|
||||
wakeTimes.push(wakeTime);
|
||||
});
|
||||
|
||||
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
|
||||
onExpire(expired);
|
||||
onExpire(expired, handoffs);
|
||||
}
|
||||
schedule(wakeTimes, current);
|
||||
const deferredIds = new Set(Object.keys(retained));
|
||||
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
|
||||
const later = (items || []).flatMap(item => {
|
||||
const wake = retained[identity(item)];
|
||||
const record = retained[identity(item)];
|
||||
const wake = record && typeof record === 'object' ? record.wake_at : record;
|
||||
return wake ? [{ ...item, deferred_until: wake }] : [];
|
||||
});
|
||||
return { active, later };
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
+4
-2
@@ -4,7 +4,7 @@
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
function validShareContinuation(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200,
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 6, search: 200,
|
||||
preview: 200, search_kind: 5, search_state: 6, search_repository: 161 };
|
||||
const params = new URLSearchParams(value.slice(3));
|
||||
const entries = Array.from(params.entries());
|
||||
@@ -35,7 +35,9 @@
|
||||
const launch = params.get('launch');
|
||||
const shared = params.get('shared');
|
||||
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
||||
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
if (shared === 'bundle') {
|
||||
if (launch !== 'new' || entries.some(([name]) => !['launch', 'shared'].includes(name))) return './';
|
||||
} else if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileDeliveryRecovery = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileDeliveryRecovery(options) {
|
||||
const byId = id => typeof document === 'undefined' ? null : document.getElementById(id);
|
||||
const elements = options.elements || (typeof document === 'undefined' ? null : {
|
||||
dialog: byId('mobile-delivery-recovery'), close: byId('close-mobile-delivery-recovery'),
|
||||
title: byId('mobile-delivery-recovery-title'), destination: byId('mobile-delivery-recovery-destination'),
|
||||
reason: byId('mobile-delivery-recovery-reason'), progress: byId('mobile-delivery-recovery-progress'),
|
||||
action: byId('mobile-delivery-recovery-action'), status: byId('mobile-delivery-recovery-status'),
|
||||
});
|
||||
const priority = {authorization: 0, attention: 1, uncertain: 2, waiting: 3};
|
||||
|
||||
function identity(item) {
|
||||
return String(item?.outbox_id || item?.id || '');
|
||||
}
|
||||
|
||||
function state(item) {
|
||||
if (item?.status === 'authorization') return 'authorization';
|
||||
if (item?.status === 'attention' || item?.checklist_conflict || item?.quarantined) return 'attention';
|
||||
if (item?.delivery_state === 'uncertain') return 'uncertain';
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
function primary(item) {
|
||||
const kind = state(item);
|
||||
if (kind === 'authorization') {
|
||||
return item?.outbox_kind === 'issue-close' ? 'Authorize & close' : 'Authorize & send';
|
||||
}
|
||||
if (item?.checklist_conflict) return 'Review changes';
|
||||
if (kind === 'attention') return item?.quarantined ? 'Copy content' : 'Open current item';
|
||||
if (kind === 'uncertain') return 'Verified not posted — retry';
|
||||
return 'Retry now';
|
||||
}
|
||||
|
||||
function ordered() {
|
||||
return [...(options.getItems ? options.getItems() : [])].sort((left, right) => {
|
||||
const byState = priority[state(left)] - priority[state(right)];
|
||||
if (byState) return byState;
|
||||
return identity(left).localeCompare(identity(right));
|
||||
});
|
||||
}
|
||||
|
||||
const stateLabels = {
|
||||
authorization: 'Authorization required',
|
||||
attention: 'Needs attention',
|
||||
uncertain: 'Delivery uncertain',
|
||||
waiting: 'Ready to retry',
|
||||
};
|
||||
let currentId = '';
|
||||
let active = false;
|
||||
let inFlight = null;
|
||||
|
||||
function currentItem(items) {
|
||||
return items.find(item => identity(item) === currentId) || items[0];
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const items = ordered();
|
||||
const item = currentItem(items);
|
||||
if (item) currentId = identity(item);
|
||||
else currentId = '';
|
||||
const index = item ? items.indexOf(item) : -1;
|
||||
return {
|
||||
count: items.length,
|
||||
current: item ? {
|
||||
id: identity(item),
|
||||
state: state(item),
|
||||
primary: primary(item),
|
||||
position: index + 1,
|
||||
total: items.length,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
const result = snapshot();
|
||||
if (!elements) return result;
|
||||
const items = ordered();
|
||||
const item = currentItem(items);
|
||||
if (!item) {
|
||||
if (active) {
|
||||
active = false;
|
||||
if (elements.dialog?.open) elements.dialog.close();
|
||||
if (options.onComplete) options.onComplete();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
elements.title.textContent = item.title || 'Queued delivery';
|
||||
elements.destination.textContent = [item.repository, item.details].filter(Boolean).join(' · ') || 'Saved delivery';
|
||||
elements.reason.textContent = item.last_attempt_error || item.ownership || stateLabels[state(item)];
|
||||
elements.progress.textContent = 'Delivery ' + result.current.position + ' of ' + result.current.total + ' · ' + stateLabels[state(item)];
|
||||
elements.action.textContent = primary(item);
|
||||
if (elements.status) elements.status.textContent = '';
|
||||
return result;
|
||||
}
|
||||
|
||||
function open() {
|
||||
const queue = byId('mobile-queue-sheet');
|
||||
if (queue?.open) queue.close();
|
||||
if (options.beforeOpen) options.beforeOpen();
|
||||
currentId = '';
|
||||
const result = snapshot();
|
||||
if (!result.current) return 'empty';
|
||||
active = true;
|
||||
render();
|
||||
if (elements?.dialog && !elements.dialog.open) elements.dialog.showModal();
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
async function attend(item) {
|
||||
const index = options.getIndex ? options.getIndex(item) : -1;
|
||||
const selector = item.quarantined ? '.draft-copy' :
|
||||
item.checklist_conflict ? '.draft-review-checklist' : '.draft-resume';
|
||||
const action = document.querySelector('#my-work-list ' + selector + '[data-draft-index="' + index + '"]');
|
||||
if (!action) throw new Error('Delivery changed.');
|
||||
elements.dialog.close();
|
||||
action.scrollIntoView();
|
||||
action.focus();
|
||||
return await action.onclick();
|
||||
}
|
||||
|
||||
function activate() {
|
||||
if (inFlight) return inFlight;
|
||||
const items = ordered();
|
||||
const item = currentItem(items);
|
||||
const perform = state(item) === 'attention' ? attend : options.activate;
|
||||
if (!item) return Promise.resolve(render());
|
||||
if (elements?.action) elements.action.disabled = true;
|
||||
if (elements?.status) elements.status.textContent = 'Working…';
|
||||
inFlight = (async () => {
|
||||
let failure = null;
|
||||
try {
|
||||
await perform(item, state(item));
|
||||
} catch (error) {
|
||||
failure = String(error?.message || 'Delivery could not be completed.').trim().slice(0, 160);
|
||||
} finally {
|
||||
if (elements?.action) elements.action.disabled = false;
|
||||
}
|
||||
const result = render();
|
||||
if (failure && elements?.status) {
|
||||
if (active && !elements.dialog.open) elements.dialog.showModal();
|
||||
elements.status.textContent = failure + ' Try again.';
|
||||
}
|
||||
return result;
|
||||
})().finally(() => { inFlight = null; });
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (elements?.action?.addEventListener) elements.action.addEventListener('click', activate);
|
||||
if (elements?.close?.addEventListener) elements.close.addEventListener('click', () => elements.dialog.close());
|
||||
}
|
||||
|
||||
return {activate, open, render, snapshot, start};
|
||||
});
|
||||
@@ -5,12 +5,34 @@
|
||||
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'],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
|
||||
];
|
||||
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 +55,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 +73,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 +112,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 +161,29 @@ 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'),
|
||||
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'),
|
||||
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,15 +191,13 @@ 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(),
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
protectStorage:options.protectStorage,
|
||||
enablePush:options.enablePush,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
(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,
|
||||
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'),
|
||||
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 completed() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return false;
|
||||
try { return options.storage.getItem(currentKey) === 'complete'; }
|
||||
catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function required() {
|
||||
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !completed());
|
||||
}
|
||||
|
||||
function markComplete() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return;
|
||||
try { options.storage.setItem(currentKey, 'complete'); }
|
||||
catch (_error) {}
|
||||
}
|
||||
|
||||
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() && !completed()) {
|
||||
markComplete();
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
return 'completed';
|
||||
}
|
||||
if (options.sheet.open) render();
|
||||
if (required() && options.hasWork()) return 'awaiting-start';
|
||||
return required() ? 'required' : 'inactive';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return {required, open, refresh, render, start};
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileInsights = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function (o) {
|
||||
const route = '#/insights';
|
||||
const events = o.eventTarget || self;
|
||||
const location = o.location || self.location;
|
||||
let active = false;
|
||||
let previous = '#/my-work';
|
||||
let retries = 0;
|
||||
|
||||
function attr(element, name, value) {
|
||||
if (value === null) element.removeAttribute(name);
|
||||
else element.setAttribute(name, value);
|
||||
}
|
||||
|
||||
function render(open, focus = false) {
|
||||
const wasActive = active;
|
||||
active = Boolean(open && o.mediaQuery.matches);
|
||||
const detour = active ? o.detour?.() : null;
|
||||
const started = detour?.beginDetour('insights');
|
||||
if (detour && !started && retries++ < 40) setTimeout(sync, 250);
|
||||
else if (started && retries) { retries = 0; setTimeout(sync, 500); }
|
||||
else retries = 0;
|
||||
if (wasActive && !active) o.detour?.()?.finishDetour();
|
||||
attr(o.root, 'data-mobile-open', active ? 'true' : null);
|
||||
attr(o.root, 'role', active ? 'dialog' : null);
|
||||
attr(o.root, 'aria-modal', active ? 'true' : null);
|
||||
attr(o.root, 'aria-hidden', active || !o.mediaQuery.matches ? null : 'true');
|
||||
o.root.inert = !active && o.mediaQuery.matches;
|
||||
o.backgrounds.forEach(element => { element.inert = active; });
|
||||
o.dock.hidden = active;
|
||||
attr(o.hud, 'data-overlay-hidden', active ? 'true' : null);
|
||||
if (active) o.closeButton.focus();
|
||||
else if (focus) o.launcher.focus();
|
||||
}
|
||||
|
||||
function url(hash) {
|
||||
return (location.pathname || '') + (location.search || '') + hash;
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!o.mediaQuery.matches || active) return false;
|
||||
previous = location.hash && location.hash !== route ? location.hash : '#/my-work';
|
||||
const menu = o.menu || o.launcher.closest?.('details');
|
||||
if (menu) menu.open = false;
|
||||
o.history.pushState({ ...(o.history.state || {}), mobileInsights:true, previousHash:previous }, '', url(route));
|
||||
render(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
render(location.hash === route, active && location.hash !== route);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!active) return false;
|
||||
if (o.history.state?.mobileInsights) o.history.back();
|
||||
else {
|
||||
const state = { ...(o.history.state || {}) };
|
||||
delete state.mobileInsights;
|
||||
delete state.previousHash;
|
||||
o.history.replaceState(state, '', url(previous));
|
||||
render(false, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function returnToToday() {
|
||||
if (!active) return false;
|
||||
const state = { ...(o.history.state || {}) };
|
||||
delete state.mobileInsights;
|
||||
delete state.previousHash;
|
||||
o.history.replaceState(state, '', url(previous));
|
||||
render(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
o.launcher.addEventListener('click', open);
|
||||
o.closeButton.addEventListener('click', close);
|
||||
(o.returnButton || o.root.querySelector?.('[data-return-from-detour]'))?.addEventListener('click', returnToToday);
|
||||
events.addEventListener('popstate', sync);
|
||||
events.addEventListener('hashchange', sync);
|
||||
events.addEventListener('keydown', event => {
|
||||
if (active && event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
}
|
||||
});
|
||||
o.mediaQuery.addEventListener?.('change', sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
return { start, open, close, sync, current:() => active };
|
||||
});
|
||||
@@ -14,7 +14,7 @@ function createMobileIssueDetailNavigation(options) {
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
function navigate(name, navigationOptions = {}) {
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
const prepare = options.beforeNavigate && options.beforeNavigate[name];
|
||||
@@ -24,7 +24,7 @@ function createMobileIssueDetailNavigation(options) {
|
||||
block: 'start',
|
||||
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'reply') target.focus();
|
||||
if (name === 'reply' && navigationOptions.focus !== false) target.focus();
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ function createMobileIssueDetailNavigation(options) {
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
@@ -55,7 +56,11 @@ function createMobileIssueDetailNavigation(options) {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
if (visible) {
|
||||
const name = targetNames.get(visible.target);
|
||||
select(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
|
||||
}
|
||||
}, Array.from(targetNames.keys()).filter(Boolean));
|
||||
},
|
||||
stop() {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.createMobilePullRefresh = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobilePullRefresh(options) {
|
||||
const surface = options.surface || document.querySelector('#my-work');
|
||||
const indicator = options.indicator || document.querySelector('#mobile-pull-refresh');
|
||||
const threshold = options.threshold || 64;
|
||||
const isMobile = options.isMobile || (() => window.matchMedia('(max-width: 600px)').matches);
|
||||
const getScrollY = options.getScrollY || (() => window.scrollY);
|
||||
const isBlocked = options.isBlocked || (() => Array.from(options.overlays || document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal')).some(overlay =>
|
||||
(overlay.checkVisibility ? overlay.checkVisibility() : !overlay.closest('[hidden]')) &&
|
||||
(overlay.classList.contains('open') || overlay.getAttribute('aria-modal') === 'true')));
|
||||
let gesture = null;
|
||||
let inFlight = null;
|
||||
|
||||
function show(state, text) {
|
||||
indicator.hidden = false;
|
||||
indicator.dataset.state = state;
|
||||
indicator.textContent = text;
|
||||
}
|
||||
|
||||
function resetGesture() {
|
||||
gesture = null;
|
||||
}
|
||||
|
||||
function startsOnExcludedTarget(target) {
|
||||
if (!target) return false;
|
||||
if (target.closest?.('button, a, input, textarea, select, summary, [contenteditable="true"], [role="button"]')) return true;
|
||||
for (let node = target; node && node !== surface; node = node.parentElement) {
|
||||
if ((node.scrollWidth || 0) > (node.clientWidth || 0)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (inFlight || event.pointerType !== 'touch' || !isMobile() || getScrollY() > 0 || isBlocked() || startsOnExcludedTarget(event.target)) return;
|
||||
gesture = {id:event.pointerId, x:event.clientX, y:event.clientY, ready:false};
|
||||
surface.setPointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
const dx = event.clientX - gesture.x;
|
||||
const dy = event.clientY - gesture.y;
|
||||
if (dy <= 0 || Math.abs(dx) > dy) return resetGesture();
|
||||
event.preventDefault();
|
||||
gesture.ready = dy >= threshold;
|
||||
show(gesture.ready ? 'ready' : 'pulling', gesture.ready ? 'Release to refresh' : 'Pull to refresh');
|
||||
}
|
||||
|
||||
async function onPointerUp(event) {
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
const shouldRefresh = gesture.ready;
|
||||
resetGesture();
|
||||
surface.releasePointerCapture?.(event.pointerId);
|
||||
if (!shouldRefresh || inFlight) return;
|
||||
show('refreshing', 'Refreshing My Work…');
|
||||
inFlight = Promise.resolve().then(options.refresh);
|
||||
try {
|
||||
await inFlight;
|
||||
show('success', 'My Work is up to date');
|
||||
} catch (_) {
|
||||
show('error', 'Could not refresh · pull to retry');
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerCancel() {
|
||||
resetGesture();
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
surface.addEventListener('pointerdown', onPointerDown);
|
||||
surface.addEventListener('pointermove', onPointerMove);
|
||||
surface.addEventListener('pointerup', onPointerUp);
|
||||
surface.addEventListener('pointercancel', onPointerCancel);
|
||||
},
|
||||
stop() {
|
||||
surface.removeEventListener('pointerdown', onPointerDown);
|
||||
surface.removeEventListener('pointermove', onPointerMove);
|
||||
surface.removeEventListener('pointerup', onPointerUp);
|
||||
surface.removeEventListener('pointercancel', onPointerCancel);
|
||||
resetGesture();
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -9,10 +9,12 @@
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['delivery', 'Recover Delivery'],
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['filed', 'Review Filed'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
@@ -27,6 +29,7 @@
|
||||
}
|
||||
|
||||
function open(name) {
|
||||
if (name === 'delivery' && options.openDelivery) return options.openDelivery();
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
|
||||
@@ -14,14 +14,14 @@ function createMobileReviewDetailNavigation(options) {
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
function navigate(name, navigationOptions = {}) {
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
target.scrollIntoView({
|
||||
block:'start',
|
||||
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'feedback' && options.summaryComposer) {
|
||||
if (name === 'feedback' && options.summaryComposer && navigationOptions.focus !== false) {
|
||||
options.summaryComposer.focus({ preventScroll:true });
|
||||
}
|
||||
select(name);
|
||||
@@ -39,6 +39,7 @@ function createMobileReviewDetailNavigation(options) {
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
@@ -58,7 +59,11 @@ function createMobileReviewDetailNavigation(options) {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
if (visible) {
|
||||
const name = targetNames.get(visible.target);
|
||||
select(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
|
||||
}
|
||||
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
|
||||
},
|
||||
stop() {
|
||||
|
||||
@@ -5,16 +5,50 @@
|
||||
const checkpointKey = 'stackchain.mobile-start-day.v1';
|
||||
const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
|
||||
const reviewOrder = [
|
||||
['delivery', 'Delivery recovery'],
|
||||
['agenda', 'Agenda'],
|
||||
['attention', 'Attention'],
|
||||
['update', 'Updates'],
|
||||
['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');
|
||||
@@ -68,18 +102,22 @@
|
||||
|
||||
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 = phases.find(phase => phase.name === 'delivery')?.count || 0;
|
||||
const other = total - delivery;
|
||||
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
|
||||
const nextLabel = phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work');
|
||||
const nextLabel = next === 'delivery' ? 'Review Delivery' :
|
||||
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'));
|
||||
return {
|
||||
total,
|
||||
next,
|
||||
label: nextLabel,
|
||||
summary: total ? total + ' items before Today · ' + today + ' planned' :
|
||||
summary: delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
|
||||
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
|
||||
' · ' + today + ' planned' :
|
||||
total ? total + ' items before Today · ' + today + ' planned' :
|
||||
(today ? 'Review clear · ' + today + ' planned' : 'Review clear · Today is empty'),
|
||||
phases,
|
||||
};
|
||||
@@ -107,9 +145,9 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function reconcile({authoritative = false} = {}) {
|
||||
function reconcile({authoritative = false, authoritativePhases = []} = {}) {
|
||||
const saved = checkpoint();
|
||||
if (!authoritative || !saved?.phase) return false;
|
||||
if (!saved?.phase || (!authoritative && !authoritativePhases.includes(saved.phase))) return false;
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
if (count(counts[saved.phase]) > 0) return false;
|
||||
return completePhase(saved.phase);
|
||||
|
||||
@@ -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',
|
||||
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||
prepare:'Prepare', 'prepare-resume':'Resume prep',
|
||||
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
|
||||
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', queue
|
||||
? (mode === 'update' ? 'Resume ' : 'Open ') + text
|
||||
: mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today'));
|
||||
const actionLabel = mode === 'prepare' ? 'Prepare Today' :
|
||||
mode === 'prepare-resume' ? 'Resume preparation' :
|
||||
queue ? (mode === 'update' ? 'Resume ' : 'Open ') + text :
|
||||
mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today');
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', actionLabel);
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
@@ -91,9 +97,9 @@
|
||||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = 'today agenda attention update filed later draft'.split(' ');
|
||||
const names = 'today agenda delivery attention update filed later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const actionableNames = ['today', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||
const actionableNames = ['today', 'delivery', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
@@ -117,6 +123,7 @@
|
||||
? 'Queues: no active queues; no upcoming deadlines'
|
||||
: 'Queues: Today ' + normalized.today
|
||||
+ ', Agenda ' + normalized.agenda + ' due'
|
||||
+ ', Delivery ' + normalized.delivery
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Filed ' + normalized.filed
|
||||
|
||||
@@ -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() };
|
||||
});
|
||||
@@ -14,7 +14,7 @@ function createMobileUpdateDetailNavigation(options) {
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
function navigate(name, navigationOptions = {}) {
|
||||
if (name === 'activity') {
|
||||
if (options.jumpToNewActivity) options.jumpToNewActivity();
|
||||
select(name);
|
||||
@@ -27,7 +27,7 @@ function createMobileUpdateDetailNavigation(options) {
|
||||
block:'start',
|
||||
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'reply' && options.replyComposer) {
|
||||
if (name === 'reply' && options.replyComposer && navigationOptions.focus !== false) {
|
||||
options.replyComposer.focus({ preventScroll:true });
|
||||
}
|
||||
select(name);
|
||||
@@ -46,6 +46,7 @@ function createMobileUpdateDetailNavigation(options) {
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
@@ -65,7 +66,11 @@ function createMobileUpdateDetailNavigation(options) {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
if (visible) {
|
||||
const name = targetNames.get(visible.target);
|
||||
select(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
|
||||
}
|
||||
}, Array.from(targetNames.keys()).filter(Boolean));
|
||||
},
|
||||
stop() {
|
||||
|
||||
@@ -4,20 +4,26 @@
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
const preparation = options.getPreparationState?.();
|
||||
if (preparation?.active) return 'prepare-resume';
|
||||
if (preparation?.next && !['today', 'find'].includes(preparation.next)) return 'prepare';
|
||||
const recommended = options.queueLauncher && options.queueLauncher.recommend();
|
||||
if (recommended && recommended.name !== 'today') return recommended.name;
|
||||
if (recommended && !['today', 'find'].includes(recommended.name)) return recommended.name;
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
if (options.shouldActivate?.()) return 'activate';
|
||||
return 'find';
|
||||
}
|
||||
|
||||
function open() {
|
||||
const current = mode();
|
||||
if (current === 'continue') options.continueToday();
|
||||
else if (['prepare', 'prepare-resume'].includes(current)) options.prepareToday();
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else if (current === 'activate') options.openActivation();
|
||||
else if (current === 'find') options.findWork();
|
||||
else options.queueLauncher.open(current);
|
||||
return current;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.inspectStackchainPrivateDatabases = factory(root.indexedDB);
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function createPrivateDataInspector(indexedDB) {
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Private storage inventory failed.'));
|
||||
request.onblocked = () => reject(new Error('Private storage inventory was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function countDatabase(name) {
|
||||
const database = await requestResult(indexedDB.open(name));
|
||||
try {
|
||||
const storeNames = Array.from(database.objectStoreNames);
|
||||
if (!storeNames.length) return 0;
|
||||
const transaction = database.transaction(storeNames, 'readonly');
|
||||
const counts = await Promise.all(storeNames.map(storeName =>
|
||||
requestResult(transaction.objectStore(storeName).count())
|
||||
));
|
||||
return counts.reduce((total, count) => total + Number(count || 0), 0);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
return async function inspectPrivateDatabases(registeredNames) {
|
||||
if (!indexedDB?.databases) return { recordCount: 0, unavailable: true };
|
||||
try {
|
||||
const existing = new Set((await indexedDB.databases()).map(database => database.name));
|
||||
const counts = await Promise.all(
|
||||
registeredNames.filter(name => existing.has(name)).map(countDatabase)
|
||||
);
|
||||
return {
|
||||
recordCount: counts.reduce((total, count) => total + count, 0),
|
||||
unavailable: false,
|
||||
};
|
||||
} catch (_error) {
|
||||
return { recordCount: 0, unavailable: true };
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -55,9 +55,9 @@
|
||||
}
|
||||
|
||||
return async function clearPrivateDeviceData() {
|
||||
await stopWorkerOutbox();
|
||||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
for (const name of privateDatabases) await deletePrivateDatabase(name);
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
|
||||
@@ -2,9 +2,68 @@
|
||||
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,
|
||||
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 +75,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 +125,22 @@
|
||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||
await subscription?.unsubscribe?.();
|
||||
control.checked = false;
|
||||
if (testControl) testControl.hidden = true;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
configuration.subscribed = false;
|
||||
configuration.deadline_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 +159,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 +190,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 +209,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.';
|
||||
@@ -132,10 +230,30 @@
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function recoverPermission(intent = null) {
|
||||
if (!pendingIntent && (intent === 'updates' || intent === 'deadline')) pendingIntent = intent;
|
||||
if (!pendingIntent || notification.permission !== 'granted') return false;
|
||||
if (recoveryPromise) return recoveryPromise;
|
||||
recoveryPromise = (async () => {
|
||||
if (pendingIntent === 'deadline') {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
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);
|
||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
@@ -147,13 +265,12 @@
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_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.';
|
||||
renderDeliveryHealth();
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
renderDeadlineSnooze();
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
});
|
||||
@@ -5,7 +5,10 @@ function createReconnectOutboxes({
|
||||
flushAuthored,
|
||||
flushNotificationReads,
|
||||
}) {
|
||||
return async function reconnectOutboxes() {
|
||||
let activeReconnect = null;
|
||||
let followUpRequested = false;
|
||||
|
||||
async function reconnectOnce() {
|
||||
const snapshot = await refresh();
|
||||
const freshness = snapshot?.freshness?.sections?.context;
|
||||
const identityFresh = snapshot?.context && !snapshot.context.error &&
|
||||
@@ -19,6 +22,22 @@ function createReconnectOutboxes({
|
||||
flushNotificationReads(),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return function reconnectOutboxes() {
|
||||
if (activeReconnect) {
|
||||
followUpRequested = true;
|
||||
return activeReconnect;
|
||||
}
|
||||
activeReconnect = (async () => {
|
||||
let result = false;
|
||||
do {
|
||||
followUpRequested = false;
|
||||
result = await reconnectOnce();
|
||||
} while (followUpRequested);
|
||||
return result;
|
||||
})().finally(() => { activeReconnect = null; });
|
||||
return activeReconnect;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
+116
-6
@@ -1,7 +1,7 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.attachSecurityCenter = boundary => factory({ root, boundary });
|
||||
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) {
|
||||
else root.attachSecurityCenter = (boundary, options = {}) => factory({ root, boundary, ...options });
|
||||
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary, onOpen = () => {}, onClose = () => {} }) {
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
@@ -12,7 +12,99 @@
|
||||
const activityList = root.document.getElementById('security-activity-list');
|
||||
const activityStatus = root.document.getElementById('security-activity-status');
|
||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||||
const sectionButtons = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [
|
||||
section, root.document.getElementById(`security-section-${section}`),
|
||||
]));
|
||||
const sections = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [
|
||||
section, root.document.getElementById(`security-${section}-section`),
|
||||
]));
|
||||
let activityCursor = null;
|
||||
let backgroundInert = null;
|
||||
|
||||
const backgroundElements = ['header', 'main', '#mobile-task-dock']
|
||||
.map(selector => root.document.querySelector?.(selector))
|
||||
.filter(Boolean);
|
||||
const focusableControls = () => [...(devicesSheet?.querySelectorAll?.(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
|
||||
) || [])].filter(control => !control.hidden && !control.disabled);
|
||||
const containBackground = () => {
|
||||
if (!backgroundInert) {
|
||||
backgroundInert = new Map(backgroundElements.map(element => [element, element.inert]));
|
||||
}
|
||||
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
|
||||
};
|
||||
const releaseBackground = () => {
|
||||
if (!backgroundInert) return;
|
||||
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundInert = null;
|
||||
};
|
||||
const finishClose = () => {
|
||||
devicesSheet.hidden = true;
|
||||
releaseBackground();
|
||||
devicesButton?.focus();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const navigate = (section, { history = true } = {}) => {
|
||||
if (!sections[section]) section = 'activity';
|
||||
Object.entries(sectionButtons).forEach(([name, button]) => {
|
||||
if (!button) return;
|
||||
if (name === section) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
sections[section]?.scrollIntoView?.({ block: 'start' });
|
||||
if (history && root.history?.pushState) {
|
||||
root.history.pushState(
|
||||
{ ...(root.history.state || {}), stackchainSecuritySection: section },
|
||||
'',
|
||||
`${root.location.pathname}${root.location.search || ''}#security/${section}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Object.entries(sectionButtons).forEach(([section, button]) => {
|
||||
button?.addEventListener('click', () => navigate(section));
|
||||
});
|
||||
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
});
|
||||
const handleRouteChange = section => {
|
||||
if (section && !devicesSheet.hidden) {
|
||||
containBackground();
|
||||
navigate(section, { history: false });
|
||||
return;
|
||||
}
|
||||
if (!devicesSheet.hidden) {
|
||||
finishClose();
|
||||
}
|
||||
};
|
||||
root.addEventListener?.('popstate', event => {
|
||||
handleRouteChange(event.state?.stackchainSecuritySection);
|
||||
});
|
||||
root.addEventListener?.('hashchange', () => {
|
||||
handleRouteChange(root.history?.state?.stackchainSecuritySection);
|
||||
});
|
||||
root.document.addEventListener?.('keydown', event => {
|
||||
if (devicesSheet.hidden) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
else finishClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
@@ -39,8 +131,11 @@
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
if (device.current && boundary.requestSignOut) await boundary.requestSignOut(revoke);
|
||||
else if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) {
|
||||
await Promise.all([renderDevices(), renderSecurityActivity()]);
|
||||
}
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
@@ -82,6 +177,7 @@
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
await renderSecurityActivity();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
@@ -122,7 +218,11 @@
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
const action = root.document.createElement('button');
|
||||
action.type = 'button';
|
||||
action.textContent = 'Review devices';
|
||||
action.addEventListener('click', () => navigate('devices'));
|
||||
row.append(title, details, action);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
@@ -150,6 +250,13 @@
|
||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
row.append(title, details);
|
||||
if (event.kind === 'passkey_counter_anomaly') {
|
||||
const action = root.document.createElement('button');
|
||||
action.type = 'button';
|
||||
action.textContent = 'Review passkeys';
|
||||
action.addEventListener('click', () => navigate('passkeys'));
|
||||
row.append(action);
|
||||
}
|
||||
activityList.append(row);
|
||||
});
|
||||
activityCursor = page.next_cursor;
|
||||
@@ -167,8 +274,11 @@
|
||||
|
||||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
onOpen();
|
||||
containBackground();
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
|
||||
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
|
||||
};
|
||||
devicesButton?.addEventListener('click', open);
|
||||
@@ -186,5 +296,5 @@
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
return { open, navigate, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
});
|
||||
+130
-7
@@ -1,7 +1,7 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v114';
|
||||
const CACHE = 'stackchain-dashboard-shell-v123';
|
||||
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;
|
||||
@@ -15,7 +15,9 @@ 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',
|
||||
BASE + 'static/security-center.js',
|
||||
BASE + 'static/markdown.js',
|
||||
@@ -44,13 +46,21 @@ 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',
|
||||
BASE + 'static/comment-next.js',
|
||||
@@ -83,6 +93,7 @@ const SHELL = [
|
||||
BASE + 'static/mobile-search-preview-nav.js',
|
||||
BASE + 'static/mobile-plan-today-nav.js',
|
||||
BASE + 'static/mobile-find-work-nav.js',
|
||||
BASE + 'static/mobile-pull-refresh.js',
|
||||
BASE + 'static/checklist-conflict.js',
|
||||
BASE + 'static/voice-transcript-store.js',
|
||||
BASE + 'static/voice-issue-capture.js',
|
||||
@@ -95,13 +106,17 @@ 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',
|
||||
BASE + 'static/mobile-start-day.js',
|
||||
BASE + 'static/update-triage-session.js',
|
||||
BASE + 'static/update-review-handoff.js',
|
||||
@@ -112,8 +127,12 @@ const SHELL = [
|
||||
BASE + 'static/update-decision-transaction.js',
|
||||
BASE + 'static/agenda-session-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-insights.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/private-data-inventory.js',
|
||||
BASE + 'static/private-device-data.js',
|
||||
BASE + 'static/device-storage.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
@@ -379,6 +398,17 @@ async function warmOptionalFeature(cache, staleCacheNames, asset) {
|
||||
await cache.add(asset);
|
||||
}
|
||||
|
||||
async function cachedOptionalFeature(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE);
|
||||
await cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
@@ -398,6 +428,39 @@ 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-resume-outbox') event.waitUntil((async () => {
|
||||
await issueSync.resume();
|
||||
@@ -410,11 +473,20 @@ self.addEventListener('message', event => {
|
||||
try {
|
||||
await issueSync.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(updateTodayLockScreen(
|
||||
event.data.active === true,
|
||||
event.data.running === true,
|
||||
String(event.data.actionToken || ''),
|
||||
Number(event.data.breakDeadlineAt || 0)
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
@@ -442,7 +514,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},
|
||||
}
|
||||
@@ -507,9 +579,44 @@ 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 query = '?today_timer_action=' + action +
|
||||
(actionToken ? '&today_action_token=' + encodeURIComponent(actionToken) : '');
|
||||
const target = new URL(BASE + query + route, self.location.origin).href;
|
||||
return self.clients.openWindow(target);
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -525,10 +632,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') {
|
||||
@@ -631,6 +754,6 @@ self.addEventListener('fetch', event => {
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
event.respondWith(cachedOptionalFeature(request));
|
||||
}
|
||||
});
|
||||
+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,6 +1,25 @@
|
||||
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,
|
||||
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;
|
||||
}
|
||||
@@ -10,8 +29,51 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
|
||||
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,154 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createTodayHandoff = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createTodayHandoff(options) {
|
||||
const prefix = 'stackchain.today-handoff.v1.';
|
||||
const storage = options.storage || null;
|
||||
const selected = new Set();
|
||||
const excluded = new Set();
|
||||
|
||||
function key() {
|
||||
const login = String(options.getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const unique = [...new Set(ids.filter(id => typeof id === 'string' && id))];
|
||||
try {
|
||||
if (unique.length) storage.setItem(storageKey, JSON.stringify(unique));
|
||||
else storage.removeItem(storageKey);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function capture(ids) {
|
||||
const merged = [...pending()];
|
||||
(ids || []).forEach(id => {
|
||||
if (typeof id === 'string' && id && !merged.includes(id)) merged.push(id);
|
||||
if (typeof id === 'string' && id) {
|
||||
selected.add(id);
|
||||
excluded.delete(id);
|
||||
}
|
||||
});
|
||||
return write(merged);
|
||||
}
|
||||
|
||||
function review(items) {
|
||||
const byIdentity = new Map((items || options.items?.() || []).map(item => [options.identity(item), item]));
|
||||
return pending().flatMap(identity => {
|
||||
const item = byIdentity.get(identity);
|
||||
if (!excluded.has(identity)) selected.add(identity);
|
||||
return item ? [{ identity, item, selected:selected.has(identity) }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function choose(identity, include) {
|
||||
if (!pending().includes(identity)) return false;
|
||||
if (include) {
|
||||
selected.add(identity);
|
||||
excluded.delete(identity);
|
||||
} else {
|
||||
selected.delete(identity);
|
||||
excluded.add(identity);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function render(items, list, escapeHtml) {
|
||||
const rows = review(items);
|
||||
list.innerHTML = rows.map(row => {
|
||||
const title = String(row.item.title || row.identity).slice(0, 180);
|
||||
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
|
||||
return '<div class="today-handoff-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||
'data-today-handoff="' + escapeHtml(row.identity) + '"' + (row.selected ? ' checked' : '') +
|
||||
'> Today</label></div>';
|
||||
}).join('');
|
||||
list.querySelectorAll('[data-today-handoff]').forEach(input => input.addEventListener('change', () =>
|
||||
choose(input.dataset.todayHandoff, input.checked)));
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function commit(items) {
|
||||
const rows = review(items);
|
||||
const remaining = pending();
|
||||
let scheduled = 0;
|
||||
let blocked = 0;
|
||||
for (const row of rows) {
|
||||
if (!selected.has(row.identity)) continue;
|
||||
if (options.todayWork.contains(row.item)) {
|
||||
const index = remaining.indexOf(row.identity);
|
||||
if (index >= 0) remaining.splice(index, 1);
|
||||
selected.delete(row.identity);
|
||||
continue;
|
||||
}
|
||||
const added = options.todayWork.add(row.item);
|
||||
if (added !== 'added') {
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
if (!options.todaySync.enqueue('add', row.identity)) {
|
||||
options.todayWork.remove?.(row.item);
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
const index = remaining.indexOf(row.identity);
|
||||
if (index >= 0) remaining.splice(index, 1);
|
||||
selected.delete(row.identity);
|
||||
scheduled += 1;
|
||||
}
|
||||
write(remaining);
|
||||
if (scheduled) await options.todaySync.flush();
|
||||
return { scheduled, blocked, remaining:remaining.length };
|
||||
}
|
||||
|
||||
function mount({ qs, escapeHtml, onComplete = () => {} }) {
|
||||
const list = qs('#today-handoff-items');
|
||||
let prompted = false;
|
||||
function open() {
|
||||
if (prompted) return false;
|
||||
const rows = render(null, list, escapeHtml);
|
||||
if (!rows.length) return false;
|
||||
qs('#today-handoff-status').textContent = rows.length +
|
||||
(rows.length === 1 ? ' commitment is ready to review.' : ' commitments are ready to review.');
|
||||
const dialog = qs('#today-handoff-dialog');
|
||||
if (!dialog.open) dialog.showModal();
|
||||
prompted = true;
|
||||
requestAnimationFrame(() => (list.querySelector('input') || qs('#confirm-today-handoff')).focus());
|
||||
return true;
|
||||
}
|
||||
qs('#close-today-handoff').addEventListener('click', () => qs('#today-handoff-dialog').close());
|
||||
qs('#confirm-today-handoff').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
qs('#today-handoff-status').textContent = 'Saving Today’s plan…';
|
||||
try {
|
||||
const result = await commit();
|
||||
if (result.blocked) qs('#today-handoff-status').textContent =
|
||||
'Today is full. ' + result.remaining + ' commitment remains safe in My Work.';
|
||||
else qs('#today-handoff-dialog').close();
|
||||
onComplete(result);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, reset:() => { prompted = false; } };
|
||||
}
|
||||
|
||||
return { capture, choose, commit, mount, pending, render, review };
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
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() {
|
||||
let url;
|
||||
try { url = new URL(locationRef?.href || ''); }
|
||||
catch (_error) { return false; }
|
||||
const action = url.searchParams.get('today_timer_action');
|
||||
if (!['pause', 'resume', 'complete'].includes(action)) return false;
|
||||
const token = url.searchParams.get('today_action_token') || '';
|
||||
url.searchParams.delete('today_timer_action');
|
||||
url.searchParams.delete('today_action_token');
|
||||
historyRef?.replaceState?.(null, '', url.pathname + url.search + url.hash);
|
||||
return await consumeAction(action, token);
|
||||
},
|
||||
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;
|
||||
}
|
||||
@@ -217,7 +217,7 @@ async function saveTodayRecapTime(payload) {
|
||||
}
|
||||
|
||||
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
|
||||
describeWork = () => null, adjustPlan = () => {} }) {
|
||||
describeWork = () => null, openWrapUp = () => {} }) {
|
||||
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
|
||||
const render = () => {
|
||||
const draft = recap.snapshot();
|
||||
@@ -283,6 +283,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#open-today-recaps').focus();
|
||||
};
|
||||
const saveDraft = async button => {
|
||||
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Saving recap…';
|
||||
try {
|
||||
@@ -290,7 +291,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(handoff.actual_minutes);
|
||||
openWrapUp(handoff.actual_minutes, workedItems);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
||||
} finally { button.disabled = false; }
|
||||
@@ -303,6 +304,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
return;
|
||||
}
|
||||
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
|
||||
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Logging selected time…';
|
||||
try {
|
||||
@@ -310,7 +312,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(actualMinutes);
|
||||
openWrapUp(actualMinutes, workedItems);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
|
||||
render();
|
||||
@@ -345,8 +347,8 @@ function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
|
||||
}
|
||||
|
||||
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
|
||||
describeWork, adjustPlan) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
|
||||
describeWork, openWrapUp) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, openWrapUp };
|
||||
const recap = createTodayRecap({
|
||||
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
|
||||
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
function createTodaySessionSync({
|
||||
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
|
||||
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;
|
||||
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, 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);
|
||||
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)),
|
||||
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?.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));
|
||||
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,
|
||||
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;
|
||||
}
|
||||
+38
-9
@@ -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() {
|
||||
@@ -108,10 +110,28 @@ 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 operation = record?.operation;
|
||||
const valid = operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) &&
|
||||
typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at));
|
||||
if (valid) records.push({ ...record, recordKey });
|
||||
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 => {
|
||||
@@ -173,7 +193,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() + operations.length }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('today');
|
||||
@@ -272,14 +292,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;
|
||||
}
|
||||
}
|
||||
|
||||
+370
-9
@@ -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;
|
||||
@@ -54,16 +79,43 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
const state = read();
|
||||
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, now() - Number(entry.started_at ?? now())) : 0),
|
||||
running:Boolean(entry.running),
|
||||
};
|
||||
const timedBreak = validBreak(state);
|
||||
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
adopt(identity, elapsedMs, running) {
|
||||
if (!key() || typeof identity !== 'string' || !identity ||
|
||||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
|
||||
const state = read();
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
state.entries[identity] = {
|
||||
elapsed_ms:Math.floor(Number(elapsedMs)),
|
||||
started_at:running ? now() : null,
|
||||
running:Boolean(running),
|
||||
};
|
||||
state.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 +125,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 +144,32 @@ 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;
|
||||
},
|
||||
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 +178,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 +213,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];
|
||||
@@ -200,9 +391,28 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
};
|
||||
}
|
||||
|
||||
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 +458,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 +513,45 @@ 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(); },
|
||||
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 +603,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;
|
||||
}
|
||||
+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;
|
||||
@@ -0,0 +1,129 @@
|
||||
function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
let items = [];
|
||||
const selected = new Set();
|
||||
|
||||
function open(candidates) {
|
||||
const current = new Set(todayWork.read());
|
||||
const byIdentity = new Map((candidates || []).map(item => [todayWork.identity(item), item]));
|
||||
items = todayWork.read().flatMap(identity =>
|
||||
current.has(identity) && byIdentity.has(identity) ? [byIdentity.get(identity)] : []
|
||||
);
|
||||
selected.clear();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function choose(identity, schedule) {
|
||||
if (!items.some(item => todayWork.identity(item) === identity)) return false;
|
||||
if (schedule) selected.add(identity);
|
||||
else selected.delete(identity);
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return items.map(item => ({
|
||||
item,
|
||||
identity:todayWork.identity(item),
|
||||
schedule:selected.has(todayWork.identity(item)),
|
||||
}));
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
const wake = laterWork.presetUntil('tomorrow');
|
||||
let scheduled = 0;
|
||||
for (const item of items) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (!selected.has(identity) || !todayWork.contains(item)) continue;
|
||||
const deferred = laterWork.defer(item, wake, {handoff:'today'});
|
||||
if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.');
|
||||
if (!todaySync.enqueue('remove', identity)) {
|
||||
laterWork.restore?.(item);
|
||||
throw new Error('Today sync could not be queued. Your Today plan is unchanged.');
|
||||
}
|
||||
if (todayWork.contains(item) && !todayWork.remove(item)) {
|
||||
throw new Error('Today could not be updated. Retry wrap-up.');
|
||||
}
|
||||
scheduled += 1;
|
||||
}
|
||||
await todaySync.flush();
|
||||
return { scheduled, left:items.length - scheduled, wake_at:wake.toISOString() };
|
||||
}
|
||||
|
||||
return { open, choose, snapshot, finish };
|
||||
}
|
||||
|
||||
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
|
||||
let actualMinutes = {};
|
||||
let workedItems = [];
|
||||
|
||||
function close() {
|
||||
qs('#today-wrap-up-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
onClose();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = controller.snapshot();
|
||||
qs('#today-wrap-up-items').innerHTML = rows.map(row => {
|
||||
const title = String(row.item.title || row.identity).slice(0, 180);
|
||||
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
|
||||
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
|
||||
'> Tomorrow 09:00</label></div>';
|
||||
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
|
||||
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
|
||||
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
|
||||
});
|
||||
}
|
||||
|
||||
function open(items, recapActualMinutes = {}, recapWorkedItems = []) {
|
||||
actualMinutes = { ...recapActualMinutes };
|
||||
workedItems = recapWorkedItems.map(item => ({...item}));
|
||||
controller.open(items);
|
||||
qs('#today-wrap-up-status').textContent = '';
|
||||
render();
|
||||
qs('#today-wrap-up-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
requestAnimationFrame(() => (qs('#today-wrap-up-items input') || qs('#finish-today-wrap-up')).focus());
|
||||
}
|
||||
|
||||
async function finish(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-wrap-up-status').textContent = 'Saving tomorrow’s plan…';
|
||||
try {
|
||||
const tomorrowItems = controller.snapshot().filter(row => row.schedule).map(row => ({
|
||||
identity:row.identity,
|
||||
title:String(row.item.title || row.identity).slice(0, 180),
|
||||
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
|
||||
}));
|
||||
const result = await controller.finish();
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
|
||||
close();
|
||||
onComplete(result, actualMinutes, workedItems, tomorrowItems);
|
||||
} catch (error) {
|
||||
qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
qs('#close-today-wrap-up').addEventListener('click', close);
|
||||
qs('#finish-today-wrap-up').addEventListener('click', event => finish(event.currentTarget));
|
||||
}
|
||||
|
||||
return { open, close, finish, render, bind };
|
||||
}
|
||||
|
||||
function setupTodayWrapUp(options) {
|
||||
const controller = createTodayWrapUp(options);
|
||||
const view = createTodayWrapUpView({ ...options, controller });
|
||||
view.bind();
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayWrapUp.createView = createTodayWrapUpView;
|
||||
createTodayWrapUp.setup = setupTodayWrapUp;
|
||||
module.exports = createTodayWrapUp;
|
||||
}
|
||||
@@ -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};
|
||||
+43
-9
@@ -7,6 +7,13 @@
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
|
||||
const sections = {
|
||||
issue: ['overview', 'conversation', 'reply', 'actions'],
|
||||
filed: ['overview', 'conversation', 'reply', 'actions'],
|
||||
pull: ['overview', 'conversation', 'reply', 'review'],
|
||||
review: ['overview', 'files', 'feedback', 'history'],
|
||||
update: ['activity', 'conversation', 'context', 'reply'],
|
||||
};
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
@@ -23,27 +30,37 @@
|
||||
if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
|
||||
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
|
||||
}
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
if (parts[2] === 'update' && [4, 5].includes(parts.length)) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
const section = parts[4];
|
||||
if (!notificationId || (section && !sections.update.includes(section))) return null;
|
||||
return { kind: 'update', notification_id: notificationId, ...(section ? { section } : {}) };
|
||||
}
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || ![6, 7].includes(parts.length)) return null;
|
||||
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
|
||||
const number = positiveInteger(parts[5]);
|
||||
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
|
||||
const section = parts[6];
|
||||
if (!number || (section && !sections[parts[2]].includes(section))) return null;
|
||||
return {
|
||||
kind: parts[2], repository: parts[3] + '/' + parts[4], number,
|
||||
...(section ? { section } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serialize(item) {
|
||||
if (item?.kind === 'update') {
|
||||
const notificationId = positiveInteger(item.notification_id);
|
||||
return notificationId ? '#/my-work/update/' + notificationId : '';
|
||||
if (!notificationId || (item.section && !sections.update.includes(item.section))) return '';
|
||||
return '#/my-work/update/' + notificationId + (item.section ? '/' + item.section : '');
|
||||
}
|
||||
const kind = item?.is_filed && !item?.is_assigned ? 'filed' : item?.kind;
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(kind)) return '';
|
||||
const repository = String(item.repository || '').split('/');
|
||||
const number = positiveInteger(item.number);
|
||||
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
|
||||
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number;
|
||||
if (item.section && !sections[kind].includes(item.section)) return '';
|
||||
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number +
|
||||
(item.section ? '/' + item.section : '');
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
@@ -59,7 +76,7 @@
|
||||
|
||||
function createController({
|
||||
location, history, eventTarget, onOpen, onClose, onInvalid,
|
||||
onQueue = function () {},
|
||||
onQueue = function () {}, onSection = function () {},
|
||||
resolve, onResolving = function () {}, onError = function () {},
|
||||
}) {
|
||||
let items = [];
|
||||
@@ -86,7 +103,7 @@
|
||||
return;
|
||||
}
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) });
|
||||
}).catch(error => {
|
||||
if (request !== resolution || String(location.hash || '') !== fragment) return;
|
||||
resolving = '';
|
||||
@@ -128,10 +145,16 @@
|
||||
return;
|
||||
}
|
||||
if (active === fragment) return;
|
||||
const activeRoute = parse(active);
|
||||
if (activeRoute && sameRoute(item, activeRoute)) {
|
||||
active = fragment;
|
||||
onSection(route.section || sections[route.kind][0], { restore: true });
|
||||
return;
|
||||
}
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -156,6 +179,17 @@
|
||||
onOpen(item);
|
||||
return true;
|
||||
},
|
||||
section(name, options = {}) {
|
||||
const route = parse(location.hash);
|
||||
if (!route || route.kind === 'queue' || !sections[route.kind].includes(name)) return false;
|
||||
if ((route.section || sections[route.kind][0]) === name) return true;
|
||||
const fragment = serialize({ ...route, section: name });
|
||||
const method = options.replace ? 'replaceState' : 'pushState';
|
||||
history[method]({ workRoute: fragment }, '', fragment);
|
||||
active = fragment;
|
||||
onSection(name, { restore: false });
|
||||
return true;
|
||||
},
|
||||
queue(filter) {
|
||||
if (!queueFilters.includes(filter)) return false;
|
||||
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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 retryButton = document.querySelector('#retry-workspace');
|
||||
const url = document.querySelector(
|
||||
'meta[name="stackchain-feature-today-timer"]'
|
||||
)?.content || '';
|
||||
const urls = {'today-timer':url};
|
||||
const loader = createLoader({document, urls});
|
||||
|
||||
let attempts = 0;
|
||||
const load = () => {
|
||||
if (attempts++) urls['today-timer'] = url + '?retry=' + attempts;
|
||||
return loader.load('today-timer');
|
||||
};
|
||||
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;
|
||||
};
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
window?.addEventListener('online', recover);
|
||||
retryButton?.addEventListener('click', recover);
|
||||
});
|
||||
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await new Promise(resolve => schedule(resolve));
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await waitForRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -0,0 +1 @@
|
||||
pip-audit==2.10.1
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
fastapi==0.133.1
|
||||
cryptography==50.0.0
|
||||
httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
Pillow==12.3.0
|
||||
python-multipart==0.0.22
|
||||
python-multipart==0.0.32
|
||||
pytest==9.1.1
|
||||
pywebpush==2.1.2
|
||||
rjsmin==1.2.5
|
||||
|
||||
+79
-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,92 @@ 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_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 +113,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 +158,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
+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_key,
|
||||
)
|
||||
|
||||
|
||||
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_key(),
|
||||
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()
|
||||
|
||||
+20
-15
@@ -21,21 +21,25 @@ COMMONJS_BROWSER_BRANCH = re.compile(
|
||||
)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||
"comment-actions": ("static/comment-actions.js",),
|
||||
"issue-capture": (
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.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",),
|
||||
"today-timer": (
|
||||
"static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js",
|
||||
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.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-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/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/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/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
"static/search-batch-plan.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/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",
|
||||
),
|
||||
@@ -111,10 +115,15 @@ 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="{feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{core.runtime_name}"></script>\n</body>',
|
||||
)
|
||||
|
||||
@@ -122,15 +131,11 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
for source in sources:
|
||||
if source != WORKER_RUNTIME_SOURCE:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
eager_feature = feature_bundles["today-timer"]
|
||||
optional_features = {
|
||||
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
|
||||
}
|
||||
optional_features = feature_bundles
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
" BASE + 'static/dashboard.css',\n"
|
||||
+ f" BASE + '{core.runtime_name}',\n"
|
||||
+ f" BASE + '{eager_feature.runtime_name}',\n",
|
||||
+ f" BASE + '{core.runtime_name}',\n",
|
||||
)
|
||||
worker = worker.replace(
|
||||
"const OPTIONAL_FEATURES = [\n",
|
||||
|
||||
+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_key
|
||||
|
||||
|
||||
@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_key(),
|
||||
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():
|
||||
|
||||
+104
-25
@@ -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_key
|
||||
|
||||
|
||||
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_key(),
|
||||
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:"):
|
||||
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:"):
|
||||
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,
|
||||
@@ -131,6 +189,7 @@ class LaterStore:
|
||||
item_id: str,
|
||||
*,
|
||||
wake_at: str | None = None,
|
||||
handoff: str | None = None,
|
||||
base_revision: int | None = None,
|
||||
) -> dict:
|
||||
result = self.apply_batch(login, [{
|
||||
@@ -138,6 +197,7 @@ class LaterStore:
|
||||
"action": action,
|
||||
"item_id": item_id,
|
||||
"wake_at": wake_at,
|
||||
"handoff": handoff,
|
||||
"base_revision": base_revision,
|
||||
}])
|
||||
return {"revision": result["revision"], "records": result["records"]}
|
||||
@@ -150,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] = []
|
||||
@@ -159,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:
|
||||
@@ -181,6 +245,9 @@ class LaterStore:
|
||||
wake_at = operation.get("wake_at")
|
||||
if action == "defer":
|
||||
wake_at = self._validate_wake_at(wake_at)
|
||||
handoff = operation.get("handoff")
|
||||
if handoff not in {None, "today"}:
|
||||
raise ValueError("unsupported Later handoff")
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
@@ -204,29 +271,41 @@ class LaterStore:
|
||||
|
||||
before = records.get(item_id)
|
||||
if action == "defer":
|
||||
records[item_id] = wake_at
|
||||
changed = before != wake_at
|
||||
record = {"wake_at": wake_at, "handoff": handoff} if handoff else wake_at
|
||||
records[item_id] = record
|
||||
changed = before != record
|
||||
else:
|
||||
changed = item_id in records
|
||||
records.pop(item_id, None)
|
||||
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_key,
|
||||
)
|
||||
|
||||
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_key(),
|
||||
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=(",", ":")),
|
||||
),
|
||||
)
|
||||
|
||||
+235
-37
@@ -3,6 +3,7 @@ import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
@@ -55,16 +56,24 @@ from src.push_notifications import (
|
||||
PushConfiguration,
|
||||
dispatch_deadline_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, TodaySessionConflict, TodayStore
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
from src.views import FRONTEND_BUILD, router as frontend_router
|
||||
|
||||
|
||||
@@ -347,8 +356,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",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -588,6 +605,21 @@ class TodayRecap(BaseModel):
|
||||
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
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)
|
||||
running: bool
|
||||
break_deadline_at: int | None = Field(default=None, ge=0, le=10_000_000_000_000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_break_is_paused(self):
|
||||
if self.running and self.break_deadline_at is not None:
|
||||
raise ValueError("break must remain paused")
|
||||
return self
|
||||
|
||||
|
||||
class TodayRecapTimeLog(TodayRecap):
|
||||
log_identities: list[str] = Field(min_length=1, max_length=20)
|
||||
|
||||
@@ -610,6 +642,7 @@ class LaterOperation(BaseModel):
|
||||
action: Literal["defer", "restore"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
wake_at: str | None = Field(default=None, max_length=100)
|
||||
handoff: Literal["today"] | None = None
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
@@ -1321,7 +1354,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/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 (
|
||||
@@ -2126,7 +2159,10 @@ 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()
|
||||
)
|
||||
delivery_health = await asyncio.to_thread(
|
||||
_push_subscription_store.delivery_health, device_id
|
||||
)
|
||||
return {
|
||||
"available": configuration.enabled,
|
||||
@@ -2136,6 +2172,8 @@ async def push_status(request: Request):
|
||||
"timezone": preferences["timezone"],
|
||||
"reminder_hour": preferences["reminder_hour"],
|
||||
"reminder_days": preferences["reminder_days"],
|
||||
"snoozed_until": preferences["snoozed_until"],
|
||||
"delivery_health": delivery_health,
|
||||
}
|
||||
|
||||
|
||||
@@ -2189,6 +2227,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(
|
||||
@@ -2214,6 +2298,35 @@ async def update_deadline_reminders(payload: DeadlineReminderPayload, request: R
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
@@ -2268,8 +2381,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", "")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -2293,7 +2420,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",
|
||||
@@ -2314,7 +2441,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",
|
||||
@@ -2327,7 +2454,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",
|
||||
@@ -2357,7 +2484,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",
|
||||
@@ -2370,7 +2497,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",
|
||||
@@ -2400,7 +2527,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",
|
||||
@@ -2413,7 +2540,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",
|
||||
@@ -2447,7 +2574,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",
|
||||
@@ -2455,12 +2582,43 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
|
||||
)
|
||||
|
||||
|
||||
@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",
|
||||
@@ -2482,7 +2640,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",
|
||||
@@ -2534,7 +2692,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)
|
||||
@@ -2603,7 +2761,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",
|
||||
@@ -2628,11 +2786,12 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
||||
payload.action,
|
||||
payload.item_id,
|
||||
wake_at=payload.wake_at,
|
||||
handoff=payload.handoff,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
except ValueError as error:
|
||||
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",
|
||||
@@ -3780,7 +3939,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
|
||||
|
||||
@@ -3960,7 +4119,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,
|
||||
@@ -4255,7 +4414,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,
|
||||
@@ -5877,31 +6036,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,
|
||||
@@ -5917,6 +6105,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(
|
||||
{
|
||||
@@ -5931,6 +6123,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
|
||||
+135
-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,
|
||||
@@ -160,14 +216,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,
|
||||
@@ -207,17 +273,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 +304,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(
|
||||
@@ -298,10 +382,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 +409,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 +422,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 +458,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 +494,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
|
||||
)
|
||||
|
||||
+251
-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,87 @@ class DeadlineReminderDevice:
|
||||
reminder_hour: int
|
||||
reminder_days: int
|
||||
delivered_local_day: str | None
|
||||
snoozed_until: float | 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 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 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 +146,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 +191,18 @@ 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
|
||||
);
|
||||
@@ -140,6 +249,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 +313,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 +339,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,38 +417,70 @@ 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),
|
||||
)
|
||||
@@ -313,13 +547,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_key
|
||||
|
||||
|
||||
_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_key(),
|
||||
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_key,
|
||||
)
|
||||
|
||||
|
||||
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_key(),
|
||||
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,89 @@
|
||||
"""Authenticated envelopes for retained private dashboard state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
|
||||
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 the 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:
|
||||
return decode_private_state_encryption_key(
|
||||
os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", "")
|
||||
)
|
||||
|
||||
|
||||
class PrivateStateCipher:
|
||||
"""Seal JSON values with store-specific authenticated context."""
|
||||
|
||||
def __init__(self, key: bytes, *, store: str):
|
||||
if not isinstance(key, bytes) or len(key) != 32:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption requires exactly 32 key bytes"
|
||||
)
|
||||
if not store or "\0" in store:
|
||||
raise ValueError("private state store identity is invalid")
|
||||
self._cipher = AESGCM(key)
|
||||
self._store = store
|
||||
|
||||
def _aad(self, binding: str) -> bytes:
|
||||
return f"stackchain:private-state:v1\0{self._store}\0{binding}".encode()
|
||||
|
||||
def seal(self, value: object, *, binding: str = "singleton") -> str:
|
||||
plaintext = json.dumps(value, separators=(",", ":")).encode()
|
||||
nonce = os.urandom(12)
|
||||
sealed = nonce + self._cipher.encrypt(nonce, plaintext, self._aad(binding))
|
||||
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
|
||||
|
||||
def open(self, payload: str, *, binding: str = "singleton") -> tuple[object, bool]:
|
||||
"""Return the decoded value and whether plaintext migration is required."""
|
||||
if not isinstance(payload, str):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
if not payload.startswith("v1:"):
|
||||
try:
|
||||
return json.loads(payload), True
|
||||
except (TypeError, json.JSONDecodeError) as error:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state could not be decrypted"
|
||||
) from error
|
||||
try:
|
||||
sealed = base64.b64decode(payload[3:], altchars=b"-_", validate=True)
|
||||
if len(sealed) < 28:
|
||||
raise ValueError("encrypted payload is too short")
|
||||
plaintext = self._cipher.decrypt(
|
||||
sealed[:12], sealed[12:], self._aad(binding)
|
||||
)
|
||||
return json.loads(plaintext), False
|
||||
except (
|
||||
binascii.Error,
|
||||
InvalidTag,
|
||||
UnicodeDecodeError,
|
||||
ValueError,
|
||||
json.JSONDecodeError,
|
||||
) as error:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state could not be decrypted"
|
||||
) from error
|
||||
+319
-59
@@ -7,12 +7,21 @@ from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
|
||||
|
||||
|
||||
class TodayPlanFull(ValueError):
|
||||
"""Raised when an add would exceed the bounded Today plan."""
|
||||
|
||||
|
||||
class TodaySessionConflict(ValueError):
|
||||
"""Raised when a device updates an obsolete active-session revision."""
|
||||
|
||||
def __init__(self, session: dict):
|
||||
super().__init__("active Today session changed on another device")
|
||||
self.session = session
|
||||
|
||||
|
||||
class TodayStore:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -23,6 +32,7 @@ class TodayStore:
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
recap_limit: int = 100,
|
||||
encryption_key: bytes | None = None,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
@@ -32,11 +42,15 @@ class TodayStore:
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.recap_limit = recap_limit
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
store="today",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
connection = connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 3:
|
||||
connection.close()
|
||||
return
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
@@ -106,7 +120,24 @@ class TodayStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute("PRAGMA user_version = 1")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_sessions (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
identity TEXT NOT NULL,
|
||||
elapsed_ms INTEGER NOT NULL,
|
||||
running INTEGER NOT NULL,
|
||||
break_deadline_at INTEGER,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
session_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_sessions)")}
|
||||
if "break_deadline_at" not in session_columns:
|
||||
connection.execute("ALTER TABLE today_sessions ADD COLUMN break_deadline_at INTEGER")
|
||||
connection.execute("PRAGMA user_version = 3")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
@@ -137,31 +168,139 @@ class TodayStore:
|
||||
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, "ids": [], "capacity_minutes": None, "estimates": {}}
|
||||
ids = json.loads(row[1])
|
||||
estimates = json.loads(row[3] or "{}")
|
||||
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}, False
|
||||
if row[1].startswith("v1:"):
|
||||
payload, legacy = self._cipher.open(row[1], binding=f"plan:{login}")
|
||||
if not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
ids = payload.get("ids")
|
||||
estimates = payload.get("estimates")
|
||||
capacity_minutes = payload.get("capacity_minutes")
|
||||
plan_date = payload.get("plan_date")
|
||||
timezone = payload.get("timezone")
|
||||
else:
|
||||
legacy = True
|
||||
ids = json.loads(row[1])
|
||||
estimates = json.loads(row[3] or "{}")
|
||||
capacity_minutes = row[2]
|
||||
plan_date = row[4] if len(row) > 4 else None
|
||||
timezone = row[5] if len(row) > 5 else None
|
||||
if not isinstance(ids, list) or not isinstance(estimates, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
snapshot = {
|
||||
"revision": int(row[0]),
|
||||
"ids": ids,
|
||||
"capacity_minutes": row[2],
|
||||
"capacity_minutes": capacity_minutes,
|
||||
"estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
|
||||
}
|
||||
if len(row) > 4 and row[4]:
|
||||
snapshot["plan_date"] = row[4]
|
||||
snapshot["timezone"] = row[5]
|
||||
return snapshot
|
||||
if plan_date:
|
||||
snapshot["plan_date"] = plan_date
|
||||
snapshot["timezone"] = timezone
|
||||
return snapshot, legacy
|
||||
|
||||
def _sealed_plan(self, login: str, snapshot: dict) -> str:
|
||||
return self._cipher.seal({
|
||||
"ids": snapshot["ids"],
|
||||
"capacity_minutes": snapshot["capacity_minutes"],
|
||||
"estimates": snapshot["estimates"],
|
||||
"plan_date": snapshot.get("plan_date"),
|
||||
"timezone": snapshot.get("timezone"),
|
||||
}, binding=f"plan:{login}")
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
(login,),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
snapshot, legacy = self._snapshot(row, login)
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET ids = ?, capacity_minutes = NULL, estimates = '{}', "
|
||||
"plan_date = NULL, timezone = NULL WHERE login = ? AND ids = ?",
|
||||
(self._sealed_plan(login, snapshot), login, row[1]),
|
||||
)
|
||||
return snapshot
|
||||
|
||||
@staticmethod
|
||||
def _empty_session() -> dict:
|
||||
return {
|
||||
"revision": 0, "device_id": "", "identity": "",
|
||||
"elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
|
||||
}
|
||||
|
||||
def _session_snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return self._empty_session(), False
|
||||
if row[1].startswith("v1:"):
|
||||
payload, legacy = self._cipher.open(row[1], binding=f"session:{login}")
|
||||
if not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {"revision": int(row[0]), **payload, "updated_at": row[6]}, legacy
|
||||
return {
|
||||
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
|
||||
"elapsed_ms": int(row[3]), "running": bool(row[4]),
|
||||
"break_deadline_at": row[5], "updated_at": row[6],
|
||||
}, True
|
||||
|
||||
def _sealed_session(self, login: str, session: dict) -> str:
|
||||
return self._cipher.seal({
|
||||
"device_id": session["device_id"], "identity": session["identity"],
|
||||
"elapsed_ms": session["elapsed_ms"], "running": session["running"],
|
||||
"break_deadline_at": session["break_deadline_at"],
|
||||
}, binding=f"session:{login}")
|
||||
|
||||
def get_session(self, login: str) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
|
||||
"FROM today_sessions WHERE login = ?", (login,),
|
||||
).fetchone()
|
||||
session, legacy = self._session_snapshot(row, login)
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE today_sessions SET device_id = ?, identity = '', elapsed_ms = 0, "
|
||||
"running = 0, break_deadline_at = NULL WHERE login = ? AND device_id = ?",
|
||||
(self._sealed_session(login, session), login, row[1]),
|
||||
)
|
||||
return session
|
||||
|
||||
def update_session(
|
||||
self, login: str, *, base_revision: int, device_id: str,
|
||||
identity: str, elapsed_ms: int, running: bool, break_deadline_at: int | None = None,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
updated_at = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current = connection.execute(
|
||||
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
|
||||
"FROM today_sessions WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current_session, _legacy = self._session_snapshot(current, login)
|
||||
current_revision = current_session["revision"]
|
||||
if base_revision != current_revision:
|
||||
raise TodaySessionConflict(current_session)
|
||||
revision = current_revision + 1
|
||||
session = {
|
||||
"revision": revision, "device_id": device_id, "identity": identity,
|
||||
"elapsed_ms": elapsed_ms, "running": bool(running),
|
||||
"break_deadline_at": break_deadline_at, "updated_at": updated_at,
|
||||
}
|
||||
connection.execute(
|
||||
"INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, device_id=excluded.device_id, "
|
||||
"identity=excluded.identity, elapsed_ms=excluded.elapsed_ms, running=excluded.running, "
|
||||
"break_deadline_at=excluded.break_deadline_at, updated_at=excluded.updated_at",
|
||||
(login, revision, self._sealed_session(login, session), "", 0, 0, None, updated_at),
|
||||
)
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def _normalize_recap_items(items: list[dict]) -> list[dict]:
|
||||
@@ -195,9 +334,24 @@ class TodayStore:
|
||||
})
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _recap_snapshot(session_id: str, created_at: float, serialized: str) -> dict:
|
||||
items = json.loads(serialized)
|
||||
def _recap_snapshot(
|
||||
self, login: str, encrypted_session_id: str, created_at: float, serialized: str
|
||||
) -> tuple[dict, bool]:
|
||||
if encrypted_session_id.startswith("v1:"):
|
||||
session_id, legacy_session = self._cipher.open(
|
||||
encrypted_session_id, binding=f"recap-id:{login}"
|
||||
)
|
||||
items, legacy_items = self._cipher.open(
|
||||
serialized, binding=f"recap-items:{login}:{session_id}"
|
||||
)
|
||||
else:
|
||||
session_id, legacy_session = encrypted_session_id, True
|
||||
try:
|
||||
items, legacy_items = json.loads(serialized), True
|
||||
except json.JSONDecodeError as error:
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||
if not isinstance(session_id, str) or not isinstance(items, list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
estimated = sum(item["estimate_minutes"] for item in items if item["estimate_minutes"] is not None)
|
||||
actual = sum(item["actual_minutes"] for item in items)
|
||||
return {
|
||||
@@ -207,7 +361,20 @@ class TodayStore:
|
||||
"estimated_minutes": estimated,
|
||||
"actual_minutes": actual,
|
||||
"variance_minutes": actual - estimated,
|
||||
}
|
||||
}, legacy_session or legacy_items
|
||||
|
||||
def _migrate_recap(self, connection, login: str, stored_session: str, snapshot: dict) -> None:
|
||||
encrypted_session = self._cipher.seal(
|
||||
snapshot["session_id"], binding=f"recap-id:{login}"
|
||||
)
|
||||
encrypted_items = self._cipher.seal(
|
||||
snapshot["items"], binding=f"recap-items:{login}:{snapshot['session_id']}"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE today_recaps SET session_id = ?, items = ? "
|
||||
"WHERE login = ? AND session_id = ?",
|
||||
(encrypted_session, encrypted_items, login, stored_session),
|
||||
)
|
||||
|
||||
def save_recap(self, login: str, session_id: str, items: list[dict]) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
@@ -215,19 +382,25 @@ class TodayStore:
|
||||
raise ValueError("session_id is required and bounded")
|
||||
session_id = session_id.strip()
|
||||
normalized = self._normalize_recap_items(items)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT created_at, items FROM today_recaps WHERE login = ? AND session_id = ?",
|
||||
(login, session_id),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return self._recap_snapshot(session_id, existing[0], existing[1])
|
||||
existing_rows = connection.execute(
|
||||
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ?", (login,)
|
||||
).fetchall()
|
||||
for existing in existing_rows:
|
||||
snapshot, legacy = self._recap_snapshot(login, *existing)
|
||||
if snapshot["session_id"] == session_id:
|
||||
if legacy:
|
||||
self._migrate_recap(connection, login, existing[0], snapshot)
|
||||
return snapshot
|
||||
created_at = self.clock()
|
||||
encrypted_session_id = self._cipher.seal(session_id, binding=f"recap-id:{login}")
|
||||
serialized = self._cipher.seal(
|
||||
normalized, binding=f"recap-items:{login}:{session_id}"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_recaps(login, session_id, created_at, items) VALUES (?, ?, ?, ?)",
|
||||
(login, session_id, created_at, serialized),
|
||||
(login, encrypted_session_id, created_at, serialized),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM today_recaps WHERE login = ? AND rowid NOT IN "
|
||||
@@ -235,53 +408,135 @@ class TodayStore:
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(login, login, self.recap_limit),
|
||||
)
|
||||
return self._recap_snapshot(session_id, created_at, serialized)
|
||||
return self._recap_snapshot(login, encrypted_session_id, created_at, serialized)[0]
|
||||
|
||||
def list_recaps(self, login: str, *, limit: int = 30) -> list[dict]:
|
||||
bounded_limit = max(1, min(int(limit), self.recap_limit, 100))
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||
(self._normalize_login(login), bounded_limit),
|
||||
(login, bounded_limit),
|
||||
).fetchall()
|
||||
return [self._recap_snapshot(*row) for row in rows]
|
||||
snapshots = []
|
||||
for row in rows:
|
||||
snapshot, legacy = self._recap_snapshot(login, *row)
|
||||
if legacy:
|
||||
self._migrate_recap(connection, login, row[0], snapshot)
|
||||
snapshots.append(snapshot)
|
||||
return snapshots
|
||||
|
||||
def _time_log_snapshot(self, login: str, row) -> tuple[dict, bool]:
|
||||
encrypted_session, encrypted_identity, payload_value, status_value = row
|
||||
if encrypted_session.startswith("v1:"):
|
||||
session_id = self._cipher.open(
|
||||
encrypted_session, binding=f"time-log-session:{login}"
|
||||
)[0]
|
||||
identity = self._cipher.open(
|
||||
encrypted_identity, binding=f"time-log-identity:{login}:{session_id}"
|
||||
)[0]
|
||||
payload, legacy = self._cipher.open(
|
||||
str(payload_value), binding=f"time-log-payload:{login}:{session_id}:{identity}"
|
||||
)
|
||||
if not isinstance(session_id, str) or not isinstance(identity, str) or not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {
|
||||
"session_id": session_id, "identity": identity,
|
||||
"actual_minutes": payload.get("actual_minutes"), "status": payload.get("status"),
|
||||
"stored_session": encrypted_session, "stored_identity": encrypted_identity,
|
||||
}, legacy
|
||||
return {
|
||||
"session_id": encrypted_session, "identity": encrypted_identity,
|
||||
"actual_minutes": payload_value, "status": status_value,
|
||||
"stored_session": encrypted_session, "stored_identity": encrypted_identity,
|
||||
}, True
|
||||
|
||||
def _sealed_time_log(self, login: str, session_id: str, identity: str, actual: int, status: str) -> tuple[str, str, str]:
|
||||
return (
|
||||
self._cipher.seal(session_id, binding=f"time-log-session:{login}"),
|
||||
self._cipher.seal(identity, binding=f"time-log-identity:{login}:{session_id}"),
|
||||
self._cipher.seal(
|
||||
{"actual_minutes": actual, "status": status},
|
||||
binding=f"time-log-payload:{login}:{session_id}:{identity}",
|
||||
),
|
||||
)
|
||||
|
||||
def _migrate_time_log(self, connection, login: str, snapshot: dict) -> dict:
|
||||
sealed_session, sealed_identity, sealed_payload = self._sealed_time_log(
|
||||
login, snapshot["session_id"], snapshot["identity"],
|
||||
snapshot["actual_minutes"], snapshot["status"],
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET session_id = ?, identity = ?, actual_minutes = ?, status = 'sealed' "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(sealed_session, sealed_identity, sealed_payload, login,
|
||||
snapshot["stored_session"], snapshot["stored_identity"]),
|
||||
)
|
||||
return {**snapshot, "stored_session": sealed_session, "stored_identity": sealed_identity}
|
||||
|
||||
def begin_time_log(self, login: str, session_id: str, identity: str, actual_minutes: int) -> str:
|
||||
"""Claim one recap item for upstream logging, returning its current state."""
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT actual_minutes, status FROM today_time_logs "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(login, session_id, identity),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, identity, actual_minutes, status FROM today_time_logs WHERE login = ?",
|
||||
(login,),
|
||||
).fetchall()
|
||||
match = None
|
||||
for row in rows:
|
||||
candidate, legacy = self._time_log_snapshot(login, row)
|
||||
if candidate["session_id"] == session_id and candidate["identity"] == identity:
|
||||
match = self._migrate_time_log(connection, login, candidate) if legacy else candidate
|
||||
break
|
||||
if match is None:
|
||||
sealed_session, sealed_identity, sealed_payload = self._sealed_time_log(
|
||||
login, session_id, identity, actual_minutes, "pending"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_time_logs(login, session_id, identity, actual_minutes, status) "
|
||||
"VALUES (?, ?, ?, ?, 'pending')",
|
||||
(login, session_id, identity, actual_minutes),
|
||||
"VALUES (?, ?, ?, ?, 'sealed')",
|
||||
(login, sealed_session, sealed_identity, sealed_payload),
|
||||
)
|
||||
return "claimed"
|
||||
if row[0] != actual_minutes:
|
||||
if match["actual_minutes"] != actual_minutes:
|
||||
raise ValueError("logged recap time cannot be changed")
|
||||
if row[1] == "failed":
|
||||
if match["status"] == "failed":
|
||||
sealed_payload = self._sealed_time_log(
|
||||
login, session_id, identity, actual_minutes, "pending"
|
||||
)[2]
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET status = 'pending' "
|
||||
"UPDATE today_time_logs SET actual_minutes = ?, status = 'sealed' "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(login, session_id, identity),
|
||||
(sealed_payload, login, match["stored_session"], match["stored_identity"]),
|
||||
)
|
||||
return "claimed"
|
||||
return row[1]
|
||||
return match["status"]
|
||||
|
||||
def finish_time_log(self, login: str, session_id: str, identity: str, *, succeeded: bool) -> None:
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET status = ? "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ? AND status = 'pending'",
|
||||
("logged" if succeeded else "failed", self._normalize_login(login), session_id, identity),
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, identity, actual_minutes, status FROM today_time_logs WHERE login = ?",
|
||||
(login,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
match, legacy = self._time_log_snapshot(login, row)
|
||||
if match["session_id"] != session_id or match["identity"] != identity or match["status"] != "pending":
|
||||
continue
|
||||
if legacy:
|
||||
match = self._migrate_time_log(connection, login, match)
|
||||
sealed_payload = self._sealed_time_log(
|
||||
login, session_id, identity, match["actual_minutes"],
|
||||
"logged" if succeeded else "failed",
|
||||
)[2]
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET actual_minutes = ?, status = 'sealed' "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(sealed_payload, login, match["stored_session"], match["stored_identity"]),
|
||||
)
|
||||
return
|
||||
|
||||
def apply(
|
||||
self,
|
||||
@@ -306,7 +561,7 @@ class TodayStore:
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
snapshot, _legacy = self._snapshot(row, login)
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
@@ -339,19 +594,22 @@ class TodayStore:
|
||||
changed = True
|
||||
|
||||
revision = snapshot["revision"] + (1 if changed else 0)
|
||||
serialized_ids = json.dumps(ids, separators=(",", ":"))
|
||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||
sealed = self._sealed_plan(login, {
|
||||
"ids": ids, "capacity_minutes": snapshot["capacity_minutes"],
|
||||
"estimates": estimates, "plan_date": snapshot.get("plan_date"),
|
||||
"timezone": snapshot.get("timezone"),
|
||||
})
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates,
|
||||
snapshot.get("plan_date"), snapshot.get("timezone")),
|
||||
(login, revision, sealed, None, "{}", None, None),
|
||||
)
|
||||
elif changed:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, estimates = ? WHERE login = ?",
|
||||
(revision, serialized_ids, serialized_estimates, login),
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = NULL, "
|
||||
"estimates = '{}', plan_date = NULL, timezone = NULL WHERE login = ?",
|
||||
(revision, sealed, login),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
result = {
|
||||
@@ -374,7 +632,7 @@ class TodayStore:
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
snapshot, _legacy = self._snapshot(row, login)
|
||||
ids = list(snapshot["ids"])
|
||||
capacity_minutes = snapshot["capacity_minutes"]
|
||||
estimates = dict(snapshot["estimates"])
|
||||
@@ -503,19 +761,21 @@ class TodayStore:
|
||||
self._record_operation(connection, login, operation_id)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(ids, separators=(",", ":"))
|
||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||
sealed = self._sealed_plan(login, {
|
||||
"ids": ids, "capacity_minutes": capacity_minutes, "estimates": estimates,
|
||||
"plan_date": plan_date, "timezone": timezone,
|
||||
})
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone),
|
||||
(login, revision, sealed, None, "{}", None, None),
|
||||
)
|
||||
elif accepted:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ?, "
|
||||
"plan_date = ?, timezone = ? WHERE login = ?",
|
||||
(revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone, login),
|
||||
(revision, sealed, None, "{}", None, None, login),
|
||||
)
|
||||
result = {
|
||||
"revision": revision,
|
||||
|
||||
+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=",
|
||||
)
|
||||
+101
-1
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from threading import Event
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
|
||||
@@ -51,6 +52,12 @@ class FakeGiteaServer(ThreadingHTTPServer):
|
||||
super().__init__(address, FakeGiteaHandler)
|
||||
self.created_issues: list[dict] = []
|
||||
self.issue_creation_enabled = False
|
||||
self.issue_creation_ready = Event()
|
||||
self.assigned_issue_numbers = [issue["number"] for issue in AVAILABLE_ISSUES]
|
||||
self.comments: list[tuple[int, str]] = []
|
||||
self.activity_comments: dict[int, list[dict]] = {}
|
||||
self.edited_comments: list[tuple[int, int, str]] = []
|
||||
self.requests: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
@@ -73,6 +80,7 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
parsed = urlsplit(self.path)
|
||||
path = parsed.path
|
||||
query = parse_qs(parsed.query)
|
||||
self.server.requests.append(("GET", self.path))
|
||||
if path == "/api/v1/user":
|
||||
self._json(200, USER)
|
||||
elif path == "/api/v1/user/repos":
|
||||
@@ -81,18 +89,52 @@ 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(
|
||||
name in query for name in ("assigned", "created", "review_requested", "q")
|
||||
) and query.get("type") == ["issues"]
|
||||
issues = AVAILABLE_ISSUES if is_available_scan else []
|
||||
assigned = [
|
||||
{**issue, "assignees": [USER]}
|
||||
for issue in AVAILABLE_ISSUES
|
||||
if issue["number"] in self.server.assigned_issue_numbers
|
||||
]
|
||||
available = [
|
||||
issue for issue in AVAILABLE_ISSUES
|
||||
if issue["number"] not in self.server.assigned_issue_numbers
|
||||
]
|
||||
issues = assigned if is_assigned_scan else available if is_available_scan else []
|
||||
self._json(200, issues, **{"X-Total-Count": str(len(issues))})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/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"):
|
||||
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/"):
|
||||
try:
|
||||
number = int(path.rsplit("/", 1)[-1])
|
||||
except ValueError:
|
||||
number = 0
|
||||
issue = next((item for item in AVAILABLE_ISSUES if item["number"] == number), None)
|
||||
if issue and number in self.server.assigned_issue_numbers:
|
||||
issue = {**issue, "assignees": [USER]}
|
||||
self._json(200, issue) if issue else self._json(404, {"message": "not found"})
|
||||
elif path.startswith("/api/v1/"):
|
||||
self._json(200, [], **{"X-Total-Count": "0"})
|
||||
@@ -101,11 +143,34 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = urlsplit(self.path).path
|
||||
self.server.requests.append(("POST", self.path))
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||
if path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"):
|
||||
try:
|
||||
number = int(path.split("/")[-2])
|
||||
except ValueError:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
body = str(payload.get("body", ""))
|
||||
self.server.comments.append((number, body))
|
||||
self._json(
|
||||
201,
|
||||
{
|
||||
"id": len(self.server.comments),
|
||||
"body": body,
|
||||
"user": USER,
|
||||
"created_at": "2026-08-16T20:00:00Z",
|
||||
"updated_at": "2026-08-16T20:00:00Z",
|
||||
"html_url": f"http://127.0.0.1/acme/mobile/issues/{number}#issuecomment-1",
|
||||
},
|
||||
)
|
||||
return
|
||||
if path != "/api/v1/repos/acme/mobile/issues":
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
if not self.server.issue_creation_enabled:
|
||||
self.server.issue_creation_ready.wait(timeout=45)
|
||||
if not self.server.issue_creation_enabled:
|
||||
self._json(503, {"message": "release journey is still offline"})
|
||||
return
|
||||
@@ -130,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
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile Home bootstrap runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import 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()
|
||||
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
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
workspace_requests: list[str] = []
|
||||
launch_transfer_events: list[str] = []
|
||||
live_requests: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
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",
|
||||
lambda message: browser_errors.append(message.text)
|
||||
if message.type == "error"
|
||||
else None,
|
||||
)
|
||||
page.on(
|
||||
"response",
|
||||
lambda response: failed_responses.append(f"{response.status} {response.url}")
|
||||
if response.status >= 400
|
||||
else None,
|
||||
)
|
||||
page.on(
|
||||
"request",
|
||||
lambda request: (
|
||||
workspace_requests.append(request.url),
|
||||
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",
|
||||
lambda request: live_requests.append(request.url)
|
||||
if "/api/v1/live" in request.url
|
||||
else None,
|
||||
)
|
||||
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
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)
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
window.scrollTo(0, 0);
|
||||
const surface = document.querySelector('#my-work');
|
||||
surface.setPointerCapture = () => {};
|
||||
surface.releasePointerCapture = () => {};
|
||||
const pointer = (type, y) => surface.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:y,
|
||||
}));
|
||||
pointer('pointerdown', 10);
|
||||
pointer('pointermove', 90);
|
||||
}
|
||||
"""
|
||||
)
|
||||
expect(page.locator("#mobile-pull-refresh")).to_have_text("Release to refresh")
|
||||
page.evaluate(
|
||||
"document.querySelector('#my-work').dispatchEvent(new PointerEvent('pointerup', "
|
||||
"{bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:90}))"
|
||||
)
|
||||
expect(page.locator("#mobile-pull-refresh")).to_have_text("My Work is up to date")
|
||||
assert len(live_requests) == initial_live_requests + 1
|
||||
dock = page.locator("#mobile-task-dock")
|
||||
expect(dock).to_be_visible()
|
||||
expect(dock.locator("button")).to_have_count(5)
|
||||
for control in dock.locator("button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
|
||||
expect(delivery_queue).to_be_visible()
|
||||
expect(delivery_queue).to_contain_text("Delivery")
|
||||
delivery_bounds = delivery_queue.bounding_box()
|
||||
assert delivery_bounds and delivery_bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#close-mobile-queues").click()
|
||||
|
||||
page.locator("#app-menu-toggle").click()
|
||||
page.locator("#open-insights").click()
|
||||
expect(page.locator("#insights-sheet")).to_be_visible()
|
||||
expect(page.locator("#insights-heading")).to_have_text("Insights")
|
||||
page.locator("#close-insights").click()
|
||||
expect(page.locator("#insights-sheet")).to_be_hidden()
|
||||
expect(page.locator("#my-work")).to_be_visible()
|
||||
expect(dock).to_be_visible()
|
||||
|
||||
page.locator("#app-menu-toggle").click()
|
||||
page.locator("#open-device-setup").click()
|
||||
expect(page.locator("#device-setup-sheet")).to_be_visible()
|
||||
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
|
||||
expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
|
||||
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.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():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator("#security-section-devices").click()
|
||||
expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
|
||||
page.go_back()
|
||||
expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
|
||||
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")
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
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"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Delivery recovery release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
window.__deliveryAttempts = 0;
|
||||
window.__deliverySettled = new Promise(resolve => { window.__finishDelivery = resolve; });
|
||||
window.__releaseRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => [{outbox_id:'release-retry', delivery_state:'waiting', title:'Post release note'}],
|
||||
activate: async () => {
|
||||
window.__deliveryAttempts += 1;
|
||||
await window.__deliverySettled;
|
||||
},
|
||||
});
|
||||
window.__releaseRecovery.open();
|
||||
window.__firstRecovery = window.__releaseRecovery.activate();
|
||||
window.__secondRecovery = window.__releaseRecovery.activate();
|
||||
}
|
||||
"""
|
||||
)
|
||||
action = page.locator("#mobile-delivery-recovery-action")
|
||||
expect(page.locator("#mobile-delivery-recovery")).to_be_visible()
|
||||
expect(action).to_be_disabled()
|
||||
expect(page.locator("#mobile-delivery-recovery-status")).to_have_text("Working…")
|
||||
assert page.evaluate("window.__deliveryAttempts") == 1
|
||||
bounds = action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.evaluate("window.__finishDelivery()")
|
||||
page.evaluate("Promise.all([window.__firstRecovery, window.__secondRecovery])")
|
||||
expect(action).to_be_enabled()
|
||||
assert page.evaluate("window.__deliveryAttempts") == 1
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const destination = document.createElement('button');
|
||||
destination.type = 'button';
|
||||
destination.className = 'draft-resume';
|
||||
destination.dataset.draftIndex = '0';
|
||||
destination.textContent = 'Open current review';
|
||||
window.__attentionAttempts = 0;
|
||||
window.__attentionSettled = new Promise(resolve => { window.__finishAttention = resolve; });
|
||||
destination.onclick = async () => {
|
||||
window.__attentionAttempts += 1;
|
||||
await window.__attentionSettled;
|
||||
return true;
|
||||
};
|
||||
document.querySelector('#my-work-list').append(destination);
|
||||
window.__attentionRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => [{outbox_id:'review-attention', status:'attention', title:'Review feedback'}],
|
||||
getIndex: () => 0,
|
||||
});
|
||||
window.__attentionRecovery.open();
|
||||
window.__firstAttention = window.__attentionRecovery.activate();
|
||||
window.__secondAttention = window.__attentionRecovery.activate();
|
||||
}
|
||||
"""
|
||||
)
|
||||
expect(page.locator("#mobile-delivery-recovery")).to_be_hidden()
|
||||
expect(page.locator("#my-work-list .draft-resume")).to_be_focused()
|
||||
assert page.evaluate("window.__attentionAttempts") == 1
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.evaluate("window.__finishAttention()")
|
||||
page.evaluate("Promise.all([window.__firstAttention, window.__secondAttention])")
|
||||
assert page.evaluate("window.__attentionAttempts") == 1
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
@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")
|
||||
@@ -232,6 +245,10 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
page.locator("#create-issue-repository").select_option("acme/mobile")
|
||||
expect(page.locator("#submit-new-issue")).to_be_enabled()
|
||||
|
||||
# Page offline emulation can leave service-worker requests connected.
|
||||
# Abort the mutation at the browser boundary too, matching a real
|
||||
# transport outage without poisoning server-side idempotency state.
|
||||
context.route("**/api/v1/repos/acme/mobile/issues", lambda route: route.abort())
|
||||
context.set_offline(True)
|
||||
page.locator("#submit-new-issue").click()
|
||||
expect(page.locator("#issue-filing-review")).to_be_visible()
|
||||
@@ -267,6 +284,8 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
browser_errors.clear() # Chromium reports expected network errors while the context is offline.
|
||||
failed_responses.clear()
|
||||
fake.issue_creation_enabled = True
|
||||
fake.issue_creation_ready.set()
|
||||
context.unroute("**/api/v1/repos/acme/mobile/issues")
|
||||
context.set_offline(False)
|
||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||
# Containerized Actions runners can take longer than a local browser to wake the
|
||||
@@ -275,7 +294,22 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
if fake.created_issues:
|
||||
break
|
||||
page.wait_for_timeout(250)
|
||||
assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}]
|
||||
local_debug = page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')")
|
||||
durable_debug = indexed_issue_records(page)
|
||||
assert fake.created_issues == [
|
||||
{"title": TITLE, "body": BODY, "assignee": "timmy"}
|
||||
], (
|
||||
f"url={page.url} ready={page.evaluate('document.readyState')} "
|
||||
f"status={page.locator('#my-work-action-status').inner_text()!r} "
|
||||
f"errors={browser_errors[-5:]!r} responses={failed_responses[-10:]!r} "
|
||||
f"local={local_debug!r} durable={durable_debug!r} "
|
||||
f"requests={fake.requests[-20:]!r}"
|
||||
)
|
||||
# Deferred workspace startup can report its already-issued offline request
|
||||
# after the pre-reconnect clear; retain every non-offline browser error.
|
||||
browser_errors[:] = [
|
||||
error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
|
||||
]
|
||||
|
||||
for _ in range(40):
|
||||
durable_completion = indexed_issue_records(page)
|
||||
@@ -298,6 +332,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.issue_creation_ready.set()
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,609 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Plan Today handoff journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def 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"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
|
||||
page.on(
|
||||
"response",
|
||||
lambda response: failed_responses.append(f"{response.status} {response.url}")
|
||||
if response.status >= 400
|
||||
else None,
|
||||
)
|
||||
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Plan Today release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
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)
|
||||
|
||||
navigation = page.locator(".mobile-plan-today-nav")
|
||||
expect(navigation).to_be_visible()
|
||||
for control in navigation.locator("button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator("#plan-today-available").fill("90")
|
||||
page.locator("#plan-today-available").press("Tab")
|
||||
page.locator("#build-today-plan").click()
|
||||
missing_estimates = page.locator("[data-plan-missing-estimate]")
|
||||
expect(missing_estimates).to_have_count(2)
|
||||
for _ in range(2):
|
||||
missing_estimates.nth(0).fill("30")
|
||||
missing_estimates.nth(0).press("Tab")
|
||||
expect(page.locator("#plan-today-list .plan-today-item")).to_have_count(2)
|
||||
estimates = page.locator("[data-plan-estimate]")
|
||||
expect(estimates).to_have_count(2)
|
||||
page.locator("#save-and-start-today").click()
|
||||
|
||||
try:
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden(timeout=5_000)
|
||||
except AssertionError as error:
|
||||
raise AssertionError(page.evaluate("({hidden:document.querySelector('#plan-today-sheet').hidden, state:history.state, bodyClass:document.body.className})")) from error
|
||||
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
expect(page.locator("#send-issue-comment-next")).to_be_visible()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
|
||||
page.locator("#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.")
|
||||
page.locator("#send-issue-comment-next").click()
|
||||
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
|
||||
expect(page.locator("#issue-comment")).to_have_value("")
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true")
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
|
||||
assert fake.comments == [
|
||||
(41, "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:41:", "issue:acme/mobile:42:"]
|
||||
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
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,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Today wrap-up journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on(
|
||||
"console",
|
||||
lambda message: browser_errors.append(message.text)
|
||||
if message.type == "error"
|
||||
else None,
|
||||
)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Today wrap-up release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const items=[
|
||||
{kind:'issue',repository:'acme/mobile',number:41,title:'Ship mobile capture',key:'acme/mobile#41'},
|
||||
{kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'},
|
||||
];
|
||||
const identity=item => `${item.kind}:${item.repository}:${item.number}:`;
|
||||
const today=items.map(identity); const later={}; const operations=[];
|
||||
const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{
|
||||
const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true;
|
||||
}};
|
||||
const laterWork={presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:(item,wake)=>{
|
||||
later[identity(item)]=wake.toISOString(); return 'deferred';
|
||||
}};
|
||||
const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)};
|
||||
const controller=createTodayWrapUp({todayWork,laterWork,todaySync});
|
||||
const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
||||
window.__wrapTest={view,today,later,operations};
|
||||
view.open(items,{});
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
sheet = page.locator("#today-wrap-up-sheet")
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator(".today-wrap-up-item")).to_have_count(2)
|
||||
for control in sheet.locator("button, label").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator('[data-wrap-up-identity="issue:acme/mobile:41:"]').check()
|
||||
page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))")
|
||||
expect(sheet).to_be_hidden()
|
||||
result = page.evaluate(
|
||||
"({today:window.__wrapTest.today,later:window.__wrapTest.later,operations:window.__wrapTest.operations})"
|
||||
)
|
||||
assert result == {
|
||||
"today": ["issue:acme/mobile:42:"],
|
||||
"later": {"issue:acme/mobile:41:": "2026-08-17T09:00:00.000Z"},
|
||||
"operations": [["remove", "issue:acme/mobile:41:"]],
|
||||
}
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged next-day Today handoff runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
errors: list[str] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.on("pageerror", lambda error: errors.append(error.stack or str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Tomorrow review phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.evaluate("""localStorage.setItem('stackchain.today-handoff.v1.timmy', JSON.stringify([
|
||||
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
|
||||
]))""")
|
||||
page.reload(wait_until="networkidle")
|
||||
|
||||
dialog = page.locator("#today-handoff-dialog")
|
||||
expect(dialog).to_be_visible()
|
||||
expect(page.locator("#today-handoff-items .today-handoff-item")).to_have_count(2)
|
||||
for control in dialog.locator("button, input").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 24
|
||||
buttons = dialog.locator("button")
|
||||
for control in buttons.all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator('[data-today-handoff="issue:acme/mobile:42:"]').uncheck()
|
||||
page.locator("#confirm-today-handoff").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy'))") == [
|
||||
"issue:acme/mobile:41:"
|
||||
]
|
||||
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-handoff.v1.timmy'))") == [
|
||||
"issue:acme/mobile:42:"
|
||||
]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
@@ -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):
|
||||
|
||||
@@ -17,6 +17,23 @@ def test_ci_installs_declared_requirements_before_tests():
|
||||
assert install < tests
|
||||
|
||||
|
||||
def test_ci_audits_pinned_runtime_dependencies_before_tests_and_release_build():
|
||||
text = WORKFLOW.read_text()
|
||||
lint = text[text.index(" lint:") : text.index(" build-release:")]
|
||||
build = text[text.index(" build-release:") : text.index(" browser-journey:")]
|
||||
|
||||
audit_install = lint.index("pip install -r requirements-audit.txt")
|
||||
audit = lint.index("python3 -m pip_audit -r requirements.txt --strict")
|
||||
tests = lint.index("python3 -m pytest tests/ -q")
|
||||
assert audit_install < audit < tests
|
||||
assert "needs: lint" in build
|
||||
|
||||
|
||||
def test_runtime_multipart_parser_uses_advisory_fixed_release():
|
||||
requirements = Path("requirements.txt").read_text().splitlines()
|
||||
assert "python-multipart==0.0.32" in requirements
|
||||
|
||||
|
||||
def test_release_promotion_waits_for_tests_and_bundle():
|
||||
text = WORKFLOW.read_text()
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
@@ -24,7 +41,10 @@ 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")
|
||||
|
||||
|
||||
@@ -41,6 +61,12 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
assert (
|
||||
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
|
||||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||
"tests/e2e/test_mobile_find_work_release.py -q"
|
||||
"tests/e2e/test_mobile_find_work_release.py "
|
||||
"tests/e2e/test_mobile_home_bootstrap_release.py "
|
||||
"tests/e2e/test_mobile_sign_out_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||
"tests/e2e/test_mobile_today_summary_release.py "
|
||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
||||
) in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
@@ -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-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
@@ -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))});
|
||||
@@ -850,10 +863,22 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert 'aria-labelledby="enrolled-passkeys-title"' in html
|
||||
assert 'id="enrolled-passkeys-list"' in html
|
||||
assert 'A recovery token will be required after sign-out if you remove this device’s passkey.' in html
|
||||
assert 'class="security-section-nav" aria-label="Security Center sections"' in html
|
||||
assert 'id="security-section-activity"' in html
|
||||
assert 'id="security-section-devices"' in html
|
||||
assert 'id="security-section-passkeys"' in html
|
||||
assert 'id="security-activity-section"' in html
|
||||
assert 'id="security-devices-section"' in html
|
||||
assert 'id="security-passkeys-section"' in html
|
||||
assert html.index('id="security-activity-section"') < html.index('id="security-devices-section"')
|
||||
assert 'aria-labelledby="security-activity-title"' in html
|
||||
assert 'id="security-activity-list"' in html
|
||||
assert '>Load older activity</button>' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
assert '.security-section-nav { position:sticky;' in html
|
||||
assert '.security-section-nav button { min-height:44px;' in html
|
||||
assert '.security-event button { min-height:44px;' in html
|
||||
assert '.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:' in html
|
||||
|
||||
|
||||
def test_security_activity_names_confirmed_gitea_time_logging():
|
||||
|
||||
@@ -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);
|
||||
""")
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "device-storage.js"
|
||||
|
||||
|
||||
def run_scenario(script: str) -> dict:
|
||||
harness = r"""
|
||||
const createDeviceStorage = require(__MODULE__);
|
||||
class Target {
|
||||
constructor() { this.listeners={}; this.textContent=''; this.hidden=false; this.disabled=false; this.dataset={}; }
|
||||
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
|
||||
async dispatch(name) { for (const callback of this.listeners[name] || []) await callback(); }
|
||||
}
|
||||
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
|
||||
let purgeCalls=0, purgeError='';
|
||||
let inventory={recordCount:0, unavailable:false};
|
||||
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;},
|
||||
key(index){return Array.from(this.values.keys())[index] || null;},
|
||||
};
|
||||
const caches={
|
||||
values:new Set(['stackchain-dashboard-shell-a1','other-app']),
|
||||
async keys(){return Array.from(this.values);},
|
||||
async delete(key){return this.values.delete(key);},
|
||||
};
|
||||
const 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,
|
||||
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
|
||||
});
|
||||
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);});
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
|
||||
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_inventory_reports_private_categories_and_quota_without_content():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.textContent}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"summary": "2 MB of 10 MB browser storage used.",
|
||||
"detail": "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,
|
||||
}
|
||||
|
||||
|
||||
def test_cache_only_cleanup_preserves_private_work_and_other_apps():
|
||||
result = run_scenario("""
|
||||
await controller.start();
|
||||
await clearCachesButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
caches:Array.from(caches.values),
|
||||
storage:Array.from(localStorage.values.keys()),
|
||||
status:summary.textContent,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"caches": ["other-app"],
|
||||
"storage": ["stackchain.draft.1", "other.preference"],
|
||||
"status": "Cached app copies cleared. Private work was kept.",
|
||||
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 0 cached app copies",
|
||||
}
|
||||
|
||||
|
||||
def test_full_cleanup_requires_explicit_second_press_when_private_work_exists():
|
||||
result = run_scenario("""
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
const warning={calls:purgeCalls,label:clearAllButton.textContent,status:summary.textContent};
|
||||
await clearAllButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.textContent,detail:detail.textContent}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"warning": {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
|
||||
},
|
||||
"calls": 1,
|
||||
"status": "All Stackchain private data was cleared from this device.",
|
||||
"detail": "Storage protection: Best effort · 0 private browser items · 0 private work records · 0 cached app copies",
|
||||
}
|
||||
|
||||
|
||||
def test_indexeddb_only_work_requires_confirmation_before_purge():
|
||||
result = run_scenario("""
|
||||
localStorage.values.clear();
|
||||
inventory={recordCount:3, unavailable:false};
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:purgeCalls,
|
||||
label:clearAllButton.textContent,
|
||||
status:summary.textContent,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
|
||||
"detail": "Storage protection: Best effort · 0 private browser items · 3 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_unavailable_indexeddb_inventory_requires_confirmation_before_purge():
|
||||
result = run_scenario("""
|
||||
localStorage.values.clear();
|
||||
inventory={recordCount:0, unavailable:true};
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:purgeCalls,
|
||||
label:clearAllButton.textContent,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"detail": "Storage protection: Best effort · 0 private browser items · private work status unknown · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_full_cleanup_reports_blocked_deletion_without_claiming_success():
|
||||
result = run_scenario("""
|
||||
purgeError='IndexedDB deletion was blocked.';
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
let escaped='';
|
||||
try { await clearAllButton.dispatch('click'); } catch (error) { escaped=error.message; }
|
||||
process.stdout.write(JSON.stringify({escaped,status:summary.textContent,disabled:clearAllButton.disabled}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"escaped": "",
|
||||
"status": "Private data was not fully cleared: IndexedDB deletion was blocked.",
|
||||
"disabled": False,
|
||||
}
|
||||
@@ -213,6 +213,37 @@ process.stdout.write(JSON.stringify({{item,partition:inbox.partition()}}));
|
||||
assert output["partition"]["retryable"] == []
|
||||
|
||||
|
||||
def test_delivery_partition_counts_human_action_and_orders_it_before_waiting_work():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[
|
||||
{{id:'waiting',repository:'o/r',title:'Waiting',body:'',ownerLogin:'timmy',status:'queued',queuedAt:500}},
|
||||
{{id:'uncertain',repository:'o/r',title:'Verify',body:'',ownerLogin:'timmy',status:'queued',deliveryState:'uncertain',queuedAt:100}},
|
||||
{{id:'sending',repository:'o/r',title:'Sending',body:'',ownerLogin:'timmy',status:'sending',queuedAt:400}},
|
||||
{{id:'attention',repository:'o/r',title:'Fix me',body:'',ownerLogin:'timmy',status:'attention',queuedAt:200}},
|
||||
]}})],
|
||||
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'authorize',kind:'pull-review',repository:'o/r',number:8,body:'Review',ownerLogin:'timmy',status:'authorization',queuedAt:300}},
|
||||
]}})],
|
||||
]);
|
||||
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const partition=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).partition();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
actionable:partition.actionable,
|
||||
titles:partition.deliveries.map(item=>item.title),
|
||||
waiting:partition.counts.waiting,
|
||||
sending:partition.counts.sending,
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"actionable": 3,
|
||||
"titles": ["o/r#8", "Fix me", "Verify", "Waiting", "Sending"],
|
||||
"waiting": 2,
|
||||
"sending": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
@@ -347,6 +378,10 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
|
||||
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
|
||||
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
|
||||
assert '.delivery-center button { min-height:44px;' in html
|
||||
assert "counts.delivery = draftInbox.partition(lastDrafts).actionable;" in html
|
||||
assert "openDelivery: () => mobileDeliveryRecovery.open()" in html
|
||||
assert "getItems: () => draftInbox.partition(lastDrafts).deliveries" in html
|
||||
assert "authoritativePhases:['delivery']" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -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):
|
||||
@@ -26,8 +27,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
|
||||
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
|
||||
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
|
||||
assert first.dashboard_html.count("<script src=") == 2
|
||||
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' in first.dashboard_html
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' not in first.dashboard_html
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
assert first.runtime_name.startswith("runtime-")
|
||||
assert first.runtime_name.endswith(".js")
|
||||
@@ -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,13 +49,11 @@ 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",
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
|
||||
"today-timer", "security-center",
|
||||
}
|
||||
assert (
|
||||
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{first.runtime_name}"></script>'
|
||||
) in first.dashboard_html
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
capture = first.feature_bundles["issue-capture"]
|
||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||
@@ -72,10 +71,19 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createQueueToday" not in first.runtime_bytes
|
||||
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"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
|
||||
@@ -87,13 +95,10 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
"const OPTIONAL_FEATURES = [", 1
|
||||
)
|
||||
optional_block = optional_block.split("];", 1)[0]
|
||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
|
||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block
|
||||
for name, bundle in first.feature_bundles.items():
|
||||
if name == "today-timer":
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
|
||||
else:
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
||||
assert f"BASE + '{bundle.runtime_name}'" in optional_block
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
||||
assert f"BASE + '{bundle.runtime_name}'" in optional_block
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
@@ -117,6 +122,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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages(
|
||||
"title": "Older review",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/51",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/pulls/51",
|
||||
}],
|
||||
)
|
||||
|
||||
@@ -76,7 +76,7 @@ async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_dist
|
||||
"id": 878, "number": 878, "title": "Completed delegation", "state": "closed",
|
||||
"updated_at": "2026-08-15T12:00:00Z",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/stackchain/dashboard/issues/878",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/dashboard/issues/878",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
@@ -94,7 +94,7 @@ async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_dist
|
||||
"id": 878, "number": 878, "title": "Completed delegation", "state": "closed",
|
||||
"updated_at": "2026-08-15T12:00:00Z",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/stackchain/dashboard/issues/878",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/dashboard/issues/878",
|
||||
"work_reasons": ["created_by_me"],
|
||||
}],
|
||||
}
|
||||
@@ -106,7 +106,7 @@ async def test_issue_context_merges_self_assigned_and_filed_streams_without_dupl
|
||||
del page, limit
|
||||
common = {
|
||||
"id": 7, "number": 7, "title": "My own filing", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"}, "html_url": "https://forge.example/issues/7",
|
||||
"repository": {"full_name": "stackchain/api"}, "html_url": "http://127.0.0.1:3000/issues/7",
|
||||
}
|
||||
if stream == "issue":
|
||||
return {"items": [{**common, "assignees": [{"login": "timmy"}]}], "page": 1, "total": 1, "has_more": False}
|
||||
@@ -816,7 +816,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
"updated_at": "2026-08-06T12:00:00Z",
|
||||
"due_date": "2026-08-09T23:59:59Z",
|
||||
"html_url": "https://forge.example/stackchain/mobile/issues/7",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/mobile/issues/7",
|
||||
}]
|
||||
|
||||
async def pulls():
|
||||
@@ -831,7 +831,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
|
||||
"work_reasons": ["assigned_to_me", "review_requested"],
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"updated_at": "2026-08-06T11:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/7",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/pulls/7",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
@@ -864,7 +864,7 @@ async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypat
|
||||
return {
|
||||
"title": "Review API",
|
||||
"body": "Please check the retry flow.",
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/7",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/pulls/7",
|
||||
"user": {"login": "alex"},
|
||||
"head": {"sha": "abc123"},
|
||||
}
|
||||
|
||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user