Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d3242f37 | ||
|
|
fb08bc5ce5 | ||
|
|
9d9b28af8e | ||
|
|
8ad02933c9 | ||
|
|
d5dc3f27db | ||
|
|
625d937f64 | ||
|
|
2bd20130b6 | ||
|
|
fdf6ae3ddd | ||
|
|
fc9ef1f8ae | ||
|
|
8cd1d1f684 | ||
|
|
e79c457774 | ||
|
|
d3f53b5a38 | ||
|
|
19a1184b1c | ||
|
|
ad40cb5fb9 | ||
|
|
a38934b099 | ||
|
|
3cf33869d6 | ||
|
|
41195f4c75 | ||
|
|
6db0802461 | ||
|
|
087300f9c3 | ||
|
|
abc736d32e | ||
|
|
b3a359760c | ||
|
|
5f86d48e6f | ||
|
|
1c17fcf337 | ||
|
|
1c6f5c685f | ||
|
|
473f592615 | ||
|
|
aba4b55ca0 | ||
|
|
4a53a95b2d | ||
|
|
a6f34fbfde | ||
|
|
472371d5a8 | ||
|
|
d7b8cd5e71 | ||
|
|
58cee5ea16 | ||
|
|
38efd4eb7c | ||
|
|
d4910b4398 | ||
|
|
ff34416c3c | ||
|
|
f8c2541d30 | ||
|
|
d9749e3a6f | ||
|
|
b77be4f5b0 | ||
|
|
fb7bfe0a5d | ||
|
|
79f6cecab4 | ||
|
|
0472e88d91 | ||
|
|
f710b2ce1d | ||
|
|
027ded1f8f | ||
|
|
3c8e2e2720 | ||
|
|
1bd87fc33e | ||
|
|
4009a4a4c1 | ||
|
|
339baab7ee | ||
|
|
a6c769901d | ||
|
|
a65aed5862 | ||
|
|
8b4bd480cc | ||
|
|
9c46455c35 | ||
|
|
47626338ba | ||
|
|
e290293bf1 | ||
|
|
688eadf17a | ||
|
|
daf34d7cdd | ||
|
|
1410a66a36 | ||
|
|
157d485685 | ||
|
|
4f07402936 | ||
|
|
9a4a1711fe | ||
|
|
7502d6f44d | ||
|
|
bed9bb4270 | ||
|
|
406d75287d | ||
|
|
8c604e0b04 | ||
|
|
ed15392097 | ||
|
|
af86034339 | ||
|
|
e8817056f7 | ||
|
|
a757bfe587 | ||
|
|
44aedfd623 | ||
|
|
58299311d0 | ||
|
|
89c25369fc | ||
|
|
be10dba4bd | ||
|
|
c655cf5861 | ||
|
|
2082fbd5eb | ||
|
|
55156f45c9 | ||
|
|
5bf0f97df8 | ||
|
|
ce2f04ab6d | ||
|
|
f7b2a96206 | ||
|
|
a6550f8b1f | ||
|
|
66968d90c0 | ||
|
|
442d0a157d | ||
|
|
4a85f02eae | ||
|
|
dfd33aee1c | ||
|
|
c5b466f281 | ||
|
|
1d242bab75 | ||
|
|
40284a7e30 | ||
|
|
ea08f566ff | ||
|
|
ca671f0d00 | ||
|
|
6e677f8857 | ||
|
|
e47e6329cc | ||
|
|
11395b655a | ||
|
|
ca520d2eb6 | ||
|
|
25dda21811 | ||
|
|
8a57ea73fb | ||
|
|
6729359ba2 | ||
|
|
f687389046 | ||
|
|
a7f4f9714f | ||
|
|
7ba238fb53 | ||
|
|
b845f8ba95 | ||
|
|
996309f52f | ||
|
|
8d12a0384b |
No files matched your search
@@ -57,7 +57,7 @@ jobs:
|
||||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
(cd dist && sha256sum -c ./*.sha256)
|
||||
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"
|
||||
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .
|
||||
|
||||
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"CI-tested release candidate for commit %s. Verify downloads with the attached SHA-256 checksum.","draft":true,"prerelease":true}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
|
||||
|
||||
@@ -40,10 +40,10 @@ identity and posts one ordered Markdown evidence comment. After a partial failur
|
||||
the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA
|
||||
Share Target accepts the same bounded multi-image bundle through sign-in continuation.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
issue-comment API. In issue, pull-request, and unread-update conversations, typing
|
||||
at least two characters after `@` offers repository-scoped teammate suggestions;
|
||||
touch or keyboard selection inserts the login without leaving the draft. Mention
|
||||
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
||||
issue-comment API. In issue, pull-request, unread-update, and active Today progress
|
||||
conversations, typing at least two characters after `@` offers repository-scoped
|
||||
teammate suggestions; touch or keyboard selection inserts the login without leaving
|
||||
the draft. Mention lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
||||
creation and assignment permission. A fresh capture never silently targets the first repository:
|
||||
the operator must explicitly choose one, either from the paginated browser or through the bounded
|
||||
authenticated repository search. Search results include only visible repository identities, stale
|
||||
@@ -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,17 @@ 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. Existing plaintext
|
||||
snapshot rows migrate on their first read without changing freshness, revisions, ordering, or claim
|
||||
filters. 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. 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 +267,30 @@ 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 and Find Work snapshots. 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 snapshot state fails closed without returning content.
|
||||
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 +306,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 +329,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
|
||||
|
||||
@@ -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;
|
||||
+132
-11
@@ -14,6 +14,18 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
|
||||
.issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.issue-filing-receipt[hidden] { display:none; }
|
||||
.release-receipt-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
|
||||
.release-receipt-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.release-receipt-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.release-receipt-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.release-receipt-launcher { width:100%; min-height:44px; border-color:#4ade80; }
|
||||
.release-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.release-receipt-panel button, .release-receipt-panel .button-link { box-sizing:border-box; display:flex; align-items:center; justify-content:center; min-width:0; min-height:44px; width:100%; text-align:center; }
|
||||
.release-watchlist { display:grid; gap:8px; margin-top:14px; }
|
||||
.release-watchlist-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:10px; border:1px solid #334155; border-radius:10px; }
|
||||
.release-watchlist-item > div { display:grid; min-width:0; gap:3px; overflow-wrap:anywhere; }
|
||||
.release-watchlist-item > button { width:auto; min-width:88px; }
|
||||
@media (max-width:359px) { .release-receipt-actions { grid-template-columns:1fr; } }
|
||||
.issue-filing-receipt-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; }
|
||||
.issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
@@ -28,9 +40,11 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
#open-insights { display:none; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
.notification-undo[hidden] { display:none; }
|
||||
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
|
||||
.notification-undo { position:fixed; }
|
||||
.notification-undo button { min-height:44px; }
|
||||
.notification-undo, .today-completion-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
.notification-undo[hidden], .today-completion-undo[hidden] { display:none; }
|
||||
.notification-undo button, .today-completion-undo button { min-height:44px; min-width:64px; flex:none; }
|
||||
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.draft-capacity-sheet[hidden] { display:none; }
|
||||
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
@@ -135,6 +149,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; }
|
||||
@@ -252,6 +269,59 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.today-interruption-panel h2 { margin:.25rem 0; overflow-wrap:anywhere; }
|
||||
.today-interruption-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.today-interruption-actions button { min-height:44px; width:100%; }
|
||||
.today-break-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
|
||||
.today-break-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-break-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(620px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-break-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-break-panel h2, .today-break-panel p { margin-top:0; }
|
||||
.today-break-panel header button, .today-break-actions button, .today-break-custom button { min-height:44px; }
|
||||
.today-break-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:16px 0; }
|
||||
.today-break-actions button { min-width:0; width:100%; }
|
||||
.today-break-custom { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; }
|
||||
.today-break-custom label, .today-break-custom p { grid-column:1 / -1; }
|
||||
.today-break-custom input { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.today-break-status { position:fixed; z-index:46; left:12px; right:12px; bottom:calc(224px + env(safe-area-inset-bottom)); box-sizing:border-box; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#102641; }
|
||||
.today-break-status:has(> span[hidden]) { display:none; }
|
||||
.today-break-status button { min-height:44px; min-width:112px; }
|
||||
@media(max-width:359px) { .today-break-actions { grid-template-columns:1fr; } }
|
||||
.today-progress-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
|
||||
.today-progress-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-progress-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(620px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-progress-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-progress-panel h2, .today-progress-panel p { margin-top:0; }
|
||||
.today-progress-panel header button, .today-progress-actions button { min-height:44px; }
|
||||
.today-progress-panel textarea { box-sizing:border-box; width:100%; min-height:120px; resize:vertical; }
|
||||
.today-progress-activity { display:grid; gap:8px; min-width:0; margin:0 0 14px; padding:12px; border:1px solid #294767; border-radius:12px; background:#0d1b2d; }
|
||||
.today-progress-activity-heading { display:flex; justify-content:space-between; gap:8px; }
|
||||
.today-progress-activity-list { display:grid; gap:8px; max-height:32dvh; overflow:auto; overflow-x:hidden; overflow-wrap:anywhere; }
|
||||
.today-progress-activity-list:empty::before { content:'No recent messages yet.'; color:#9ca3af; font-size:.875rem; }
|
||||
.today-progress-activity-item { min-width:0; padding:8px; border-radius:8px; background:#101f34; }
|
||||
.today-progress-activity-item .markdown-content { overflow-wrap:anywhere; }
|
||||
.today-progress-activity-actions { display:flex; gap:8px; }
|
||||
.today-progress-activity-actions button { min-height:44px; }
|
||||
.today-progress-activity-status { margin:0; }
|
||||
.today-progress-evidence { display:grid; gap:8px; min-width:0; margin-top:12px; }
|
||||
.today-progress-evidence .issue-attachment-preview { width:100%; box-sizing:border-box; }
|
||||
.today-progress-evidence .issue-evidence-note textarea { min-height:72px; }
|
||||
.today-progress-blocker { display:grid; gap:8px; margin-top:14px; padding:12px; border:1px solid #875f2a; border-radius:12px; background:#21180d; }
|
||||
.today-progress-blocker input, .today-progress-blocker button { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.today-blocker-presets { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
|
||||
.today-blocker-presets button { min-width:0; padding-inline:6px; }
|
||||
.today-progress-blocker p { margin:0; }
|
||||
.today-progress-blocker .today-progress-blocker-recovery { padding:8px; border-radius:8px; background:#3a270d; color:#fde68a; font-weight:700; }
|
||||
.today-progress-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; }
|
||||
.today-progress-actions button { width:100%; }
|
||||
.today-progress-panel .voice-conversation { margin-top:10px; }
|
||||
.today-progress-panel .voice-conversation-controls button,
|
||||
.today-progress-panel .voice-conversation-review-actions button { min-height:44px; }
|
||||
.today-progress-panel.blocker-mode .today-progress-activity,
|
||||
.today-progress-panel.blocker-mode .voice-conversation,
|
||||
.today-progress-panel.blocker-mode .today-progress-evidence,
|
||||
.today-progress-panel.blocker-mode .today-progress-update-help,
|
||||
.today-progress-panel.blocker-mode .today-progress-actions { display:none; }
|
||||
.today-progress-panel.blocker-mode textarea { min-height:112px; }
|
||||
@media(max-width:359px) { .today-blocker-presets { grid-template-columns:1fr; } }
|
||||
@media(max-width:359px) { .today-progress-actions { grid-template-columns:1fr; } }
|
||||
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-recap-sheet[hidden] { display:none; }
|
||||
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
@@ -284,6 +354,21 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.today-wrap-up-item input { width:24px; min-height:24px; }
|
||||
.today-wrap-up-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-wrap-up-actions button { min-height:44px; width:100%; }
|
||||
.today-summary-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-summary-sheet[hidden] { display:none; }
|
||||
.today-summary-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-summary-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-summary-header h2 { margin:.2rem 0; }
|
||||
.today-summary-header button { min-height:44px; min-width:44px; }
|
||||
.today-summary-items { display:grid; gap:8px; }
|
||||
.today-summary-item, .today-summary-option { display:flex; align-items:center; gap:10px; min-height:44px; padding:10px; border:1px solid #2a496e; border-radius:10px; background:#10233d; overflow-wrap:anywhere; }
|
||||
.today-summary-item input, .today-summary-option input { width:24px; min-height:24px; flex:0 0 auto; }
|
||||
.today-summary-item span { display:block; min-width:0; }
|
||||
.today-summary-note { display:grid; gap:6px; margin:14px 0; font-weight:700; }
|
||||
.today-summary-note textarea { box-sizing:border-box; width:100%; min-height:88px; resize:vertical; }
|
||||
.today-summary-preview { min-height:72px; white-space:pre-wrap; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#07101e; color:#e8f1ff; }
|
||||
.today-summary-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-summary-actions button { min-height:44px; width:100%; }
|
||||
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
|
||||
@@ -355,17 +440,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); }
|
||||
@@ -786,6 +890,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.today-capture-interruption { display:grid; gap:2px; padding:10px 12px; border:1px solid #3f6f9f; border-radius:10px; background:#10243b; }
|
||||
.today-capture-interruption[hidden] { display:none; }
|
||||
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
|
||||
.mobile-create-issue-nav { display:none; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
@@ -926,6 +1032,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
|
||||
.mobile-today-hud { display:none; }
|
||||
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
|
||||
.mobile-today-actions { box-sizing:border-box; width:min(520px,100%); max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-today-actions::backdrop { background:rgba(3,9,18,.76); }
|
||||
.mobile-today-actions-panel { box-sizing:border-box; display:grid; gap:12px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-today-actions-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.mobile-today-actions-panel h2 { margin:0; }
|
||||
.mobile-today-navigation, .mobile-today-secondary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.mobile-today-actions-panel button { min-width:0; min-height:44px; }
|
||||
.mobile-today-secondary-actions [data-mobile-today-end] { grid-column:1 / -1; border-color:#b45309; }
|
||||
.today-session-handoff { position:fixed; left:12px; right:12px; bottom:calc(68px + env(safe-area-inset-bottom)); z-index:45; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:12px; border:1px solid #60a5fa; border-radius:12px; background:rgba(16,38,65,.98); box-shadow:0 8px 28px rgba(0,0,0,.35); }
|
||||
.today-session-handoff[hidden] { display:none; }
|
||||
.today-session-handoff button { min-height:44px; }
|
||||
@media(max-width:420px) { .today-session-handoff { grid-template-columns:1fr; } .today-session-handoff button { width:100%; } }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[hidden] { display:none; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
@@ -933,9 +1051,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; }
|
||||
@@ -1060,13 +1180,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) {
|
||||
|
||||
+265
-60
@@ -97,6 +97,7 @@
|
||||
(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result);
|
||||
}
|
||||
let mobileQueueCounts = {};
|
||||
let mobilePreparationItems = {};
|
||||
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => draftInbox.partition(lastDrafts).deliveries,
|
||||
getIndex: item => lastDrafts.indexOf(item),
|
||||
@@ -131,21 +132,31 @@
|
||||
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 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,
|
||||
findWork: () => qs('#find-work').click(),
|
||||
});
|
||||
const mobileStartDay = createMobileStartDay({
|
||||
getCounts: () => mobileQueueCounts,
|
||||
getPhaseItems: () => mobilePreparationItems,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
openQueue: name => {
|
||||
const sheet = qs('#mobile-queue-sheet');
|
||||
@@ -209,13 +220,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);
|
||||
@@ -224,20 +235,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')],
|
||||
menu: qs('.app-menu'),
|
||||
history: window.history,
|
||||
location: window.location,
|
||||
eventTarget: window,
|
||||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||||
detour:() => timerView,
|
||||
});
|
||||
mobileInsights.start();
|
||||
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
|
||||
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
|
||||
let liveMode = true;
|
||||
@@ -310,6 +334,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; },
|
||||
@@ -365,11 +407,12 @@
|
||||
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 });
|
||||
@@ -485,22 +528,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' } });
|
||||
@@ -575,6 +615,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;
|
||||
@@ -821,25 +871,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);
|
||||
}
|
||||
@@ -964,6 +1010,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),
|
||||
});
|
||||
@@ -977,7 +1028,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({
|
||||
@@ -999,7 +1050,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({
|
||||
@@ -1103,6 +1154,7 @@
|
||||
return false;
|
||||
});
|
||||
}
|
||||
attachReleaseReceipt();
|
||||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
if (!wrapPreference) {
|
||||
wrapPreference = createReviewController.createWrapPreference({
|
||||
@@ -1112,6 +1164,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 });
|
||||
@@ -1795,9 +1855,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,
|
||||
@@ -1806,13 +1874,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', () => {
|
||||
@@ -1842,18 +1929,21 @@
|
||||
interruptionPrompt.resolve(button.dataset.todayInterruption)
|
||||
);
|
||||
});
|
||||
const todaySummaryView = setupTodaySummary({ qs, escapeHtml, getLogin:() => planningOwnerLogin });
|
||||
todaySummaryView.resume();
|
||||
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
|
||||
onComplete:() => {
|
||||
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 => todayWrapUpView.open(todayMyWork, actualMinutes)
|
||||
(actualMinutes, workedItems) => todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)
|
||||
);
|
||||
function updateDetailDeferLabels(active) {
|
||||
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
||||
@@ -1875,6 +1965,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);
|
||||
@@ -1904,6 +1995,7 @@
|
||||
document.querySelectorAll('[data-work-session-complete]').forEach(button => {
|
||||
button.hidden = !workSession.checkpointed();
|
||||
});
|
||||
todayProgressView.update();
|
||||
},
|
||||
onFinish: () => {
|
||||
updateWorkSessionActions();
|
||||
@@ -1917,6 +2009,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();
|
||||
}
|
||||
@@ -2115,6 +2226,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));
|
||||
@@ -3042,6 +3194,13 @@
|
||||
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,
|
||||
@@ -3394,6 +3553,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');
|
||||
@@ -3768,7 +3935,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),
|
||||
@@ -4610,7 +4777,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');
|
||||
@@ -4669,6 +4854,7 @@
|
||||
updateIssueCreateActions();
|
||||
qs('#create-issue-sheet').classList.add('open');
|
||||
creatingIssue = true;
|
||||
issueCaptureModal().open(createIssueLauncher);
|
||||
if (!sharedImageHandled && sharedImageMarker) {
|
||||
sharedImageHandled = true;
|
||||
if (createIssueAttachmentController.state()) {
|
||||
@@ -4725,7 +4911,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');
|
||||
@@ -4734,13 +4920,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) {
|
||||
@@ -5084,6 +5273,7 @@
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
restoreReleaseReceipt();
|
||||
updateDeliveryReceiptControls();
|
||||
interruptionPrompt.restore();
|
||||
}
|
||||
@@ -5294,9 +5484,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.';
|
||||
}
|
||||
@@ -5805,7 +5995,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};
|
||||
@@ -5846,16 +6035,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});
|
||||
});
|
||||
@@ -6855,7 +7047,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)
|
||||
@@ -7329,6 +7522,7 @@
|
||||
if (!saved) return false;
|
||||
const outage = mode === 'outage';
|
||||
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
||||
restoreReleaseReceipt();
|
||||
planningOwnerLogin = confirmedOwnerLogin;
|
||||
interruptionPrompt.restore();
|
||||
updatePlanningAvailability();
|
||||
@@ -7734,6 +7928,16 @@
|
||||
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,
|
||||
@@ -7774,6 +7978,7 @@
|
||||
installApp.start();
|
||||
deviceSetup = createMobileDeviceSetup.mount({
|
||||
document, installApp, promptStorage:localStorage,
|
||||
timerView,
|
||||
offlineAvailable:() => offlineStorageReady,
|
||||
offlineEnabled:() => offlineWorkStore.enabled(),
|
||||
enableOffline:() => setOfflineWorkEnabled(true),
|
||||
|
||||
+241
-2
@@ -51,6 +51,10 @@
|
||||
<div><h2>Security Center</h2><p class="small muted">Investigate activity, remove untrusted access, and verify the result.</p></div>
|
||||
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||
</div>
|
||||
<aside class="today-detour-interruption" id="security-center-today-detour" data-today-detour role="status" aria-live="polite" hidden>
|
||||
<strong data-today-detour-label>Today paused</strong>
|
||||
<button id="return-from-security-center" data-return-from-detour type="button">Return to Today</button>
|
||||
</aside>
|
||||
<nav class="security-section-nav" aria-label="Security Center sections">
|
||||
<button id="security-section-activity" type="button">Activity</button>
|
||||
<button id="security-section-devices" type="button">Devices</button>
|
||||
@@ -95,6 +99,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>
|
||||
@@ -182,6 +190,8 @@
|
||||
<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>
|
||||
@@ -205,6 +215,10 @@
|
||||
<span><strong>Today paused</strong> while handling Attention</span>
|
||||
<button id="return-to-today" type="button">Return to Today</button>
|
||||
</aside>
|
||||
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
|
||||
<strong data-today-detour-label>Today paused</strong>
|
||||
<button data-return-from-detour type="button">Return to Today</button>
|
||||
</aside>
|
||||
<section class="device-readiness-card" id="device-readiness-card" aria-labelledby="device-readiness-heading" hidden>
|
||||
<div>
|
||||
<strong id="device-readiness-heading">Make this phone work-ready</strong>
|
||||
@@ -246,6 +260,13 @@
|
||||
<button id="empty-work-create" type="button">Create an issue</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="deadline-snooze" id="deadline-snooze" aria-labelledby="deadline-snooze-heading" hidden>
|
||||
<div>
|
||||
<strong id="deadline-snooze-heading">Deadline reminders paused</strong>
|
||||
<p class="small" id="deadline-snooze-status" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<button id="review-snoozed-deadlines" type="button">Review now</button>
|
||||
</section>
|
||||
<section class="agenda-replan" id="agenda-replan" aria-labelledby="agenda-replan-heading" hidden>
|
||||
<div class="agenda-replan-launch">
|
||||
<div><strong id="agenda-replan-heading">Overdue deadlines</strong><p class="small muted">Make the Agenda credible without leaving the queue.</p></div>
|
||||
@@ -263,6 +284,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="agenda-export" id="agenda-export" aria-labelledby="agenda-export-heading" hidden>
|
||||
<div>
|
||||
<strong id="agenda-export-heading">Take deadlines with you</strong>
|
||||
<p class="small muted" id="agenda-export-empty">Review this Agenda before sharing a calendar snapshot.</p>
|
||||
</div>
|
||||
<button id="open-agenda-export" type="button">Export to calendar</button>
|
||||
</section>
|
||||
<section class="protect-today" id="protect-today-panel" aria-labelledby="protect-today-heading" hidden>
|
||||
<div>
|
||||
<strong id="protect-today-heading">Deadlines need a plan</strong>
|
||||
@@ -281,8 +309,10 @@
|
||||
<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="release-receipt-launcher" id="release-receipt-launcher" type="button" hidden>Merged · tracking release</button>
|
||||
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
|
||||
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
||||
<div class="small" id="today-session-sync-status" role="status" aria-live="polite" hidden></div>
|
||||
<div class="small" id="later-sync-status" aria-live="polite">Later is saved on this device.</div>
|
||||
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
|
||||
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
||||
@@ -297,11 +327,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>
|
||||
@@ -468,6 +525,29 @@
|
||||
</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>
|
||||
<div class="today-summary-actions">
|
||||
<button id="share-today-summary" type="button">Share summary</button>
|
||||
<button id="discard-today-summary" type="button">Discard draft</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
|
||||
<section class="today-readiness-panel">
|
||||
<div class="today-readiness-header">
|
||||
@@ -518,6 +598,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>
|
||||
@@ -595,6 +679,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>
|
||||
@@ -898,6 +986,10 @@
|
||||
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
|
||||
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
|
||||
</div>
|
||||
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
|
||||
<strong data-today-detour-label>Today paused</strong>
|
||||
<button data-return-from-detour type="button">Return to Today</button>
|
||||
</aside>
|
||||
<p class="small">Choose available issues, review the batch, then fit it into Today.</p>
|
||||
<nav class="mobile-find-work-nav" aria-label="Find Work steps">
|
||||
<button type="button" data-find-work-stage="discover">Discover</button>
|
||||
@@ -961,6 +1053,10 @@
|
||||
<h3 id="create-issue-heading">Capture work</h3>
|
||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||
</div>
|
||||
<aside class="today-capture-interruption" id="today-capture-interruption" role="status" hidden>
|
||||
<strong id="today-capture-interruption-label">Today paused</strong>
|
||||
<span class="small">Capture this thought, then return without counting interruption time.</span>
|
||||
</aside>
|
||||
<nav class="mobile-create-issue-nav" aria-label="New issue sections">
|
||||
<button type="button" data-create-issue-section="describe">Describe</button>
|
||||
<button type="button" data-create-issue-section="evidence">Evidence</button>
|
||||
@@ -1305,6 +1401,11 @@
|
||||
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
|
||||
</div>
|
||||
|
||||
<div class="today-completion-undo" id="today-completion-undo" role="status" aria-live="assertive" hidden>
|
||||
<span>Removed from Today. Undo?</span>
|
||||
<button id="undo-today-completion" type="button" aria-label="Undo removing the completed item from Today">Undo</button>
|
||||
</div>
|
||||
|
||||
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
|
||||
<section class="pull-sheet-panel">
|
||||
<div class="pull-sheet-header">
|
||||
@@ -1515,16 +1616,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>
|
||||
@@ -1610,14 +1841,20 @@
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/agenda-replan.js"></script>
|
||||
<script src="static/agenda-calendar.js"></script>
|
||||
<script src="static/protect-today.js"></script>
|
||||
<script src="static/notification-undo.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/work-selection.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
<script src="static/today-break.js"></script>
|
||||
<script src="static/today-progress.js"></script>
|
||||
<script src="static/today-lock-screen.js"></script>
|
||||
<script src="static/today-session-sync.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
<script src="static/today-wrap-up.js"></script>
|
||||
<script src="static/today-summary.js"></script>
|
||||
<script src="static/today-handoff.js"></script>
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
@@ -1656,10 +1893,12 @@
|
||||
<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-work-entry.js"></script>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
||||
];
|
||||
let trigger = options.launcher;
|
||||
let ownsDetour = false;
|
||||
|
||||
function readinessCounts(readiness) {
|
||||
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
|
||||
@@ -50,13 +51,18 @@
|
||||
|
||||
async function open(event) {
|
||||
trigger = event?.currentTarget || options.launcher;
|
||||
if (options.isMobile?.()) {
|
||||
ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup';
|
||||
}
|
||||
await render();
|
||||
options.sheet.hidden = false;
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
function close(resume = true) {
|
||||
options.sheet.hidden = true;
|
||||
if (resume && ownsDetour) options.timerView?.finishDetour?.();
|
||||
ownsDetour = false;
|
||||
trigger?.focus?.();
|
||||
}
|
||||
|
||||
@@ -70,6 +76,7 @@
|
||||
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();
|
||||
});
|
||||
@@ -108,6 +115,9 @@ function mountMobileDeviceSetup(options) {
|
||||
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,
|
||||
getReadiness:() => ({
|
||||
install:options.installApp.state(),
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
else root.createMobileInsights = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function (o) {
|
||||
const route = '#/insights';
|
||||
const events = o.eventTarget || self;
|
||||
const location = o.location || self.location;
|
||||
let active = false;
|
||||
let previous = '#/my-work';
|
||||
let retries = 0;
|
||||
|
||||
function attr(element, name, value) {
|
||||
if (value === null) element.removeAttribute(name);
|
||||
@@ -12,7 +15,14 @@
|
||||
}
|
||||
|
||||
function render(open, focus = false) {
|
||||
const wasActive = active;
|
||||
active = Boolean(open && o.mediaQuery.matches);
|
||||
const detour = active ? o.detour?.() : null;
|
||||
const started = detour?.beginDetour('insights');
|
||||
if (detour && !started && retries++ < 40) setTimeout(sync, 250);
|
||||
else if (started && retries) { retries = 0; setTimeout(sync, 500); }
|
||||
else retries = 0;
|
||||
if (wasActive && !active) o.detour?.()?.finishDetour();
|
||||
attr(o.root, 'data-mobile-open', active ? 'true' : null);
|
||||
attr(o.root, 'role', active ? 'dialog' : null);
|
||||
attr(o.root, 'aria-modal', active ? 'true' : null);
|
||||
@@ -26,20 +36,21 @@
|
||||
}
|
||||
|
||||
function url(hash) {
|
||||
return (o.location.pathname || '') + (o.location.search || '') + hash;
|
||||
return (location.pathname || '') + (location.search || '') + hash;
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!o.mediaQuery.matches || active) return false;
|
||||
previous = o.location.hash && o.location.hash !== route ? o.location.hash : '#/my-work';
|
||||
if (o.menu) o.menu.open = false;
|
||||
previous = location.hash && location.hash !== route ? location.hash : '#/my-work';
|
||||
const menu = o.menu || o.launcher.closest?.('details');
|
||||
if (menu) menu.open = false;
|
||||
o.history.pushState({ ...(o.history.state || {}), mobileInsights:true, previousHash:previous }, '', url(route));
|
||||
render(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
render(o.location.hash === route, active && o.location.hash !== route);
|
||||
render(location.hash === route, active && location.hash !== route);
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -55,12 +66,23 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function returnToToday() {
|
||||
if (!active) return false;
|
||||
const state = { ...(o.history.state || {}) };
|
||||
delete state.mobileInsights;
|
||||
delete state.previousHash;
|
||||
o.history.replaceState(state, '', url(previous));
|
||||
render(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
o.launcher.addEventListener('click', open);
|
||||
o.closeButton.addEventListener('click', close);
|
||||
o.eventTarget.addEventListener('popstate', sync);
|
||||
o.eventTarget.addEventListener('hashchange', sync);
|
||||
o.eventTarget.addEventListener('keydown', event => {
|
||||
(o.returnButton || o.root.querySelector?.('[data-return-from-detour]'))?.addEventListener('click', returnToToday);
|
||||
events.addEventListener('popstate', sync);
|
||||
events.addEventListener('hashchange', sync);
|
||||
events.addEventListener('keydown', event => {
|
||||
if (active && event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
@@ -70,5 +92,5 @@
|
||||
sync();
|
||||
}
|
||||
|
||||
return { start, open, close, current:() => active };
|
||||
return { start, open, close, sync, current:() => active };
|
||||
});
|
||||
@@ -11,11 +11,44 @@
|
||||
['update', 'Updates'],
|
||||
['filed', 'Filed'],
|
||||
];
|
||||
const anonymousItems = new WeakMap();
|
||||
let anonymousItemSequence = 0;
|
||||
|
||||
function count(value) {
|
||||
return Math.max(0, Number(value) || 0);
|
||||
}
|
||||
|
||||
function itemIdentity(item) {
|
||||
if (item === null || item === undefined) return '';
|
||||
if (typeof item !== 'object') return String(item);
|
||||
const explicit = item.outbox_id || item.id || item.key || item.url;
|
||||
if (explicit !== null && explicit !== undefined && String(explicit)) {
|
||||
return String(item.kind || 'item') + ':' + String(explicit);
|
||||
}
|
||||
if (!anonymousItems.has(item)) anonymousItems.set(item, 'anonymous:' + (++anonymousItemSequence));
|
||||
return anonymousItems.get(item);
|
||||
}
|
||||
|
||||
function reviewPhases(counts) {
|
||||
if (!options.getPhaseItems) {
|
||||
return reviewOrder
|
||||
.map(([name, label]) => ({name, label, count: count(counts[name])}))
|
||||
.filter(phase => phase.count > 0);
|
||||
}
|
||||
const items = options.getPhaseItems() || {};
|
||||
const seen = new Set();
|
||||
return reviewOrder.flatMap(([name, label]) => {
|
||||
let phaseCount = 0;
|
||||
(items[name] || []).forEach(item => {
|
||||
const identity = itemIdentity(item);
|
||||
if (!identity || seen.has(identity)) return;
|
||||
seen.add(identity);
|
||||
phaseCount += 1;
|
||||
});
|
||||
return phaseCount ? [{name, label, count:phaseCount}] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function localDay() {
|
||||
const now = new Date();
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
@@ -69,12 +102,10 @@
|
||||
|
||||
function briefing() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const phases = reviewOrder
|
||||
.map(([name, label]) => ({name, label, count: count(counts[name])}))
|
||||
.filter(phase => phase.count > 0);
|
||||
const phases = reviewPhases(counts);
|
||||
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
|
||||
const today = count(counts.today);
|
||||
const delivery = count(counts.delivery);
|
||||
const delivery = phases.find(phase => phase.name === 'delivery')?.count || 0;
|
||||
const other = total - delivery;
|
||||
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
|
||||
const nextLabel = next === 'delivery' ? 'Review Delivery' :
|
||||
|
||||
@@ -33,8 +33,9 @@
|
||||
options.queueSheet.showModal();
|
||||
}
|
||||
|
||||
function closeQueues() {
|
||||
function closeQueues(returnToToday = false) {
|
||||
options.queueSheet?.open && options.queueSheet.close();
|
||||
if (returnToToday) options.detour?.()?.finishDetour();
|
||||
}
|
||||
|
||||
function start() {
|
||||
@@ -42,14 +43,16 @@
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
|
||||
if (name === 'queues') options.detour?.()?.beginDetour(name);
|
||||
options.actions?.[name]?.();
|
||||
if (name === 'queues' && options.queueSheet) openQueues();
|
||||
});
|
||||
});
|
||||
if (options.queueSheet) {
|
||||
options.queueClose?.addEventListener('click', closeQueues);
|
||||
options.queueClose?.addEventListener('click', () => closeQueues(true));
|
||||
options.queueSheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
closeQueues();
|
||||
closeQueues(true);
|
||||
});
|
||||
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
|
||||
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
||||
@@ -66,13 +69,16 @@
|
||||
function updateWork(mode) {
|
||||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
prepare:'Prepare', 'prepare-resume':'Resume prep',
|
||||
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', queue
|
||||
? (mode === 'update' ? 'Resume ' : 'Open ') + text
|
||||
: mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today'));
|
||||
const actionLabel = mode === 'prepare' ? 'Prepare Today' :
|
||||
mode === 'prepare-resume' ? 'Resume preparation' :
|
||||
queue ? (mode === 'update' ? 'Resume ' : 'Open ') + text :
|
||||
mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today');
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', actionLabel);
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.createMobileTodayCommandBar = factory;
|
||||
})(typeof window !== 'undefined' ? window : this, function createMobileTodayCommandBar({
|
||||
more,
|
||||
sheet,
|
||||
close,
|
||||
firstAction,
|
||||
endAction,
|
||||
existingEndControl,
|
||||
actionButtons = [],
|
||||
hud,
|
||||
dock,
|
||||
style,
|
||||
ResizeObserverImpl = typeof ResizeObserver === 'undefined' ? null : ResizeObserver,
|
||||
}) {
|
||||
if (!more || !sheet) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
if (sheet.open) sheet.close();
|
||||
};
|
||||
const open = () => {
|
||||
if (sheet.open) return;
|
||||
more.setAttribute('aria-expanded', 'true');
|
||||
sheet.showModal();
|
||||
firstAction?.focus();
|
||||
};
|
||||
|
||||
more.setAttribute('aria-expanded', 'false');
|
||||
more.addEventListener('click', open);
|
||||
close?.addEventListener('click', dismiss);
|
||||
actionButtons.forEach(button => button?.addEventListener('click', dismiss));
|
||||
endAction?.addEventListener('click', () => {
|
||||
dismiss();
|
||||
existingEndControl?.click();
|
||||
});
|
||||
sheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
dismiss();
|
||||
});
|
||||
sheet.addEventListener('close', () => {
|
||||
more.setAttribute('aria-expanded', 'false');
|
||||
more.focus();
|
||||
});
|
||||
|
||||
const measure = () => {
|
||||
if (!style || !hud || !dock) return;
|
||||
style.setProperty('--mobile-today-clearance', `${hud.offsetHeight + dock.offsetHeight + 16}px`);
|
||||
};
|
||||
const observer = ResizeObserverImpl && hud && dock ? new ResizeObserverImpl(measure) : null;
|
||||
observer?.observe(hud);
|
||||
observer?.observe(dock);
|
||||
measure();
|
||||
|
||||
return { open, close:dismiss, measure, destroy:() => observer?.disconnect() };
|
||||
});
|
||||
@@ -4,6 +4,9 @@
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
const preparation = options.getPreparationState?.();
|
||||
if (preparation?.active) return 'prepare-resume';
|
||||
if (preparation?.next && !['today', 'find'].includes(preparation.next)) return 'prepare';
|
||||
const recommended = options.queueLauncher && options.queueLauncher.recommend();
|
||||
if (recommended && !['today', 'find'].includes(recommended.name)) return recommended.name;
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
@@ -15,6 +18,7 @@
|
||||
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();
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
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, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
||||
notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
let configuration = null;
|
||||
|
||||
@@ -16,6 +18,32 @@
|
||||
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.'};
|
||||
@@ -136,6 +164,7 @@
|
||||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
@@ -153,6 +182,7 @@
|
||||
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};
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
@@ -52,6 +52,7 @@
|
||||
if (!devicesSheet.hidden) {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -222,6 +223,7 @@
|
||||
|
||||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
onOpen();
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
|
||||
|
||||
+107
-6
@@ -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-v117';
|
||||
const CACHE = 'stackchain-dashboard-shell-v121';
|
||||
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;
|
||||
@@ -45,14 +45,20 @@ const SHELL = [
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/agenda-replan.js',
|
||||
BASE + 'static/agenda-calendar.js',
|
||||
BASE + 'static/protect-today.js',
|
||||
BASE + 'static/notification-undo.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/work-selection.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-break.js',
|
||||
BASE + 'static/today-progress.js',
|
||||
BASE + 'static/today-lock-screen.js',
|
||||
BASE + 'static/today-session-sync.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
BASE + 'static/today-wrap-up.js',
|
||||
BASE + 'static/today-summary.js',
|
||||
BASE + 'static/today-handoff.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
@@ -99,10 +105,12 @@ 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-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
@@ -418,6 +426,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();
|
||||
@@ -430,11 +471,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 => {
|
||||
@@ -462,7 +512,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},
|
||||
}
|
||||
@@ -527,9 +577,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));
|
||||
@@ -545,10 +630,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') {
|
||||
|
||||
+16
-1
@@ -45,6 +45,12 @@
|
||||
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 returnFromSecurity = root.document.getElementById('return-from-security-center');
|
||||
const beginSecurityDetour = () => {
|
||||
if (root.matchMedia?.('(max-width: 600px)')?.matches) {
|
||||
root.stackchainTodayTimerView?.beginDetour?.('security-center');
|
||||
}
|
||||
};
|
||||
const securityFeatures = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
@@ -57,6 +63,7 @@
|
||||
const openSecurityCenter = async () => {
|
||||
if (loadingSecurityCenter) return;
|
||||
loadingSecurityCenter = true;
|
||||
beginSecurityDetour();
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
try {
|
||||
@@ -66,7 +73,10 @@
|
||||
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 +88,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();
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -283,6 +283,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#open-today-recaps').focus();
|
||||
};
|
||||
const saveDraft = async button => {
|
||||
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Saving recap…';
|
||||
try {
|
||||
@@ -290,7 +291,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
openWrapUp(handoff.actual_minutes);
|
||||
openWrapUp(handoff.actual_minutes, workedItems);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
||||
} finally { button.disabled = false; }
|
||||
@@ -303,6 +304,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
return;
|
||||
}
|
||||
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
|
||||
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Logging selected time…';
|
||||
try {
|
||||
@@ -310,7 +312,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
openWrapUp(actualMinutes);
|
||||
openWrapUp(actualMinutes, workedItems);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
|
||||
render();
|
||||
|
||||
@@ -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,240 @@
|
||||
function createTodaySummary({ storage = null, getLogin = () => '', share = null, copy = null } = {}) {
|
||||
let draft = null;
|
||||
let draftKey = '';
|
||||
|
||||
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),
|
||||
} : 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');
|
||||
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
|
||||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000) {
|
||||
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:'',
|
||||
};
|
||||
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;
|
||||
},
|
||||
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-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;
|
||||
}
|
||||
}
|
||||
|
||||
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('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
|
||||
}
|
||||
|
||||
return {
|
||||
open, close, render, bind, shareDraft,
|
||||
resume() { if (summary.restore()) show(); },
|
||||
};
|
||||
}
|
||||
|
||||
function setupTodaySummary({ qs, escapeHtml, getLogin }) {
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+368
-8
@@ -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'].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,12 +79,38 @@ 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();
|
||||
@@ -73,6 +124,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 +143,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 +177,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 +212,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'].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 +390,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 +457,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 +512,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 +602,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;
|
||||
@@ -53,6 +53,7 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
|
||||
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
|
||||
let actualMinutes = {};
|
||||
let workedItems = [];
|
||||
|
||||
function close() {
|
||||
qs('#today-wrap-up-sheet').hidden = true;
|
||||
@@ -75,8 +76,9 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
|
||||
});
|
||||
}
|
||||
|
||||
function open(items, recapActualMinutes = {}) {
|
||||
function open(items, recapActualMinutes = {}, recapWorkedItems = []) {
|
||||
actualMinutes = { ...recapActualMinutes };
|
||||
workedItems = recapWorkedItems.map(item => ({...item}));
|
||||
controller.open(items);
|
||||
qs('#today-wrap-up-status').textContent = '';
|
||||
render();
|
||||
@@ -89,10 +91,15 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
|
||||
button.disabled = true;
|
||||
qs('#today-wrap-up-status').textContent = 'Saving tomorrow’s plan…';
|
||||
try {
|
||||
const tomorrowItems = controller.snapshot().filter(row => row.schedule).map(row => ({
|
||||
identity:row.identity,
|
||||
title:String(row.item.title || row.identity).slice(0, 180),
|
||||
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
|
||||
}));
|
||||
const result = await controller.finish();
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
|
||||
close();
|
||||
onComplete(result, actualMinutes);
|
||||
onComplete(result, actualMinutes, workedItems, tomorrowItems);
|
||||
} catch (error) {
|
||||
qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.';
|
||||
} finally {
|
||||
|
||||
@@ -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};
|
||||
@@ -1,4 +1,5 @@
|
||||
fastapi==0.133.1
|
||||
cryptography==50.0.0
|
||||
httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
Pillow==12.3.0
|
||||
|
||||
+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()
|
||||
|
||||
@@ -25,7 +25,7 @@ FEATURE_SOURCES = {
|
||||
"issue-capture": (
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": (
|
||||
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
|
||||
@@ -34,11 +34,11 @@ FEATURE_SOURCES = {
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/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-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",
|
||||
),
|
||||
|
||||
+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")),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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=(",", ":")),
|
||||
),
|
||||
)
|
||||
|
||||
+166
-23
@@ -57,14 +57,20 @@ from src.push_notifications import (
|
||||
dispatch_unread_updates,
|
||||
)
|
||||
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.views import FRONTEND_BUILD, router as frontend_router
|
||||
|
||||
|
||||
@@ -347,8 +353,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 +602,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)
|
||||
|
||||
@@ -1322,7 +1351,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 (
|
||||
@@ -2127,7 +2156,7 @@ 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()
|
||||
)
|
||||
return {
|
||||
"available": configuration.enabled,
|
||||
@@ -2137,6 +2166,7 @@ async def push_status(request: Request):
|
||||
"timezone": preferences["timezone"],
|
||||
"reminder_hour": preferences["reminder_hour"],
|
||||
"reminder_days": preferences["reminder_days"],
|
||||
"snoozed_until": preferences["snoozed_until"],
|
||||
}
|
||||
|
||||
|
||||
@@ -2215,6 +2245,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
|
||||
@@ -2269,8 +2328,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", "")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -2371,7 +2444,7 @@ async def get_unfiled_drafts(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
except (OSError, sqlite3.Error, UnfiledDraftEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Draft synchronization is unavailable",
|
||||
@@ -2401,7 +2474,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",
|
||||
@@ -2456,6 +2529,37 @@ 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):
|
||||
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):
|
||||
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()
|
||||
@@ -5879,31 +5983,60 @@ async def merge_assigned_pull(
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
|
||||
async def merge_pull():
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
return await gitea_proxy.merge_assigned_pull(
|
||||
repository, number, submission.expected_head_sha
|
||||
try:
|
||||
assigned = await asyncio.wait_for(
|
||||
gitea_proxy.is_assigned_pull(repository, number),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The pull request assignment could not be verified. Nothing was merged."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
if not assigned:
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"pull_merged",
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
return JSONResponse(
|
||||
{"error": "Security activity is temporarily unavailable. Nothing was merged."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
merge_pull(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"pull_merged",
|
||||
target=f"{repository}#{number}",
|
||||
gitea_proxy.merge_assigned_pull(
|
||||
repository, number, submission.expected_head_sha
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except gitea_proxy.StalePullError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "New commits were pushed. Refresh before merging."},
|
||||
status_code=409,
|
||||
)
|
||||
except gitea_proxy.PullNotMergeableError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "This pull request is not currently safe to merge. Refresh its status."},
|
||||
status_code=409,
|
||||
@@ -5919,6 +6052,10 @@ async def merge_assigned_pull(
|
||||
except Exception:
|
||||
merged = False
|
||||
if merged:
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return {"number": number, "merged": True, "state": "closed"}
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -5933,6 +6070,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
|
||||
+89
-15
@@ -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,59 @@ 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
|
||||
|
||||
|
||||
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,7 +210,7 @@ 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
|
||||
)
|
||||
@@ -207,7 +257,7 @@ 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
|
||||
)
|
||||
@@ -298,10 +348,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 +375,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 +388,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 +424,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,7 +460,9 @@ 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:
|
||||
if isinstance(error, UnsafePushEndpoint):
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_deadline_reminder_delivered, device.session_id, local_day
|
||||
|
||||
+167
-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,75 @@ 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 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 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 +134,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 +179,7 @@ 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
|
||||
);
|
||||
@@ -140,6 +226,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 +290,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:
|
||||
@@ -215,38 +333,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 +463,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}))
|
||||
|
||||
@@ -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
|
||||
+80
-2
@@ -13,6 +13,14 @@ 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,
|
||||
@@ -36,7 +44,7 @@ class TodayStore:
|
||||
|
||||
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 +114,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()
|
||||
|
||||
@@ -163,6 +188,59 @@ class TodayStore:
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
@staticmethod
|
||||
def _empty_session() -> dict:
|
||||
return {
|
||||
"revision": 0, "device_id": "", "identity": "",
|
||||
"elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
|
||||
}
|
||||
|
||||
def get_session(self, login: str) -> dict:
|
||||
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 = ?",
|
||||
(self._normalize_login(login),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return self._empty_session()
|
||||
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],
|
||||
}
|
||||
|
||||
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_revision = int(current[0]) if current else 0
|
||||
if base_revision != current_revision:
|
||||
session = self._empty_session() if current is None else {
|
||||
"revision": current_revision, "device_id": current[1], "identity": current[2],
|
||||
"elapsed_ms": int(current[3]), "running": bool(current[4]),
|
||||
"break_deadline_at": current[5], "updated_at": current[6],
|
||||
}
|
||||
raise TodaySessionConflict(session)
|
||||
revision = current_revision + 1
|
||||
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, device_id, identity, elapsed_ms, int(running), break_deadline_at, updated_at),
|
||||
)
|
||||
return self.get_session(login)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_recap_items(items: list[dict]) -> list[dict]:
|
||||
if not isinstance(items, list) or not items:
|
||||
|
||||
+190
-8
@@ -3,17 +3,22 @@
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
_KEY_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
|
||||
|
||||
|
||||
class UnfiledDraftConflict(ValueError):
|
||||
@@ -24,16 +29,97 @@ class UnfiledDraftConflict(ValueError):
|
||||
self.snapshot = snapshot
|
||||
|
||||
|
||||
class UnfiledDraftEncryptionError(RuntimeError):
|
||||
"""Raised when private draft state cannot be authenticated and decrypted."""
|
||||
|
||||
|
||||
def decode_unfiled_draft_encryption_key(encoded: str) -> bytes:
|
||||
"""Decode the independently injected 256-bit draft-encryption key."""
|
||||
try:
|
||||
key = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError) as error:
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"unfiled draft encryption key is invalid"
|
||||
) from error
|
||||
if len(key) != 32:
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"unfiled draft encryption key must decode to exactly 32 bytes"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def decode_unfiled_draft_encryption_keyring(
|
||||
encoded: str, active_key_id: str
|
||||
) -> tuple[dict[str, bytes], str]:
|
||||
"""Decode a bounded JSON object of named keys without exposing key material."""
|
||||
def unique_object(pairs):
|
||||
value = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("duplicate key id")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
try:
|
||||
raw = json.loads(encoded, object_pairs_hook=unique_object)
|
||||
if not isinstance(raw, dict) or not raw or len(raw) > 8:
|
||||
raise ValueError("invalid keyring size")
|
||||
if active_key_id not in raw:
|
||||
raise ValueError("active key is unavailable")
|
||||
keys = {}
|
||||
for key_id, value in raw.items():
|
||||
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
|
||||
raise ValueError("invalid key id")
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("invalid key value")
|
||||
keys[key_id] = decode_unfiled_draft_encryption_key(value)
|
||||
if len(set(keys.values())) != len(keys):
|
||||
raise ValueError("duplicate encryption key")
|
||||
return keys, active_key_id
|
||||
except (json.JSONDecodeError, TypeError, ValueError, UnfiledDraftEncryptionError) as error:
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"unfiled draft encryption keyring is invalid"
|
||||
) from error
|
||||
|
||||
|
||||
class UnfiledDraftStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
encryption_key: bytes | None = None,
|
||||
encryption_keys: dict[str, bytes] | None = None,
|
||||
active_key_id: str | None = None,
|
||||
limit: int = 20,
|
||||
max_total_bytes: int = 12 * 1024 * 1024,
|
||||
timeout: float = 1.0,
|
||||
):
|
||||
self.path = Path(path)
|
||||
if encryption_keys is None:
|
||||
if not isinstance(encryption_key, bytes) or len(encryption_key) != 32:
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"unfiled draft encryption requires exactly 32 key bytes"
|
||||
)
|
||||
self._ciphers = {"legacy": AESGCM(encryption_key)}
|
||||
self._active_key_id = None
|
||||
else:
|
||||
if (
|
||||
not encryption_keys
|
||||
or len(encryption_keys) > 8
|
||||
or active_key_id not in encryption_keys
|
||||
or any(
|
||||
not isinstance(key_id, str)
|
||||
or not _KEY_ID.fullmatch(key_id)
|
||||
or not isinstance(key, bytes)
|
||||
or len(key) != 32
|
||||
for key_id, key in encryption_keys.items()
|
||||
)
|
||||
):
|
||||
raise UnfiledDraftEncryptionError("unfiled draft encryption keyring is invalid")
|
||||
self._ciphers = {
|
||||
key_id: AESGCM(key) for key_id, key in encryption_keys.items()
|
||||
}
|
||||
self._active_key_id = active_key_id
|
||||
self.limit = limit
|
||||
self.max_total_bytes = max_total_bytes
|
||||
self.timeout = timeout
|
||||
@@ -61,18 +147,114 @@ class UnfiledDraftStore:
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
return {"revision": 0, "drafts": []} if row is None else {
|
||||
"revision": int(row[0]), "drafts": json.loads(row[1])
|
||||
}
|
||||
def _aad(login: str, revision: int) -> bytes:
|
||||
return f"stackchain:unfiled-drafts:v1\0{login}\0{revision}".encode()
|
||||
|
||||
@staticmethod
|
||||
def _v2_aad(login: str, revision: int, key_id: str) -> bytes:
|
||||
return f"stackchain:unfiled-drafts:v2\0{key_id}\0{login}\0{revision}".encode()
|
||||
|
||||
def _encrypt(self, login: str, revision: int, drafts: list[dict]) -> str:
|
||||
plaintext = json.dumps(drafts, separators=(",", ":")).encode()
|
||||
nonce = os.urandom(12)
|
||||
if self._active_key_id is None:
|
||||
sealed = nonce + self._ciphers["legacy"].encrypt(
|
||||
nonce, plaintext, self._aad(login, revision)
|
||||
)
|
||||
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
|
||||
key_id = self._active_key_id
|
||||
sealed = nonce + self._ciphers[key_id].encrypt(
|
||||
nonce, plaintext, self._v2_aad(login, revision, key_id)
|
||||
)
|
||||
return f"v2:{key_id}:" + base64.urlsafe_b64encode(sealed).decode()
|
||||
|
||||
def _decrypt(self, login: str, revision: int, payload: str) -> list[dict]:
|
||||
try:
|
||||
if payload.startswith("v1:"):
|
||||
encoded = payload[3:]
|
||||
cipher = self._ciphers.get("legacy")
|
||||
aad = self._aad(login, revision)
|
||||
elif payload.startswith("v2:"):
|
||||
_version, key_id, encoded = payload.split(":", 2)
|
||||
cipher = self._ciphers.get(key_id)
|
||||
aad = self._v2_aad(login, revision, key_id)
|
||||
else:
|
||||
raise ValueError("encrypted payload version is invalid")
|
||||
if cipher is None:
|
||||
raise ValueError("encrypted payload key is unavailable")
|
||||
sealed = base64.b64decode(encoded, altchars=b"-_", validate=True)
|
||||
if len(sealed) < 12 + 16:
|
||||
raise ValueError("encrypted payload is too short")
|
||||
plaintext = cipher.decrypt(sealed[:12], sealed[12:], aad)
|
||||
drafts = json.loads(plaintext)
|
||||
if not isinstance(drafts, list):
|
||||
raise ValueError("decrypted payload is not a collection")
|
||||
return drafts
|
||||
except (binascii.Error, InvalidTag, UnicodeDecodeError, ValueError, json.JSONDecodeError) as error:
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"unfiled drafts could not be decrypted"
|
||||
) from error
|
||||
|
||||
def _snapshot(self, login: str, row) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "drafts": []}, False
|
||||
revision, payload = int(row[0]), row[1]
|
||||
if payload.startswith(("v1:", "v2:")):
|
||||
drafts = self._decrypt(login, revision, payload)
|
||||
active_prefix = (
|
||||
"v1:" if self._active_key_id is None else f"v2:{self._active_key_id}:"
|
||||
)
|
||||
return {"revision": revision, "drafts": drafts}, not payload.startswith(active_prefix)
|
||||
try:
|
||||
drafts = json.loads(payload)
|
||||
except (TypeError, json.JSONDecodeError) as error:
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"legacy unfiled drafts could not be decoded"
|
||||
) from error
|
||||
if not isinstance(drafts, list):
|
||||
raise UnfiledDraftEncryptionError(
|
||||
"legacy unfiled drafts could not be decoded"
|
||||
)
|
||||
return {"revision": revision, "drafts": drafts}, True
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
|
||||
(self._login(login),),
|
||||
(login,),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
snapshot, legacy = self._snapshot(login, row)
|
||||
if legacy:
|
||||
connection.execute(
|
||||
"UPDATE unfiled_drafts SET drafts = ? WHERE login = ?",
|
||||
(
|
||||
self._encrypt(login, snapshot["revision"], snapshot["drafts"]),
|
||||
login,
|
||||
),
|
||||
)
|
||||
return snapshot
|
||||
|
||||
def rewrap_all(self) -> dict[str, int]:
|
||||
"""Rewrap every readable row and return aggregate counts only."""
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT login, drafts FROM unfiled_drafts ORDER BY login"
|
||||
).fetchall()
|
||||
result = {"total": len(rows), "migrated": 0, "current": 0, "failed": 0}
|
||||
active_prefix = (
|
||||
"v1:" if self._active_key_id is None else f"v2:{self._active_key_id}:"
|
||||
)
|
||||
for login, payload in rows:
|
||||
was_current = payload.startswith(active_prefix)
|
||||
try:
|
||||
self.get(login)
|
||||
except UnfiledDraftEncryptionError:
|
||||
result["failed"] += 1
|
||||
else:
|
||||
result["current" if was_current else "migrated"] += 1
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _filing_plan(raw: object) -> dict | None:
|
||||
@@ -233,16 +415,16 @@ class UnfiledDraftStore:
|
||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||
raise ValueError("revision is invalid")
|
||||
normalized = self._normalize(drafts)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current = self._snapshot(row)
|
||||
current, _legacy = self._snapshot(login, row)
|
||||
if current["revision"] != expected_revision:
|
||||
raise UnfiledDraftConflict(current)
|
||||
revision = expected_revision + 1
|
||||
serialized = self._encrypt(login, revision, normalized)
|
||||
connection.execute(
|
||||
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Test-only secret injection for encrypted private state stores."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
os.environ.setdefault(
|
||||
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
|
||||
"c3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3M=",
|
||||
)
|
||||
os.environ.setdefault(
|
||||
"STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY",
|
||||
"cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHA=",
|
||||
)
|
||||
+56
-1
@@ -55,6 +55,8 @@ class FakeGiteaServer(ThreadingHTTPServer):
|
||||
self.issue_creation_ready = Event()
|
||||
self.assigned_issue_numbers = [issue["number"] for issue in AVAILABLE_ISSUES]
|
||||
self.comments: list[tuple[int, str]] = []
|
||||
self.activity_comments: dict[int, list[dict]] = {}
|
||||
self.edited_comments: list[tuple[int, int, str]] = []
|
||||
self.requests: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
@@ -87,6 +89,11 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
self._json(200, REPOSITORY)
|
||||
elif path == "/api/v1/repos/search":
|
||||
self._json(200, {"data": [REPOSITORY], "ok": True})
|
||||
elif path == "/api/v1/repos/acme/mobile/assignees":
|
||||
self._json(200, [
|
||||
USER,
|
||||
{"id": 2, "login": "alex", "full_name": "Alexander", "active": True},
|
||||
])
|
||||
elif path == "/api/v1/repos/issues/search":
|
||||
is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"]
|
||||
is_available_scan = not any(
|
||||
@@ -103,8 +110,21 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
]
|
||||
issues = assigned if is_assigned_scan else available if is_available_scan else []
|
||||
self._json(200, issues, **{"X-Total-Count": str(len(issues))})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/comments/"):
|
||||
try:
|
||||
comment_id = int(path.rsplit("/", 1)[-1])
|
||||
except ValueError:
|
||||
comment_id = 0
|
||||
comment = next((item for comments in self.server.activity_comments.values()
|
||||
for item in comments if item.get("id") == comment_id), None)
|
||||
self._json(200, {**comment, "user": USER}) if comment else self._json(404, {"message": "not found"})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"):
|
||||
self._json(200, [], **{"X-Total-Count": "0"})
|
||||
try:
|
||||
number = int(path.split("/")[-2])
|
||||
except ValueError:
|
||||
number = 0
|
||||
comments = self.server.activity_comments.get(number, [])
|
||||
self._json(200, comments, **{"X-Total-Count": str(len(comments))})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
|
||||
self._json(200, [])
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):
|
||||
@@ -175,8 +195,43 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_PATCH(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = urlsplit(self.path).path
|
||||
self.server.requests.append(("PATCH", self.path))
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) == 8 and parts[:7] == ["api", "v1", "repos", "acme", "mobile", "issues", "comments"]:
|
||||
try:
|
||||
comment_id = int(parts[7])
|
||||
except ValueError:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
match = next(((number, item) for number, comments in self.server.activity_comments.items()
|
||||
for item in comments if item.get("id") == comment_id), None)
|
||||
if match is None:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
number, comment = match
|
||||
body = str(payload.get("body", ""))
|
||||
comment["body"] = body
|
||||
self.server.edited_comments.append((number, comment_id, body))
|
||||
self._json(200, {**comment, "user": USER, "updated_at": "2026-08-18T01:00:00Z"})
|
||||
return
|
||||
if len(parts) == 9 and parts[:5] == ["api", "v1", "repos", "acme", "mobile"] and parts[5] == "issues" and parts[7] == "comments":
|
||||
try:
|
||||
number, comment_id = int(parts[6]), int(parts[8])
|
||||
except ValueError:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
comments = self.server.activity_comments.get(number, [])
|
||||
comment = next((item for item in comments if item.get("id") == comment_id), None)
|
||||
if comment is None:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
body = str(payload.get("body", ""))
|
||||
comment["body"] = body
|
||||
self.server.edited_comments.append((number, comment_id, body))
|
||||
self._json(200, {**comment, "user": USER, "updated_at": "2026-08-18T01:00:00Z"})
|
||||
return
|
||||
if not path.startswith("/api/v1/repos/acme/mobile/issues/"):
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
|
||||
@@ -11,7 +11,7 @@ if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from fake_gitea import AVAILABLE_ISSUES, FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@@ -250,3 +250,72 @@ def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reviews_and_downloads_mobile_agenda_snapshot(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
original_due_dates = [item.get("due_date") for item in AVAILABLE_ISSUES]
|
||||
AVAILABLE_ISSUES[0]["due_date"] = "2026-08-19"
|
||||
AVAILABLE_ISSUES[1]["due_date"] = "2026-08-20"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
|
||||
page.on("response", lambda response: failed_responses.append(f"{response.status} {response.url}") if response.status >= 400 else None)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Agenda export release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="agenda"]').click()
|
||||
expect(page.locator("#issue-sheet")).to_be_visible()
|
||||
page.locator("#close-issue-sheet").click()
|
||||
expect(page.locator("#issue-sheet")).to_be_hidden()
|
||||
expect(page.locator("#agenda-export")).to_be_visible()
|
||||
trigger = page.locator("#open-agenda-export")
|
||||
expect(trigger).to_be_enabled()
|
||||
bounds = trigger.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
trigger.click()
|
||||
expect(page.locator("#agenda-export-sheet")).to_be_visible()
|
||||
expect(page.locator("#agenda-export-items input:checked")).to_have_count(2)
|
||||
page.locator("#agenda-export-items input").nth(1).uncheck()
|
||||
expect(page.locator("#agenda-export-status")).to_have_text("1 of 2 deadlines selected.")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
with page.expect_download() as pending:
|
||||
page.locator("#share-agenda-export").click()
|
||||
download = pending.value
|
||||
text = Path(download.path()).read_text()
|
||||
assert download.suggested_filename.startswith("stackchain-agenda-")
|
||||
assert text.count("BEGIN:VEVENT") == 1
|
||||
assert "SUMMARY:Ship mobile capture" in text
|
||||
assert "Polish desktop filters" not in text
|
||||
expect(trigger).to_be_focused()
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
browser.close()
|
||||
finally:
|
||||
for item, due_date in zip(AVAILABLE_ISSUES, original_due_dates):
|
||||
if due_date is None:
|
||||
item.pop("due_date", None)
|
||||
else:
|
||||
item["due_date"] = due_date
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -84,6 +84,9 @@ def release_server(archive: Path, tmp_path: Path, gitea_url: str):
|
||||
"STACKCHAIN_DASHBOARD_AUTH_MODE": "operator",
|
||||
"STACKCHAIN_DASHBOARD_ACCESS_TOKEN": ACCESS_TOKEN,
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET": "artifact-browser-independent-session-secret-863",
|
||||
"STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY": (
|
||||
"ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQ="
|
||||
),
|
||||
"STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN": origin,
|
||||
"STACKCHAIN_STATE_DIR": str(tmp_path / "state"),
|
||||
}
|
||||
@@ -187,7 +190,17 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
|
||||
new_action.click()
|
||||
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
|
||||
assert page.evaluate("document.activeElement?.id") != "create-issue-title"
|
||||
assert page.evaluate("document.activeElement?.id") == "cancel-new-issue"
|
||||
assert page.evaluate("document.querySelector('main').inert") is True
|
||||
page.keyboard.press("Shift+Tab")
|
||||
assert page.evaluate("document.activeElement.closest('#create-issue-sheet')?.id") == "create-issue-sheet"
|
||||
page.keyboard.press("Escape")
|
||||
expect(page.locator("#create-issue-sheet")).not_to_have_class("create-issue-sheet open")
|
||||
assert page.evaluate("document.activeElement?.dataset.mobileTask") == "new"
|
||||
assert page.evaluate("document.querySelector('main').inert") is False
|
||||
new_action.click()
|
||||
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
|
||||
assert page.evaluate("document.activeElement?.id") == "cancel-new-issue"
|
||||
create_navigation = page.locator(".mobile-create-issue-nav")
|
||||
expect(create_navigation).to_be_visible()
|
||||
create_navigation_buttons = create_navigation.locator("button")
|
||||
|
||||
@@ -15,6 +15,13 @@ from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def open_today_action(page, selector: str):
|
||||
page.locator("[data-mobile-today-more]").click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_visible()
|
||||
page.locator(selector).click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_hidden()
|
||||
|
||||
|
||||
def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
@@ -46,7 +53,10 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
try:
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
except AssertionError as error:
|
||||
raise AssertionError({"browser_errors": browser_errors, "failed_responses": failed_responses}) from error
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#plan-today-candidates .plan-today-item")).to_have_count(2)
|
||||
@@ -80,6 +90,221 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
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()
|
||||
@@ -88,9 +313,34 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
expect(page.locator("#issue-comment")).to_have_value("")
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true")
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
|
||||
assert fake.comments == [(41, "Handoff complete; continuing with the next Today item.")]
|
||||
assert fake.comments == [
|
||||
(41, "Pairing with @alex"),
|
||||
(41, "Handoff complete; continuing with the next Today item."),
|
||||
]
|
||||
|
||||
receipt = page.locator("#today-completion-undo")
|
||||
expect(receipt).to_be_visible()
|
||||
expect(receipt).to_contain_text("Removed from Today. Undo?")
|
||||
undo = page.locator("#undo-today-completion")
|
||||
bounds = undo.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator("#close-issue-sheet").click()
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||
expect(page.locator(".mobile-task-dock")).to_be_visible()
|
||||
layout = page.evaluate("""() => {
|
||||
const receipt = document.querySelector('#today-completion-undo').getBoundingClientRect();
|
||||
const hud = document.querySelector('[data-mobile-today-hud]').getBoundingClientRect();
|
||||
const dock = document.querySelector('.mobile-task-dock').getBoundingClientRect();
|
||||
return {receiptBottom:receipt.bottom, hudTop:hud.top, dockTop:dock.top};
|
||||
}""")
|
||||
assert layout["receiptBottom"] <= min(layout["hudTop"], layout["dockTop"])
|
||||
undo.click()
|
||||
expect(receipt).to_be_hidden()
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
|
||||
|
||||
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
|
||||
assert today == ["issue:acme/mobile:42:"]
|
||||
assert today == ["issue:acme/mobile:41:", "issue:acme/mobile:42:"]
|
||||
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
@@ -99,3 +349,239 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Today detour phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
page.locator("#plan-today-available").fill("90")
|
||||
page.locator("#plan-today-available").press("Tab")
|
||||
page.locator("#build-today-plan").click()
|
||||
missing = page.locator("[data-plan-missing-estimate]")
|
||||
expect(missing).to_have_count(2)
|
||||
for _ in range(2):
|
||||
missing.nth(0).fill("30")
|
||||
missing.nth(0).press("Tab")
|
||||
page.locator("#save-and-start-today").click()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
page.locator("#close-issue-sheet").click(force=True)
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
page.locator('[data-mobile-task="find"]').click()
|
||||
find_pause = page.locator("#find-work-sheet [data-today-detour]")
|
||||
expect(find_pause).to_be_visible()
|
||||
expect(find_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
bounds = find_pause.locator("[data-return-from-detour]").bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'find';
|
||||
}""")
|
||||
page.locator("#close-find-work").click()
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
|
||||
}""")
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
queue_pause = page.locator("#mobile-queue-sheet [data-today-detour]")
|
||||
expect(queue_pause).to_be_visible()
|
||||
expect(queue_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
page.locator('[data-mobile-queue="later"]').click()
|
||||
persistent_pause = page.locator("#my-work > [data-today-detour]")
|
||||
expect(persistent_pause).to_be_visible()
|
||||
return_button = persistent_pause.locator("[data-return-from-detour]")
|
||||
bounds = return_button.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
return_button.click()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
|
||||
}""")
|
||||
|
||||
page.locator("#close-issue-sheet").click()
|
||||
page.locator("#app-menu-toggle").click()
|
||||
page.locator("#open-insights").click()
|
||||
insights_pause = page.locator("#insights-sheet > [data-today-detour]")
|
||||
expect(insights_pause).to_be_visible()
|
||||
expect(insights_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
bounds = insights_pause.locator("[data-return-from-detour]").bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'insights';
|
||||
}""")
|
||||
page.reload(wait_until="networkidle")
|
||||
restored_pause = page.locator("#insights-sheet > [data-today-detour]")
|
||||
expect(restored_pause).to_be_visible()
|
||||
expect(restored_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
restored_pause.locator("[data-return-from-detour]").click()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
|
||||
}""")
|
||||
|
||||
page.locator("#close-issue-sheet").click()
|
||||
page.locator("#app-menu-toggle").click()
|
||||
page.locator("#active-devices").click()
|
||||
security_pause = page.locator("#security-center-today-detour")
|
||||
expect(security_pause).to_be_visible()
|
||||
expect(security_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
bounds = page.locator("#return-from-security-center").bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'security-center';
|
||||
}""")
|
||||
page.locator("#security-section-devices").click()
|
||||
page.go_back()
|
||||
expect(security_pause).to_be_visible()
|
||||
page.go_back()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
||||
assert page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
|
||||
}""")
|
||||
|
||||
page.locator("#active-devices").click()
|
||||
expect(security_pause).to_be_visible()
|
||||
page.locator("#return-from-security-center").click()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_mobile_issue(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Blocker flow phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
page.locator("#plan-today-available").fill("90")
|
||||
page.locator("#plan-today-available").press("Tab")
|
||||
page.locator("#build-today-plan").click()
|
||||
missing = page.locator("[data-plan-missing-estimate]")
|
||||
expect(missing).to_have_count(2)
|
||||
for _ in range(2):
|
||||
missing.nth(0).fill("30")
|
||||
missing.nth(0).press("Tab")
|
||||
page.locator("#save-and-start-today").click()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
page.locator("#close-issue-sheet").click()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||
|
||||
open_today_action(page, "[data-mobile-today-update]")
|
||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||
page.locator("#today-progress-body").fill("Blocked waiting for the design owner")
|
||||
page.locator("#post-today-progress").click()
|
||||
expect(page.locator("#today-progress-sheet")).to_be_hidden()
|
||||
open_today_action(page, "[data-today-break-open]")
|
||||
page.locator('[data-today-break-minutes="5"]').click()
|
||||
expect(page.locator("#resume-today-break")).to_be_visible()
|
||||
|
||||
page.evaluate("""() => {
|
||||
localStorage.setItem('stackchain.today-progress.v1.timmy', JSON.stringify({
|
||||
version: 1,
|
||||
drafts: {
|
||||
'issue:acme/mobile:41:': {
|
||||
body: 'Blocked 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")
|
||||
comments = [item for item in state["outbox"]["items"] if item["kind"] == "issue-comment"]
|
||||
assert len(comments) == 1
|
||||
assert comments[0]["number"] == 41
|
||||
assert comments[0]["body"] == "Blocked 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,111 @@
|
||||
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),
|
||||
});
|
||||
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.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,118 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
AGENDA_CALENDAR = Path(__file__).parents[1] / "frontend" / "agenda-calendar.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
|
||||
|
||||
def run_node(source: str):
|
||||
completed = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_calendar_snapshot_serializes_ordered_all_day_events_with_stable_identity():
|
||||
items = [
|
||||
{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 12,
|
||||
"title": "Plan, ship; verify\\path\nnext",
|
||||
"due_date": "2026-08-19T23:00:00-07:00",
|
||||
"url": "https://forge.example/git/stackchain/dashboard/issues/12",
|
||||
},
|
||||
{
|
||||
"repository": "stackchain/api",
|
||||
"number": 7,
|
||||
"title": "API release",
|
||||
"due_date": "2026-08-20",
|
||||
"url": "https://forge.example/git/stackchain/api/issues/7",
|
||||
},
|
||||
]
|
||||
source = f"""
|
||||
const calendar = require({json.dumps(str(AGENDA_CALENDAR))});
|
||||
const text = calendar.serializeAgendaCalendar({json.dumps(items)}, {{generatedOn:'20260818'}});
|
||||
process.stdout.write(JSON.stringify({{text}}));
|
||||
"""
|
||||
|
||||
text = run_node(source)["text"]
|
||||
assert text.startswith("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n")
|
||||
assert text.endswith("END:VCALENDAR\r\n")
|
||||
assert text.count("BEGIN:VEVENT") == 2
|
||||
assert text.index("stackchain/dashboard#12") < text.index("stackchain/api#7")
|
||||
assert "UID:issue-12@stackchain-dashboard" in text
|
||||
assert "DTSTART;VALUE=DATE:20260819" in text
|
||||
assert "SUMMARY:Plan\\, ship\\; verify\\\\path\\nnext" in text
|
||||
assert "URL:https://forge.example/git/stackchain/dashboard/issues/12" in text
|
||||
assert "DTSTAMP:20260818T000000Z" in text
|
||||
assert "\n" not in text.replace("\r\n", "")
|
||||
|
||||
|
||||
def test_calendar_snapshot_folds_utf8_lines_to_rfc_octet_limit_without_splitting_characters():
|
||||
item = {
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 99,
|
||||
"title": "Launch 🚀 " + "é" * 60,
|
||||
"due_date": "2026-08-21",
|
||||
"url": "https://forge.example/git/stackchain/dashboard/issues/99",
|
||||
}
|
||||
source = f"""
|
||||
const calendar = require({json.dumps(str(AGENDA_CALENDAR))});
|
||||
const text = calendar.serializeAgendaCalendar([{json.dumps(item)}], {{generatedOn:'20260818'}});
|
||||
process.stdout.write(JSON.stringify({{text}}));
|
||||
"""
|
||||
|
||||
text = run_node(source)["text"]
|
||||
physical_lines = text.split("\r\n")
|
||||
assert all(len(line.encode("utf-8")) <= 75 for line in physical_lines)
|
||||
assert any(line.startswith(" ") for line in physical_lines)
|
||||
unfolded = text.replace("\r\n ", "")
|
||||
assert "SUMMARY:Launch 🚀 " + "é" * 60 in unfolded
|
||||
|
||||
|
||||
def test_calendar_snapshot_prefers_native_file_share_and_falls_back_to_one_download():
|
||||
source = f"""
|
||||
const calendar = require({json.dumps(str(AGENDA_CALENDAR))});
|
||||
class FakeFile {{ constructor(parts, name, options) {{ this.parts=parts; this.name=name; this.type=options.type; }} }}
|
||||
const shared=[];
|
||||
const nativeNavigator={{canShare:payload => payload.files[0].name.endsWith('.ics'), share:async payload => shared.push(payload)}};
|
||||
const clicks=[]; const revoked=[];
|
||||
const document={{createElement:() => ({{click(){{clicks.push({{download:this.download,href:this.href}})}}}})}};
|
||||
const urlApi={{createObjectURL:()=>'blob:agenda', revokeObjectURL:value=>revoked.push(value)}};
|
||||
(async () => {{
|
||||
const native = await calendar.deliverCalendarSnapshot({{text:'BEGIN:VCALENDAR', filename:'stackchain-agenda.ics', navigator:nativeNavigator, document, urlApi, FileCtor:FakeFile}});
|
||||
const fallback = await calendar.deliverCalendarSnapshot({{text:'BEGIN:VCALENDAR', filename:'stackchain-agenda.ics', navigator:{{}}, document, urlApi, FileCtor:FakeFile}});
|
||||
process.stdout.write(JSON.stringify({{native,fallback,shared:shared.length,clicks,revoked}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(source) == {
|
||||
"native": "shared",
|
||||
"fallback": "downloaded",
|
||||
"shared": 1,
|
||||
"clicks": [{"download": "stackchain-agenda.ics", "href": "blob:agenda"}],
|
||||
"revoked": ["blob:agenda"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_agenda_export_review_requires_selection_and_exposes_snapshot_privacy_boundary():
|
||||
markup = INDEX.read_text()
|
||||
source = await dashboard()
|
||||
feature_source = source + AGENDA_CALENDAR.read_text()
|
||||
|
||||
assert 'id="open-agenda-export"' in markup
|
||||
assert 'id="agenda-export-sheet"' in markup
|
||||
assert 'id="agenda-export-items"' in markup
|
||||
assert 'id="share-agenda-export"' in markup
|
||||
assert "This exports a snapshot" in markup
|
||||
assert "agendaMyWork(activeMyWork)" in feature_source
|
||||
assert "checkbox.checked" in feature_source
|
||||
assert "serializeAgendaCalendar(selected" in feature_source
|
||||
assert "deliverCalendarSnapshot" in feature_source
|
||||
assert "agenda-export-empty" in feature_source
|
||||
@@ -15,6 +15,44 @@ def run_node(script: str):
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_repeated_operation_id_rejects_a_different_comment_payload():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy'}});
|
||||
const original={{kind:'issue-comment',repository:'o/r',number:7,body:'Done',operationId:'stable',attachments:[{{filename:'before.jpg',contentType:'image/jpeg',note:'Before',operationId:'photo-1'}}]}};
|
||||
const identical=outbox.enqueue(original);
|
||||
let error='';
|
||||
try {{ outbox.enqueue({{...original,attachments:[{{filename:'after.jpg',contentType:'image/jpeg',note:'After',operationId:'photo-2'}}]}}); }}
|
||||
catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{identical,error,items:outbox.list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["identical"]["operationId"] == "stable"
|
||||
assert output["error"] == "This operation ID is already bound to a different queued action."
|
||||
assert len(output["items"]) == 1
|
||||
assert output["items"][0]["attachment"]["operationId"] == "photo-1"
|
||||
|
||||
|
||||
def test_repeated_pull_review_operation_uses_the_default_comment_decision():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy'}});
|
||||
const review={{kind:'pull-review',repository:'o/r',number:8,body:'Notes',expectedHeadSha:'abc',operationId:'review-op'}};
|
||||
outbox.enqueue(review);
|
||||
let error='';
|
||||
try {{ outbox.enqueue(review); }} catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["error"] == ""
|
||||
assert len(output["items"]) == 1
|
||||
assert output["items"][0]["decision"] == "comment"
|
||||
|
||||
|
||||
def test_authored_outbox_quarantines_legacy_and_cross_account_messages():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -1,7 +1,78 @@
|
||||
import threading
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
|
||||
|
||||
PRIVATE_KEY = b"a" * 32
|
||||
|
||||
|
||||
def test_published_find_work_catalog_is_encrypted_at_rest_and_survives_restart(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
store = AvailableIssueSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
|
||||
owner = store.try_acquire_refresh(lease_seconds=5)
|
||||
canary = "private-find-work-body-canary"
|
||||
|
||||
published = store.publish(
|
||||
owner,
|
||||
items=[{"repository": "stackchain/api", "number": 7, "body": canary}],
|
||||
)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v1:")
|
||||
assert canary not in payload
|
||||
assert AvailableIssueSnapshotStore(
|
||||
path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY
|
||||
).load().items == published.items
|
||||
|
||||
|
||||
def test_find_work_catalog_lazily_migrates_plaintext_without_changing_freshness(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
store = AvailableIssueSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, retry_at = ?",
|
||||
('[{"repository":"stackchain/api","number":7}]', 91.0, 105.0),
|
||||
)
|
||||
|
||||
state = store.load()
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
migrated = connection.execute(
|
||||
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()[0]
|
||||
assert state.items == [{"repository": "stackchain/api", "number": 7}]
|
||||
assert (state.created_at, state.retry_at) == (91.0, 105.0)
|
||||
assert migrated.startswith("v1:")
|
||||
|
||||
|
||||
def test_find_work_ciphertext_cannot_be_substituted_into_live_snapshot(tmp_path):
|
||||
available_path = tmp_path / "available.sqlite3"
|
||||
available = AvailableIssueSnapshotStore(
|
||||
available_path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY
|
||||
)
|
||||
owner = available.try_acquire_refresh(lease_seconds=5)
|
||||
available.publish(owner, items=[{"body": "secret"}])
|
||||
with sqlite3.connect(available_path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()[0]
|
||||
|
||||
live_path = tmp_path / "live.sqlite3"
|
||||
from src.live_snapshot_store import LiveSnapshotStore
|
||||
live = LiveSnapshotStore(live_path, encryption_key=PRIVATE_KEY)
|
||||
with sqlite3.connect(live_path) as connection:
|
||||
connection.execute("UPDATE live_snapshot SET value_json = ?", (payload,))
|
||||
|
||||
with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
|
||||
live.load()
|
||||
|
||||
|
||||
def test_independent_workers_allow_only_one_catalog_refresh(tmp_path):
|
||||
|
||||
@@ -41,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")
|
||||
|
||||
|
||||
@@ -62,6 +65,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
"tests/e2e/test_mobile_home_bootstrap_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||
"tests/e2e/test_mobile_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-v117" in worker
|
||||
assert "stackchain-dashboard-shell-v121" 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))});
|
||||
|
||||
@@ -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);
|
||||
""")
|
||||
|
||||
|
||||
@@ -74,10 +74,13 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
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 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
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
+56
-24
@@ -131,7 +131,7 @@ async def test_global_search_specific_type_uses_one_full_width_scoped_stream():
|
||||
"title": "Review mobile search",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -161,7 +161,7 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
|
||||
"number": 42,
|
||||
"title": "Repair mobile queue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/42",
|
||||
}],
|
||||
"partial": False,
|
||||
}
|
||||
@@ -180,7 +180,7 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
|
||||
"number": 42,
|
||||
"title": "Repair mobile queue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/42",
|
||||
}], "partial": False}
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
|
||||
"author": "alex",
|
||||
"labels": ["P1"],
|
||||
"assignees": [],
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
"assigned_to_me": False,
|
||||
}
|
||||
@@ -233,7 +233,7 @@ async def test_global_search_preview_conversation_returns_a_bounded_authorized_p
|
||||
"author": "alexander",
|
||||
"body": "Current **decision**",
|
||||
"created_at": "2026-08-14T12:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42#issuecomment-7",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/42#issuecomment-7",
|
||||
}],
|
||||
"page": 3,
|
||||
"older_page": 2,
|
||||
@@ -278,7 +278,7 @@ async def test_global_search_preview_reply_is_idempotent_for_an_authorized_pull(
|
||||
"author": "timmy",
|
||||
"body": body,
|
||||
"created_at": "2026-08-14T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/pulls/42#issuecomment-91",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/pulls/42#issuecomment-91",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
@@ -345,7 +345,7 @@ async def test_global_search_preview_attachment_is_idempotent_for_exact_visible_
|
||||
|
||||
async def upload(repository, number, filename, content_type, content):
|
||||
uploads.append((repository, number, filename, content_type, content))
|
||||
return {"name": filename, "url": "https://forge.example/evidence/photo.png", "size": len(content)}
|
||||
return {"name": filename, "url": "http://127.0.0.1:3000/evidence/photo.png", "size": len(content)}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
|
||||
@@ -367,7 +367,7 @@ async def test_global_search_preview_attachment_is_idempotent_for_exact_visible_
|
||||
|
||||
assert first.status_code == replay.status_code == 201
|
||||
assert replay.json() == first.json()
|
||||
assert first.json()["markdown"] == ""
|
||||
assert first.json()["markdown"] == ""
|
||||
assert previews == [("stackchain/api", "pull", 42)]
|
||||
assert uploads == [("stackchain/api", 42, "photo.png", "image/png", image)]
|
||||
|
||||
@@ -408,11 +408,11 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
|
||||
}, {
|
||||
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
|
||||
}, {
|
||||
"id": 5, "number": 43, "title": "Unsafe", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
@@ -421,7 +421,7 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 8, "number": 9, "title": "Improve search", "state": "closed",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -440,11 +440,11 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
|
||||
"items": [{
|
||||
"kind": "issue", "repository": "stackchain/api", "number": 42,
|
||||
"title": "Repair queue", "state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/42",
|
||||
}, {
|
||||
"kind": "pull", "repository": "stackchain/web", "number": 9,
|
||||
"title": "Improve search", "state": "closed",
|
||||
"url": "https://forge.example/stackchain/web/pulls/9",
|
||||
"url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
|
||||
}],
|
||||
"partial": False,
|
||||
"has_more": False,
|
||||
@@ -454,6 +454,38 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_keeps_results_on_the_configured_forge_origin(monkeypatch):
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example/git")
|
||||
|
||||
async def handler(request):
|
||||
if request.url.params["type"] == "pulls":
|
||||
return httpx.Response(200, json=[])
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 1, "number": 41, "title": "Foreign", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://attacker.example/stackchain/api/issues/41",
|
||||
}, {
|
||||
"id": 3, "number": 43, "title": "Downgraded", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "http://forge.example/git/stackchain/api/issues/43",
|
||||
}, {
|
||||
"id": 2, "number": 42, "title": "Relative", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "/git/stackchain/api/issues/42#issuecomment-7",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.global_search("queue", 10)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(item["number"], item["url"]) for item in result["items"]] == [
|
||||
(42, "https://forge.example/git/stackchain/api/issues/42#issuecomment-7")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_returns_healthy_stream_when_other_stream_fails():
|
||||
async def handler(request):
|
||||
@@ -464,7 +496,7 @@ async def test_global_search_returns_healthy_stream_when_other_stream_fails():
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -480,7 +512,7 @@ async def test_global_search_returns_healthy_stream_when_other_stream_fails():
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/web/pulls/9",
|
||||
"url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
|
||||
}],
|
||||
"partial": True,
|
||||
"has_more": True,
|
||||
@@ -509,7 +541,7 @@ async def test_global_search_retries_a_failed_stream_without_advancing_its_page(
|
||||
"title": f'{params["type"]} {number}',
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}',
|
||||
"html_url": f'http://127.0.0.1:3000/stackchain/web/{params["type"]}/{number}',
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -546,7 +578,7 @@ async def test_global_search_odd_limit_keeps_both_streams_contiguous_across_page
|
||||
"title": f'{params["type"]} {number}',
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}',
|
||||
"html_url": f'http://127.0.0.1:3000/stackchain/web/{params["type"]}/{number}',
|
||||
} for number in range(start, start + stream_limit)])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -579,7 +611,7 @@ async def test_global_search_caps_combined_results_to_requested_limit():
|
||||
"title": f"{kind} {number}",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f"https://forge.example/stackchain/web/{kind}/{number}",
|
||||
"html_url": f"http://127.0.0.1:3000/stackchain/web/{kind}/{number}",
|
||||
} for number in (1, 2)]
|
||||
return httpx.Response(200, json=items)
|
||||
|
||||
@@ -607,7 +639,7 @@ async def test_global_search_pages_each_stream_and_balances_results():
|
||||
"title": f"{kind} page {page} result {number}",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f"https://forge.example/stackchain/web/{kind}/{page * 10 + number}",
|
||||
"html_url": f"http://127.0.0.1:3000/stackchain/web/{kind}/{page * 10 + number}",
|
||||
} for number in (1, 2, 3, 4)]
|
||||
return httpx.Response(200, json=items)
|
||||
|
||||
@@ -650,7 +682,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
|
||||
"title": "Repair queue",
|
||||
"body": "Keep mobile operators moving.",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
|
||||
"user": {"login": "alex"},
|
||||
"labels": [{"name": "P1"}, None],
|
||||
"assignees": [],
|
||||
@@ -672,7 +704,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
|
||||
"author": "alex",
|
||||
"labels": ["P1"],
|
||||
"assignees": [],
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
"reopenable": False,
|
||||
"assigned_to_me": False,
|
||||
@@ -690,7 +722,7 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
|
||||
"pull_request": {"merged": False},
|
||||
"assignees": [],
|
||||
})
|
||||
@@ -722,7 +754,7 @@ async def test_work_preview_marks_authoritative_requested_pull_review_actionable
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
|
||||
"pull_request": {"merged": False},
|
||||
"assignees": [],
|
||||
})
|
||||
@@ -746,7 +778,7 @@ async def test_work_preview_offers_reopen_only_for_closed_issues():
|
||||
"number": 42,
|
||||
"title": "Resume work",
|
||||
"state": "closed",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
|
||||
+38
-38
@@ -680,7 +680,7 @@ async def test_gitea_edit_issue_confirms_already_applied_content_after_lost_resp
|
||||
"updated_at": "2026-08-07T10:02:00Z",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"pull_request": None,
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -699,7 +699,7 @@ async def test_gitea_edit_issue_confirms_already_applied_content_after_lost_resp
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "title": "My draft",
|
||||
"body": "Draft body", "state": "open", "updated_at": "2026-08-07T10:02:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
|
||||
@@ -720,7 +720,7 @@ async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "title": "Clarified", "body": "New body", "state": "open",
|
||||
"updated_at": "2026-08-07T10:01:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -740,7 +740,7 @@ async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "title": "Clarified",
|
||||
"body": "New body", "state": "open", "updated_at": "2026-08-07T10:01:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
|
||||
@@ -804,7 +804,7 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
"labels": ["P0"],
|
||||
"assignees": [assignee],
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
@@ -1014,7 +1014,7 @@ async def test_repository_search_endpoint_returns_minimal_visible_matches(monkey
|
||||
"id": 151,
|
||||
"name": "mobile",
|
||||
"full_name": "stackchain/mobile",
|
||||
"html_url": "https://forge.example/stackchain/mobile",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/mobile",
|
||||
"description": "secret context that the picker does not need",
|
||||
"private": True,
|
||||
}]
|
||||
@@ -1042,7 +1042,7 @@ async def test_repository_page_endpoint_and_issue_creation_support_later_reposit
|
||||
return {
|
||||
"items": [{
|
||||
"id": 51, "name": "later", "full_name": "stackchain/later",
|
||||
"description": "", "html_url": "https://forge.example/stackchain/later",
|
||||
"description": "", "html_url": "http://127.0.0.1:3000/stackchain/later",
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
}],
|
||||
"page": 2, "total": 51, "has_more": False,
|
||||
@@ -1061,7 +1061,7 @@ async def test_repository_page_endpoint_and_issue_creation_support_later_reposit
|
||||
"id": 403, "number": 403, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
"url": "https://forge.example/stackchain/later/issues/403",
|
||||
"url": "http://127.0.0.1:3000/stackchain/later/issues/403",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_page", page)
|
||||
@@ -1087,7 +1087,7 @@ def test_context_exposes_truthful_repository_pagination():
|
||||
repositories = gitea_proxy.RepositoryItems(
|
||||
[{
|
||||
"id": 1, "name": "api", "full_name": "stackchain/api",
|
||||
"description": "", "html_url": "https://forge.example/stackchain/api",
|
||||
"description": "", "html_url": "http://127.0.0.1:3000/stackchain/api",
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
}],
|
||||
{"page": 1, "total": 51, "has_more": True},
|
||||
@@ -1124,7 +1124,7 @@ async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypa
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"due_date": "2026-08-31T23:59:59Z",
|
||||
"updated_at": "2026-08-07T20:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/221",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/221",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
@@ -1176,7 +1176,7 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
|
||||
"id": 81, "number": 17, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
@@ -1222,7 +1222,7 @@ async def test_completed_issue_creation_replays_after_ledger_reconstruction(monk
|
||||
"id": 201, "number": 201, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-07T15:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/201",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/201",
|
||||
}
|
||||
|
||||
database = tmp_path / "issues.sqlite3"
|
||||
@@ -1280,7 +1280,7 @@ async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_
|
||||
"id": 81, "number": 17, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
@@ -1324,7 +1324,7 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
|
||||
"id": 81, "number": 17, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
|
||||
@@ -1367,7 +1367,7 @@ async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
|
||||
"id": 81, "number": 17, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
ledger = IdempotencyLedger(
|
||||
@@ -1407,7 +1407,7 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
|
||||
return httpx.Response(201, json={
|
||||
"id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"labels": [{"id": 3, "name": "P0"}],
|
||||
})
|
||||
@@ -1441,7 +1441,7 @@ async def test_gitea_create_issue_omits_assignment_and_confirms_no_owner():
|
||||
return httpx.Response(201, json={
|
||||
"id": 83, "number": 19, "title": "Backlog capture", "state": "open",
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/19",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/19",
|
||||
"assignees": [], "labels": [],
|
||||
})
|
||||
|
||||
@@ -1634,7 +1634,7 @@ async def test_gitea_create_issue_requires_confirmed_self_assignment():
|
||||
async def handler(_request):
|
||||
return httpx.Response(201, json={
|
||||
"id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17", "assignees": [],
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17", "assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -1657,14 +1657,14 @@ async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_
|
||||
return httpx.Response(200, json={
|
||||
"id": 81, "number": 17, "title": "Available", "state": "open",
|
||||
"assignees": None, "pull_request": None, "labels": [],
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
})
|
||||
if request.method == "GET" and request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"id": 81, "number": 17, "title": "Available", "state": "open",
|
||||
"assignees": [{"login": "timmy"}], "labels": [],
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -1693,14 +1693,14 @@ async def test_gitea_reopen_issue_rechecks_closed_state_and_confirms_self_assign
|
||||
return httpx.Response(200, json={
|
||||
"id": 81, "number": 17, "title": "Resume work", "state": "closed",
|
||||
"assignees": [], "pull_request": None, "labels": [{"name": "P1"}],
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
})
|
||||
if request.method == "GET" and request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"id": 81, "number": 17, "title": "Resume work", "state": "open",
|
||||
"assignees": [{"login": "timmy"}], "labels": [{"name": "P1"}],
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -1719,7 +1719,7 @@ async def test_gitea_reopen_issue_rechecks_closed_state_and_confirms_self_assign
|
||||
"id": 81, "number": 17, "title": "Resume work", "state": "open",
|
||||
"repository": "stackchain/api", "labels": ["P1"],
|
||||
"assignees": ["timmy"], "updated_at": "",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
|
||||
@@ -1734,7 +1734,7 @@ async def test_gitea_reopen_issue_retry_accepts_already_open_self_assigned_issue
|
||||
return httpx.Response(200, json={
|
||||
"id": 81, "number": 17, "title": "Resume work", "state": "open",
|
||||
"assignees": [{"login": "timmy"}], "pull_request": None, "labels": [],
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -1757,7 +1757,7 @@ async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkey
|
||||
return {
|
||||
"id": 81, "number": number, "title": "Available", "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": ["timmy"],
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
|
||||
@@ -1797,7 +1797,7 @@ async def test_reopen_issue_endpoint_returns_confirmed_resumable_work_item(monke
|
||||
return {
|
||||
"id": 81, "number": number, "title": "Resume work", "state": "open",
|
||||
"repository": repository, "labels": ["P1"], "assignees": ["timmy"],
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "reopen_issue", reopen, raising=False)
|
||||
@@ -2564,7 +2564,7 @@ async def test_gitea_issue_comment_posts_body_and_returns_safe_identity():
|
||||
"id": 82,
|
||||
"body": "Ready to ship",
|
||||
"created_at": "2026-08-07T10:10:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-82",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-82",
|
||||
"user": {"login": "timmy"},
|
||||
},
|
||||
)
|
||||
@@ -2584,7 +2584,7 @@ async def test_gitea_issue_comment_posts_body_and_returns_safe_identity():
|
||||
"author": "timmy",
|
||||
"body": "Ready to ship",
|
||||
"created_at": "2026-08-07T10:10:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-82",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-82",
|
||||
}
|
||||
|
||||
|
||||
@@ -2602,7 +2602,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"number": 3,
|
||||
"title": "Restore signing service",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/platform/issues/3",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/platform/issues/3",
|
||||
"repository": {"full_name": "stackchain/platform"},
|
||||
},
|
||||
{
|
||||
@@ -2621,7 +2621,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"id": 81,
|
||||
"body": "Latest update",
|
||||
"created_at": "2026-08-07T10:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-81",
|
||||
"user": {"login": "sam"},
|
||||
}
|
||||
],
|
||||
@@ -2635,7 +2635,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"body": "Full issue context",
|
||||
"updated_at": "2026-08-07T09:59:00Z",
|
||||
"milestone": {"id": 9, "title": "August RC", "description": "not exposed"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
|
||||
"labels": [{"name": "P1"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
},
|
||||
@@ -2661,7 +2661,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"updated_at": "2026-08-07T09:59:00Z",
|
||||
"due_date": None,
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"url": "https://forge.example/stackchain/api/issues/7",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/7",
|
||||
"labels": ["P1"],
|
||||
"assignees": ["timmy"],
|
||||
"dependencies_available": True,
|
||||
@@ -2671,7 +2671,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"number": 3,
|
||||
"title": "Restore signing service",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/platform/issues/3",
|
||||
"url": "http://127.0.0.1:3000/stackchain/platform/issues/3",
|
||||
}
|
||||
],
|
||||
"comments": [
|
||||
@@ -2680,7 +2680,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"author": "sam",
|
||||
"body": "Latest update",
|
||||
"created_at": "2026-08-07T10:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-81",
|
||||
}
|
||||
],
|
||||
"conversation": {
|
||||
@@ -2690,7 +2690,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"author": "sam",
|
||||
"body": "Latest update",
|
||||
"created_at": "2026-08-07T10:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
|
||||
"url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-81",
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
@@ -2984,7 +2984,7 @@ async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmat
|
||||
return httpx.Response(200, json=[{
|
||||
"number": 9, "state": "open", "title": "Restore API",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/9",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/9",
|
||||
}] if mutated else [])
|
||||
if request.method == "POST" and path.endswith("/dependencies"):
|
||||
return httpx.Response(201, json={})
|
||||
@@ -3003,7 +3003,7 @@ async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmat
|
||||
assert json.loads(mutation.content) == {"owner": "stackchain", "repo": "api", "index": 9}
|
||||
assert result["dependencies"] == [{
|
||||
"repository": "stackchain/api", "number": 9, "title": "Restore API",
|
||||
"state": "open", "url": "https://forge.example/stackchain/api/issues/9",
|
||||
"state": "open", "url": "http://127.0.0.1:3000/stackchain/api/issues/9",
|
||||
}]
|
||||
|
||||
|
||||
@@ -3013,7 +3013,7 @@ async def test_gitea_dependency_retry_returns_canonical_state_without_duplicate_
|
||||
dependency = {
|
||||
"number": 9, "state": "open", "title": "Restore API",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/9",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/9",
|
||||
}
|
||||
|
||||
async def handler(request):
|
||||
|
||||
@@ -356,7 +356,7 @@ async def test_gitea_attachment_revalidates_assignment_and_sends_multipart():
|
||||
"id": 9,
|
||||
"name": "checkout.png",
|
||||
"size": len(PNG_BYTES),
|
||||
"browser_download_url": "https://forge.example/attachments/checkout.png",
|
||||
"browser_download_url": "http://127.0.0.1:3000/attachments/checkout.png",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -379,7 +379,7 @@ async def test_gitea_attachment_revalidates_assignment_and_sends_multipart():
|
||||
assert PNG_BYTES in upload.content
|
||||
assert result == {
|
||||
"name": "checkout.png",
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"url": "http://127.0.0.1:3000/attachments/checkout.png",
|
||||
"size": len(PNG_BYTES),
|
||||
}
|
||||
|
||||
@@ -514,7 +514,7 @@ async def test_gitea_pull_attachment_revalidates_assignment_before_exact_pull_up
|
||||
})
|
||||
return httpx.Response(201, json={
|
||||
"name": "pull.png", "size": len(PNG_BYTES),
|
||||
"browser_download_url": "https://forge.example/attachments/pull.png",
|
||||
"browser_download_url": "http://127.0.0.1:3000/attachments/pull.png",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
@@ -531,4 +531,4 @@ async def test_gitea_pull_attachment_revalidates_assignment_before_exact_pull_up
|
||||
("POST", "/api/v1/repos/stackchain/web/issues/31/assets"),
|
||||
]
|
||||
assert PNG_BYTES in requests[-1].content
|
||||
assert result["url"] == "https://forge.example/attachments/pull.png"
|
||||
assert result["url"] == "http://127.0.0.1:3000/attachments/pull.png"
|
||||
@@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -6,6 +6,74 @@ import pytest
|
||||
|
||||
from src import live_snapshot_store
|
||||
from src.live_snapshot_store import LiveSnapshotStore, RefreshLeaseLost
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
|
||||
|
||||
PRIVATE_KEY = b"l" * 32
|
||||
|
||||
|
||||
def test_published_live_snapshot_is_encrypted_at_rest_and_survives_restart(tmp_path):
|
||||
path = tmp_path / "live.sqlite3"
|
||||
store = LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
|
||||
lease = store.try_acquire_refresh({"context"}, lease_seconds=5)
|
||||
canary = "private-live-title-canary"
|
||||
|
||||
published = store.publish_refresh(
|
||||
lease,
|
||||
value={"context": {"title": canary}, "events": [], "notifications": []},
|
||||
created_at={section: 100.0 for section in live_snapshot_store.SECTIONS},
|
||||
failure_count={section: 0 for section in live_snapshot_store.SECTIONS},
|
||||
retry_at={section: None for section in live_snapshot_store.SECTIONS},
|
||||
changed_sections={"context"},
|
||||
)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT value_json FROM live_snapshot WHERE singleton = 1"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v1:")
|
||||
assert canary not in payload
|
||||
assert LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY).load().value == published.value
|
||||
|
||||
|
||||
def test_live_snapshot_lazily_migrates_plaintext_without_changing_metadata(tmp_path):
|
||||
path = tmp_path / "live.sqlite3"
|
||||
store = LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
|
||||
legacy = {"context": {"title": "legacy"}, "events": [], "notifications": []}
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
|
||||
('{"context":{"title":"legacy"},"events":[],"notifications":[]}',
|
||||
'{"context":4,"events":2,"notifications":1}'),
|
||||
)
|
||||
|
||||
state = store.load()
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
migrated = connection.execute(
|
||||
"SELECT value_json FROM live_snapshot WHERE singleton = 1"
|
||||
).fetchone()[0]
|
||||
assert state.value == legacy
|
||||
assert state.revisions == {"context": 4, "events": 2, "notifications": 1}
|
||||
assert migrated.startswith("v1:")
|
||||
|
||||
|
||||
def test_live_snapshot_authentication_failure_returns_no_private_content(tmp_path):
|
||||
path = tmp_path / "live.sqlite3"
|
||||
store = LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
|
||||
lease = store.try_acquire_refresh({"context"}, lease_seconds=5)
|
||||
metadata = {section: None for section in live_snapshot_store.SECTIONS}
|
||||
store.publish_refresh(
|
||||
lease,
|
||||
value={"context": {"title": "secret"}},
|
||||
created_at=metadata,
|
||||
failure_count={section: 0 for section in metadata},
|
||||
retry_at=metadata,
|
||||
changed_sections={"context"},
|
||||
)
|
||||
|
||||
with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
|
||||
LiveSnapshotStore(path, encryption_key=b"x" * 32).load()
|
||||
|
||||
|
||||
def test_metadata_load_does_not_retrieve_or_decode_snapshot_value(tmp_path):
|
||||
@@ -24,7 +92,7 @@ def test_metadata_load_does_not_retrieve_or_decode_snapshot_value(tmp_path):
|
||||
assert metadata.revisions == {
|
||||
section: 0 for section in ("context", "events", "notifications")
|
||||
}
|
||||
with pytest.raises(ValueError):
|
||||
with pytest.raises(PrivateStateEncryptionError):
|
||||
store.load()
|
||||
|
||||
|
||||
|
||||
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
assert "stackchain-dashboard-shell-v121" in worker
|
||||
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
assert "stackchain-dashboard-shell-v121" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
@@ -54,12 +54,13 @@ def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
css = CSS.read_text()
|
||||
|
||||
assert '<script src="static/mention-composer.js"></script>' in html
|
||||
for composer in ("issue-comment", "pull-comment", "update-reply"):
|
||||
for composer in ("issue-comment", "pull-comment", "update-reply", "today-progress-body"):
|
||||
assert f'id="{composer}-mentions"' in html
|
||||
assert f'id="{composer}-mention-status"' in html
|
||||
assert f"textarea:qs('#{composer}')" in dashboard
|
||||
assert f"listbox:qs('#{composer}-mentions')" in dashboard
|
||||
assert dashboard.count("createMentionComposer({") == 3
|
||||
assert f"['{composer}'," in dashboard
|
||||
assert dashboard.count("createMentionComposer({") == 1
|
||||
assert "['today-progress-body',()=>currentTodayProgressTarget()?.repository]" in dashboard
|
||||
assert "mentions:todayProgressMentions" in dashboard
|
||||
assert "mention-candidates?q=" in dashboard
|
||||
assert ".mention-options" in css
|
||||
assert "min-height:44px" in css
|
||||
|
||||
@@ -15,7 +15,10 @@ class FakeTarget {
|
||||
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
|
||||
focus() { state.focused = this; }
|
||||
}
|
||||
const state = {installCalls:0, offlineCalls:0, pushCalls:0, deadlineCalls:0, focused:null};
|
||||
const state = {
|
||||
installCalls:0, offlineCalls:0, pushCalls:0, deadlineCalls:0, focused:null,
|
||||
events:[], beginCalls:[], finishCalls:0,
|
||||
};
|
||||
const launcher = new FakeTarget();
|
||||
const closeButton = new FakeTarget();
|
||||
const sheet = new FakeTarget(); sheet.hidden = true;
|
||||
@@ -33,6 +36,7 @@ const promptCard = new FakeTarget(); promptCard.hidden = true;
|
||||
const promptSummary = new FakeTarget();
|
||||
const promptLauncher = new FakeTarget();
|
||||
const promptDismiss = new FakeTarget();
|
||||
const returnButton = new FakeTarget();
|
||||
let now = 1000;
|
||||
const promptStorage = {
|
||||
values:{},
|
||||
@@ -49,8 +53,13 @@ const setup = createMobileDeviceSetup({
|
||||
launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton,
|
||||
installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
|
||||
promptCard, promptSummary, promptLauncher, promptDismiss,
|
||||
returnButton, isMobile:() => true,
|
||||
timerView:{
|
||||
beginDetour:reason => { state.events.push('pause'); state.beginCalls.push(reason); return {reason}; },
|
||||
finishDetour:() => { state.events.push('resume'); state.finishCalls += 1; },
|
||||
},
|
||||
promptStorage, now:() => now,
|
||||
getReadiness:() => readiness,
|
||||
getReadiness:() => { state.events.push('readiness'); return readiness; },
|
||||
install:async () => { state.installCalls += 1; },
|
||||
enableOffline:async () => { state.offlineCalls += 1; },
|
||||
enablePush:async () => { state.pushCalls += 1; },
|
||||
@@ -170,6 +179,35 @@ process.stdout.write(JSON.stringify({
|
||||
assert result == {"hidden": True, "launcherFocused": True}
|
||||
|
||||
|
||||
def test_mobile_setup_pauses_before_readiness_and_each_exit_resumes_once():
|
||||
result = run_scenario("""
|
||||
state.events = [];
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
const firstOpen = {events:[...state.events], reasons:[...state.beginCalls]};
|
||||
await sheet.dispatch('click', {target:sheet});
|
||||
const backdropFinish = state.finishCalls;
|
||||
await promptLauncher.dispatch('click', {currentTarget:promptLauncher});
|
||||
await escapeTarget.dispatch('keydown', {key:'Escape'});
|
||||
const escapeFinish = state.finishCalls;
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
returnButton.addEventListener('click', () => { state.events.push('shared-resume'); state.finishCalls += 1; });
|
||||
await returnButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
firstOpen, backdropFinish, escapeFinish,
|
||||
returnFinish:state.finishCalls,
|
||||
hidden:sheet.hidden,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"firstOpen": {"events": ["pause", "readiness"], "reasons": ["device-setup"]},
|
||||
"backdropFinish": 1,
|
||||
"escapeFinish": 2,
|
||||
"returnFinish": 3,
|
||||
"hidden": True,
|
||||
}
|
||||
|
||||
|
||||
def test_deadline_step_runs_one_setup_action_and_uses_confirmed_readiness():
|
||||
result = run_scenario("""
|
||||
readiness.push = {state:'complete', detail:'New update notifications are enabled.'};
|
||||
@@ -207,6 +245,8 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert 'id="dismiss-device-readiness"' in html
|
||||
assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"')
|
||||
assert 'id="device-setup-sheet"' in html
|
||||
assert 'id="device-setup-today-detour"' in html
|
||||
assert 'id="return-from-device-setup"' in html
|
||||
assert 'aria-labelledby="device-setup-heading"' in html
|
||||
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline"))
|
||||
assert 'id="device-storage-summary"' in html
|
||||
@@ -218,12 +258,14 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert 'id="device-setup-deadline-hour"' in html
|
||||
assert '<script src="static/mobile-device-setup.js"></script>' in html
|
||||
assert "createMobileDeviceSetup.mount({" in dashboard
|
||||
assert "timerView," in dashboard
|
||||
assert "isMobile:options.isMobile || (() => innerWidth <= 600)" in MODULE.read_text()
|
||||
assert "createDeviceStorage.mount(document)" in dashboard
|
||||
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
assert "stackchain-dashboard-shell-v121" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -150,6 +150,70 @@ process.stdout.write(JSON.stringify({{backs:history.backs,prevented}}));
|
||||
assert run_node(script) == {"backs": 1, "prevented": 1}
|
||||
|
||||
|
||||
def test_insights_route_owns_the_today_detour_for_open_back_and_reload():
|
||||
script = f"""
|
||||
const createInsights=require({json.dumps(str(CONTROLLER))});
|
||||
const listeners={{}};
|
||||
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
|
||||
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
|
||||
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/today'}};
|
||||
const calls=[];
|
||||
const detour={{beginDetour(reason){{calls.push('begin:'+reason);return {{identity:'issue:r:42:',reason}};}},finishDetour(){{calls.push('finish');return {{identity:'issue:r:42:'}};}}}};
|
||||
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{}}}};
|
||||
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
|
||||
controller.start();
|
||||
launcher.listeners.click();
|
||||
location.hash='#/my-work/today'; history.state=null; listeners.popstate();
|
||||
listeners.popstate();
|
||||
location.hash='#/insights'; listeners.hashchange();
|
||||
process.stdout.write(JSON.stringify({{calls,current:controller.current()}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": ["begin:insights", "finish", "begin:insights"],
|
||||
"current": True,
|
||||
}
|
||||
|
||||
|
||||
def test_return_to_today_closes_insights_and_finishes_the_detour_once():
|
||||
script = f"""
|
||||
const createInsights=require({json.dumps(str(CONTROLLER))});
|
||||
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
|
||||
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
|
||||
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/today'}};
|
||||
const calls=[];
|
||||
const detour={{beginDetour(reason){{calls.push('begin:'+reason);}},finishDetour(){{calls.push('finish');}}}};
|
||||
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},back(){{}}}};
|
||||
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(){{}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
|
||||
controller.start(); launcher.listeners.click(); returnButton.listeners.click();
|
||||
process.stdout.write(JSON.stringify({{calls,current:controller.current(),hash:location.hash}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": ["begin:insights", "finish"],
|
||||
"current": False,
|
||||
"hash": "#/my-work/today",
|
||||
}
|
||||
|
||||
|
||||
def test_direct_insights_route_retries_detour_after_identity_restores():
|
||||
script = f"""
|
||||
const createInsights=require({json.dumps(str(CONTROLLER))});
|
||||
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
|
||||
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
|
||||
const location={{pathname:'/dashboard/',search:'',hash:'#/insights'}};
|
||||
let attempts=0; let retry;
|
||||
global.setTimeout=callback=>{{retry=callback;}};
|
||||
const detour={{beginDetour(){{attempts+=1;return attempts > 1 ? {{identity:'issue:r:42:'}} : null;}},finishDetour(){{}}}};
|
||||
const history={{state:null,replaceState(){{}},pushState(){{}},back(){{}}}};
|
||||
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(){{}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
|
||||
controller.start(); retry();
|
||||
process.stdout.write(JSON.stringify({{attempts,current:controller.current()}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"attempts": 2, "current": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_home_progressively_discloses_secondary_panels_as_insights():
|
||||
html = await dashboard()
|
||||
@@ -157,6 +221,11 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||
assert 'id="open-insights"' in html
|
||||
assert 'id="insights-sheet"' in html
|
||||
assert 'id="close-insights"' in html
|
||||
insights = html[html.index('id="insights-sheet"'):html.index('</section>', html.index('id="insights-sheet"'))]
|
||||
assert 'data-today-detour' in insights
|
||||
assert 'data-return-from-detour' in insights
|
||||
assert "detour:() => timerView" in html
|
||||
assert html.index("const timerView = createTodayTimerView({") < html.index("mobileInsights.start();")
|
||||
assert html.index('id="my-work"') < html.index('id="insights-sheet"')
|
||||
assert html.index('id="insights-sheet"') < html.index('data-panel-key="context"')
|
||||
assert html.index('data-panel-key="markdown"') < html.index('</section>\n</main>')
|
||||
@@ -174,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
assert "stackchain-dashboard-shell-v121" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -110,6 +110,42 @@ process.stdout.write(JSON.stringify({{blocked, handed, opened, handoffs}}));
|
||||
assert output["handoffs"] == ["agenda"]
|
||||
|
||||
|
||||
def test_prepare_today_counts_each_work_identity_in_only_its_highest_priority_phase():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
const agendaUpdate = {{kind:'issue', key:'lab#1'}};
|
||||
const attentionUpdate = {{kind:'pull', key:'lab#2'}};
|
||||
const filed = {{kind:'issue', key:'lab#3'}};
|
||||
const delivery = {{outbox_id:'draft-4'}};
|
||||
const controller = createStartDay({{
|
||||
getCounts: () => ({{today:2}}),
|
||||
getPhaseItems: () => ({{
|
||||
delivery:[delivery],
|
||||
agenda:[agendaUpdate],
|
||||
attention:[agendaUpdate, attentionUpdate],
|
||||
update:[agendaUpdate, attentionUpdate],
|
||||
filed:[filed],
|
||||
}}),
|
||||
openQueue: () => {{}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(controller.briefing()));
|
||||
"""
|
||||
|
||||
output = run_node(script)
|
||||
assert output == {
|
||||
"total": 4,
|
||||
"next": "delivery",
|
||||
"label": "Review Delivery",
|
||||
"summary": "1 delivery needs action before Today · 3 other items · 2 planned",
|
||||
"phases": [
|
||||
{"name": "delivery", "label": "Delivery recovery", "count": 1},
|
||||
{"name": "agenda", "label": "Agenda", "count": 1},
|
||||
{"name": "attention", "label": "Attention", "count": 1},
|
||||
{"name": "filed", "label": "Filed", "count": 1},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_start_day_view_renders_refreshed_phases_and_launches_primary_action():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
@@ -322,4 +358,24 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v117" in service_worker
|
||||
assert "stackchain-dashboard-shell-v121" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_primary_mobile_work_action_opens_a_unique_item_prepare_today_briefing():
|
||||
html = await dashboard()
|
||||
|
||||
assert "getPreparationState: () => mobileStartDay.state()" in html
|
||||
assert "prepareToday: openMobileStartDay" in html
|
||||
assert "function openMobileStartDay()" in html
|
||||
assert "qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';" in html
|
||||
assert "qs('#mobile-queue-sheet').showModal();" in html
|
||||
assert "qs('#mobile-start-day-action').focus();" in html
|
||||
assert "let mobilePreparationItems = {};" in html
|
||||
assert "getPhaseItems: () => mobilePreparationItems" in html
|
||||
assert "mobilePreparationItems = {" in html
|
||||
assert "delivery:draftInbox.partition(lastDrafts).deliveries" in html
|
||||
assert "agenda:agendaMyWork(activeMyWork)" in html
|
||||
assert "attention:activeMyWork.filter(item => item.needs_attention)" in html
|
||||
assert "update:activeMyWork.filter(item => item.has_update)" in html
|
||||
assert "filed:activeMyWork.filter(item => item.is_filed)" in html
|
||||
@@ -85,6 +85,37 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_entry_opens_prepare_today_before_direct_queue_work():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const calls = [];
|
||||
const state = {{active:true, preparation:{{active:false, next:'attention'}}}};
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => state.active,
|
||||
isTodayResumable: () => false,
|
||||
getTodayCount: () => 0,
|
||||
getEligibleCount: () => 3,
|
||||
getPreparationState: () => state.preparation,
|
||||
queueLauncher: {{recommend: () => ({{name:'attention'}}), open: name => calls.push('queue:' + name)}},
|
||||
continueToday: () => calls.push('continue'),
|
||||
prepareToday: () => calls.push('prepare'),
|
||||
planToday: () => calls.push('plan'),
|
||||
findWork: () => calls.push('find'),
|
||||
}});
|
||||
const modes = [entry.open()];
|
||||
state.active = false; modes.push(entry.open());
|
||||
state.preparation.active = true; modes.push(entry.open());
|
||||
process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"modes": ["continue", "prepare", "prepare-resume"],
|
||||
"calls": ["continue", "prepare", "prepare"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_entry_plans_eligible_work_before_find_fallback():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
@@ -178,6 +209,60 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_pauses_before_queues_and_distinguishes_close_from_selection():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
class FakeElement {{
|
||||
constructor() {{ this.listeners = {{}}; this.open = false; this.attributes = {{}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
showModal() {{ this.open = true; }}
|
||||
close() {{ this.open = false; this.listeners.close?.(); }}
|
||||
focus() {{}}
|
||||
}}
|
||||
const nav = new FakeElement();
|
||||
nav.hidden = false;
|
||||
const queues = new FakeElement();
|
||||
const close = new FakeElement();
|
||||
const row = new FakeElement();
|
||||
const button = new FakeElement();
|
||||
const calls = [];
|
||||
const dock = createDock({{
|
||||
nav, buttons:{{queues:button}}, queueSheet:queues, queueClose:close, queueRows:{{update:row}}, overlays:[],
|
||||
detour:()=>({{beginDetour:reason=>calls.push('pause:' + reason), finishDetour:()=>calls.push('return')}}),
|
||||
onSelectQueue:name=>calls.push('select:' + name),
|
||||
observe:()=>({{disconnect(){{}}}}),
|
||||
}});
|
||||
dock.start();
|
||||
button.listeners.click({{currentTarget:button}});
|
||||
close.listeners.click();
|
||||
button.listeners.click({{currentTarget:button}});
|
||||
row.listeners.click();
|
||||
process.stdout.write(JSON.stringify({{calls, open:queues.open}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": ["pause:queues", "return", "pause:queues", "select:update"],
|
||||
"open": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_find_and_queue_detours_are_visible_and_wired_to_today_timing():
|
||||
html = await dashboard()
|
||||
|
||||
assert html.count('data-today-detour role="status"') == 6
|
||||
assert html.count('data-return-from-detour type="button"') == 6
|
||||
assert "detour:() => timerView" in html
|
||||
timer_source = TIMER.read_text()
|
||||
assert "createTodayDetourInterruption" in timer_source
|
||||
assert "queryAll('#find-work-sheet')" in timer_source
|
||||
assert '.today-detour-interruption button { min-height:44px;' in html
|
||||
|
||||
|
||||
def test_mobile_task_dock_programmatically_selects_destination_without_running_action():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
@@ -651,6 +736,28 @@ process.stdout.write(JSON.stringify(labels));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_labels_prepare_today_as_the_primary_action():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }}}};
|
||||
const workLabel = {{textContent:''}};
|
||||
const dock = createDock({{nav:{{}}, buttons:{{work}}, workLabel}});
|
||||
const labels = {{}};
|
||||
for (const mode of ['prepare','prepare-resume']) {{
|
||||
dock.updateWork(mode);
|
||||
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
|
||||
}}
|
||||
process.stdout.write(JSON.stringify(labels));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"prepare": ["Prepare", "Prepare Today"],
|
||||
"prepare-resume": ["Resume prep", "Resume preparation"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_accessible_mobile_queue_completion_handoff():
|
||||
html = await dashboard()
|
||||
@@ -762,7 +869,7 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
assert "search: () => qs('#open-palette').click()" in html
|
||||
assert "queues: () => {}" in html
|
||||
assert "detour:() => timerView" in html
|
||||
assert "mobileTaskDock.updateQueues(counts)" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
||||
@@ -802,10 +909,13 @@ async def test_dashboard_renders_phone_safe_today_session_hud_above_task_dock():
|
||||
assert "completeTodayItem(item)" in html
|
||||
assert "workSession.reopen(item)" in html
|
||||
assert '.mobile-today-hud { display:none;' in html
|
||||
assert 'grid-template-areas:' in html
|
||||
assert '[data-mobile-today-complete] { grid-area:complete;' in html
|
||||
assert 'data-mobile-today-more' in html
|
||||
assert 'class="mobile-today-actions"' in html
|
||||
assert 'grid-template-columns:repeat(6,minmax(0,1fr));' in html
|
||||
assert '[data-mobile-today-complete] { grid-column:1 / 3;' in html
|
||||
assert 'bottom:calc(56px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-today-hud button { min-height:44px;' in html
|
||||
assert '.mobile-today-hud button { min-width:0; min-height:44px;' in html
|
||||
assert 'var(--mobile-today-clearance, 166px)' in html
|
||||
assert '.mobile-task-action[hidden] { display:none;' in html
|
||||
assert 'max-width:100%;' in html
|
||||
assert 'overflow:hidden;' in html
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SOURCE = Path(__file__).parents[1] / "frontend" / "mobile-today-command-bar.js"
|
||||
FRONTEND = SOURCE.parent
|
||||
|
||||
|
||||
def run_node(body: str) -> dict:
|
||||
script = f"const createCommandBar = require({json.dumps(str(SOURCE))});\n" + body
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_more_sheet_opens_modally_and_restores_focus_after_close():
|
||||
result = run_node(r"""
|
||||
function element() {
|
||||
const listeners = {};
|
||||
return {
|
||||
listeners, open:false, hidden:false, attributes:{}, focusCount:0,
|
||||
addEventListener:(type, listener) => { (listeners[type] ||= []).push(listener); },
|
||||
dispatch(type, event={}) { (listeners[type] || []).forEach(listener => listener({preventDefault(){}, target:this, ...event})); },
|
||||
setAttribute(name, value) { this.attributes[name] = value; },
|
||||
showModal() { this.open = true; },
|
||||
close() { this.open = false; this.dispatch('close'); },
|
||||
focus() { this.focusCount += 1; },
|
||||
};
|
||||
}
|
||||
const more = element();
|
||||
const sheet = element();
|
||||
const close = element();
|
||||
const firstAction = element();
|
||||
createCommandBar({more, sheet, close, firstAction});
|
||||
more.dispatch('click');
|
||||
const opened = {open:sheet.open, expanded:more.attributes['aria-expanded'], firstFocused:firstAction.focusCount};
|
||||
close.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({opened, closed:!sheet.open, restored:more.focusCount}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"opened": {"open": True, "expanded": "true", "firstFocused": 1},
|
||||
"closed": True,
|
||||
"restored": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_end_session_action_reuses_existing_session_control_and_closes_sheet():
|
||||
result = run_node(r"""
|
||||
function element() {
|
||||
const listeners = {};
|
||||
return {
|
||||
open:false, clicks:0, attributes:{},
|
||||
addEventListener:(type, listener) => { (listeners[type] ||= []).push(listener); },
|
||||
dispatch(type, event={}) { (listeners[type] || []).forEach(listener => listener({preventDefault(){}, target:this, ...event})); },
|
||||
setAttribute(name, value) { this.attributes[name] = value; },
|
||||
showModal() { this.open = true; },
|
||||
close() { this.open = false; this.dispatch('close'); },
|
||||
click() { this.clicks += 1; this.dispatch('click'); },
|
||||
focus() {},
|
||||
};
|
||||
}
|
||||
const more = element();
|
||||
const sheet = element();
|
||||
const endAction = element();
|
||||
const existingEndControl = element();
|
||||
createCommandBar({more, sheet, endAction, existingEndControl});
|
||||
more.dispatch('click');
|
||||
endAction.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({open:sheet.open, endClicks:existingEndControl.clicks}));
|
||||
""")
|
||||
|
||||
assert result == {"open": False, "endClicks": 1}
|
||||
|
||||
|
||||
def test_secondary_action_closes_command_sheet_before_existing_handler_runs():
|
||||
result = run_node(r"""
|
||||
function element() {
|
||||
const listeners = {};
|
||||
return {
|
||||
open:false, attributes:{}, observedOpen:null,
|
||||
addEventListener:(type, listener) => { (listeners[type] ||= []).push(listener); },
|
||||
dispatch(type) { (listeners[type] || []).forEach(listener => listener({preventDefault(){}, target:this})); },
|
||||
setAttribute(name, value) { this.attributes[name] = value; },
|
||||
showModal() { this.open = true; }, close() { this.open = false; this.dispatch('close'); }, focus() {},
|
||||
};
|
||||
}
|
||||
const more = element();
|
||||
const sheet = element();
|
||||
const update = element();
|
||||
createCommandBar({more, sheet, actionButtons:[update]});
|
||||
update.addEventListener('click', () => { update.observedOpen = sheet.open; });
|
||||
more.dispatch('click');
|
||||
update.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({open:sheet.open, handlerSawOpen:update.observedOpen}));
|
||||
""")
|
||||
|
||||
assert result == {"open": False, "handlerSawOpen": False}
|
||||
|
||||
|
||||
def test_rendered_hud_and_dock_height_define_scroll_clearance():
|
||||
result = run_node(r"""
|
||||
const listeners = {};
|
||||
const element = () => ({
|
||||
open:false, attributes:{},
|
||||
addEventListener:(type, listener) => { (listeners[type] ||= []).push(listener); },
|
||||
setAttribute(name, value) { this.attributes[name] = value; },
|
||||
focus() {}, showModal() { this.open = true; }, close() { this.open = false; },
|
||||
});
|
||||
const values = {};
|
||||
let resize;
|
||||
const ResizeObserverImpl = class {
|
||||
constructor(callback) { resize = callback; }
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
const more = element();
|
||||
const sheet = element();
|
||||
createCommandBar({
|
||||
more, sheet,
|
||||
hud:{offsetHeight:96}, dock:{offsetHeight:56},
|
||||
style:{setProperty:(name, value) => { values[name] = value; }},
|
||||
ResizeObserverImpl,
|
||||
});
|
||||
resize();
|
||||
process.stdout.write(JSON.stringify(values));
|
||||
""")
|
||||
|
||||
assert result == {"--mobile-today-clearance": "168px"}
|
||||
|
||||
|
||||
def test_mobile_command_bar_keeps_primary_actions_visible_and_secondary_actions_modal():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
hud = html[html.index('<section class="mobile-today-hud"'):html.index('</section>', html.index('<section class="mobile-today-hud"'))]
|
||||
sheet = html[html.index('<dialog class="mobile-today-actions"'):html.index('</dialog>', html.index('<dialog class="mobile-today-actions"'))]
|
||||
|
||||
assert "data-mobile-today-open" in hud
|
||||
assert "data-mobile-today-complete" in hud
|
||||
assert "data-mobile-today-toggle" in hud
|
||||
assert "data-mobile-today-more" in hud
|
||||
for secondary in (
|
||||
"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",
|
||||
"data-mobile-today-end",
|
||||
):
|
||||
assert secondary not in hud
|
||||
assert secondary in sheet
|
||||
assert 'aria-modal="true"' in sheet
|
||||
assert "body.mobile-today-active { padding-bottom:calc(var(--mobile-today-clearance" in css
|
||||
assert "createMobileTodayCommandBar({" in dashboard
|
||||
assert '<script src="static/mobile-today-command-bar.js"></script>' in html
|
||||
@@ -4771,8 +4771,9 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
|
||||
assert 'id="start-work-session"' in html
|
||||
assert html.count('class="work-session-nav"') == 4
|
||||
assert html.count('<span class="small" aria-live="polite" data-work-session-progress>') == 4
|
||||
assert html.count('<button type="button" data-work-session-previous>') == 4
|
||||
assert html.count('<button type="button" data-work-session-next>') == 4
|
||||
assert html.count('<button type="button" data-work-session-previous>') == 5
|
||||
assert html.count('<button type="button" data-work-session-next>') == 5
|
||||
assert 'class="mobile-today-actions"' in html
|
||||
assert '.work-session-nav button { min-height:44px;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
||||
assert 'aria-live="polite" data-work-session-progress' in html
|
||||
|
||||
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -84,7 +84,7 @@ def test_private_sqlite_connection_rejects_a_symlink_before_opening_target(tmp_p
|
||||
TodayStore,
|
||||
LaterStore,
|
||||
SavedSearchStore,
|
||||
UnfiledDraftStore,
|
||||
lambda path: UnfiledDraftStore(path, encryption_key=b"d" * 32),
|
||||
CompletedFiledReviewStore,
|
||||
PushSubscriptionStore,
|
||||
LiveSnapshotStore,
|
||||
|
||||
@@ -4,6 +4,7 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
from src.security_event_store import SecurityEventStoreError
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -312,11 +313,98 @@ async def test_assigned_pull_merge_requires_current_eligible_head(monkeypatch):
|
||||
assert calls == [("stackchain/api", 7, "abc123")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_merge_fails_before_gitea_when_audit_reservation_fails(
|
||||
monkeypatch,
|
||||
):
|
||||
merge_calls = []
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
async def assigned(*_args):
|
||||
return True
|
||||
|
||||
async def merge(*args):
|
||||
merge_calls.append(args)
|
||||
return {"number": 7, "merged": True, "state": "closed"}
|
||||
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {
|
||||
"error": "Security activity is temporarily unavailable. Nothing was merged."
|
||||
}
|
||||
assert merge_calls == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_merge_reports_success_and_retains_pending_audit_when_finalize_fails(
|
||||
monkeypatch,
|
||||
):
|
||||
lifecycle = []
|
||||
|
||||
class InterruptedJournal:
|
||||
def reserve(self, kind, *, target):
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "merge-operation"
|
||||
|
||||
def finalize(self, operation_id):
|
||||
lifecycle.append(("finalize", operation_id))
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
def discard(self, operation_id):
|
||||
lifecycle.append(("discard", operation_id))
|
||||
|
||||
async def assigned(*_args):
|
||||
return True
|
||||
|
||||
async def merge(*_args):
|
||||
return {"number": 7, "merged": True, "state": "closed"}
|
||||
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: InterruptedJournal())
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"number": 7, "merged": True, "state": "closed"}
|
||||
assert lifecycle == [
|
||||
("reserve", "pull_merged", "stackchain/api#7"),
|
||||
("finalize", "merge-operation"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypatch):
|
||||
calls = []
|
||||
merged = False
|
||||
|
||||
class LifecycleJournal:
|
||||
def reserve(self, kind, *, target):
|
||||
calls.append(("reserve", kind, target))
|
||||
return "merge-operation"
|
||||
|
||||
def finalize(self, operation_id):
|
||||
calls.append(("finalize", operation_id))
|
||||
|
||||
def discard(self, operation_id):
|
||||
calls.append(("discard", operation_id))
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
@@ -332,6 +420,7 @@ async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypa
|
||||
return merged
|
||||
|
||||
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: LifecycleJournal())
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_pull_merged_at_head", confirm, raising=False)
|
||||
@@ -345,8 +434,10 @@ async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypa
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"number": 7, "merged": True, "state": "closed"}
|
||||
assert calls == [
|
||||
("reserve", "pull_merged", "stackchain/api#7"),
|
||||
("merge", "stackchain/api", 7, "abc123"),
|
||||
("confirm", "stackchain/api", 7, "abc123"),
|
||||
("finalize", "merge-operation"),
|
||||
]
|
||||
|
||||
|
||||
@@ -435,6 +526,47 @@ async def test_assigned_pull_merge_returns_conflict_without_mutating_stale_head(
|
||||
assert "New commits" in response.json()["error"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"rejection",
|
||||
[gitea_proxy.StalePullError("changed"), gitea_proxy.PullNotMergeableError("unsafe")],
|
||||
)
|
||||
async def test_assigned_pull_merge_discards_audit_reservation_after_definite_rejection(
|
||||
monkeypatch, rejection
|
||||
):
|
||||
lifecycle = []
|
||||
|
||||
class LifecycleJournal:
|
||||
def reserve(self, kind, *, target):
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "merge-operation"
|
||||
|
||||
def discard(self, operation_id):
|
||||
lifecycle.append(("discard", operation_id))
|
||||
|
||||
async def assigned(*_args):
|
||||
return True
|
||||
|
||||
async def merge(*_args):
|
||||
raise rejection
|
||||
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: LifecycleJournal())
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert lifecycle == [
|
||||
("reserve", "pull_merged", "stackchain/api#7"),
|
||||
("discard", "merge-operation"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_merge_rejects_failed_ci_without_upstream_mutation():
|
||||
requests = []
|
||||
@@ -601,3 +733,96 @@ async def test_gitea_pull_release_removes_current_login_case_insensitively():
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert released["assignees"] == ["sam"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_merge_returns_the_exact_merge_commit_for_release_tracking():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "state": "open", "draft": False, "mergeable": True,
|
||||
"merged": False, "head": {"sha": "abc123"},
|
||||
})
|
||||
if request.url.path.endswith("/commits/abc123/status"):
|
||||
return httpx.Response(200, json={"state": "success"})
|
||||
if request.url.path.endswith("/pulls/7/merge"):
|
||||
return httpx.Response(200, json={"merged": True, "sha": "merge456"})
|
||||
raise AssertionError(request.url.path)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.merge_assigned_pull("stackchain/api", 7, "abc123")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"number": 7, "merged": True, "state": "closed", "merge_commit_sha": "merge456"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_release_receipt_matches_only_the_captured_commit_and_reports_checks(monkeypatch):
|
||||
async def receipt(repository, commit_sha):
|
||||
assert (repository, commit_sha) == ("stackchain/api", "merge456")
|
||||
return {
|
||||
"commit_sha": "merge456",
|
||||
"ci_state": "pending",
|
||||
"checks": [{"name": "browser", "state": "pending", "url": ""}],
|
||||
"release": None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "release_receipt_status", receipt, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/release-receipt/merge456"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["checks"] == [
|
||||
{"name": "browser", "state": "pending", "url": ""}
|
||||
]
|
||||
assert response.json()["release"] is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_release_receipt_ignores_other_commits_and_normalizes_matching_assets():
|
||||
forge = gitea_proxy.GITEA_URL.rstrip("/")
|
||||
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/commits/merge456/status"):
|
||||
return httpx.Response(200, json={
|
||||
"state": "success",
|
||||
"statuses": [{"context": "browser", "status": "success", "target_url": ""}],
|
||||
})
|
||||
if request.url.path.endswith("/releases"):
|
||||
return httpx.Response(200, json=[
|
||||
{"tag_name": "newer", "target_commitish": "other789", "html_url": f"{forge}/stackchain/api/releases/tag/newer"},
|
||||
{
|
||||
"tag_name": "rc-42", "target_commitish": "merge456",
|
||||
"html_url": f"{forge}/stackchain/api/releases/tag/rc-42",
|
||||
"assets": [
|
||||
{"name": "manifest.json", "browser_download_url": f"{forge}/stackchain/api/releases/download/rc-42/manifest.json"},
|
||||
],
|
||||
},
|
||||
])
|
||||
raise AssertionError(request.url.path)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.release_receipt_status("stackchain/api", "merge456")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["commit_sha"] == "merge456"
|
||||
assert result["ci_state"] == "success"
|
||||
assert result["checks"] == [{"name": "browser", "state": "success", "url": ""}]
|
||||
assert result["release"] == {
|
||||
"tag": "rc-42",
|
||||
"url": f"{forge}/stackchain/api/releases/tag/rc-42",
|
||||
"assets": [{
|
||||
"name": "manifest.json",
|
||||
"url": f"{forge}/stackchain/api/releases/download/rc-42/manifest.json",
|
||||
}],
|
||||
}
|
||||
@@ -22,6 +22,9 @@ const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callba
|
||||
const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback};
|
||||
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
|
||||
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||
const deadlineSnooze = {hidden:true};
|
||||
const deadlineSnoozeStatus = {set textContent(value) { state.snoozeText = value; }, get textContent() { return state.snoozeText; }};
|
||||
const deadlineSnoozeReview = {disabled:false, addEventListener:(_name, callback) => state.reviewSnooze = callback};
|
||||
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
||||
const registration = {pushManager:{
|
||||
getSubscription: async () => state.current || null,
|
||||
@@ -29,9 +32,11 @@ const registration = {pushManager:{
|
||||
}};
|
||||
const feature = createPushNotifications({
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
|
||||
onReviewDeadlines:() => { state.reviewed = true; },
|
||||
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
||||
serviceWorker: {ready:Promise.resolve(registration)},
|
||||
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); if (state.failResume && options.method === 'DELETE' && url.endsWith('/deadlines/snooze')) throw new Error('offline'); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||
});
|
||||
(async () => { __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
|
||||
@@ -158,6 +163,52 @@ process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:
|
||||
assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"]
|
||||
|
||||
|
||||
def test_active_deadline_snooze_is_visible_in_agenda_with_a_local_resume_time():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,snoozed_until:1765003600,public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
process.stdout.write(JSON.stringify({hidden:deadlineSnooze.hidden,text:state.snoozeText}));
|
||||
""")
|
||||
|
||||
assert result["hidden"] is False
|
||||
assert result["text"].startswith("Deadline reminders snoozed until ")
|
||||
|
||||
|
||||
def test_review_now_resumes_this_device_and_opens_the_deadline_review_flow():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,deadline_enabled:true,snoozed_until:1765003600,public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
await state.reviewSnooze();
|
||||
process.stdout.write(JSON.stringify({hidden:deadlineSnooze.hidden,disabled:deadlineSnoozeReview.disabled,reviewed:state.reviewed,requests:state.requests}));
|
||||
""")
|
||||
|
||||
assert result["requests"][-1][0:2] == [
|
||||
"api/v1/push-subscription/deadlines/snooze", "DELETE",
|
||||
]
|
||||
assert result["hidden"] is True
|
||||
assert result["disabled"] is False
|
||||
assert result["reviewed"] is True
|
||||
|
||||
|
||||
def test_failed_resume_keeps_the_snooze_visible_and_retryable():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,deadline_enabled:true,snoozed_until:1765003600,public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
state.failResume = true;
|
||||
await state.reviewSnooze();
|
||||
process.stdout.write(JSON.stringify({hidden:deadlineSnooze.hidden,disabled:deadlineSnoozeReview.disabled,text:state.snoozeText,reviewed:state.reviewed}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"hidden": False,
|
||||
"disabled": False,
|
||||
"text": "Could not resume deadline reminders. Check your connection and try again.",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
@@ -181,3 +232,21 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
assert "pywebpush==" in requirements
|
||||
assert "STACKCHAIN_VAPID_PUBLIC_KEY" in readme
|
||||
assert "STACKCHAIN_VAPID_PRIVATE_KEY" in readme
|
||||
|
||||
|
||||
def test_mobile_agenda_mounts_a_touch_safe_snooze_recovery_flow():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="deadline-snooze"' in html
|
||||
assert 'id="deadline-snooze-status"' in html
|
||||
assert 'id="review-snoozed-deadlines"' in html
|
||||
assert "deadlineSnooze:qs('#deadline-snooze')" in dashboard
|
||||
assert "onReviewDeadlines:" in dashboard
|
||||
assert "window.location.hash = '#/my-work/agenda'" in dashboard
|
||||
assert "qs('#protect-today')" in dashboard
|
||||
assert ".deadline-snooze" in css
|
||||
assert ".deadline-snooze button { min-height:44px;" in css
|
||||
assert "overflow-wrap:anywhere" in css
|
||||
@@ -7,9 +7,15 @@ from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from src import dashboard_auth, gitea_proxy, main
|
||||
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||
from src.push_notifications import (
|
||||
PushConfiguration,
|
||||
dispatch_unread_updates,
|
||||
send_web_push,
|
||||
)
|
||||
from src import push_subscription_store as push_store_module
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint
|
||||
|
||||
@@ -48,6 +54,133 @@ def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
|
||||
assert database.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_subscription_store_encrypts_device_credentials_and_reopens_with_same_key(tmp_path):
|
||||
database = tmp_path / "push.sqlite3"
|
||||
key = b"p" * 32
|
||||
subscription = {
|
||||
"endpoint": "https://push.example/private-device-canary",
|
||||
"keys": {"p256dh": "public-key-canary", "auth": "auth-secret-canary"},
|
||||
}
|
||||
|
||||
PushSubscriptionStore(database, encryption_key=key).upsert("session-a", subscription)
|
||||
|
||||
persisted = database.read_bytes()
|
||||
assert b"private-device-canary" not in persisted
|
||||
assert b"public-key-canary" not in persisted
|
||||
assert b"auth-secret-canary" not in persisted
|
||||
reopened = PushSubscriptionStore(database, encryption_key=key)
|
||||
assert reopened.claim_unseen({42: "r1"})[0].subscription == subscription
|
||||
|
||||
|
||||
def test_subscription_store_migrates_legacy_plaintext_without_resetting_checkpoints(tmp_path):
|
||||
database = tmp_path / "push.sqlite3"
|
||||
subscription = {
|
||||
"endpoint": "https://push.example/legacy-device-canary",
|
||||
"keys": {"p256dh": "legacy-public-canary", "auth": "legacy-auth-canary"},
|
||||
}
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE push_subscriptions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
subscription_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE push_deliveries (
|
||||
session_id TEXT NOT NULL,
|
||||
thread_id INTEGER NOT NULL,
|
||||
revision TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (session_id, thread_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO push_subscriptions VALUES (?, ?, ?)",
|
||||
("session-a", subscription["endpoint"], json.dumps(subscription)),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO push_deliveries VALUES (?, ?, ?)",
|
||||
("session-a", 42, "r1"),
|
||||
)
|
||||
|
||||
store = PushSubscriptionStore(database, encryption_key=b"m" * 32)
|
||||
|
||||
assert store.claim_unseen({42: "r1"}) == []
|
||||
updated = store.claim_unseen({42: "r2"})
|
||||
assert updated[0].subscription == subscription
|
||||
persisted = database.read_bytes()
|
||||
assert b"legacy-device-canary" not in persisted
|
||||
assert b"legacy-public-canary" not in persisted
|
||||
assert b"legacy-auth-canary" not in persisted
|
||||
|
||||
|
||||
def test_push_store_factory_only_requires_its_key_when_push_is_enabled(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.delenv("STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY", raising=False)
|
||||
factory = getattr(push_store_module, "build_push_subscription_store", None)
|
||||
assert callable(factory), "push store factory is missing"
|
||||
|
||||
disabled = factory(tmp_path / "disabled.sqlite3", push_enabled=False)
|
||||
assert disabled.is_subscribed("session-a") is False
|
||||
assert disabled.deadline_preferences("session-a")["enabled"] is False
|
||||
assert not (tmp_path / "disabled.sqlite3").exists()
|
||||
with pytest.raises(RuntimeError, match="encryption key"):
|
||||
factory(tmp_path / "enabled.sqlite3", push_enabled=True)
|
||||
|
||||
|
||||
def test_subscription_store_fails_closed_for_wrong_key_or_tampered_payload(tmp_path):
|
||||
database = tmp_path / "push.sqlite3"
|
||||
subscription = {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
}
|
||||
PushSubscriptionStore(database, encryption_key=b"a" * 32).upsert(
|
||||
"session-a", subscription
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="could not be decrypted"):
|
||||
PushSubscriptionStore(database, encryption_key=b"b" * 32)
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT subscription_json FROM push_subscriptions WHERE session_id = ?",
|
||||
("session-a",),
|
||||
).fetchone()[0]
|
||||
replacement = "A" if payload[-1] != "A" else "B"
|
||||
connection.execute(
|
||||
"UPDATE push_subscriptions SET subscription_json = ? WHERE session_id = ?",
|
||||
(payload[:-1] + replacement, "session-a"),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="could not be decrypted"):
|
||||
PushSubscriptionStore(database, encryption_key=b"a" * 32)
|
||||
|
||||
|
||||
def test_subscription_ciphertext_cannot_be_substituted_between_sessions(tmp_path):
|
||||
database = tmp_path / "push.sqlite3"
|
||||
key = b"s" * 32
|
||||
store = PushSubscriptionStore(database, encryption_key=key)
|
||||
for session_id in ("session-a", "session-b"):
|
||||
store.upsert(
|
||||
session_id,
|
||||
{
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": f"key-{session_id}", "auth": f"auth-{session_id}"},
|
||||
},
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
payloads = connection.execute(
|
||||
"SELECT session_id, subscription_json FROM push_subscriptions ORDER BY session_id"
|
||||
).fetchall()
|
||||
connection.executemany(
|
||||
"UPDATE push_subscriptions SET subscription_json = ? WHERE session_id = ?",
|
||||
((payloads[1][1], payloads[0][0]), (payloads[0][1], payloads[1][0])),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="could not be decrypted"):
|
||||
PushSubscriptionStore(database, encryption_key=key)
|
||||
|
||||
|
||||
def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
subscription = {
|
||||
@@ -692,6 +825,75 @@ async def test_dispatch_removes_an_endpoint_that_rebinds_private_without_contact
|
||||
assert store.is_subscribed("unsafe-device") is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_web_push_transport_dials_the_address_validated_for_the_original_tls_host():
|
||||
endpoint = "https://push.example/device-a"
|
||||
resolved = SimpleNamespace(
|
||||
endpoint=endpoint,
|
||||
hostname="push.example",
|
||||
port=443,
|
||||
addresses=("93.184.216.34",),
|
||||
)
|
||||
resolutions = []
|
||||
connection = {}
|
||||
|
||||
async def resolve_once(value):
|
||||
resolutions.append(value)
|
||||
return resolved
|
||||
|
||||
def inspect_transport(**kwargs):
|
||||
session = kwargs["requests_session"]
|
||||
assert session.trust_env is False
|
||||
request = session.prepare_request(requests.Request("POST", endpoint))
|
||||
adapter = session.get_adapter(endpoint)
|
||||
adapter.add_headers(request)
|
||||
host, tls = adapter.build_connection_pool_key_attributes(request, True)
|
||||
connection.update(host=host, tls=tls, headers=dict(request.headers))
|
||||
|
||||
await send_web_push(
|
||||
{"endpoint": endpoint, "keys": {"p256dh": "key", "auth": "secret"}},
|
||||
"{}",
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
endpoint_resolver=resolve_once,
|
||||
webpush_sender=inspect_transport,
|
||||
)
|
||||
|
||||
assert resolutions == [endpoint]
|
||||
assert connection["host"] == {
|
||||
"scheme": "https",
|
||||
"host": "93.184.216.34",
|
||||
"port": 443,
|
||||
}
|
||||
assert connection["tls"]["assert_hostname"] == "push.example"
|
||||
assert connection["tls"]["server_hostname"] == "push.example"
|
||||
assert connection["headers"]["Host"] == "push.example"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connect_time_rebinding_rejection_removes_only_the_unsafe_device(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("unsafe-device", "healthy-device"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 8}]}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
if subscription["endpoint"].endswith("unsafe-device"):
|
||||
raise UnsafePushEndpoint("Endpoint rebound before connection")
|
||||
sent.append(subscription["endpoint"])
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
||||
assert store.is_subscribed("unsafe-device") is False
|
||||
assert store.is_subscribed("healthy-device") is True
|
||||
assert sent == ["https://push.example/healthy-device"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -981,6 +1183,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
"timezone": "UTC",
|
||||
"reminder_hour": 9,
|
||||
"reminder_days": 2,
|
||||
"snoozed_until": None,
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
assert (await main.push_status(request))["subscribed"] is True
|
||||
@@ -1016,6 +1219,89 @@ async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monk
|
||||
assert store.deadline_preferences("session-a")["reminder_days"] == 7
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_can_snooze_its_enabled_deadline_reminder(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("session-a", "session-b"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
monkeypatch.setattr(main.time, "time", lambda: 1_765_000_000)
|
||||
|
||||
async def management_id(_session):
|
||||
return "session-a"
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
|
||||
assert await main.snooze_deadline_reminder(request) == {
|
||||
"snoozed": True,
|
||||
"snoozed_until": 1_765_003_600,
|
||||
}
|
||||
devices = {device.session_id: device for device in store.deadline_reminder_devices()}
|
||||
assert devices["session-a"].snoozed_until == 1_765_003_600
|
||||
assert devices["session-b"].snoozed_until is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_status_exposes_only_the_current_devices_active_deadline_snooze(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("session-a", "session-b"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
store.snooze_deadline_reminder("session-a", now=1_765_000_000)
|
||||
store.snooze_deadline_reminder("session-b", now=1_765_001_000)
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
monkeypatch.setattr(main.time, "time", lambda: 1_765_000_100)
|
||||
|
||||
async def management_id(_session):
|
||||
return "session-a"
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
|
||||
status = await main.push_status(request)
|
||||
|
||||
assert status["snoozed_until"] == 1_765_003_600
|
||||
assert store.deadline_preferences("session-a", now=1_765_004_000)["snoozed_until"] is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_can_resume_only_its_own_deadline_reminders(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("session-a", "session-b"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
store.snooze_deadline_reminder(session_id, now=1_765_000_000)
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
|
||||
async def management_id(_session):
|
||||
return "session-a"
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
|
||||
assert await main.resume_deadline_reminders(request) == {"snoozed": False}
|
||||
devices = {device.session_id: device for device in store.deadline_reminder_devices()}
|
||||
assert devices["session-a"].snoozed_until is None
|
||||
assert devices["session-b"].snoozed_until == 1_765_003_600
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
|
||||
@@ -14,13 +14,32 @@ VERIFIER = ROOT / "scripts" / "verify_release.py"
|
||||
def _fixture(root: Path) -> None:
|
||||
(root / "src").mkdir(parents=True)
|
||||
(root / "frontend").mkdir()
|
||||
(root / "scripts").mkdir()
|
||||
(root / "src" / "main.py").write_text("print('ready')\n")
|
||||
(root / "frontend" / "index.html").write_text("<h1>Stackchain</h1>\n")
|
||||
(root / "requirements.txt").write_text("fastapi==1.0\n")
|
||||
(root / "README.md").write_text("# Stackchain\n")
|
||||
(root / "scripts" / "rotate_unfiled_drafts.py").write_text("print('rotate')\n")
|
||||
|
||||
|
||||
def _build(source: Path, output: Path) -> subprocess.CompletedProcess[str]:
|
||||
def _commit_fixture(source: Path) -> str:
|
||||
subprocess.run(["git", "init", "-q", str(source)], check=True)
|
||||
subprocess.run(["git", "-C", str(source), "config", "user.name", "Release Test"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(source), "config", "user.email", "release-test@example.invalid"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(["git", "-C", str(source), "add", "."], check=True)
|
||||
subprocess.run(["git", "-C", str(source), "commit", "-qm", "fixture"], check=True)
|
||||
return subprocess.run(
|
||||
["git", "-C", str(source), "rev-parse", "HEAD"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _build(source: Path, output: Path, commit: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
@@ -30,7 +49,7 @@ def _build(source: Path, output: Path) -> subprocess.CompletedProcess[str]:
|
||||
"--output-dir",
|
||||
str(output),
|
||||
"--commit",
|
||||
"a" * 40,
|
||||
commit,
|
||||
"--source-date-epoch",
|
||||
"1720000000",
|
||||
],
|
||||
@@ -40,14 +59,20 @@ def _build(source: Path, output: Path) -> subprocess.CompletedProcess[str]:
|
||||
)
|
||||
|
||||
|
||||
def test_release_bundle_is_reproducible(tmp_path):
|
||||
def test_release_bundle_is_reproducible_from_declared_commit(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
_fixture(source)
|
||||
commit = _commit_fixture(source)
|
||||
|
||||
first = _build(source, tmp_path / "first")
|
||||
second = _build(source, tmp_path / "second")
|
||||
|
||||
first = _build(source, tmp_path / "first", commit)
|
||||
assert first.returncode == 0, first.stderr
|
||||
|
||||
(source / "src" / "__pycache__").mkdir()
|
||||
(source / "src" / "__pycache__" / "main.cpython-311.pyc").write_bytes(b"bytecode")
|
||||
(source / "src" / "untracked-secret.txt").write_text("must not ship")
|
||||
(source / "src" / "main.py").write_text("print('dirty worktree')\n")
|
||||
second = _build(source, tmp_path / "second", commit)
|
||||
|
||||
assert second.returncode == 0, second.stderr
|
||||
first_archive = next((tmp_path / "first").glob("*.tar.gz"))
|
||||
second_archive = next((tmp_path / "second").glob("*.tar.gz"))
|
||||
@@ -55,13 +80,14 @@ def test_release_bundle_is_reproducible(tmp_path):
|
||||
|
||||
manifest = json.loads(next((tmp_path / "first").glob("*.manifest.json")).read_text())
|
||||
checksum = next((tmp_path / "first").glob("*.sha256")).read_text().split()[0]
|
||||
assert manifest["commit"] == "a" * 40
|
||||
assert manifest["commit"] == commit
|
||||
assert manifest["artifact"]["sha256"] == checksum
|
||||
assert checksum == hashlib.sha256(first_archive.read_bytes()).hexdigest()
|
||||
assert sorted(manifest["files"]) == [
|
||||
"README.md",
|
||||
"frontend/index.html",
|
||||
"requirements.txt",
|
||||
"scripts/rotate_unfiled_drafts.py",
|
||||
"src/main.py",
|
||||
]
|
||||
with tarfile.open(first_archive, "r:gz") as archive:
|
||||
@@ -72,21 +98,43 @@ def test_release_bundle_is_reproducible(tmp_path):
|
||||
"frontend/index.html",
|
||||
"release-manifest.json",
|
||||
"requirements.txt",
|
||||
"scripts/rotate_unfiled_drafts.py",
|
||||
"src/main.py",
|
||||
]
|
||||
assert embedded["commit"] == "a" * 40
|
||||
assert embedded["commit"] == commit
|
||||
assert embedded["files"] == manifest["files"]
|
||||
|
||||
|
||||
def test_release_bundle_rejects_unknown_commit(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
_fixture(source)
|
||||
_commit_fixture(source)
|
||||
|
||||
built = _build(source, tmp_path / "dist", "a" * 40)
|
||||
|
||||
assert built.returncode != 0
|
||||
assert "declared commit is unavailable" in built.stderr
|
||||
|
||||
|
||||
def test_release_bundle_verifier_enforces_integrity(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
output = tmp_path / "dist"
|
||||
_fixture(source)
|
||||
built = _build(source, output)
|
||||
commit = _commit_fixture(source)
|
||||
built = _build(source, output, commit)
|
||||
assert built.returncode == 0, built.stderr
|
||||
|
||||
valid = subprocess.run(
|
||||
[sys.executable, str(VERIFIER), "--input-dir", str(output), "--commit", "a" * 40],
|
||||
[
|
||||
sys.executable,
|
||||
str(VERIFIER),
|
||||
"--input-dir",
|
||||
str(output),
|
||||
"--commit",
|
||||
commit,
|
||||
"--repository",
|
||||
str(source),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
@@ -96,9 +144,18 @@ def test_release_bundle_verifier_enforces_integrity(tmp_path):
|
||||
archive = next(output.glob("*.tar.gz"))
|
||||
archive.write_bytes(archive.read_bytes() + b"tampered")
|
||||
tampered = subprocess.run(
|
||||
[sys.executable, str(VERIFIER), "--input-dir", str(output), "--commit", "a" * 40],
|
||||
[
|
||||
sys.executable,
|
||||
str(VERIFIER),
|
||||
"--input-dir",
|
||||
str(output),
|
||||
"--commit",
|
||||
commit,
|
||||
"--repository",
|
||||
str(source),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert tampered.returncode != 0
|
||||
assert tampered.returncode != 0
|
||||
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SOURCE = Path(__file__).parents[1] / "frontend" / "release-receipt.js"
|
||||
|
||||
|
||||
def run_node(body: str) -> dict:
|
||||
script = f"const createReleaseReceipt=require({json.dumps(str(SOURCE))});\n" + body
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_receipt_survives_reload_for_confirmed_account_only_and_tracks_exact_commit():
|
||||
output = run_node(r"""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.has(key)?values.get(key):null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let login='timmy'; const calls=[];
|
||||
const first=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:async path=>{calls.push(path);return {commit_sha:'merge456',ci_state:'pending',checks:[{name:'browser',state:'pending',url:''}],release:null};}});
|
||||
const captured=first.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
|
||||
const restored=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:first.fetchJson}).restore();
|
||||
restored && first.refresh().then(status=>{
|
||||
login='alex';
|
||||
const other=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:first.fetchJson}).restore();
|
||||
process.stdout.write(JSON.stringify({captured,restored,status,other,calls,keys:[...values.keys()]}));
|
||||
});
|
||||
""")
|
||||
|
||||
assert output["captured"]["commit_sha"] == "merge456"
|
||||
assert output["restored"][0]["repository"] == "stackchain/api"
|
||||
assert output["status"][0]["label"] == "Checks running"
|
||||
assert output["status"][0]["checks"] == ["browser"]
|
||||
assert output["other"] == []
|
||||
assert output["calls"] == ["api/v1/repos/stackchain/api/release-receipt/merge456"]
|
||||
assert output["keys"] == ["stackchain.release-receipt.v1:timmy"]
|
||||
|
||||
|
||||
def test_receipt_distinguishes_failed_checks_waiting_for_release_and_exact_release():
|
||||
output = run_node(r"""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const responses=[
|
||||
{commit_sha:'merge456',ci_state:'failure',checks:[{name:'unit',state:'success'},{name:'browser',state:'failure'}],release:null},
|
||||
{commit_sha:'merge456',ci_state:'success',checks:[{name:'browser',state:'success'}],release:null},
|
||||
{commit_sha:'merge456',ci_state:'success',checks:[{name:'browser',state:'success'}],release:{tag:'rc-42',url:'https://forge.example/git/x/releases/tag/rc-42',assets:[{name:'manifest.json',url:'https://forge.example/manifest'}]}},
|
||||
];
|
||||
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async()=>responses.shift()});
|
||||
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
|
||||
(async()=>{
|
||||
const failed=await receipt.refresh(); const waiting=await receipt.refresh(); const released=await receipt.refresh();
|
||||
receipt.dismiss();
|
||||
process.stdout.write(JSON.stringify({failed,waiting,released,restored:receipt.restore()}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert output["failed"][0]["label"] == "Checks failed"
|
||||
assert output["failed"][0]["checks"] == ["browser"]
|
||||
assert output["waiting"][0]["label"] == "Checks passed · waiting for release"
|
||||
assert output["released"][0]["label"] == "Released · rc-42"
|
||||
assert output["released"][0]["release"]["assets"][0]["name"] == "manifest.json"
|
||||
assert output["restored"] == []
|
||||
|
||||
|
||||
def test_watchlist_preserves_distinct_merges_deduplicates_and_dismisses_one():
|
||||
output = run_node(r"""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async()=>{throw new Error('unused')}});
|
||||
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
|
||||
receipt.capture({repository:'stackchain/web',number:8,key:'stackchain/web#8'}, {merge_commit_sha:'merge789'});
|
||||
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
|
||||
const before=receipt.restore();
|
||||
receipt.dismiss('stackchain/api', 'merge456');
|
||||
const after=receipt.restore();
|
||||
process.stdout.write(JSON.stringify({before,after,stored:JSON.parse([...values.values()][0])}));
|
||||
""")
|
||||
|
||||
assert [item["commit_sha"] for item in output["before"]] == ["merge456", "merge789"]
|
||||
assert [item["commit_sha"] for item in output["after"]] == ["merge789"]
|
||||
assert output["stored"]["version"] == 2
|
||||
assert len(output["stored"]["entries"]) == 1
|
||||
|
||||
|
||||
def test_watchlist_refreshes_every_exact_commit_in_one_single_flight():
|
||||
output = run_node(r"""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const calls=[];
|
||||
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async path=>{
|
||||
calls.push(path); await new Promise(resolve=>setTimeout(resolve, 5));
|
||||
const sha=path.split('/').pop();
|
||||
return sha === 'merge456'
|
||||
? {commit_sha:sha,ci_state:'failure',checks:[{name:'browser',state:'failure'}],release:null}
|
||||
: {commit_sha:sha,ci_state:'success',checks:[],release:{tag:'rc-9',url:'https://forge.example/rc-9'}};
|
||||
}});
|
||||
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
|
||||
receipt.capture({repository:'stackchain/web',number:8,key:'stackchain/web#8'}, {merge_commit_sha:'merge789'});
|
||||
(async()=>{
|
||||
const [first,second]=await Promise.all([receipt.refresh(),receipt.refresh()]);
|
||||
process.stdout.write(JSON.stringify({first,second,calls,restored:receipt.restore()}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert output["calls"] == [
|
||||
"api/v1/repos/stackchain/api/release-receipt/merge456",
|
||||
"api/v1/repos/stackchain/web/release-receipt/merge789",
|
||||
]
|
||||
assert [status["label"] for status in output["first"]] == ["Checks failed", "Released · rc-9"]
|
||||
assert output["second"] == output["first"]
|
||||
assert [item["status"]["commit_sha"] for item in output["restored"]] == ["merge456", "merge789"]
|
||||
|
||||
|
||||
def test_watchlist_renders_failed_merges_first_and_dismisses_individually():
|
||||
output = run_node(r"""
|
||||
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
|
||||
global.document={createElement:tag=>node(tag)};
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const launcher=node('button'), listNode=node();
|
||||
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',launcher,listNode,fetchJson:async path=>{
|
||||
const sha=path.split('/').pop();
|
||||
return sha === 'failed' ? {commit_sha:sha,ci_state:'failure',checks:[{name:'browser',state:'failure'}]}
|
||||
: {commit_sha:sha,ci_state:'pending',checks:[{name:'unit',state:'pending'}]};
|
||||
}});
|
||||
receipt.capture({repository:'stackchain/api',number:1,key:'stackchain/api#1'}, {merge_commit_sha:'pending'});
|
||||
receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'failed'});
|
||||
(async()=>{
|
||||
await receipt.refresh();
|
||||
const before=listNode.children.map(row=>({key:row.children[0].children[0].textContent,state:row.children[0].children[1].textContent,label:row.children[1]['aria-label']}));
|
||||
const beforeLauncher=launcher.textContent;
|
||||
listNode.children[0].children[1].click();
|
||||
process.stdout.write(JSON.stringify({before,launcher:beforeLauncher,after:receipt.restore()}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert output["before"] == [
|
||||
{"key": "stackchain/web#2", "state": "Checks failed", "label": "Dismiss stackchain/web#2 from release tracking"},
|
||||
{"key": "stackchain/api#1", "state": "Checks running", "label": "Dismiss stackchain/api#1 from release tracking"},
|
||||
]
|
||||
assert output["launcher"] == "1 release failure · 2 tracked"
|
||||
assert [item["commit_sha"] for item in output["after"]] == ["pending"]
|
||||
|
||||
|
||||
def test_watchlist_polls_pending_merges_only_while_foregrounded():
|
||||
output = run_node(r"""
|
||||
const values=new Map(), scheduled=[], calls=[];
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const documentRef={hidden:false,addEventListener(type,fn){this[type]=fn}};
|
||||
const windowRef={addEventListener(type,fn){this[type]=fn}};
|
||||
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',documentRef,windowRef,pollMs:25000,
|
||||
setTimer:(fn,ms)=>{scheduled.push({fn,ms});return scheduled.length},clearTimer:()=>{},
|
||||
fetchJson:async path=>{calls.push(path);const sha=path.split('/').pop();return {commit_sha:sha,ci_state:'success',checks:[],release:{tag:'rc-'+sha,url:'https://forge.example/'+sha}};}});
|
||||
receipt.bind();
|
||||
receipt.capture({repository:'stackchain/api',number:1,key:'stackchain/api#1'}, {merge_commit_sha:'one'});
|
||||
receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'two'});
|
||||
(async()=>{
|
||||
const first=scheduled.at(-1); await first.fn();
|
||||
documentRef.hidden=true; documentRef.visibilitychange();
|
||||
process.stdout.write(JSON.stringify({delay:first.ms,calls,scheduled:scheduled.length,restored:receipt.restore()}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert output["delay"] == 25000
|
||||
assert output["calls"] == [
|
||||
"api/v1/repos/stackchain/api/release-receipt/one",
|
||||
"api/v1/repos/stackchain/web/release-receipt/two",
|
||||
]
|
||||
assert output["scheduled"] == 2
|
||||
assert all(item["status"]["release"] for item in output["restored"])
|
||||
|
||||
|
||||
def test_watchlist_keeps_refreshing_other_merges_when_one_status_request_fails():
|
||||
output = run_node(r"""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async path=>{
|
||||
const sha=path.split('/').pop();
|
||||
if (sha === 'offline') throw new Error('Network unavailable');
|
||||
return {commit_sha:sha,ci_state:'success',checks:[],release:{tag:'rc-ok',url:'https://forge.example/rc-ok'}};
|
||||
}});
|
||||
receipt.capture({repository:'stackchain/api',number:1,key:'stackchain/api#1'}, {merge_commit_sha:'offline'});
|
||||
receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'released'});
|
||||
(async()=>{ const statuses=await receipt.refresh(); process.stdout.write(JSON.stringify({statuses,entries:receipt.restore()})); })();
|
||||
""")
|
||||
|
||||
assert output["statuses"][0]["label"] == "Status unavailable"
|
||||
assert output["statuses"][1]["label"] == "Released · rc-ok"
|
||||
assert output["entries"][0]["status"]["error"] == "Network unavailable"
|
||||
assert output["entries"][1]["status"]["release"]["url"] == "https://forge.example/rc-ok"
|
||||
|
||||
|
||||
def test_watchlist_exposes_release_link_on_the_matching_merge_row():
|
||||
output = run_node(r"""
|
||||
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(){}}; }
|
||||
global.document={createElement:tag=>node(tag)};
|
||||
const values=new Map(), listNode=node();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',listNode,fetchJson:async()=>({commit_sha:'released',ci_state:'success',checks:[],release:{tag:'rc-12',url:'https://forge.example/rc-12'}})});
|
||||
receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'released'});
|
||||
(async()=>{ await receipt.refresh(); const link=listNode.children[0].children[0].children[2]; process.stdout.write(JSON.stringify({tag:link.tag,href:link.href,text:link.textContent})); })();
|
||||
""")
|
||||
|
||||
assert output == {"tag": "a", "href": "https://forge.example/rc-12", "text": "Open release rc-12"}
|
||||
|
||||
|
||||
def test_mobile_release_receipt_is_wired_into_the_merge_flow_and_phone_safe():
|
||||
root = Path(__file__).parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert '<script src="static/release-receipt.js"></script>' in html
|
||||
assert 'id="release-receipt-launcher"' in html
|
||||
assert 'id="release-receipt-sheet"' in html
|
||||
assert 'id="release-watchlist"' in html
|
||||
assert 'releaseReceipt.capture(merging, mergeResult)' in dashboard
|
||||
assert "listNode:qs('#release-watchlist')" in dashboard
|
||||
assert "min-height:44px" in css[css.index(".release-receipt-sheet"):]
|
||||
assert "overflow-x:hidden" in css[css.index(".release-receipt-sheet"):]
|
||||
assert ".release-watchlist-item" in css
|
||||
@@ -237,7 +237,7 @@ async def test_gitea_review_submission_checks_head_then_maps_decision_upstream()
|
||||
json={
|
||||
"id": 91,
|
||||
"state": "APPROVED",
|
||||
"html_url": "https://forge.example/reviews/91",
|
||||
"html_url": "http://127.0.0.1:3000/reviews/91",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -257,7 +257,7 @@ async def test_gitea_review_submission_checks_head_then_maps_decision_upstream()
|
||||
assert result == {
|
||||
"id": 91,
|
||||
"state": "APPROVED",
|
||||
"url": "https://forge.example/reviews/91",
|
||||
"url": "http://127.0.0.1:3000/reviews/91",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from src.unfiled_draft_store import UnfiledDraftStore
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "scripts" / "rotate_unfiled_drafts.py"
|
||||
OLD_KEY = "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28="
|
||||
NEW_KEY = "bm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm4="
|
||||
|
||||
|
||||
def _draft(title):
|
||||
return {"id": "draft", "title": title, "body": "private", "saved_at": 1}
|
||||
|
||||
|
||||
def test_rotation_cli_migrates_readable_rows_and_fails_closed_on_unreadable_rows(tmp_path):
|
||||
path = tmp_path / "drafts.sqlite3"
|
||||
store = UnfiledDraftStore(path, encryption_key=b"o" * 32)
|
||||
store.replace("timmy", 0, [_draft("Secret launch")])
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?)",
|
||||
("broken", 1, "v2:missing:not-ciphertext"),
|
||||
)
|
||||
env = {
|
||||
**os.environ,
|
||||
"STACKCHAIN_UNFILED_DRAFT_DB": str(path),
|
||||
"STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS": json.dumps(
|
||||
{"legacy": OLD_KEY, "new": NEW_KEY}
|
||||
),
|
||||
"STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID": "new",
|
||||
}
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(SCRIPT)],
|
||||
cwd=SCRIPT.parents[1],
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 1
|
||||
assert json.loads(completed.stdout) == {
|
||||
"current": 0,
|
||||
"failed": 1,
|
||||
"migrated": 1,
|
||||
"total": 2,
|
||||
}
|
||||
assert "timmy" not in completed.stdout
|
||||
assert "Secret launch" not in completed.stdout
|
||||
assert completed.stderr == ""
|
||||
@@ -230,6 +230,49 @@ const boundary={{
|
||||
assert output["pushed"] == ["/dashboard/#security/activity", "/dashboard/#security/devices"]
|
||||
|
||||
|
||||
def test_security_center_finishes_its_today_detour_only_when_the_sheet_closes():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const listeners={{}};
|
||||
const element=()=>({{
|
||||
hidden:true,textContent:'',children:[],attributes:{{}},
|
||||
addEventListener(){{}},focus(){{}},replaceChildren(){{this.children=[];}},append(...values){{this.children.push(...values);}},
|
||||
setAttribute(name,value){{this.attributes[name]=value;}},removeAttribute(name){{delete this.attributes[name];}},scrollIntoView(){{}},
|
||||
}});
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices',
|
||||
'security-section-activity','security-section-devices','security-section-passkeys',
|
||||
'security-activity-section','security-devices-section','security-passkeys-section'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element()]));
|
||||
const root={{
|
||||
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
|
||||
history:{{state:null,pushState(state){{this.state=state;}}}},
|
||||
location:{{pathname:'/dashboard/',search:''}},
|
||||
addEventListener:(type,listener)=>listeners[type]=listener,
|
||||
}};
|
||||
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
|
||||
let closes=0;
|
||||
(async()=>{{
|
||||
const controller=attachSecurityCenter({{root,boundary,onClose:()=>closes++}});
|
||||
await controller.open();
|
||||
listeners.popstate({{state:{{stackchainSecuritySection:'devices'}}}});
|
||||
const afterSectionBack=closes;
|
||||
listeners.popstate({{state:null}});
|
||||
listeners.popstate({{state:null}});
|
||||
console.log(JSON.stringify({{afterSectionBack,closes,hidden:ids['active-devices-sheet'].hidden}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"afterSectionBack": 0,
|
||||
"closes": 1,
|
||||
"hidden": True,
|
||||
}
|
||||
|
||||
|
||||
def test_close_uses_history_back_to_remove_security_center_route():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
|
||||
+237
-22
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
@@ -73,7 +73,10 @@ const context = {{
|
||||
matchAll: async () => state.clientList || [],
|
||||
openWindow: async url => {{ state.opened.push(url); }},
|
||||
}},
|
||||
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
|
||||
registration: {{
|
||||
showNotification: async (title, options) => state.notifications.push({{title,options}}),
|
||||
getNotifications: async () => [{{close:()=>{{state.closedNotifications += 1;}}}}],
|
||||
}},
|
||||
__issueSync: {{
|
||||
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
|
||||
purge: async () => {{ state.outboxPurges += 1; state.outboxLifecycle.push('purge'); }},
|
||||
@@ -135,11 +138,11 @@ async function dispatchMessage(data, ports = []) {{
|
||||
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null, url = null) {{
|
||||
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null, url = null, actionToken = '') {{
|
||||
let pending;
|
||||
listeners.notificationclick({{
|
||||
action,
|
||||
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId, ...(url ? {{url}} : {{}})}}, close: () => {{ state.notificationClosed = true; }}}},
|
||||
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId, ...(url ? {{url}} : {{}}), ...(actionToken ? {{actionToken}} : {{}})}}, close: () => {{ state.notificationClosed = true; }}}},
|
||||
waitUntil: promise => {{ pending = promise; }},
|
||||
}});
|
||||
if (pending) await pending;
|
||||
@@ -165,13 +168,13 @@ async function dispatchPush(payload) {{
|
||||
def test_offline_activation_migration_rolls_the_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -180,7 +183,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
@@ -189,7 +192,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
@@ -197,14 +200,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -212,7 +215,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -220,7 +223,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
@@ -230,14 +233,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -246,21 +249,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -501,6 +504,167 @@ def test_authenticated_resume_rearms_a_previously_purged_worker_before_flushing(
|
||||
assert result["state"]["backgroundResumes"] == 1
|
||||
|
||||
|
||||
def test_active_today_message_replaces_one_privacy_safe_lock_screen_notification():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:true,identity:'secret/repo#42',actionToken:'opaque-token-1234567890'});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [
|
||||
{
|
||||
"title": "Today session running",
|
||||
"options": {
|
||||
"body": "Your active Today timer is running.",
|
||||
"tag": "stackchain-today-session",
|
||||
"renotify": False,
|
||||
"silent": True,
|
||||
"actions": [
|
||||
{"action": "pause-today", "title": "Pause"},
|
||||
{"action": "finish-today", "title": "Finish current"},
|
||||
],
|
||||
"data": {"route": "#/my-work/today", "actionToken": "opaque-token-1234567890"},
|
||||
},
|
||||
}
|
||||
]
|
||||
assert "secret" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_live_today_break_shows_return_time_and_safe_resume_controls():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const deadline = Date.now() + 5 * 60 * 1000;
|
||||
await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:false,breakDeadlineAt:deadline,identity:'secret/repo#42',actionToken:'opaque-token-1234567890'});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
notification = result["notifications"][0]
|
||||
assert notification["title"] == "On a Today break"
|
||||
assert notification["options"]["body"].startswith("Return at ")
|
||||
assert notification["options"]["actions"] == [
|
||||
{"action": "resume-today", "title": "Resume now"},
|
||||
{"action": "open-today", "title": "Open Today"},
|
||||
]
|
||||
assert notification["options"]["data"]["actionToken"] == "opaque-token-1234567890"
|
||||
assert "secret" not in json.dumps(notification)
|
||||
|
||||
|
||||
def test_invalid_or_expired_break_deadline_falls_back_to_paused_controls():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:false,breakDeadlineAt:Date.now()-1,actionToken:'opaque-token-1234567890'});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"][0]["title"] == "Today session paused"
|
||||
assert result["notifications"][0]["options"]["actions"][0] == {
|
||||
"action": "resume-today", "title": "Resume"
|
||||
}
|
||||
|
||||
|
||||
def test_inactive_today_message_closes_the_lock_screen_notification():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-today-lock-screen',active:false,running:false});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == []
|
||||
assert result["closedNotifications"] == 1
|
||||
|
||||
|
||||
def test_private_data_purge_also_removes_the_today_lock_screen_notification():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:()=>{}}]);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["outboxPurges"] == 1
|
||||
assert result["closedNotifications"] == 1
|
||||
|
||||
|
||||
def test_today_lock_screen_action_updates_an_open_dashboard_without_navigation():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.clientMessages=[];
|
||||
state.clientList=[{
|
||||
url:'https://forge.example/dashboard/#/my-work/today',
|
||||
postMessage:message=>state.clientMessages.push(message),
|
||||
focus:async()=>state.focused.push('today'),
|
||||
}];
|
||||
await dispatchNotificationClick('#/my-work/today','pause-today',null,'stackchain-today-session');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["clientMessages"] == [
|
||||
{"type": "stackchain-today-timer-action", "action": "pause"}
|
||||
]
|
||||
assert result["focused"] == ["today"]
|
||||
assert result["opened"] == []
|
||||
|
||||
|
||||
def test_today_lock_screen_action_opens_a_validated_one_shot_route_when_closed():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["opened"] == [
|
||||
"https://forge.example/dashboard/?today_timer_action=resume#/my-work/today"
|
||||
]
|
||||
|
||||
|
||||
def test_finish_today_lock_screen_action_forwards_only_its_opaque_token():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.clientMessages=[];
|
||||
state.clientList=[{
|
||||
url:'https://forge.example/dashboard/#/my-work/today',
|
||||
postMessage:message=>state.clientMessages.push(message),
|
||||
focus:async()=>state.focused.push('today'),
|
||||
}];
|
||||
await dispatchNotificationClick('#/my-work/today','finish-today',null,'stackchain-today-session',null,'opaque-token-1234567890');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["clientMessages"] == [{
|
||||
"type": "stackchain-today-timer-action",
|
||||
"action": "complete",
|
||||
"actionToken": "opaque-token-1234567890",
|
||||
}]
|
||||
|
||||
|
||||
def test_break_resume_action_forwards_opaque_token_to_reject_stale_breaks():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.clientMessages=[];
|
||||
state.clientList=[{
|
||||
url:'https://forge.example/dashboard/#/my-work/today',
|
||||
postMessage:message=>state.clientMessages.push(message),
|
||||
focus:async()=>state.focused.push('today'),
|
||||
}];
|
||||
await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session',null,'opaque-token-1234567890');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["clientMessages"] == [{
|
||||
"type": "stackchain-today-timer-action",
|
||||
"action": "resume",
|
||||
"actionToken": "opaque-token-1234567890",
|
||||
}]
|
||||
|
||||
|
||||
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -686,7 +850,7 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c
|
||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_private_copy():
|
||||
def test_deadline_digest_push_offers_protect_today_and_snooze_without_rendering_private_copy():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchPush({
|
||||
@@ -706,7 +870,7 @@ def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_
|
||||
"tag": "stackchain-deadline-digest-2026-08-13",
|
||||
"actions": [
|
||||
{"action": "protect-today", "title": "Protect Today"},
|
||||
{"action": "open-agenda", "title": "Open Agenda"},
|
||||
{"action": "snooze-deadline", "title": "Remind in 1 hour"},
|
||||
],
|
||||
"data": {
|
||||
"route": "#/my-work/agenda",
|
||||
@@ -720,19 +884,62 @@ def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_
|
||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_deadline_digest_open_agenda_action_preserves_browsing_flow():
|
||||
def test_deadline_digest_snooze_action_records_device_bound_reminder_without_opening_app():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchPush({
|
||||
tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda',
|
||||
protect_route:'#/my-work/agenda/protect-today', deadline_count:1,
|
||||
});
|
||||
await dispatchNotificationClick('#/my-work/agenda', 'open-agenda', null, 'stackchain-deadline-digest-2026-08-13');
|
||||
const calls = [];
|
||||
context.fetch = async (request, options = {}) => {
|
||||
const url = String(request.url || request);
|
||||
const headers = new Headers(options.headers || {});
|
||||
calls.push({url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token')});
|
||||
if (url.endsWith('/api/v1/session')) {
|
||||
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
||||
status:200, headers:{'Content-Type':'application/json'},
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({snoozed:true}), {
|
||||
status:200, headers:{'Content-Type':'application/json'},
|
||||
});
|
||||
};
|
||||
await dispatchNotificationClick('#/my-work/agenda', 'snooze-deadline', null, 'stackchain-deadline-digest-2026-08-13');
|
||||
process.stdout.write(JSON.stringify({state,calls}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["calls"] == [
|
||||
{
|
||||
"url": "https://forge.example/dashboard/api/v1/session",
|
||||
"method": "GET",
|
||||
"csrf": None,
|
||||
},
|
||||
{
|
||||
"url": "https://forge.example/dashboard/api/v1/push-subscription/deadlines/snooze",
|
||||
"method": "PATCH",
|
||||
"csrf": "session-proof",
|
||||
},
|
||||
]
|
||||
assert result["state"]["notificationClosed"] is True
|
||||
assert result["state"]["opened"] == []
|
||||
|
||||
|
||||
def test_deadline_digest_failed_snooze_opens_agenda_instead_of_losing_reminder():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
context.fetch = async () => { throw new Error('offline'); };
|
||||
await dispatchNotificationClick(
|
||||
'#/my-work/agenda', 'snooze-deadline', null,
|
||||
'stackchain-deadline-digest-2026-08-13'
|
||||
);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["opened"] == ["https://forge.example/dashboard/#/my-work/agenda"]
|
||||
assert result["notificationClosed"] is True
|
||||
|
||||
|
||||
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
|
||||
@@ -930,7 +1137,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "stackchain-dashboard-shell-v121" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -982,14 +1189,20 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/offline-today.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/agenda-replan.js",
|
||||
"/dashboard/static/agenda-calendar.js",
|
||||
"/dashboard/static/protect-today.js",
|
||||
"/dashboard/static/notification-undo.js",
|
||||
"/dashboard/static/card-planning.js",
|
||||
"/dashboard/static/work-selection.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-timer.js",
|
||||
"/dashboard/static/today-break.js",
|
||||
"/dashboard/static/today-progress.js",
|
||||
"/dashboard/static/today-lock-screen.js",
|
||||
"/dashboard/static/today-session-sync.js",
|
||||
"/dashboard/static/today-recap.js",
|
||||
"/dashboard/static/today-wrap-up.js",
|
||||
"/dashboard/static/today-summary.js",
|
||||
"/dashboard/static/today-handoff.js",
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/today-readiness.js",
|
||||
@@ -1036,10 +1249,12 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/queue-today.js",
|
||||
"/dashboard/static/pull-sheet.js",
|
||||
"/dashboard/static/review-sheet.js",
|
||||
"/dashboard/static/release-receipt.js",
|
||||
"/dashboard/static/work-route.js",
|
||||
"/dashboard/static/task-overlay-history.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/live-data-status.js",
|
||||
"/dashboard/static/mobile-today-command-bar.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
TIMER = ROOT / "frontend" / "today-timer.js"
|
||||
BREAK = ROOT / "frontend" / "today-break.js"
|
||||
|
||||
|
||||
def run_node(source: str) -> dict:
|
||||
completed = subprocess.run(
|
||||
["node", "-e", source], capture_output=True, text=True
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_timed_break_pauses_immediately_and_restores_absolute_deadline():
|
||||
script = TIMER.read_text() + r"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
let now = 100000;
|
||||
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
|
||||
timer.activate('issue:r:42:');
|
||||
now = 112000;
|
||||
const started = timer.startBreak(15);
|
||||
const paused = timer.snapshot();
|
||||
now = 200000;
|
||||
const restored = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now}).breakSnapshot();
|
||||
process.stdout.write(JSON.stringify({started,paused,restored}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"started": {
|
||||
"identity": "issue:r:42:",
|
||||
"deadline_at": 1012000,
|
||||
"expired": False,
|
||||
},
|
||||
"paused": {
|
||||
"identity": "issue:r:42:", "elapsed_ms": 12000, "running": False,
|
||||
"break_deadline_at": 1012000,
|
||||
},
|
||||
"restored": {
|
||||
"identity": "issue:r:42:",
|
||||
"deadline_at": 1012000,
|
||||
"expired": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_timed_break_snapshot_carries_deadline_for_cross_device_sync():
|
||||
script = TIMER.read_text() + r"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>100000});
|
||||
timer.activate('issue:r:42:');
|
||||
timer.startBreak(15);
|
||||
process.stdout.write(JSON.stringify(timer.snapshot()));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"identity": "issue:r:42:", "elapsed_ms": 0, "running": False,
|
||||
"break_deadline_at": 1_000_000,
|
||||
}
|
||||
|
||||
|
||||
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
|
||||
script = TIMER.read_text() + r"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
let now = 0;
|
||||
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
|
||||
timer.activate('issue:r:42:');
|
||||
const invalid = [timer.startBreak(0), timer.startBreak(121), timer.startBreak(1.5)];
|
||||
const started = timer.startBreak(5);
|
||||
now = 300001;
|
||||
const expired = timer.breakSnapshot();
|
||||
const stillPaused = timer.snapshot();
|
||||
const resumed = timer.resumeBreak();
|
||||
now = 305001;
|
||||
const running = timer.snapshot();
|
||||
const replay = timer.resumeBreak();
|
||||
process.stdout.write(JSON.stringify({invalid,started,expired,stillPaused,resumed,running,replay,remaining:timer.breakSnapshot()}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["invalid"] == [False, False, False]
|
||||
assert result["started"]["deadline_at"] == 300000
|
||||
assert result["expired"]["expired"] is True
|
||||
assert result["stillPaused"]["running"] is False
|
||||
assert result["resumed"] is True
|
||||
assert result["running"] == {
|
||||
"identity": "issue:r:42:", "elapsed_ms": 5000, "running": True
|
||||
}
|
||||
assert result["replay"] is False
|
||||
assert result["remaining"] is None
|
||||
|
||||
|
||||
def test_mobile_break_sheet_starts_counts_down_and_requires_explicit_resume():
|
||||
script = f"""
|
||||
const createBreak = require({json.dumps(str(BREAK))});
|
||||
class Element {{
|
||||
constructor() {{ this.listeners={{}}; this.hidden=false; this.textContent=''; this.value=''; this.open=false; this.dataset={{}}; }}
|
||||
addEventListener(name, fn) {{ this.listeners[name]=fn; }}
|
||||
click() {{ return this.listeners.click?.({{preventDefault(){{}}}}); }}
|
||||
showModal() {{ this.open=true; }}
|
||||
close() {{ this.open=false; this.listeners.close?.(); }}
|
||||
focus() {{}}
|
||||
}}
|
||||
let now=100000;
|
||||
let pending=null;
|
||||
const calls=[];
|
||||
const timer={{
|
||||
snapshot:()=>({{identity:'issue:r:42:',elapsed_ms:5000,running:!pending}}),
|
||||
breakSnapshot:()=>pending && {{...pending,expired:now>=pending.deadline_at}},
|
||||
startBreak:minutes=>{{calls.push(['start',minutes]);pending={{identity:'issue:r:42:',deadline_at:now+minutes*60000}};return {{...pending,expired:false}};}},
|
||||
resumeBreak:()=>{{if(!pending)return false;calls.push(['resume']);pending=null;return true;}},
|
||||
}};
|
||||
const sheet=new Element(), opener=new Element(), cancel=new Element(), preset=new Element(), status=new Element(), resume=new Element();
|
||||
preset.dataset.todayBreakMinutes='15';
|
||||
let tick;
|
||||
const controller=createBreak({{timer,sheet,openButtons:[opener],cancelButton:cancel,presetButtons:[preset],status,resumeButton:resume,now:()=>now,setIntervalRef:fn=>(tick=fn,1),clearIntervalRef:()=>{{}},onChange:()=>calls.push(['change']),onResume:identity=>calls.push(['return',identity])}});
|
||||
opener.click();
|
||||
const opened=sheet.open;
|
||||
cancel.click();
|
||||
const cancelled={{open:sheet.open,calls:[...calls]}};
|
||||
opener.click(); preset.click();
|
||||
const started={{open:sheet.open,text:status.textContent,statusHidden:status.hidden,resumeHidden:resume.hidden,openerHidden:opener.hidden}};
|
||||
now=100000+15*60000+1; tick();
|
||||
const expired={{text:status.textContent,resumeHidden:resume.hidden,running:timer.snapshot().running}};
|
||||
resume.click();
|
||||
process.stdout.write(JSON.stringify({{opened,cancelled,started,expired,final:{{calls,statusHidden:status.hidden,openerHidden:opener.hidden,pending}}}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"opened": True,
|
||||
"cancelled": {"open": False, "calls": []},
|
||||
"started": {
|
||||
"open": False,
|
||||
"text": "On break · resume in 15:00",
|
||||
"statusHidden": False,
|
||||
"resumeHidden": False,
|
||||
"openerHidden": True,
|
||||
},
|
||||
"expired": {
|
||||
"text": "Break over · ready to resume",
|
||||
"resumeHidden": False,
|
||||
"running": False,
|
||||
},
|
||||
"final": {
|
||||
"calls": [["start", 15], ["change"], ["resume"], ["return", "issue:r:42:"], ["change"]],
|
||||
"statusHidden": True,
|
||||
"openerHidden": False,
|
||||
"pending": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_timed_break_is_packaged_as_an_accessible_mobile_flow():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
||||
|
||||
assert 'data-today-break-open' in html
|
||||
assert 'id="today-break-sheet"' in html
|
||||
assert 'data-today-break-minutes="5"' in html
|
||||
assert 'data-today-break-minutes="15"' in html
|
||||
assert 'data-today-break-minutes="30"' in html
|
||||
assert 'id="today-break-custom-minutes"' in html
|
||||
assert 'min="1" max="120" step="1"' in html
|
||||
assert 'id="today-break-status"' in html
|
||||
assert 'id="resume-today-break"' in html
|
||||
assert '<script src="static/today-break.js"></script>' in html
|
||||
assert "createTodayBreak({" in (ROOT / "frontend" / "today-timer.js").read_text()
|
||||
assert '"static/today-break.js"' in bundle
|
||||
assert ".today-break-panel" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
assert ".today-break-actions button" in css
|
||||
assert "min-height:44px" in css
|
||||
|
||||
|
||||
def test_custom_break_validation_is_side_effect_free_until_valid():
|
||||
script = f"""
|
||||
const createBreak = require({json.dumps(str(BREAK))});
|
||||
class Element {{
|
||||
constructor() {{this.listeners={{}};this.open=true;this.hidden=false;this.value='';this.textContent='';}}
|
||||
addEventListener(name,fn){{this.listeners[name]=fn;}}
|
||||
close(){{this.open=false;}}
|
||||
focus(){{this.focused=true;}}
|
||||
}}
|
||||
const form=new Element(), input=new Element(), error=new Element(), status=new Element(), resume=new Element(), sheet=new Element();
|
||||
const calls=[];
|
||||
const timer={{snapshot:()=>({{identity:'issue:r:1:'}}),breakSnapshot:()=>null,startBreak:value=>{{calls.push(value);return value===45;}},resumeBreak:()=>false}};
|
||||
createBreak({{timer,sheet,form,minutesInput:input,error,status,resumeButton:resume,setIntervalRef:()=>1,clearIntervalRef:()=>{{}}}});
|
||||
input.value='2.5'; form.listeners.submit({{preventDefault(){{}}}});
|
||||
const invalid={{open:sheet.open,error:error.textContent,focused:input.focused||false}};
|
||||
input.value='45'; form.listeners.submit({{preventDefault(){{}}}});
|
||||
process.stdout.write(JSON.stringify({{invalid,valid:{{open:sheet.open,error:error.textContent}},calls}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"invalid": {
|
||||
"open": True,
|
||||
"error": "Choose a whole number from 1 to 120 minutes.",
|
||||
"focused": True,
|
||||
},
|
||||
"valid": {"open": False, "error": ""},
|
||||
"calls": [2.5, 45],
|
||||
}
|
||||
|
||||
|
||||
def test_replacing_or_stopping_active_work_clears_an_old_break():
|
||||
script = TIMER.read_text() + r"""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
let now=0;
|
||||
const timer=createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
|
||||
timer.activate('issue:r:1:'); timer.startBreak(5); timer.activate('issue:r:2:');
|
||||
const replaced=timer.breakSnapshot();
|
||||
timer.startBreak(5); timer.stop();
|
||||
const stopped=timer.breakSnapshot();
|
||||
timer.activate('issue:r:3:'); timer.startBreak(5); timer.adopt('issue:r:4:',1000,false);
|
||||
process.stdout.write(JSON.stringify({replaced,stopped,adopted:timer.breakSnapshot()}));
|
||||
"""
|
||||
assert run_node(script) == {"replaced": None, "stopped": None, "adopted": None}
|
||||
|
||||
|
||||
def test_browser_back_closes_break_sheet_without_starting_a_break():
|
||||
script = f"""
|
||||
const createBreak=require({json.dumps(str(BREAK))});
|
||||
class Element{{constructor(){{this.listeners={{}};this.open=false;this.hidden=true;}}addEventListener(n,f){{this.listeners[n]=f;}}showModal(){{this.open=true;}}close(){{this.open=false;}}}}
|
||||
const sheet=new Element(), opener=new Element(), status=new Element(), resume=new Element();
|
||||
const listeners={{}}, pushes=[];
|
||||
const historyRef={{state:{{route:'today'}},pushState:state=>{{historyRef.state=state;pushes.push(state);}},back:()=>listeners.popstate?.()}};
|
||||
const windowRef={{addEventListener:(name,fn)=>listeners[name]=fn}};
|
||||
const timer={{snapshot:()=>({{identity:'issue:r:1:'}}),breakSnapshot:()=>null,resumeBreak:()=>false}};
|
||||
createBreak({{timer,sheet,openButtons:[opener],status,resumeButton:resume,historyRef,windowRef,queryAll:()=>[]}});
|
||||
opener.listeners.click();
|
||||
const opened={{open:sheet.open,state:historyRef.state,pushes}};
|
||||
listeners.popstate();
|
||||
process.stdout.write(JSON.stringify({{opened,closed:!sheet.open}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"opened": {
|
||||
"open": True,
|
||||
"state": {"route": "today", "todayBreak": True},
|
||||
"pushes": [{"route": "today", "todayBreak": True}],
|
||||
},
|
||||
"closed": True,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
INDEX = ROOT / "frontend" / "index.html"
|
||||
TIMER = ROOT / "frontend" / "today-timer.js"
|
||||
|
||||
|
||||
def test_quick_capture_wires_timer_interruption_to_all_exit_paths():
|
||||
source = DASHBOARD.read_text()
|
||||
timer_source = TIMER.read_text()
|
||||
html = INDEX.read_text()
|
||||
|
||||
assert 'id="today-capture-interruption"' in html
|
||||
assert 'id="today-capture-interruption-label"' in html
|
||||
assert "view.beginCapture();" in timer_source
|
||||
assert "timerView.transferCapture()" in source
|
||||
assert "timerView.finishCapture()" in source
|
||||
assert "result ? 'Save & return to Today' : 'Save to Drafts'" in timer_source
|
||||
assert "if (!capture)" in source
|
||||
|
||||
|
||||
def test_capture_save_returns_only_after_durable_admission():
|
||||
source = DASHBOARD.read_text()
|
||||
|
||||
saved = source.index("const savedCapture = await unfiledCaptures.save(captureDraft);")
|
||||
returned = source.index("timerView.finishCapture()", saved)
|
||||
failure = source.index("} catch (error) {", saved)
|
||||
assert saved < returned < failure
|
||||
@@ -0,0 +1,183 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SOURCE = Path(__file__).parents[1] / "frontend" / "today-lock-screen.js"
|
||||
ROOT = SOURCE.parents[1]
|
||||
|
||||
|
||||
def run_scenario(scenario: str) -> dict:
|
||||
script = SOURCE.read_text() + r"""
|
||||
const values = new Map();
|
||||
const messages = [];
|
||||
const listeners = {};
|
||||
const control = {checked:false, disabled:false, addEventListener:(name, fn)=>listeners[name]=fn};
|
||||
const status = {textContent:''};
|
||||
const serviceWorker = {
|
||||
controller:{postMessage:message=>messages.push(message)},
|
||||
ready:Promise.resolve({active:{postMessage:message=>messages.push(message)}}),
|
||||
addEventListener:(name, fn)=>listeners['sw-' + name]=fn,
|
||||
};
|
||||
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let permission = 'default';
|
||||
const NotificationRef = {get permission(){return permission;},requestPermission:async()=>{permission='granted';return permission;}};
|
||||
const actions = [];
|
||||
const locationRef = {href:'https://forge.example/dashboard/#/my-work/today'};
|
||||
const historyRef = {replaceState:(_a,_b,url)=>{locationRef.href=new URL(url, locationRef.href).href;}};
|
||||
let tokenCounter = 0;
|
||||
const tokens = ['opaque-token-1234567890', 'new-opaque-token-0987654321'];
|
||||
(async()=>{
|
||||
const lockScreen = createTodayLockScreen({storage,getLogin:()=> 'Timmy',serviceWorker,NotificationRef,control,status,locationRef,historyRef,randomToken:()=> tokens[tokenCounter++],fingerprint:async (_token, identity)=>identity.includes('break:1000000') ? 'opaque-break-fingerprint' : identity.startsWith('issue:') ? 'opaque-issue-fingerprint' : 'opaque-pull-fingerprint',onAction:(action, identity)=>actions.push([action, identity])});
|
||||
%SCENARIO%
|
||||
})().catch(error=>{console.error(error);process.exit(1)});
|
||||
""".replace("%SCENARIO%", scenario)
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_operator_opt_in_syncs_one_privacy_safe_session_notification():
|
||||
result = run_scenario(r"""
|
||||
await lockScreen.enable();
|
||||
await lockScreen.sync({identity:'issue:secret/repo:42:',running:true,elapsed_ms:125000}, true);
|
||||
process.stdout.write(JSON.stringify({checked:control.checked,status:status.textContent,messages,stored:[...values.entries()]}));
|
||||
""")
|
||||
|
||||
assert result["checked"] is True
|
||||
assert result["status"] == "Lock-screen Today controls are on."
|
||||
assert result["stored"] == [
|
||||
["stackchain.today-lock-screen.v1.timmy", "1"],
|
||||
["stackchain.today-lock-screen-action.v1.timmy", '{"token":"opaque-token-1234567890","fingerprint":"opaque-issue-fingerprint"}'],
|
||||
]
|
||||
assert result["messages"][-1] == {
|
||||
"type": "stackchain-today-lock-screen",
|
||||
"active": True,
|
||||
"running": True,
|
||||
"actionToken": "opaque-token-1234567890",
|
||||
}
|
||||
assert "secret" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_disabling_or_ending_session_removes_notification():
|
||||
result = run_scenario(r"""
|
||||
await lockScreen.enable();
|
||||
await lockScreen.sync({identity:'issue:r:42:',running:false,elapsed_ms:0}, false);
|
||||
await lockScreen.disable();
|
||||
process.stdout.write(JSON.stringify({checked:control.checked,messages,stored:[...values.entries()]}));
|
||||
""")
|
||||
|
||||
assert result["checked"] is False
|
||||
assert result["stored"] == []
|
||||
assert result["messages"][-2:] == [
|
||||
{"type": "stackchain-today-lock-screen", "active": False, "running": False},
|
||||
{"type": "stackchain-today-lock-screen", "active": False, "running": False},
|
||||
]
|
||||
|
||||
|
||||
def test_valid_notification_action_is_consumed_once_and_removed_from_url():
|
||||
result = run_scenario(r"""
|
||||
values.set('stackchain.today-lock-screen.v1.timmy','1');
|
||||
locationRef.href='https://forge.example/dashboard/?today_timer_action=pause#/my-work/today';
|
||||
const consumed = await lockScreen.consumeLaunchAction();
|
||||
const second = await lockScreen.consumeLaunchAction();
|
||||
listeners['sw-message']({data:{type:'stackchain-today-timer-action',action:'resume'}});
|
||||
process.stdout.write(JSON.stringify({consumed,second,actions,href:locationRef.href}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"consumed": True,
|
||||
"second": False,
|
||||
"actions": [["pause", None], ["resume", None]],
|
||||
"href": "https://forge.example/dashboard/#/my-work/today",
|
||||
}
|
||||
|
||||
|
||||
def test_finish_action_is_bound_to_the_exact_active_identity_and_consumed_once():
|
||||
result = run_scenario(r"""
|
||||
await lockScreen.enable();
|
||||
await lockScreen.sync({identity:'issue:private/repo:42:',running:true}, true);
|
||||
const first = await lockScreen.consumeAction('complete', 'opaque-token-1234567890');
|
||||
const replay = await lockScreen.consumeAction('complete', 'opaque-token-1234567890');
|
||||
await lockScreen.sync({identity:'pull:private/repo:9:',running:true}, true);
|
||||
const stale = await lockScreen.consumeAction('complete', 'opaque-token-1234567890');
|
||||
process.stdout.write(JSON.stringify({first,replay,stale,actions,messages,stored:[...values.entries()]}));
|
||||
""")
|
||||
|
||||
assert result["first"] is True
|
||||
assert result["replay"] is False
|
||||
assert result["stale"] is False
|
||||
assert result["actions"] == [["complete", "issue:private/repo:42:"]]
|
||||
assert all("private/repo" not in json.dumps(message) for message in result["messages"])
|
||||
|
||||
|
||||
def test_break_sync_carries_deadline_and_resume_is_bound_to_that_exact_break():
|
||||
result = run_scenario(r"""
|
||||
await lockScreen.enable();
|
||||
await lockScreen.sync({identity:'issue:private/repo:42:',running:false,break_deadline_at:1000000}, true);
|
||||
const resumed = await lockScreen.consumeAction('resume', 'opaque-token-1234567890');
|
||||
await lockScreen.sync({identity:'issue:private/repo:42:',running:false,break_deadline_at:2000000}, true);
|
||||
const stale = await lockScreen.consumeAction('resume', 'opaque-token-1234567890');
|
||||
process.stdout.write(JSON.stringify({resumed,stale,actions,messages,stored:[...values.entries()]}));
|
||||
""")
|
||||
|
||||
assert result["resumed"] is True
|
||||
assert result["stale"] is False
|
||||
assert result["actions"] == [["resume", "issue:private/repo:42:"]]
|
||||
assert result["messages"][0] == {
|
||||
"type": "stackchain-today-lock-screen",
|
||||
"active": True,
|
||||
"running": False,
|
||||
"breakDeadlineAt": 1_000_000,
|
||||
"actionToken": "opaque-token-1234567890",
|
||||
}
|
||||
assert "private/repo" not in json.dumps(result["messages"])
|
||||
|
||||
|
||||
def test_cold_launch_finish_action_is_removed_from_history_before_completion():
|
||||
result = run_scenario(r"""
|
||||
await lockScreen.enable();
|
||||
await lockScreen.sync({identity:'issue:private/repo:42:',running:true}, true);
|
||||
locationRef.href='https://forge.example/dashboard/?today_timer_action=complete&today_action_token=opaque-token-1234567890#/my-work/today';
|
||||
const consumed = await lockScreen.consumeLaunchAction();
|
||||
process.stdout.write(JSON.stringify({consumed,actions,href:locationRef.href}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"consumed": True,
|
||||
"actions": [["complete", "issue:private/repo:42:"]],
|
||||
"href": "https://forge.example/dashboard/#/my-work/today",
|
||||
}
|
||||
|
||||
|
||||
def test_permission_denial_is_truthful_and_does_not_persist_opt_in():
|
||||
result = run_scenario(r"""
|
||||
NotificationRef.requestPermission=async()=>{permission='denied';return permission;};
|
||||
const enabled = await lockScreen.enable();
|
||||
process.stdout.write(JSON.stringify({enabled,checked:control.checked,status:status.textContent,stored:[...values.entries()]}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"enabled": False,
|
||||
"checked": False,
|
||||
"status": "Lock-screen controls are blocked in browser settings.",
|
||||
"stored": [],
|
||||
}
|
||||
|
||||
|
||||
def test_lock_screen_flow_is_wired_into_the_packaged_today_journey():
|
||||
index = (ROOT / "frontend" / "index.html").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
||||
|
||||
assert 'id="today-lock-screen"' in index
|
||||
assert 'id="today-lock-screen-status"' in index
|
||||
assert '<script src="static/today-lock-screen.js"></script>' in index
|
||||
assert index.index('static/today-lock-screen.js') < index.index('static/dashboard.js')
|
||||
assert 'createTodayLockScreen({' in dashboard
|
||||
assert "action === 'complete'" in dashboard
|
||||
assert "completeTodayItem(item)" in dashboard
|
||||
assert "todayLockScreen.consumeLaunchAction()" in dashboard
|
||||
assert dashboard.index("const completeTodayItem = createTodayCompletion({") < dashboard.index("todayLockScreen.consumeLaunchAction()")
|
||||
assert "todayLockScreen.sync(snapshot, workSession.checkpointed())" in dashboard
|
||||
assert "todayLockScreen.consumeLaunchAction()" in dashboard
|
||||
assert '"static/today-lock-screen.js"' in bundle
|
||||
@@ -0,0 +1,747 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
TODAY_PROGRESS = ROOT / "frontend" / "today-progress.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_progress_drafts_are_account_and_item_scoped_bounded_and_durable():
|
||||
script = f"""
|
||||
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let login = 'Timmy';
|
||||
const progress = createProgress({{storage, getLogin:() => login, admit:async () => ({{}}), maxLength:20}});
|
||||
const issue = {{identity:'issue:stackchain/dashboard:12:', kind:'issue', repository:'stackchain/dashboard', number:12}};
|
||||
const pull = {{identity:'pull:stackchain/dashboard:13:', kind:'pull', repository:'stackchain/dashboard', number:13}};
|
||||
const saved = progress.save(issue.identity, ' Investigated latency ');
|
||||
progress.save(pull.identity, 'Reviewing tests');
|
||||
const restored = createProgress({{storage, getLogin:() => login, admit:async () => ({{}}), maxLength:20}});
|
||||
login = 'alexander';
|
||||
const otherAccount = restored.load(issue.identity);
|
||||
login = 'timmy';
|
||||
process.stdout.write(JSON.stringify({{
|
||||
saved,
|
||||
issue:restored.load(issue.identity),
|
||||
pull:restored.load(pull.identity),
|
||||
otherAccount,
|
||||
tooLong:restored.save(issue.identity, 'x'.repeat(21)),
|
||||
keys:[...values.keys()],
|
||||
}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["saved"] is True
|
||||
assert output["issue"] == "Investigated latency"
|
||||
assert output["pull"] == "Reviewing tests"
|
||||
assert output["otherAccount"] == ""
|
||||
assert output["tooLong"] is False
|
||||
assert output["keys"] == ["stackchain.today-progress.v1.timmy"]
|
||||
|
||||
|
||||
def test_post_admits_exact_comment_once_and_clears_only_after_durable_admission():
|
||||
script = f"""
|
||||
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
const calls=[]; let fail=true;
|
||||
const progress=createProgress({{
|
||||
storage, getLogin:()=> 'timmy', makeId:()=> 'progress-op-1',
|
||||
admit:async message => {{ calls.push(message); if (fail) throw new Error('offline store unavailable'); return {{background:true,item:{{id:message.operationId}}}}; }},
|
||||
}});
|
||||
const target={{identity:'issue:stackchain/dashboard:12:',kind:'issue',repository:'stackchain/dashboard',number:12}};
|
||||
progress.save(target.identity, 'Shipped the first slice');
|
||||
let error='';
|
||||
try {{ await progress.post(target); }} catch (caught) {{ error=caught.message; }}
|
||||
const retained=progress.load(target.identity);
|
||||
fail=false;
|
||||
const admitted=await progress.post(target);
|
||||
process.stdout.write(JSON.stringify({{error,retained,after:progress.load(target.identity),calls,admitted}}));
|
||||
"""
|
||||
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
|
||||
assert output["error"] == "offline store unavailable"
|
||||
assert output["retained"] == "Shipped the first slice"
|
||||
assert output["after"] == ""
|
||||
assert len(output["calls"]) == 2
|
||||
assert output["calls"][0] == output["calls"][1] == {
|
||||
"kind": "issue-comment",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 12,
|
||||
"body": "Shipped the first slice",
|
||||
"operationId": "progress-op-1",
|
||||
}
|
||||
assert output["admitted"]["background"] is True
|
||||
|
||||
|
||||
def test_photo_only_progress_is_durably_admitted_and_retained_until_success():
|
||||
script = f"""
|
||||
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
const photo = {{filename:'result.jpg',contentType:'image/jpeg',blob:{{size:42}},operationId:'photo-op-1'}};
|
||||
const calls=[]; let fail=true;
|
||||
const progress=createProgress({{
|
||||
storage, getLogin:()=> 'timmy', makeId:()=> 'progress-photo-op-1',
|
||||
admit:async message => {{ calls.push(message); if (fail) throw new Error('photo store unavailable'); return {{background:true}}; }},
|
||||
}});
|
||||
const target={{identity:'issue:stackchain/dashboard:12:',kind:'issue',repository:'stackchain/dashboard',number:12}};
|
||||
let error='';
|
||||
try {{ await progress.post(target, '', [photo]); }} catch (caught) {{ error=caught.message; }}
|
||||
const retained=progress.load(target.identity);
|
||||
fail=false;
|
||||
await progress.post(target, undefined, [photo]);
|
||||
process.stdout.write(JSON.stringify({{error,retained,after:progress.load(target.identity),calls}}));
|
||||
"""
|
||||
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
|
||||
assert output["error"] == "photo store unavailable"
|
||||
assert output["retained"] == ""
|
||||
assert output["after"] == ""
|
||||
assert len(output["calls"]) == 2
|
||||
assert output["calls"][0] == output["calls"][1] == {
|
||||
"kind": "issue-comment",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 12,
|
||||
"body": "",
|
||||
"operationId": "progress-photo-op-1",
|
||||
"attachments": [{
|
||||
"filename": "result.jpg",
|
||||
"contentType": "image/jpeg",
|
||||
"blob": {"size": 42},
|
||||
"operationId": "photo-op-1",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_photo_payload_changes_rotate_the_progress_operation_identity():
|
||||
script = f"""
|
||||
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const values = new Map(); const ids = ['progress-1', 'progress-2', 'progress-3'];
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
const progress=createProgress({{storage,getLogin:()=> 'timmy',makeId:()=>ids.shift(),admit:async()=>({{}})}});
|
||||
const identity='issue:stackchain/dashboard:12:';
|
||||
const before={{filename:'proof.jpg',contentType:'image/jpeg',note:'Before',operationId:'photo-1'}};
|
||||
const replacement={{filename:'proof.jpg',contentType:'image/jpeg',note:'After',operationId:'photo-2'}};
|
||||
progress.save(identity, 'Repair complete', [before]);
|
||||
const first=JSON.parse(values.values().next().value).drafts[identity].operation_id;
|
||||
progress.save(identity, 'Repair complete', [before]);
|
||||
const unchanged=JSON.parse(values.values().next().value).drafts[identity].operation_id;
|
||||
progress.save(identity, 'Repair complete', [replacement]);
|
||||
const replaced=JSON.parse(values.values().next().value).drafts[identity].operation_id;
|
||||
replacement.note='Redacted replacement';
|
||||
progress.save(identity, 'Repair complete', [replacement]);
|
||||
const recaptioned=JSON.parse(values.values().next().value).drafts[identity].operation_id;
|
||||
process.stdout.write(JSON.stringify({{first,unchanged,replaced,recaptioned}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output == {
|
||||
"first": "progress-1",
|
||||
"unchanged": "progress-1",
|
||||
"replaced": "progress-2",
|
||||
"recaptioned": "progress-3",
|
||||
}
|
||||
|
||||
|
||||
def test_photo_cleanup_failure_resumes_cleanup_without_readmitting_delivery():
|
||||
script = f"""
|
||||
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
const calls=[]; let cleanupFails=true; let cleanupCalls=0;
|
||||
const options={{storage,getLogin:()=> 'timmy',makeId:()=> 'stable-progress-op',admit:async message=>{{calls.push(message);return {{background:true}};}}}};
|
||||
let progress=createProgress(options);
|
||||
const target={{identity:'pull:stackchain/dashboard:22:',kind:'pull',repository:'stackchain/dashboard',number:22}};
|
||||
const photo={{filename:'proof.webp',contentType:'image/webp',blob:{{size:9}},operationId:'photo-op'}};
|
||||
const cleanup=async()=>{{cleanupCalls++;if(cleanupFails)throw new Error('draft cleanup blocked')}};
|
||||
let error=''; let deliveryAdmitted=false;
|
||||
try {{await progress.post(target,'',[photo],cleanup);}} catch(caught){{error=caught.message;deliveryAdmitted=caught.deliveryAdmitted===true;}}
|
||||
progress=createProgress(options);
|
||||
cleanupFails=false;
|
||||
const recovered=await progress.post(target,'edited text must not be admitted',[{{...photo,operationId:'replacement'}}],cleanup);
|
||||
process.stdout.write(JSON.stringify({{error,deliveryAdmitted,calls,cleanupCalls,recovered,after:progress.load(target.identity)}}));
|
||||
"""
|
||||
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
|
||||
assert output["error"] == "Progress update is already queued; photo cleanup is pending."
|
||||
assert output["deliveryAdmitted"] is True
|
||||
assert len(output["calls"]) == 1
|
||||
assert output["cleanupCalls"] == 2
|
||||
assert output["recovered"]["alreadyAdmitted"] is True
|
||||
assert output["after"] == ""
|
||||
|
||||
|
||||
def test_blocker_retry_moves_on_without_admitting_the_comment_twice():
|
||||
script = f"""
|
||||
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
const admissions=[]; const transitions=[]; let transitionFails=true;
|
||||
const options={{
|
||||
storage,getLogin:()=> 'timmy',makeId:()=> 'blocker-op-1',
|
||||
admit:async message=>{{admissions.push(message);return {{background:true}};}},
|
||||
}};
|
||||
let progress=createProgress(options);
|
||||
const target={{identity:'issue:stackchain/dashboard:1066:',kind:'issue',repository:'stackchain/dashboard',number:1066}};
|
||||
const until='2026-08-19T09:00:00.000Z';
|
||||
const transition=async(value,wakeAt)=>{{transitions.push([value.identity,wakeAt]);return !transitionFails;}};
|
||||
let firstError=''; let admitted=false;
|
||||
try {{await progress.postBlocker(target,'Waiting for the API owner',[],null,until,transition);}}
|
||||
catch(caught){{firstError=caught.message;admitted=caught.deliveryAdmitted===true;}}
|
||||
progress=createProgress(options);
|
||||
transitionFails=false;
|
||||
const recovered=await progress.postBlocker(target,undefined,[],null,until,transition);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
firstError,admitted,admissions,transitions,recovered,after:progress.load(target.identity),stored:[...values.values()],
|
||||
}}));
|
||||
"""
|
||||
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
|
||||
assert output["firstError"] == "Blocker was posted, but Today could not move on. Retry the planning step."
|
||||
assert output["admitted"] is True
|
||||
assert len(output["admissions"]) == 1
|
||||
assert output["admissions"][0] == {
|
||||
"kind": "issue-comment",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 1066,
|
||||
"body": "Waiting for the API owner",
|
||||
"operationId": "blocker-op-1",
|
||||
}
|
||||
assert output["transitions"] == [
|
||||
["issue:stackchain/dashboard:1066:", "2026-08-19T09:00:00.000Z"],
|
||||
["issue:stackchain/dashboard:1066:", "2026-08-19T09:00:00.000Z"],
|
||||
]
|
||||
assert output["recovered"]["alreadyAdmitted"] is True
|
||||
assert output["after"] == ""
|
||||
assert output["stored"] == []
|
||||
|
||||
|
||||
def test_admitted_blocker_recovery_restores_and_replaces_its_planning_time_without_readmission():
|
||||
script = f"""
|
||||
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
const admissions=[]; const transitions=[]; let transitionFails=true;
|
||||
const options={{
|
||||
storage,getLogin:()=> 'timmy',makeId:()=> 'stable-blocker-op',
|
||||
admit:async message=>{{admissions.push(message);return {{background:true}};}},
|
||||
}};
|
||||
let progress=createProgress(options);
|
||||
const target={{identity:'issue:stackchain/dashboard:1068:',kind:'issue',repository:'stackchain/dashboard',number:1068}};
|
||||
const expired='2026-08-18T08:00:00.000Z';
|
||||
const replacement='2026-08-19T09:30:00.000Z';
|
||||
const transition=async(value,wakeAt)=>{{transitions.push([value.identity,wakeAt]);return !transitionFails;}};
|
||||
try {{await progress.postBlocker(target,'Waiting for production access',[],null,expired,transition);}} catch (_error) {{}}
|
||||
progress=createProgress(options);
|
||||
const restored=progress.blockerRecovery(target.identity);
|
||||
transitionFails=false;
|
||||
const recovered=await progress.postBlocker(target,'changed text must not be admitted',[],null,replacement,transition);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
restored,recovered,admissions,transitions,after:progress.blockerRecovery(target.identity),body:progress.load(target.identity),
|
||||
}}));
|
||||
"""
|
||||
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
|
||||
assert output["restored"] == {
|
||||
"pending": True,
|
||||
"until": "2026-08-18T08:00:00.000Z",
|
||||
}
|
||||
assert len(output["admissions"]) == 1
|
||||
assert output["admissions"][0]["body"] == "Waiting for production access"
|
||||
assert output["transitions"] == [
|
||||
["issue:stackchain/dashboard:1068:", "2026-08-18T08:00:00.000Z"],
|
||||
["issue:stackchain/dashboard:1068:", "2026-08-19T09:30:00.000Z"],
|
||||
]
|
||||
assert output["recovered"]["alreadyAdmitted"] is True
|
||||
assert output["after"] is None
|
||||
assert output["body"] == ""
|
||||
|
||||
|
||||
def test_progress_rejects_inactive_or_unsupported_targets_without_mutation():
|
||||
script = f"""
|
||||
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const values=new Map(); let admitted=0;
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
const progress=createProgress({{storage,getLogin:()=> 'timmy',admit:async()=>{{admitted++;}}}});
|
||||
const results=[];
|
||||
for (const target of [null, {{identity:'review:r:1:',kind:'review',repository:'r',number:1}}, {{identity:'issue:r:0:',kind:'issue',repository:'r',number:0}}]) {{
|
||||
try {{ await progress.post(target, 'note'); results.push('accepted'); }} catch (error) {{ results.push(error.message); }}
|
||||
}}
|
||||
process.stdout.write(JSON.stringify({{results,admitted,keys:[...values.keys()]}}));
|
||||
"""
|
||||
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
|
||||
assert output["admitted"] == 0
|
||||
assert output["keys"] == []
|
||||
assert output["results"] == [
|
||||
"An active Today issue or pull request is required.",
|
||||
"An active Today issue or pull request is required.",
|
||||
"An active Today issue or pull request is required.",
|
||||
]
|
||||
|
||||
|
||||
def test_progress_sheet_scopes_voice_to_the_open_item_and_aborts_on_close():
|
||||
script = f"""
|
||||
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.listeners={{}}; }}
|
||||
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ return this.listeners.click?.(); }}
|
||||
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
|
||||
}}
|
||||
const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
|
||||
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress'];
|
||||
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
|
||||
const target={{identity:'issue:stackchain/dashboard:1058:',kind:'issue',repository:'stackchain/dashboard',number:1058,label:'#1058',title:'Voice progress'}};
|
||||
const calls=[];
|
||||
const voice={{open:async identity=>calls.push(['open',identity]),cancel:()=>calls.push(['cancel'])}};
|
||||
const activity={{open:async value=>calls.push(['activity-open',value.identity]),close:()=>calls.push(['activity-close'])}};
|
||||
const view=createView({{
|
||||
progress:{{load:()=> 'Saved text',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],voice,activity,
|
||||
photos:{{open:async()=>calls.push(['photos'])}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await elements['[data-mobile-today-update]'].click();
|
||||
const opened={{body:elements['#today-progress-body'].value,sheet:elements['#today-progress-sheet'].open,calls:[...calls]}};
|
||||
await elements['#cancel-today-progress'].click();
|
||||
process.stdout.write(JSON.stringify({{opened,closed:!elements['#today-progress-sheet'].open,calls}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"opened": {
|
||||
"body": "Saved text",
|
||||
"sheet": True,
|
||||
"calls": [["open", "issue:stackchain/dashboard:1058:"], ["photos"], ["activity-open", "issue:stackchain/dashboard:1058:"]],
|
||||
},
|
||||
"closed": True,
|
||||
"calls": [
|
||||
["open", "issue:stackchain/dashboard:1058:"],
|
||||
["photos"],
|
||||
["activity-open", "issue:stackchain/dashboard:1058:"],
|
||||
["cancel"],
|
||||
["activity-close"],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_blocked_hud_opens_a_focused_flow_with_quick_return_choices():
|
||||
script = f"""
|
||||
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||
class Element {{
|
||||
constructor() {{
|
||||
this.hidden=false;this.value='';this.textContent='';this.open=false;this.focused=false;this.listeners={{}};
|
||||
const names=new Set();
|
||||
this.classList={{add:name=>names.add(name),remove:name=>names.delete(name),contains:name=>names.has(name)}};
|
||||
}}
|
||||
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ return this.listeners.click?.(); }}
|
||||
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{ this.focused=true; }}
|
||||
}}
|
||||
const selectors=['#today-progress-sheet','.today-progress-panel','#today-progress-title','#today-progress-body',
|
||||
'#today-progress-status','[data-mobile-today-update]','[data-mobile-today-blocked]',
|
||||
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress',
|
||||
'#today-blocker-return-at','#post-today-blocker','[data-today-blocker-preset="later-today"]',
|
||||
'[data-today-blocker-preset="tomorrow"]','[data-today-blocker-custom]'];
|
||||
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
|
||||
const target={{identity:'issue:stackchain/dashboard:1070:',kind:'issue',repository:'stackchain/dashboard',number:1070,label:'#1070',title:'Focused blocker'}};
|
||||
const calls=[];
|
||||
const view=createView({{
|
||||
progress:{{load:()=> 'Waiting for access',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],
|
||||
voice:{{open:async()=>calls.push('voice')}},photos:{{open:async()=>calls.push('photos')}},
|
||||
activity:{{open:async()=>calls.push('activity')}},now:()=>new Date('2026-08-18T10:15:00Z'),
|
||||
}});
|
||||
(async()=>{{
|
||||
const availability={{update:elements['[data-mobile-today-update]'].hidden,blocked:elements['[data-mobile-today-blocked]'].hidden}};
|
||||
await elements['[data-mobile-today-blocked]'].click();
|
||||
const opened={{
|
||||
title:elements['#today-progress-title'].textContent,target:elements['#today-progress-target'].textContent,
|
||||
focused:elements['#today-progress-body'].focused,mode:elements['.today-progress-panel'].classList.contains('blocker-mode'),
|
||||
calls:[...calls],sheet:elements['#today-progress-sheet'].open,
|
||||
}};
|
||||
await elements['[data-today-blocker-preset="later-today"]'].click();
|
||||
const laterToday=elements['#today-blocker-return-at'].value;
|
||||
await elements['[data-today-blocker-preset="tomorrow"]'].click();
|
||||
const tomorrow=elements['#today-blocker-return-at'].value;
|
||||
elements['#today-blocker-return-at'].focused=false;
|
||||
await elements['[data-today-blocker-custom]'].click();
|
||||
process.stdout.write(JSON.stringify({{availability,opened,laterToday,tomorrow,customFocused:elements['#today-blocker-return-at'].focused}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"availability": {"update": False, "blocked": False},
|
||||
"opened": {
|
||||
"title": "Report blocker",
|
||||
"target": "#1070 · Focused blocker",
|
||||
"focused": True,
|
||||
"mode": True,
|
||||
"calls": [],
|
||||
"sheet": True,
|
||||
},
|
||||
"laterToday": "2026-08-18T14:15",
|
||||
"tomorrow": "2026-08-19T09:00",
|
||||
"customFocused": True,
|
||||
}
|
||||
|
||||
|
||||
def test_progress_sheet_dismisses_mentions_when_opened_switched_or_closed():
|
||||
script = f"""
|
||||
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.listeners={{}}; }}
|
||||
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ return this.listeners.click?.(); }}
|
||||
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
|
||||
}}
|
||||
const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
|
||||
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress'];
|
||||
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
|
||||
const issue={{identity:'issue:stackchain/dashboard:1064:',kind:'issue',repository:'stackchain/dashboard',number:1064,label:'#1064',title:'Mentions'}};
|
||||
const pull={{identity:'pull:stackchain/dashboard:77:',kind:'pull',repository:'stackchain/dashboard',number:77,label:'#77',title:'Other'}};
|
||||
let target=issue; let dismissals=0;
|
||||
const view=createView({{
|
||||
progress:{{load:()=>'',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],
|
||||
mentions:{{dismiss:()=>dismissals++}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await elements['[data-mobile-today-update]'].click();
|
||||
const afterOpen=dismissals;
|
||||
target=pull; view.update();
|
||||
const afterSwitch=dismissals;
|
||||
await elements['#cancel-today-progress'].click();
|
||||
process.stdout.write(JSON.stringify({{afterOpen,afterSwitch,afterClose:dismissals,closed:!elements['#today-progress-sheet'].open}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"afterOpen": 1,
|
||||
"afterSwitch": 2,
|
||||
"afterClose": 3,
|
||||
"closed": True,
|
||||
}
|
||||
|
||||
|
||||
def test_progress_sheet_posts_blocker_then_defers_and_continues_exact_active_item():
|
||||
script = f"""
|
||||
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.disabled=false;this.listeners={{}}; }}
|
||||
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ return this.listeners.click?.(); }}
|
||||
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
|
||||
}}
|
||||
const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
|
||||
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress',
|
||||
'#today-blocker-return-at','#post-today-blocker'];
|
||||
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
|
||||
const item={{kind:'issue',repository:'stackchain/dashboard',number:1066,title:'Blocked flow'}};
|
||||
const target={{identity:'issue:stackchain/dashboard:1066:',kind:'issue',repository:'stackchain/dashboard',number:1066,label:'#1066',title:'Blocked flow',item}};
|
||||
const calls=[]; const announcements=[];
|
||||
const progress={{
|
||||
load:()=>'',save:()=>true,
|
||||
postBlocker:async(...args)=>{{calls.push(args);return {{background:true}};}},
|
||||
}};
|
||||
createView({{
|
||||
progress,currentTarget:()=>target,qs:selector=>elements[selector],
|
||||
photos:{{open:async()=>{{}},checkpoint:async()=>{{}},serialize:async()=>[]}},
|
||||
moveOn:async(value,until)=>{{calls.push(['move-on',value.identity,until]);return true;}},
|
||||
announce:value=>announcements.push(value),
|
||||
}});
|
||||
(async()=>{{
|
||||
await elements['[data-mobile-today-update]'].click();
|
||||
elements['#today-progress-body'].value='Waiting for API access';
|
||||
elements['#today-blocker-return-at'].value='2099-08-19T09:00';
|
||||
await elements['#post-today-blocker'].click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
posted:calls[0].slice(0,3),transitionResult:await calls[0][5](target,calls[0][4]),
|
||||
moveCall:calls[1],announcements,closed:!elements['#today-progress-sheet'].open,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["posted"][0]["identity"] == "issue:stackchain/dashboard:1066:"
|
||||
assert output["posted"][1] == "Waiting for API access"
|
||||
assert output["posted"][2] == []
|
||||
assert output["moveCall"][0:2] == ["move-on", "issue:stackchain/dashboard:1066:"]
|
||||
assert output["moveCall"][2].startswith("2099-08-19T09:00")
|
||||
assert output["transitionResult"] is True
|
||||
assert output["announcements"] == ["Blocker queued, deferred to Later, and Today moved on."]
|
||||
assert output["closed"] is True
|
||||
|
||||
|
||||
def test_progress_sheet_restores_admitted_blocker_recovery_and_accepts_a_new_planning_time():
|
||||
script = f"""
|
||||
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.disabled=false;this.listeners={{}}; }}
|
||||
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ return this.listeners.click?.(); }}
|
||||
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
|
||||
}}
|
||||
const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
|
||||
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress',
|
||||
'#today-blocker-return-at','#post-today-blocker','#today-progress-blocker-recovery'];
|
||||
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
|
||||
const target={{identity:'issue:stackchain/dashboard:1068:',kind:'issue',repository:'stackchain/dashboard',number:1068,label:'#1068',title:'Recover blocker'}};
|
||||
const calls=[];
|
||||
const progress={{
|
||||
load:()=> 'Waiting for production access',save:()=>true,
|
||||
blockerRecovery:()=>({{pending:true,until:'2000-01-02T03:04:00.000Z'}}),
|
||||
postBlocker:async(...args)=>{{calls.push(args);return {{background:true,alreadyAdmitted:true}};}},
|
||||
}};
|
||||
createView({{
|
||||
progress,currentTarget:()=>target,qs:selector=>elements[selector],
|
||||
photos:{{open:async()=>{{}},checkpoint:async()=>{{}},serialize:async()=>[]}},moveOn:async()=>true,
|
||||
}});
|
||||
(async()=>{{
|
||||
await elements['[data-mobile-today-update]'].click();
|
||||
const restored={{
|
||||
body:elements['#today-progress-body'].value,bodyDisabled:elements['#today-progress-body'].disabled,
|
||||
until:elements['#today-blocker-return-at'].value,recoveryHidden:elements['#today-progress-blocker-recovery'].hidden,
|
||||
recoveryText:elements['#today-progress-blocker-recovery'].textContent,button:elements['#post-today-blocker'].textContent,
|
||||
saveDisabled:elements['#save-today-progress'].disabled,postDisabled:elements['#post-today-progress'].disabled,
|
||||
}};
|
||||
elements['#today-blocker-return-at'].value='2099-08-19T09:30';
|
||||
await elements['#post-today-blocker'].click();
|
||||
process.stdout.write(JSON.stringify({{restored,posted:{{body:calls[0][1],until:calls[0][4]}},closed:!elements['#today-progress-sheet'].open}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["restored"] == {
|
||||
"body": "Waiting for production access",
|
||||
"bodyDisabled": True,
|
||||
"until": "2000-01-02T03:04",
|
||||
"recoveryHidden": False,
|
||||
"recoveryText": "Blocker queued—finish moving on. The posted update cannot be changed.",
|
||||
"button": "Finish moving on",
|
||||
"saveDisabled": True,
|
||||
"postDisabled": True,
|
||||
}
|
||||
assert output["posted"]["body"] == "Waiting for production access"
|
||||
assert output["posted"]["until"].startswith("2099-08-19T09:30")
|
||||
assert output["closed"] is True
|
||||
|
||||
|
||||
def test_recent_activity_loads_the_exact_issue_newest_page_before_rendering():
|
||||
script = f"""
|
||||
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const calls=[]; const paints=[];
|
||||
const page={{comments:[{{id:2,body:'Latest',user:{{login:'alex'}},created_at:'2026-08-18T01:00:00Z'}}],page:3,older_page:2,total:41}};
|
||||
const activity=createActivity({{
|
||||
fetchJson:async path=>{{calls.push(path);return page;}},
|
||||
createPager:({{loadPage}})=>({{reset:value=>value,loadOlder:()=>loadPage(2)}}),
|
||||
paint:value=>paints.push(value), setStatus:value=>calls.push('status:'+value),
|
||||
}});
|
||||
const target={{identity:'issue:stackchain/dashboard:1060:',kind:'issue',repository:'stackchain/dashboard',number:1060}};
|
||||
(async()=>{{await activity.open(target);process.stdout.write(JSON.stringify({{calls,paints}}));}})()
|
||||
.catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["calls"] == [
|
||||
"status:Loading recent activity…",
|
||||
"api/v1/repos/stackchain/dashboard/issues/1060/comments?limit=20",
|
||||
"status:",
|
||||
]
|
||||
assert output["paints"] == [{
|
||||
"comments": [{
|
||||
"id": 2,
|
||||
"body": "Latest",
|
||||
"user": {"login": "alex"},
|
||||
"created_at": "2026-08-18T01:00:00Z",
|
||||
}],
|
||||
"page": 3,
|
||||
"older_page": 2,
|
||||
"total": 41,
|
||||
}]
|
||||
|
||||
|
||||
def test_recent_activity_rejects_a_slow_response_after_close():
|
||||
script = f"""
|
||||
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||
let resolve; const paints=[]; const statuses=[];
|
||||
const activity=createActivity({{
|
||||
fetchJson:()=>new Promise(done=>{{resolve=done;}}),
|
||||
createPager:()=>({{reset:value=>value}}), paint:value=>paints.push(value), setStatus:value=>statuses.push(value),
|
||||
}});
|
||||
const target={{identity:'pull:stackchain/dashboard:9:',kind:'pull',repository:'stackchain/dashboard',number:9}};
|
||||
(async()=>{{const pending=activity.open(target);activity.close();resolve({{comments:[{{id:1}}]}});const result=await pending;process.stdout.write(JSON.stringify({{result,paints,statuses}}));}})()
|
||||
.catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": False,
|
||||
"paints": [],
|
||||
"statuses": ["Loading recent activity…", ""],
|
||||
}
|
||||
|
||||
|
||||
def test_recent_activity_retry_recovers_the_same_pull_after_failure():
|
||||
script = f"""
|
||||
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const calls=[]; const paints=[]; let fail=true;
|
||||
const activity=createActivity({{
|
||||
fetchJson:async path=>{{calls.push(path);if(fail)throw new Error('offline');return {{comments:[],page:1,older_page:null,total:0}};}},
|
||||
createPager:()=>({{reset:value=>value}}), paint:value=>paints.push(value),
|
||||
}});
|
||||
const target={{identity:'pull:stackchain/dashboard:9:',kind:'pull',repository:'stackchain/dashboard',number:9}};
|
||||
(async()=>{{const opened=await activity.open(target);fail=false;const retried=await activity.retry();process.stdout.write(JSON.stringify({{opened,retried,calls,paints}}));}})()
|
||||
.catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["opened"] is False
|
||||
assert output["retried"] is True
|
||||
assert output["calls"] == [
|
||||
"api/v1/repos/stackchain/dashboard/pulls/9/comments?limit=20",
|
||||
"api/v1/repos/stackchain/dashboard/pulls/9/comments?limit=20",
|
||||
]
|
||||
assert output["paints"] == [{"comments": [], "page": 1, "older_page": None, "total": 0}]
|
||||
|
||||
|
||||
def test_recent_activity_load_older_paints_the_deduplicated_page():
|
||||
script = f"""
|
||||
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const calls=[]; const paints=[];
|
||||
const activity=createActivity({{
|
||||
fetchJson:async path=>{{calls.push(path);return {{comments:[],page:2,older_page:null,total:20}};}},
|
||||
createPager:({{loadPage}})=>({{reset:value=>value,loadOlder:async()=>{{await loadPage(2);return {{comments:[{{id:1}}],page:2,older_page:null,total:21}};}}}}),
|
||||
paint:value=>paints.push(value),
|
||||
}});
|
||||
const target={{identity:'issue:stackchain/dashboard:1060:',kind:'issue',repository:'stackchain/dashboard',number:1060}};
|
||||
(async()=>{{await activity.open(target);const loaded=await activity.loadOlder();process.stdout.write(JSON.stringify({{loaded,calls,paints}}));}})()
|
||||
.catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["loaded"] is True
|
||||
assert output["calls"][-1] == "api/v1/repos/stackchain/dashboard/issues/1060/comments?page=2&limit=20"
|
||||
assert output["paints"][-1]["comments"] == [{"id": 1}]
|
||||
|
||||
|
||||
def test_recent_activity_mount_renders_owned_actions_on_the_exact_comment_card():
|
||||
script = f"""
|
||||
const {{mountActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const createPager=require({json.dumps(str(ROOT / 'frontend' / 'conversation.js'))});
|
||||
class Element {{
|
||||
constructor(){{this.innerHTML='';this.textContent='';this.hidden=false;this.listeners={{}};this.scrollHeight=0;this.scrollTop=0;}}
|
||||
addEventListener(name,callback){{this.listeners[name]=callback;}}
|
||||
}}
|
||||
const selectors=['#today-progress-activity-list','#today-progress-activity-count','#load-older-today-progress-activity',
|
||||
'#today-progress-activity-status','#retry-today-progress-activity'];
|
||||
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
|
||||
const actions={{actionHtml:comment=>comment.author==='timmy'?'<button data-comment-action="edit">Edit</button>':''}};
|
||||
const activity=mountActivity(selector=>elements[selector],async()=>({{
|
||||
comments:[{{id:17,author:'timmy',body:'Needs correction',created_at:'2026-08-18T00:00:00Z'}}],page:1,older_page:null,total:1,
|
||||
}}),{{getActions:async()=>actions,createPager,escape:value=>String(value),render:value=>String(value)}});
|
||||
(async()=>{{
|
||||
await activity.open({{identity:'issue:stackchain/dashboard:1062:',kind:'issue',repository:'stackchain/dashboard',number:1062}});
|
||||
process.stdout.write(JSON.stringify({{html:elements['#today-progress-activity-list'].innerHTML,count:elements['#today-progress-activity-count'].textContent}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert 'class="today-progress-activity-item issue-comment"' in output["html"]
|
||||
assert 'data-comment-id="17"' in output["html"]
|
||||
assert 'data-comment-action="edit"' in output["html"]
|
||||
assert output["count"] == "1 of 1 messages"
|
||||
|
||||
|
||||
def test_recent_activity_edit_control_preserves_a_failed_correction_for_retry():
|
||||
script = f"""
|
||||
const {{mountActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||
const createPager=require({json.dumps(str(ROOT / 'frontend' / 'conversation.js'))});
|
||||
const createActions=require({json.dumps(str(ROOT / 'frontend' / 'comment-actions.js'))});
|
||||
class Element {{constructor(){{this.innerHTML='';this.textContent='';this.hidden=false;this.listeners={{}};this.scrollHeight=0;this.scrollTop=0;}}addEventListener(n,c){{this.listeners[n]=c;}}}}
|
||||
const selectors=['#today-progress-activity-list','#today-progress-activity-count','#load-older-today-progress-activity','#today-progress-activity-status','#retry-today-progress-activity'];
|
||||
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
|
||||
let state={{comments:[{{id:17,author:'timmy',body:'Wrong'}}],page:1,older_page:null,total:1}};
|
||||
let attempted='';
|
||||
const actions=createActions({{
|
||||
getLogin:()=> 'timmy',
|
||||
fetchJson:async(_path,options)=>{{attempted=JSON.parse(options.body).body;throw new Error('network unavailable');}},
|
||||
}});
|
||||
const activity=mountActivity(selector=>elements[selector],async()=>state,{{getActions:async()=>actions,createPager,escape:value=>String(value),render:value=>String(value),isOffline:()=>false}});
|
||||
const textarea={{value:'Corrected transcript',focuses:0,focus(){{this.focuses++;}}}};
|
||||
const save={{disabled:false,listeners:{{}},addEventListener(n,c){{this.listeners[n]=c;}}}};
|
||||
const cancel={{listeners:{{}},addEventListener(n,c){{this.listeners[n]=c;}}}};
|
||||
const card={{dataset:{{commentId:'17'}},innerHTML:'',querySelector:selector=>({{'.comment-edit-textarea':textarea,'[data-comment-edit-save]':save,'[data-comment-edit-cancel]':cancel}})[selector]}};
|
||||
const button={{dataset:{{commentAction:'edit'}},closest:selector=>selector==='[data-comment-action]'?button:card}};
|
||||
(async()=>{{
|
||||
await activity.open({{identity:'issue:stackchain/dashboard:1062:',kind:'issue',repository:'stackchain/dashboard',number:1062}});
|
||||
await elements['#today-progress-activity-list'].listeners.click({{target:button}});
|
||||
await save.listeners.click({{currentTarget:save}});
|
||||
process.stdout.write(JSON.stringify({{attempted,value:textarea.value,focuses:textarea.focuses,disabled:save.disabled,status:elements['#today-progress-activity-status'].textContent}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output == {
|
||||
"attempted": "Corrected transcript",
|
||||
"value": "Corrected transcript",
|
||||
"focuses": 2,
|
||||
"disabled": False,
|
||||
"status": "network unavailable",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
||||
|
||||
assert 'data-mobile-today-update' in html
|
||||
assert 'id="today-progress-sheet"' in html
|
||||
assert 'aria-labelledby="today-progress-title"' in html
|
||||
assert 'id="today-progress-body"' in html
|
||||
assert 'id="today-progress-activity"' in html
|
||||
assert 'id="today-progress-activity-list"' in html
|
||||
assert 'id="today-progress-activity-status"' in html
|
||||
assert 'id="retry-today-progress-activity"' in html
|
||||
assert 'id="load-older-today-progress-activity"' in html
|
||||
for control in (
|
||||
"voice-today-progress",
|
||||
"start-voice-today-progress",
|
||||
"stop-voice-today-progress",
|
||||
"voice-today-progress-review",
|
||||
"voice-today-progress-transcript",
|
||||
"append-voice-today-progress",
|
||||
"replace-with-voice-today-progress",
|
||||
"discard-voice-today-progress",
|
||||
"voice-today-progress-status",
|
||||
):
|
||||
assert f'id="{control}"' in html
|
||||
assert 'for="voice-today-progress-transcript"' in html
|
||||
assert 'data-draft-label="progress update"' in html
|
||||
assert 'id="take-today-progress-photo"' in html
|
||||
assert 'id="today-progress-attachment"' in html
|
||||
assert 'id="today-progress-attachment-preview"' in html
|
||||
assert 'maxlength="2000"' in html
|
||||
assert 'id="save-today-progress"' in html
|
||||
assert 'id="post-today-progress"' in html
|
||||
assert 'id="today-blocker-return-at"' in html
|
||||
assert 'id="post-today-blocker"' in html
|
||||
assert 'Post blocker & move on' in html
|
||||
assert '<script src="static/today-progress.js"></script>' in html
|
||||
assert '"static/today-progress.js"' in bundle
|
||||
assert "workSession.target('continue')" in dashboard
|
||||
assert "authoredOutbox.enqueueDurably" in dashboard
|
||||
assert "createTodayProgressPhotos" in dashboard
|
||||
assert "scope:'today-progress'" in TODAY_PROGRESS.read_text()
|
||||
assert "lanes:{ today:" in TODAY_PROGRESS.read_text()
|
||||
assert "photos:todayProgressPhotos" in dashboard
|
||||
assert "['today-progress', '#today-progress-body']" in dashboard
|
||||
assert "voice:todayProgressVoice" in dashboard
|
||||
assert "mountTodayProgressActivity" in dashboard
|
||||
assert "activity:mountTodayProgressActivity" in dashboard
|
||||
assert "moveOn:(target,until) => detailDefer.deferUntil(target.item,until)" in dashboard
|
||||
assert "fetchReviewJson,actionHydrator" in dashboard
|
||||
assert ".today-progress-activity-list" in css
|
||||
assert ".today-progress-activity-actions button" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "const controller = issueAttachment.mount" in TODAY_PROGRESS.read_text()
|
||||
assert "const checkpointAttachments = await photos?.serialize?.() || [];" in TODAY_PROGRESS.read_text()
|
||||
assert "progress.save(openedTarget.identity, body.value, checkpointAttachments)" in TODAY_PROGRESS.read_text()
|
||||
assert "error.deliveryAdmitted" in TODAY_PROGRESS.read_text()
|
||||
assert ".today-progress-panel" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert ".today-progress-actions button" in css and "min-height:44px" in css
|
||||
assert ".today-progress-panel .voice-conversation-controls button" in css
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v117';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v121';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -880,9 +880,9 @@ async def test_dashboard_renders_mobile_today_recap_flow():
|
||||
assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source
|
||||
assert 'class="today-recap-variance"' in recap_source
|
||||
assert "await recap.saveForReplan()" in recap_source
|
||||
assert "openWrapUp(handoff.actual_minutes)" in recap_source
|
||||
assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap_source
|
||||
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
|
||||
assert "todayWrapUpView.open(todayMyWork, actualMinutes)" in dashboard
|
||||
assert "todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)" in dashboard
|
||||
assert ".today-recap-row { grid-template-columns:1fr; }" in css
|
||||
|
||||
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user