Compare commits
213
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdba0554df | ||
|
|
d2a85cd5ea | ||
|
|
1f034a6f64 | ||
|
|
de6d6d0e61 | ||
|
|
8dc3880167 | ||
|
|
b93e63ab70 | ||
|
|
8c82644515 | ||
|
|
6c6cd3c71b | ||
|
|
4f6cebae7c | ||
|
|
341a3283ea | ||
|
|
9c276fa251 | ||
|
|
940f7c05d2 | ||
|
|
d34c082da2 | ||
|
|
1c086be09b | ||
|
|
4cfcb92eec | ||
|
|
b484bbef03 | ||
|
|
623817eb7d | ||
|
|
11347e5fe0 | ||
|
|
bb1712279c | ||
|
|
e7d5196987 | ||
|
|
64ff5127d8 | ||
|
|
4ac5a0f499 | ||
|
|
715df380e0 | ||
|
|
1fc70f6890 | ||
|
|
523968f5a3 | ||
|
|
af27bb06e0 | ||
|
|
4410af09a6 | ||
|
|
880f60831a | ||
|
|
e860d16d29 | ||
|
|
abf8e23685 | ||
|
|
02a472f841 | ||
|
|
c5e6e17fc1 | ||
|
|
8a12524595 | ||
|
|
cbe4017bdc | ||
|
|
fcc9ead33e | ||
|
|
0d29b29bef | ||
|
|
dce9ae04c9 | ||
|
|
9ec88179c3 | ||
|
|
93890e72de | ||
|
|
8f0df3d393 | ||
|
|
fc79e4523d | ||
|
|
741d545199 | ||
|
|
9320016b50 | ||
|
|
b069379df0 | ||
|
|
8387c57ceb | ||
|
|
72842ecbcd | ||
|
|
c503994be1 | ||
|
|
5e6328a8d0 | ||
|
|
4209834d6d | ||
|
|
46888d2876 | ||
|
|
7d7abefb0b | ||
|
|
2796808759 | ||
|
|
a2f83b0955 | ||
|
|
6b56188d30 | ||
|
|
467c8c665d | ||
|
|
1b311c9cd4 | ||
|
|
71d06347fd | ||
|
|
1de6d73e75 | ||
|
|
46c75f0b22 | ||
|
|
5cdb3f7b3e | ||
|
|
15d197327a | ||
|
|
df8385b325 | ||
|
|
47c6412a12 | ||
|
|
6cac778acd | ||
|
|
a8a93b2f3b | ||
|
|
ef37a66fd7 | ||
|
|
52e2c26304 | ||
|
|
3817d2c67b | ||
|
|
ad6c1edf80 | ||
|
|
d2b2c7bada | ||
|
|
9ba03613d4 | ||
|
|
1132075318 | ||
|
|
16b1d8670f | ||
|
|
e5c5c8ec31 | ||
|
|
87e0d74414 | ||
|
|
f031a37e8d | ||
|
|
057bec7dc2 | ||
|
|
654ae8714e | ||
|
|
a0dd044995 | ||
|
|
c93a682dfb | ||
|
|
5675ca5d41 | ||
|
|
0767ccaa9d | ||
|
|
c6eabb5fc7 | ||
|
|
77fab85a66 | ||
|
|
dcf26c896c | ||
|
|
fba1f4238d | ||
|
|
9e301924b4 | ||
|
|
8edd542379 | ||
|
|
0fbe047e03 | ||
|
|
995d8f8527 | ||
|
|
fabd6123f8 | ||
|
|
2553e7121b | ||
|
|
681e10c7db | ||
|
|
d96a77c5d5 | ||
|
|
97eb928187 | ||
|
|
80a4096118 | ||
|
|
f04a547e19 | ||
|
|
ea3712ca3e | ||
|
|
7421a8f62d | ||
|
|
982c2a1fb7 | ||
|
|
3e7d6f7111 | ||
|
|
32394fcd8f | ||
|
|
ea3194cdca | ||
|
|
85043b4af6 | ||
|
|
dc202bde11 | ||
|
|
c4ba78c8cb | ||
|
|
e35d7dea35 | ||
|
|
b81d695585 | ||
|
|
75da76c6f5 | ||
|
|
f78d69fba5 | ||
|
|
8ce64d2aa7 | ||
|
|
48f75cff05 | ||
|
|
58260d8a26 | ||
|
|
54ff44462e | ||
|
|
a87a5a4a90 | ||
|
|
c91194c283 | ||
|
|
5806d47b61 | ||
|
|
95f24a4e90 | ||
|
|
c016261b1a | ||
|
|
bbc3e10525 | ||
|
|
9737300d55 | ||
|
|
a08a51af02 | ||
|
|
528d721cd3 | ||
|
|
a91788276c | ||
|
|
28a2c9faf1 | ||
|
|
b8befc2041 | ||
|
|
d23c0c894b | ||
|
|
7948d2e353 | ||
|
|
1079ec6561 | ||
|
|
8d142c808b | ||
|
|
776ae70019 | ||
|
|
285fdff869 | ||
|
|
89d153d6a5 | ||
|
|
fcb0575e38 | ||
|
|
14ba1bcf14 | ||
|
|
a68639668c | ||
|
|
1f0e9fb59d | ||
|
|
065275b4ef | ||
|
|
90f1c48565 | ||
|
|
ae07dd185c | ||
|
|
f43f776479 | ||
|
|
48e4344f28 | ||
|
|
269916486c | ||
|
|
07235d3870 | ||
|
|
8c32a9dd81 | ||
|
|
9edd3f6441 | ||
|
|
4faf0447be | ||
|
|
8ea46974ff | ||
|
|
075a277265 | ||
|
|
a7ec4437d9 | ||
|
|
bf2cf37151 | ||
|
|
fb58ba30b2 | ||
|
|
17823352f2 | ||
|
|
14a3e1ee8d | ||
|
|
8dc155e49a | ||
|
|
7aa25f1d8c | ||
|
|
f023414929 | ||
|
|
96e7b35419 | ||
|
|
4c13e13275 | ||
|
|
ebe19f2281 | ||
|
|
798790ce1a | ||
|
|
9629643a63 | ||
|
|
9f3855d779 | ||
|
|
c99dd3e3be | ||
|
|
7e173640ed | ||
|
|
87f63370f6 | ||
|
|
614ffae188 | ||
|
|
aaa9aef78b | ||
|
|
10a8b2738b | ||
|
|
379f2b8203 | ||
|
|
53bf89bd47 | ||
|
|
697f72da09 | ||
|
|
c170240057 | ||
|
|
3043ccc846 | ||
|
|
c5ddb1275b | ||
|
|
1fb26e0a86 | ||
|
|
be7bfcaa5a | ||
|
|
6a3c6c7a9e | ||
|
|
cab230e3a2 | ||
|
|
80ea81ad73 | ||
|
|
8cd2459468 | ||
|
|
7ee10619a4 | ||
|
|
a8da5ce949 | ||
|
|
8e357a097b | ||
|
|
43f5833f7f | ||
|
|
27fff15da4 | ||
|
|
a2b78710f2 | ||
|
|
e9271f5aab | ||
|
|
257bc0110e | ||
|
|
5e0683cd4a | ||
|
|
c94fc5308f | ||
|
|
0304f5ab2c | ||
|
|
50d51ed159 | ||
|
|
8e74f022eb | ||
|
|
92b0fd56ca | ||
|
|
7ce352553d | ||
|
|
f4a4ed84c3 | ||
|
|
b21888fd7a | ||
|
|
320d6348ca | ||
|
|
7f8ee771dc | ||
|
|
f446e10670 | ||
|
|
79309196b8 | ||
|
|
cdf6ecdcb8 | ||
|
|
797fd0d544 | ||
|
|
a13e25f6c9 | ||
|
|
91e5b1e0c9 | ||
|
|
1f05521094 | ||
|
|
59c263fc59 | ||
|
|
4c64cc4c3e | ||
|
|
6e90e3fa9e | ||
|
|
83702e75b0 | ||
|
|
446281674b | ||
|
|
9c5b100d6a |
No files matched your search
@@ -24,13 +24,16 @@ For online delivery, the screenshot uploads before the comment is posted—to th
|
||||
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
|
||||
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
|
||||
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
||||
either stage. The mobile **New issue**
|
||||
sheet accepts the same image formats and stores the screenshot with its account-bound
|
||||
outbox capture. Durable admission writes the complete screenshot capture as a binary Blob to IndexedDB
|
||||
before confirmation; localStorage keeps only bounded attachment metadata, avoiding base64
|
||||
quota pressure and synchronous multi-megabyte writes. Online and background delivery send the
|
||||
original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing
|
||||
queued base64 screenshot payloads remain readable and are converted only at delivery time.
|
||||
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
|
||||
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
|
||||
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
|
||||
preview when the operator later chooses a repository. The source Draft remains available until its
|
||||
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
|
||||
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
|
||||
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
|
||||
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
|
||||
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
|
||||
delivery time.
|
||||
Delivery creates the issue exactly once, then uploads and comments with
|
||||
the image; after a partial failure, retry resumes with the confirmed issue instead of
|
||||
creating a duplicate.
|
||||
@@ -67,7 +70,8 @@ and an active Today session shows the current estimate plus estimated remaining
|
||||
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. After a reload or installed-app
|
||||
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.
|
||||
After a reload or installed-app
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
||||
**Comment & next** on that current issue or pull request posts the handoff online or admits it
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
@@ -162,11 +166,12 @@ export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
||||
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
|
||||
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
||||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||
export STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN='https://forge.example.com'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
|
||||
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3.
|
||||
export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3'
|
||||
# Recommended behind a proxy; WebAuthn assertions must match these public values.
|
||||
# Optional compatibility overrides; defaults derive from the required public origin.
|
||||
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
|
||||
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
|
||||
# Optional; defaults to eight hours.
|
||||
@@ -187,6 +192,34 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# 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.
|
||||
# The feature stays disabled unless all three values are present. Privacy-safe update
|
||||
# alerts offer Mark read and Tomorrow; Tomorrow syncs the unread item to Later at
|
||||
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
|
||||
# three individual alerts followed by one private digest that opens Updates. A later
|
||||
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent.
|
||||
# Browser push services must resolve exclusively to public IP addresses. Stackchain
|
||||
# validates endpoints at enrollment and again before delivery, rejects redirects,
|
||||
# and removes legacy subscriptions that resolve to private or reserved networks.
|
||||
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'
|
||||
# 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
|
||||
# within each device; one slow device does not delay healthy devices behind it.
|
||||
export STACKCHAIN_PUSH_POLL_SECONDS=30
|
||||
# Unread updates and deadline reminders run on independent, fixed-cadence
|
||||
# workers, so a slow channel cannot delay the other or add drift to its ticks.
|
||||
# On each phone, Device Setup can enable deadline reminders and choose any local
|
||||
# reminder hour from 00:00 through 23:00; the server-confirmed choice is restored.
|
||||
export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10
|
||||
export STACKCHAIN_PUSH_MAX_CONCURRENCY=8
|
||||
# Maximum individual alerts per device and poll before one digest covers the rest.
|
||||
export STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS=3
|
||||
export STACKCHAIN_PUSH_LEASE_SECONDS=60
|
||||
export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqlite3'
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
@@ -245,7 +278,12 @@ session linked to the credential is revoked atomically; unrelated credentials an
|
||||
sessions remain valid. Removing the current device's passkey keeps its current
|
||||
session active, so the sheet warns that the recovery token will be required after
|
||||
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
|
||||
normally keeps the passkey available for the next sign-in.
|
||||
normally keeps the passkey available for the next sign-in. Passkey assertion counters
|
||||
advance with an atomic compare-and-swap across workers: stale or equal nonzero counters
|
||||
fail before a session or authorization grant is issued, while counterless authenticators
|
||||
remain compatible. The bounded **Passkey counter anomaly** Security activity entry names
|
||||
only the device label and attempted action; repeated alerts should prompt removal and
|
||||
re-enrollment of that passkey.
|
||||
|
||||
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
||||
remote device, or signing out every device—require a passkey assertion or the
|
||||
@@ -302,12 +340,14 @@ successful response is JSON containing
|
||||
to search commands plus issues and pull requests across every repository visible to
|
||||
the configured Gitea token. Remote search starts after two characters, is debounced,
|
||||
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
|
||||
result opens a mobile-safe, read-only preview without discarding the search query;
|
||||
open unassigned issues can be claimed in place and handed into My Work after Gitea
|
||||
confirms the assignment. Closed work and pull requests remain read-only with a safe
|
||||
canonical Gitea link. The bounded APIs are available at
|
||||
`GET /api/v1/search?q=<query>&limit=<1-25>` and
|
||||
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token
|
||||
result opens a mobile-safe preview without discarding the search query. Open
|
||||
unassigned issues can be claimed in place and handed into My Work after Gitea
|
||||
confirms the assignment. A closed issue can be reopened, self-assigned, added to
|
||||
Today, and resumed through the same capacity-guarded flow; pull requests remain
|
||||
read-only with a safe canonical Gitea link. The bounded APIs are available at
|
||||
`GET /api/v1/search?q=<query>&limit=<1-25>`,
|
||||
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`, and
|
||||
`PATCH /api/v1/repos/<owner>/<repo>/issues/<number>/reopen`. Never commit the token
|
||||
or place it in a tracked configuration file.
|
||||
|
||||
For service monitoring, GET `/healthz` is a liveness check that confirms the
|
||||
@@ -337,7 +377,10 @@ account and never contain the Gitea token. Find Work uses the same worker-shared
|
||||
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
|
||||
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
|
||||
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
|
||||
bursts, and confirmed claims are removed from every worker's retained catalog. Releasing
|
||||
bursts, and confirmed claims are removed from every worker's retained catalog. On a cold
|
||||
catalog, non-owner workers wait within the bounded foreground deadline for the lease owner
|
||||
to publish; if that owner releases or abandons the lease, a waiter can take over the scan
|
||||
within its remaining request budget. Releasing
|
||||
an assignment invalidates the shared catalog so the newly available issue can be discovered
|
||||
by the next authoritative scan. Each bounded opaque revision token includes the
|
||||
store generation, so a token from a different deployment or before replacement of the store
|
||||
|
||||
+2
-1
@@ -6,4 +6,5 @@ def explicit_local_dashboard_auth_mode(monkeypatch):
|
||||
"""Keep tests intentional now that deployed authentication fails closed."""
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
|
||||
@@ -0,0 +1,83 @@
|
||||
function createAgendaReplan({ now = () => new Date(), update }) {
|
||||
let items = [];
|
||||
let index = 0;
|
||||
let pending = false;
|
||||
let active = false;
|
||||
|
||||
const dayKey = date => [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
const dueDay = value => {
|
||||
const match = String(value || '').match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/);
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
const current = () => active ? items[index] || null : null;
|
||||
const snapshot = () => ({
|
||||
active,
|
||||
index,
|
||||
total: items.length,
|
||||
current: current()?.key || null,
|
||||
pending,
|
||||
});
|
||||
const advance = () => {
|
||||
index += 1;
|
||||
if (index >= items.length) active = false;
|
||||
return { ok:true, done:!active };
|
||||
};
|
||||
const change = async dueDate => {
|
||||
const item = current();
|
||||
if (!item) return { ok:false, error:'No overdue deadline selected.' };
|
||||
if (pending) return { ok:false, error:'Deadline update already in progress.' };
|
||||
pending = true;
|
||||
try {
|
||||
await update(item, dueDate);
|
||||
return advance();
|
||||
} catch (error) {
|
||||
return { ok:false, error:error?.message || 'Deadline could not be updated.' };
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start(overdue) {
|
||||
items = (overdue || []).slice().sort((left, right) =>
|
||||
dueDay(left.due_date).localeCompare(dueDay(right.due_date)) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
);
|
||||
index = 0;
|
||||
pending = false;
|
||||
active = items.length > 0;
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
current,
|
||||
keep() {
|
||||
if (pending) return Promise.resolve({ ok:false, error:'Deadline update already in progress.' });
|
||||
if (!current()) return Promise.resolve({ ok:false, error:'No overdue deadline selected.' });
|
||||
return Promise.resolve(advance());
|
||||
},
|
||||
tomorrow() {
|
||||
const date = new Date(now());
|
||||
date.setDate(date.getDate() + 1);
|
||||
return change(dayKey(date) + 'T23:59:59Z');
|
||||
},
|
||||
choose(value) {
|
||||
const chosen = String(value || '');
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(chosen) || chosen <= dayKey(now())) {
|
||||
return Promise.resolve({ ok:false, error:'Choose a future date.' });
|
||||
}
|
||||
return change(chosen + 'T23:59:59Z');
|
||||
},
|
||||
cancel() {
|
||||
active = false;
|
||||
pending = false;
|
||||
return snapshot();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAgendaReplan;
|
||||
@@ -0,0 +1,33 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createAgendaSessionLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createAgendaSessionLauncher(options) {
|
||||
let pending = null;
|
||||
|
||||
function open() {
|
||||
if (pending) return pending;
|
||||
options.selectAgenda();
|
||||
pending = Promise.resolve()
|
||||
.then(() => options.discover())
|
||||
.then(complete => {
|
||||
if (!options.isAgendaSelected()) return 'cancelled';
|
||||
if (complete === false || options.hasMore()) {
|
||||
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
|
||||
return 'incomplete';
|
||||
}
|
||||
const opened = options.hasCheckpoint() ? options.resume() : options.start();
|
||||
if (!opened) options.announce('No deadlines are ready in Agenda.');
|
||||
return opened ? 'opened' : 'empty';
|
||||
})
|
||||
.catch(() => {
|
||||
if (options.isAgendaSelected()) {
|
||||
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
|
||||
}
|
||||
return 'incomplete';
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {open};
|
||||
});
|
||||
@@ -55,6 +55,20 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
return transact;
|
||||
}
|
||||
|
||||
function createUnfiledAttachmentStore(indexedDB = globalThis.indexedDB) {
|
||||
const transact = createIndexedDbTransaction(indexedDB, 'stackchain-unfiled-captures-v1');
|
||||
return {
|
||||
put: (id, value) => transact(records => records.put({id, ...value})),
|
||||
get: id => transact(async records => {
|
||||
const value = await records.get(id);
|
||||
if (!value) return null;
|
||||
const {id: _id, ...attachment} = value;
|
||||
return attachment;
|
||||
}),
|
||||
delete: id => transact(records => records.delete(id)),
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueSyncStore({
|
||||
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
|
||||
createToken = () => globalThis.crypto?.randomUUID?.() ||
|
||||
@@ -798,4 +812,5 @@ if (typeof module !== 'undefined' && module.exports) module.exports = createBack
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
|
||||
globalThis.createIssueSyncStore = createIssueSyncStore;
|
||||
globalThis.createUnfiledAttachmentStore = createUnfiledAttachmentStore;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
function createBatchFindWork({
|
||||
capacity,
|
||||
claim,
|
||||
queue,
|
||||
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
|
||||
persistEstimate = () => {},
|
||||
onProgress = () => {},
|
||||
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
||||
owner = () => '',
|
||||
}) {
|
||||
let request = null;
|
||||
const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || ''));
|
||||
|
||||
function readJournal() {
|
||||
if (!storage || !String(owner() || '')) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(journalKey()) || 'null');
|
||||
return value?.owner === String(owner()) && Array.isArray(value.items) ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeJournal(value) {
|
||||
if (!storage || !String(owner() || '')) return;
|
||||
storage.setItem(journalKey(), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clearJournal() {
|
||||
if (storage && String(owner() || '')) storage.removeItem(journalKey());
|
||||
}
|
||||
|
||||
function result(status, selected, available, queued = [], failed = []) {
|
||||
return { status, selected, available, queued, failed };
|
||||
}
|
||||
|
||||
function key(item) {
|
||||
return String(item.repository || '') + '#' + String(item.number || '');
|
||||
}
|
||||
|
||||
async function processJournal(journal) {
|
||||
const selected = journal.items;
|
||||
const queued = [];
|
||||
const failed = [];
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
const entry = selected[index];
|
||||
const item = entry.item;
|
||||
if (entry.state === 'queued') {
|
||||
queued.push(key(item));
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const confirmed = entry.confirmed || await claim(item);
|
||||
if (!entry.confirmed) {
|
||||
entry.confirmed = confirmed;
|
||||
entry.state = 'assigned';
|
||||
writeJournal(journal);
|
||||
}
|
||||
const queueResult = await queue(confirmed);
|
||||
if (queueResult === 'queued' || queueResult === 'exists') {
|
||||
queued.push(key(item));
|
||||
entry.state = 'queued';
|
||||
if (Number.isInteger(entry.estimate) && entry.estimate > 0) {
|
||||
persistEstimate(confirmed, entry.estimate);
|
||||
}
|
||||
writeJournal(journal);
|
||||
} else {
|
||||
failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true });
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
||||
}
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
}
|
||||
const outcome = result('complete', selected.length, journal.available, queued, failed);
|
||||
if (!failed.length) clearJournal();
|
||||
else writeJournal(journal);
|
||||
onProgress({ ...outcome, processed: selected.length });
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function run(items, estimates = {}) {
|
||||
if (request) return request;
|
||||
const selected = Array.isArray(items) ? items.slice() : [];
|
||||
const available = Math.max(0, Number(capacity()) || 0);
|
||||
if (selected.length > available) {
|
||||
const outcome = result('full', selected.length, available);
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
const budget = timeBudget() || {};
|
||||
if (Number.isInteger(budget.capacity_minutes) && budget.capacity_minutes > 0) {
|
||||
const remaining = Math.max(0, budget.capacity_minutes - (Number(budget.planned_minutes) || 0));
|
||||
const invalid = selected.map(key).filter(id =>
|
||||
!Number.isInteger(estimates[id]) || estimates[id] <= 0
|
||||
);
|
||||
const requested = selected.reduce((sum, item) => sum +
|
||||
(Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0 ? estimates[key(item)] : 0), 0);
|
||||
if (invalid.length) {
|
||||
const outcome = { ...result('estimates-required', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, invalid };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
if (requested > remaining) {
|
||||
const outcome = { ...result('over-budget', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, over_minutes: requested - remaining };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
}
|
||||
const journal = {
|
||||
owner: String(owner() || ''), available,
|
||||
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
||||
};
|
||||
writeJournal(journal);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (request) return request;
|
||||
const journal = readJournal();
|
||||
if (!journal) return Promise.resolve(null);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function mountRecovery(button, opener) {
|
||||
const show = () => {
|
||||
const journal = readJournal();
|
||||
button.hidden = !journal;
|
||||
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
|
||||
};
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const outcome = await resume();
|
||||
button.disabled = false;
|
||||
show();
|
||||
if (outcome) {
|
||||
button.previousElementSibling.textContent = outcome.failed.length ?
|
||||
outcome.failed.length + ' still need recovery.' : outcome.queued.length + ' queued · batch recovered.';
|
||||
}
|
||||
});
|
||||
opener.addEventListener('click', show);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') mountRecovery(
|
||||
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
|
||||
);
|
||||
return { run, resume, mountRecovery };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
||||
@@ -0,0 +1,103 @@
|
||||
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
|
||||
const encodedRepository = repository => String(repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/');
|
||||
|
||||
function pathFor(context, commentId) {
|
||||
const item = context?.item || {};
|
||||
if (context?.kind === 'update') {
|
||||
return 'api/v1/notifications/' + encodeURIComponent(item.notification_id) +
|
||||
'/comments/' + encodeURIComponent(commentId);
|
||||
}
|
||||
if (!['issue', 'pull'].includes(context?.kind)) throw new Error('Comment conversation is unavailable.');
|
||||
return 'api/v1/repos/' + encodedRepository(item.repository) + '/' +
|
||||
(context.kind === 'pull' ? 'pulls/' : 'issues/') + encodeURIComponent(item.number) +
|
||||
'/comments/' + encodeURIComponent(commentId);
|
||||
}
|
||||
|
||||
const controller = {
|
||||
isOwned(comment) {
|
||||
const login = String(getLogin() || '').trim();
|
||||
return Boolean(login && comment && comment.author === login);
|
||||
},
|
||||
async edit(context, pager, commentId, body) {
|
||||
const draft = String(body || '').trim();
|
||||
if (!draft) throw new Error('Comment must not be blank.');
|
||||
const comment = await fetchJson(pathFor(context, commentId), {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: draft }),
|
||||
});
|
||||
pager.replace(comment);
|
||||
return pager.snapshot();
|
||||
},
|
||||
async remove(context, pager, commentId) {
|
||||
if (!confirmDelete('Delete this comment permanently?')) return null;
|
||||
const result = await fetchJson(pathFor(context, commentId), {
|
||||
method: 'DELETE', headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!result?.deleted || Number(result.id) !== Number(commentId)) {
|
||||
throw new Error('Comment deletion was not confirmed.');
|
||||
}
|
||||
pager.remove(commentId);
|
||||
return pager.snapshot();
|
||||
},
|
||||
actionHtml(comment) {
|
||||
return controller.isOwned(comment) ?
|
||||
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
|
||||
'<button type="button" data-comment-action="edit">Edit</button>' +
|
||||
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
|
||||
},
|
||||
wire({ root, getSurface, isOffline, escapeHtml }) {
|
||||
root.addEventListener('click', async event => {
|
||||
const button = event.target.closest('[data-comment-action]');
|
||||
if (!button) return;
|
||||
const card = button.closest('.issue-comment');
|
||||
const commentId = Number(card?.dataset.commentId);
|
||||
const surface = getSurface();
|
||||
const comment = surface.pager?.snapshot().comments.find(item => item.id === commentId);
|
||||
if (!comment || !controller.isOwned(comment)) return;
|
||||
if (isOffline()) {
|
||||
surface.status.textContent = 'Reconnect to edit or delete this comment.';
|
||||
return;
|
||||
}
|
||||
if (button.dataset.commentAction === 'delete') {
|
||||
try {
|
||||
const state = await controller.remove(surface.context, surface.pager, commentId);
|
||||
if (state) {
|
||||
surface.render(state);
|
||||
surface.status.textContent = 'Comment deleted.';
|
||||
}
|
||||
} catch (error) {
|
||||
surface.status.textContent = error.message || 'Comment deletion failed. Retry or open it in Gitea.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
card.innerHTML = '<div class="small">Editing your comment</div>' +
|
||||
'<textarea class="comment-edit-textarea" maxlength="10000" aria-label="Edit comment">' +
|
||||
escapeHtml(comment.body || '') + '</textarea>' +
|
||||
'<div class="comment-owned-actions"><button type="button" data-comment-edit-save>Save</button>' +
|
||||
'<button type="button" data-comment-edit-cancel>Cancel</button></div>';
|
||||
const textarea = card.querySelector('.comment-edit-textarea');
|
||||
textarea.focus();
|
||||
card.querySelector('[data-comment-edit-cancel]').addEventListener('click', () => surface.render(surface.pager.snapshot()));
|
||||
card.querySelector('[data-comment-edit-save]').addEventListener('click', async saveEvent => {
|
||||
const save = saveEvent.currentTarget;
|
||||
save.disabled = true;
|
||||
surface.status.textContent = 'Saving comment…';
|
||||
try {
|
||||
const state = await controller.edit(surface.context, surface.pager, commentId, textarea.value);
|
||||
surface.render(state);
|
||||
surface.status.textContent = 'Comment updated.';
|
||||
} catch (error) {
|
||||
save.disabled = false;
|
||||
surface.status.textContent = error.message || 'Comment update failed. Your edit is safe; retry or open it in Gitea.';
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentActions;
|
||||
@@ -37,6 +37,8 @@ function createContextPoller({
|
||||
let retainedSnapshot = null;
|
||||
let nextDelayMs = intervalMs;
|
||||
let failureStreak = 0;
|
||||
let nextRetryAt = null;
|
||||
let lastSuccessAt = null;
|
||||
|
||||
function snapshotDelay(snapshot) {
|
||||
const freshness = snapshot && snapshot.freshness;
|
||||
@@ -69,8 +71,10 @@ function createContextPoller({
|
||||
function schedule(delayMs = intervalMs) {
|
||||
cancelTimer();
|
||||
if (stopped || isHidden()) return;
|
||||
nextRetryAt = Date.now() + delayMs;
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
nextRetryAt = null;
|
||||
refresh();
|
||||
}, delayMs);
|
||||
}
|
||||
@@ -125,6 +129,7 @@ function createContextPoller({
|
||||
.then((snapshot) => {
|
||||
if (activeRequest !== requestState) return retainedSnapshot;
|
||||
failureStreak = 0;
|
||||
lastSuccessAt = Date.now();
|
||||
nextDelayMs = snapshotDelay(snapshot);
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
@@ -165,6 +170,14 @@ function createContextPoller({
|
||||
start: refresh,
|
||||
refresh,
|
||||
setVisible,
|
||||
getState() {
|
||||
return {
|
||||
refreshing: Boolean(activeRequest),
|
||||
nextRetryAt,
|
||||
lastSuccessAt,
|
||||
failureStreak,
|
||||
};
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
|
||||
@@ -63,6 +63,21 @@ function createConversationPager({ loadPage }) {
|
||||
}
|
||||
return snapshot();
|
||||
},
|
||||
replace(comment) {
|
||||
if (!comment || !Number.isInteger(comment.id)) return snapshot();
|
||||
state = {
|
||||
...state,
|
||||
comments: state.comments.map(existing => existing.id === comment.id ? { ...comment } : existing),
|
||||
};
|
||||
return snapshot();
|
||||
},
|
||||
remove(commentId) {
|
||||
const comments = state.comments.filter(comment => comment.id !== commentId);
|
||||
if (comments.length !== state.comments.length) {
|
||||
state = { ...state, comments, total: Math.max(0, state.total - 1) };
|
||||
}
|
||||
return snapshot();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ function normalizeSharedContent(value = {}) {
|
||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
|
||||
let pending = null;
|
||||
let duplicateRequest = 0;
|
||||
let repositorySearchRequest = 0;
|
||||
@@ -126,6 +127,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function pendingFollowUp() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(followUpStorageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(parsed.repository || '')) ? String(parsed.repository) : '';
|
||||
const title = String(parsed.title || '').trim().slice(0, 240);
|
||||
const body = String(parsed.body || '').trim().slice(0, 9500);
|
||||
return title || body ? {repository, title, body, labelIds: []} : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function acceptFollowUp() {
|
||||
const followUp = pendingFollowUp();
|
||||
if (!followUp) return loadDraft();
|
||||
const accepted = saveDraft(followUp);
|
||||
try { storage.removeItem(followUpStorageKey); }
|
||||
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function discardFollowUp() {
|
||||
try { storage.removeItem(followUpStorageKey); }
|
||||
catch (_error) { /* The existing capture remains authoritative. */ }
|
||||
}
|
||||
|
||||
function stageFollowUp(value) {
|
||||
const followUp = {repository: String(value?.repository || ''), title: String(value?.title || ''), body: String(value?.body || '')};
|
||||
if (!followUp.title && !followUp.body) return {status: 'empty'};
|
||||
try { storage.setItem(followUpStorageKey, JSON.stringify(followUp)); }
|
||||
catch (_error) { /* The in-page flow can still continue. */ }
|
||||
const existing = loadDraft();
|
||||
if (existing.title || existing.body) return {status: 'conflict'};
|
||||
acceptFollowUp();
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function loadLabels(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
@@ -254,6 +291,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
searchRepositories, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+211
-13
@@ -5,11 +5,32 @@ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text);
|
||||
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
|
||||
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.live-data-status-sheet[hidden] { display:none; }
|
||||
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
|
||||
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
|
||||
.live-data-status-feeds { display:grid; gap:8px; margin:14px 0; }
|
||||
.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; }
|
||||
.live-data-status-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
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; }
|
||||
.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; }
|
||||
.draft-capacity-panel h3 { margin:.25rem 0; overflow-wrap:anywhere; }
|
||||
.draft-capacity-oldest { margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; overflow-wrap:anywhere; }
|
||||
.draft-capacity-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-capacity-actions button { min-height:44px; width:100%; }
|
||||
#replace-oldest-draft { grid-column:1 / -1; border-color:#b45309; }
|
||||
#sign-out-all { min-height:44px; }
|
||||
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.active-devices-sheet[hidden] { display:none; }
|
||||
@@ -18,6 +39,24 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.device-setup-sheet[hidden] { display:none; }
|
||||
.device-setup-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 #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.device-setup-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.device-setup-header h2, .device-setup-header p { margin-top:0; }
|
||||
.device-setup-header button, .device-setup-action { min-height:44px; }
|
||||
.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; }
|
||||
.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.device-setup-step p { margin:5px 0 0; }
|
||||
.device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; }
|
||||
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
|
||||
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
|
||||
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
||||
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
|
||||
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
|
||||
.device-readiness-summary { color:#bfdbfe; font-weight:700; }
|
||||
.device-readiness-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.device-readiness-actions button { min-height:44px; min-width:0; }
|
||||
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
|
||||
.active-device-current { color:#55d6be; font-weight:700; }
|
||||
@@ -35,6 +74,10 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
||||
#load-more-security-activity { width:100%; min-height:44px; }
|
||||
button:hover { filter: brightness(1.15); }
|
||||
.issue-comment { min-width:0; overflow-wrap:anywhere; }
|
||||
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
|
||||
.comment-owned-actions button { min-height:44px; min-width:72px; }
|
||||
.comment-edit-textarea { box-sizing:border-box; display:block; width:100%; max-width:100%; min-height:132px; resize:vertical; overflow-wrap:anywhere; }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
.panel > summary h2 { display: inline-block; margin-left: 4px; }
|
||||
@@ -47,6 +90,14 @@ main { position: relative; z-index: 1; display: grid; grid-template-columns: 300
|
||||
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
||||
a { color: #60a5fa; text-decoration: none; }
|
||||
.muted { color: #94a3b8; }
|
||||
.empty-work-start { display:grid; gap:12px; margin:12px 0; padding:16px; border:1px solid #31577f; border-radius:14px; background:#0b1b2f; }
|
||||
.empty-work-start[hidden] { display:none; }
|
||||
.empty-work-start p { margin:6px 0 0; }
|
||||
.empty-work-start-actions { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); gap:8px; }
|
||||
.empty-work-start-actions button { min-width:0; min-height:44px; white-space:normal; }
|
||||
@media (max-width:420px) {
|
||||
.empty-work-start-actions { grid-template-columns:1fr; }
|
||||
}
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
||||
.kv .label { color: #94a3b8; }
|
||||
.kv .value { color: #e5e7eb; }
|
||||
@@ -81,6 +132,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.offline-status[hidden] { display:none; }
|
||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||
.push-update-control { min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
@@ -100,13 +152,24 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
|
||||
.plan-today-header button { min-width:44px; min-height:44px; }
|
||||
.plan-today-list button, .plan-today-candidates button { min-height:44px; }
|
||||
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
|
||||
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
|
||||
.plan-today-available span { display:flex; align-items:center; gap:6px; }
|
||||
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
|
||||
.plan-today-build { min-height:44px; width:100%; font-weight:700; }
|
||||
.plan-today-build-status { min-height:1.4em; margin-top:6px; color:#bfdbfe; }
|
||||
.plan-today-estimates { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.plan-today-estimates h3, .plan-today-estimates p { margin-top:0; }
|
||||
.plan-today-estimates input { min-height:44px; width:108px; box-sizing:border-box; }
|
||||
.plan-today-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:8px 0; border-top:1px solid #29496d; }
|
||||
.plan-today-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.plan-today-skipped { display:grid; gap:6px; margin:8px 0; }
|
||||
.plan-today-skipped-item { padding:8px 10px; border-left:3px solid #b45309; background:#261a13; overflow-wrap:anywhere; }
|
||||
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
|
||||
.plan-today-error { min-height:1.4em; color:#fca5a5; }
|
||||
.discard-recap-replan { min-height:44px; margin-bottom:8px; }
|
||||
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
|
||||
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
|
||||
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
|
||||
@@ -117,6 +180,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-preview-actions button { min-height:44px; }
|
||||
.plan-today-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; }
|
||||
.plan-today-actions button { min-height:44px; width:100%; }
|
||||
.today-interruption-sheet { position:fixed; inset:0; z-index:92; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-interruption-sheet[hidden] { display:none; }
|
||||
.today-interruption-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; }
|
||||
.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-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; }
|
||||
.today-recap-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-recap-header h2 { margin:.2rem 0; }
|
||||
.today-recap-header button { min-height:44px; min-width:44px; }
|
||||
.today-recap-items, .today-recap-history { display:grid; gap:8px; }
|
||||
.today-recap-row, .today-recap-history-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:10px; }
|
||||
.today-recap-row strong { overflow-wrap:anywhere; }
|
||||
.today-recap-row input { width:6rem; min-height:44px; }
|
||||
.today-recap-context { display:block; overflow-wrap:anywhere; }
|
||||
.today-recap-variance { grid-column:1 / -1; color:#bfdbfe; font-weight:700; }
|
||||
.today-recap-log { grid-column:1 / -1; display:flex; align-items:center; gap:8px; min-height:44px; }
|
||||
.today-recap-log input { width:24px; min-height:24px; }
|
||||
.today-recap-log-success { color:#86efac; font-weight:700; }
|
||||
.today-recap-log-verify { color:#fde68a; font-weight:700; }
|
||||
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
|
||||
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-recap-actions button { min-height:44px; width:100%; }
|
||||
@media (max-width:420px) {
|
||||
.today-interruption-actions { grid-template-columns:1fr; }
|
||||
.today-recap-actions { grid-template-columns:1fr; }
|
||||
.today-recap-row { grid-template-columns:1fr; }
|
||||
.today-recap-row input { width:min(100%,8rem); }
|
||||
.today-recap-variance { grid-column:1; }
|
||||
}
|
||||
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-readiness-sheet[hidden] { display:none; }
|
||||
.today-readiness-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 #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
@@ -134,8 +229,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
||||
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.queue-finder { display:grid; gap:6px; margin:10px 0; }
|
||||
.queue-finder > label { font-weight:700; }
|
||||
.queue-finder-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.queue-finder input, .queue-finder button { min-height:44px; box-sizing:border-box; }
|
||||
.queue-finder input { min-width:0; width:100%; padding:8px 10px; border:1px solid #31577f; border-radius:10px; background:#08111f; color:var(--text); font:inherit; }
|
||||
.queue-finder button { padding-inline:14px; }
|
||||
#search-older-work { width:100%; }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
|
||||
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
@@ -148,15 +251,35 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
||||
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
||||
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
|
||||
.draft-filing-session[hidden] { display:none; }
|
||||
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-filing-session-actions button { min-height:44px; width:100%; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.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 button { min-height:44px; width:100%; }
|
||||
.agenda-replan-actions label { grid-column:1/-1; }
|
||||
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
|
||||
.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%; } }
|
||||
.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); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.update-selection-controls button { min-height:44px; }
|
||||
.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.selection-scope-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; max-width:100%; }
|
||||
.selection-scope-actions button { min-height:44px; max-width:100%; }
|
||||
.update-selector { min-height:44px; }
|
||||
.work-selector { min-height:44px; }
|
||||
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input, .work-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.my-work-card.selection-active { border-color:#31577f; }
|
||||
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
@@ -189,6 +312,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.retry-work-route[hidden] { display:none; }
|
||||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-bulk button { min-height:44px; width:100%; }
|
||||
.my-work-bulk-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.my-work-bulk-actions button { min-height:44px; width:100%; }
|
||||
.my-work-bulk-actions #bulk-mark-read { grid-column:1 / -1; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.review-sheet.open { display:flex; }
|
||||
@@ -257,6 +383,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-header button { min-height:44px; }
|
||||
.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||||
@@ -274,6 +401,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-ownership-actions button { min-width:0; width:100%; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions #mute-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
@@ -340,27 +468,58 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff, .pull-ownership { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select, .pull-ownership select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff select, .issue-handoff button { min-height:44px; }
|
||||
.pull-ownership select, .pull-ownership button { min-height:44px; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.resume-today-session, .end-today-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav[hidden] { display:none; }
|
||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||
.work-session-nav [data-work-session-timer-toggle] { min-height:44px; }
|
||||
.work-session-nav button { min-height:44px; width:100%; }
|
||||
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; 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; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header-actions { display:flex; gap:8px; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
|
||||
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.find-work-search input, .find-work-search button { min-height:44px; }
|
||||
.find-work-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; }
|
||||
.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
|
||||
.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; }
|
||||
.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; }
|
||||
.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
|
||||
.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; }
|
||||
.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
#clear-find-work-filters { min-height:44px; width:100%; }
|
||||
.find-work-list { display:grid; gap:10px; }
|
||||
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||
.find-work-select { display:flex; align-items:center; gap:10px; min-height:44px; }
|
||||
.find-work-select input { width:22px; height:22px; }
|
||||
.fill-find-work-today { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-batch-actions { position:sticky; bottom:0; z-index:2; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:12px 0 calc(12px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.find-work-batch-actions[hidden] { display:none; }
|
||||
.find-work-batch-actions span { grid-column:1 / -1; }
|
||||
.find-work-batch-actions button { min-height:44px; }
|
||||
.find-work-batch-recovery { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.find-work-estimate-review[hidden] { display:none; }
|
||||
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
|
||||
.find-work-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
|
||||
.find-work-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.find-work-estimate-review input { min-height:44px; width:76px; }
|
||||
.find-work-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.find-work-estimate-actions button { min-height:44px; }
|
||||
.find-work-card button { width:100%; font-weight:700; }
|
||||
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
|
||||
@@ -393,8 +552,12 @@ 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; }
|
||||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-capture-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.create-issue-capture-actions[hidden] { display:none; }
|
||||
.create-issue-filing { display:grid; gap:12px; min-width:0; }
|
||||
.create-issue-filing[hidden] { display:none; }
|
||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
|
||||
@@ -448,11 +611,19 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.mobile-task-dock { display:none; }
|
||||
.mobile-task-dock[hidden] { display:none; }
|
||||
|
||||
.mobile-today-hud { display:none; }
|
||||
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
|
||||
.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; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; max-width:100%; padding:1px 5px; border-radius:999px; background:#31577f; font-size:10px; line-height:16px; white-space:nowrap; }
|
||||
.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; }
|
||||
@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)); }
|
||||
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; }
|
||||
@@ -462,13 +633,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.app-menu:not([open]) > .app-menu-panel { display:none; }
|
||||
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
|
||||
.app-menu-panel button { min-height:44px; width:100%; }
|
||||
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.device-setup-step { grid-template-columns:1fr; }
|
||||
.device-setup-action { width:100%; }
|
||||
.device-readiness-card:not([hidden]) { display:grid; gap:12px; margin-left:max(0px,env(safe-area-inset-left)); margin-right:max(0px,env(safe-area-inset-right)); }
|
||||
.my-work { margin:0; }
|
||||
.attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { width:100%; flex-wrap:nowrap; }
|
||||
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
|
||||
.my-work-actions { display:none; }
|
||||
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.queue-finder { position:sticky; top:64px; z-index:5; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); padding:8px; background:rgba(11,21,38,.98); border:1px solid #2a496e; border-radius:10px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
|
||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||
@@ -500,13 +676,35 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-panel.composer-keyboard-active,
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
.pull-sheet-panel.composer-keyboard-active,
|
||||
.review-sheet-panel.composer-keyboard-active,
|
||||
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
|
||||
.composer-keyboard-active .create-issue-form,
|
||||
.composer-keyboard-active .issue-comment-composer,
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .review-inline-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.mobile-queue-panel h2 { margin:0; }
|
||||
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
|
||||
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||
.mobile-queue-list small { color:var(--muted); }
|
||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
||||
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||
.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; }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
|
||||
+1333
-210
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,65 @@
|
||||
function showDraftCapacityDialog(captures) {
|
||||
const state = captures.capacity();
|
||||
if (!state.full) return false;
|
||||
const oldest = state.oldest;
|
||||
qs('#draft-capacity-oldest').innerHTML = '<strong>' + escapeHtml(oldest.title) + '</strong><div class="small">' +
|
||||
escapeHtml(oldest.body || 'No note') + (oldest.hasAttachment ? ' · Screenshot attached' : '') + '</div>';
|
||||
qs('#draft-capacity-sheet').dataset.oldestId = oldest.id;
|
||||
qs('#draft-capacity-status').textContent = '';
|
||||
qs('#draft-capacity-sheet').hidden = false;
|
||||
qs('#keep-editing-draft').focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindDraftCapacityDialog() {
|
||||
qs('#keep-editing-draft').addEventListener('click', () => {
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
qs('#save-unfiled-issue').focus();
|
||||
});
|
||||
qs('#review-full-drafts').addEventListener('click', () => {
|
||||
const oldestId = qs('#draft-capacity-sheet').dataset.oldestId;
|
||||
saveIssueCaptureDraft();
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => {
|
||||
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
|
||||
card?.scrollIntoView({block:'nearest'});
|
||||
card?.focus({preventScroll:true});
|
||||
});
|
||||
});
|
||||
qs('#replace-oldest-draft').addEventListener('click', async () => {
|
||||
const oldest = unfiledCaptures.capacity().oldest;
|
||||
if (!oldest || oldest.id !== qs('#draft-capacity-sheet').dataset.oldestId) {
|
||||
qs('#draft-capacity-status').textContent = 'Drafts changed. Review them before replacing anything.';
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('Replace “' + oldest.title + '” and permanently delete its saved contents?')) return;
|
||||
const button = qs('#replace-oldest-draft');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const draft = {
|
||||
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
|
||||
attachment:await createIssueAttachmentController.serialize(),
|
||||
};
|
||||
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
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');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus());
|
||||
qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.';
|
||||
} catch (error) {
|
||||
qs('#draft-capacity-status').textContent = error.message + ' Nothing was replaced.';
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = {showDraftCapacityDialog, bindDraftCapacityDialog};
|
||||
@@ -0,0 +1,92 @@
|
||||
function createDraftFilingSession({list}) {
|
||||
let orderedIds = [];
|
||||
let currentId = '';
|
||||
|
||||
function visibleIds() {
|
||||
const available = new Set(list().map(item => item.id));
|
||||
orderedIds = orderedIds.filter(id => available.has(id));
|
||||
for (const item of list()) {
|
||||
if (!orderedIds.includes(item.id)) orderedIds.push(item.id);
|
||||
}
|
||||
return orderedIds;
|
||||
}
|
||||
|
||||
function state() {
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) {
|
||||
currentId = '';
|
||||
return null;
|
||||
}
|
||||
if (!ids.includes(currentId)) currentId = ids[0];
|
||||
const index = ids.indexOf(currentId);
|
||||
return {id:currentId, position:index + 1, total:ids.length, remaining:ids.length - index - 1};
|
||||
}
|
||||
|
||||
function start(id) {
|
||||
orderedIds = list().map(item => item.id);
|
||||
if (!orderedIds.includes(id)) throw new Error('This capture is no longer available.');
|
||||
currentId = id;
|
||||
return state();
|
||||
}
|
||||
|
||||
function move(offset) {
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) return state();
|
||||
const index = Math.max(0, ids.indexOf(currentId));
|
||||
currentId = ids[(index + offset + ids.length) % ids.length];
|
||||
return state();
|
||||
}
|
||||
|
||||
function removeCurrentAndNext(removedId) {
|
||||
const previousIds = orderedIds.slice();
|
||||
const removedIndex = Math.max(0, previousIds.indexOf(removedId));
|
||||
orderedIds = previousIds.filter(id => id !== removedId);
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) return state();
|
||||
currentId = ids[Math.min(removedIndex, ids.length - 1)];
|
||||
return state();
|
||||
}
|
||||
|
||||
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
|
||||
session.attach = (qs, dependencies) => {
|
||||
const openCapture = async captureId => {
|
||||
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
|
||||
dependencies.issueCapture.saveDraft(resumed);
|
||||
dependencies.setResumedId(captureId);
|
||||
await dependencies.openSheet();
|
||||
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
|
||||
dependencies.setFilingMode(true);
|
||||
session.render();
|
||||
};
|
||||
return Object.assign(session, {
|
||||
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
|
||||
render() {
|
||||
const state = session.current();
|
||||
qs('#draft-filing-session').hidden = !state;
|
||||
if (!state) return;
|
||||
qs('#draft-filing-progress').textContent = `Draft ${state.position} of ${state.total}`;
|
||||
qs('#draft-filing-remaining').textContent = state.remaining ? `${state.remaining} remaining` : 'Last Draft';
|
||||
qs('#previous-draft').disabled = qs('#skip-draft').disabled = state.total < 2;
|
||||
qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft';
|
||||
},
|
||||
bind() {
|
||||
[['#previous-draft','previous'],['#skip-draft','next']].forEach(([selector, method]) =>
|
||||
qs(selector).addEventListener('click', async () => {
|
||||
const state = session[method]();
|
||||
if (state) await openCapture(state.id);
|
||||
})
|
||||
);
|
||||
},
|
||||
async advance(completedId) {
|
||||
const state = session.removeCurrentAndNext(completedId);
|
||||
if (state) { await openCapture(state.id); return true; }
|
||||
qs('#draft-filing-session').hidden = true;
|
||||
qs('#submit-new-issue').textContent = 'Create & assign to me';
|
||||
return false;
|
||||
},
|
||||
});
|
||||
};
|
||||
return session;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftFilingSession;
|
||||
@@ -47,7 +47,8 @@ function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
|
||||
} catch (_error) {
|
||||
if (status) {
|
||||
const label = name === 'issue-capture' ? 'Issue capture' :
|
||||
name === 'pull-workflow' ? 'Pull workspace' : name.replace(/-/g, ' ');
|
||||
name === 'pull-workflow' ? 'Pull workspace' :
|
||||
name === 'security-center' ? 'Security center' : name.replace(/-/g, ' ');
|
||||
status.textContent = label + ' could not load. ' + retryLabel;
|
||||
}
|
||||
return false;
|
||||
|
||||
+328
-20
@@ -12,12 +12,13 @@
|
||||
<canvas id="bg"></canvas>
|
||||
<header>
|
||||
<div class="app-brand"><span class="obi"></span><strong>Stackchain</strong><span class="muted">Dashboard</span></div>
|
||||
<div class="app-live-status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
||||
<button class="app-live-status" id="open-live-data-status" type="button" aria-controls="live-data-status-sheet" aria-expanded="false"><span class="dot"></span><span class="small" id="status">Live</span></button>
|
||||
<details class="app-menu">
|
||||
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
|
||||
<div class="app-menu-panel">
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="open-palette">Command</button>
|
||||
<button id="open-device-setup" type="button">Device setup</button>
|
||||
<button id="active-devices" type="button">Active devices</button>
|
||||
<button id="sign-out" type="button">Sign out & clear this device</button>
|
||||
<button id="sign-out-all" type="button">Sign out all devices</button>
|
||||
@@ -25,6 +26,20 @@
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
|
||||
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
|
||||
<div class="live-data-status-header">
|
||||
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
|
||||
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
|
||||
</div>
|
||||
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
|
||||
<p id="live-data-status-retry" class="small muted"></p>
|
||||
<div class="live-data-status-actions">
|
||||
<button id="refresh-live-data" type="button">Refresh now</button>
|
||||
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="offline-status" id="offline-status" role="status" aria-live="polite" hidden>
|
||||
Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
|
||||
</div>
|
||||
@@ -59,6 +74,43 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="device-setup-sheet" class="device-setup-sheet" hidden>
|
||||
<section class="device-setup-panel" role="dialog" aria-modal="true" aria-labelledby="device-setup-heading">
|
||||
<div class="device-setup-header">
|
||||
<div>
|
||||
<h2 id="device-setup-heading">Make this device work-ready</h2>
|
||||
<p class="small muted">Prepare this phone for fast launch, outages, and new update alerts. Nothing asks for permission until you choose it.</p>
|
||||
</div>
|
||||
<button id="close-device-setup" type="button" aria-label="Close device setup">Close</button>
|
||||
</div>
|
||||
<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>
|
||||
<button class="device-setup-action" id="device-setup-install" type="button">Install</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
|
||||
<div class="device-setup-deadline-controls">
|
||||
<label for="device-setup-deadline-hour">Local hour</label>
|
||||
<select id="device-setup-deadline-hour" aria-label="Deadline reminder local hour"></select>
|
||||
<label for="device-setup-deadline-days">Warn me</label>
|
||||
<select id="device-setup-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>
|
||||
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section class="panel my-work" id="my-work" tabindex="-1">
|
||||
<div class="my-work-header">
|
||||
@@ -68,6 +120,7 @@
|
||||
</div>
|
||||
<div class="my-work-actions">
|
||||
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
|
||||
<button class="today-recap-history-action" id="open-today-recaps" type="button">Recaps</button>
|
||||
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
|
||||
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
|
||||
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
|
||||
@@ -80,6 +133,7 @@
|
||||
<div class="work-filters" aria-label="Filter My Work">
|
||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
||||
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
|
||||
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
|
||||
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
@@ -97,6 +151,12 @@
|
||||
<div class="offline-work-controls">
|
||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
||||
<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>
|
||||
@@ -116,11 +176,76 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<aside class="attention-interruption" id="attention-interruption" role="status" aria-live="polite" hidden>
|
||||
<span><strong>Today paused</strong> while handling Attention</span>
|
||||
<button id="return-to-today" 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>
|
||||
<p class="small muted">Install, save work offline, and enable update alerts.</p>
|
||||
<p class="small device-readiness-summary" id="device-readiness-summary" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div class="device-readiness-actions">
|
||||
<button id="finish-device-setup" type="button">Finish setup</button>
|
||||
<button id="dismiss-device-readiness" type="button">Not now</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="update-selection-controls" id="update-selection-controls" hidden>
|
||||
<button id="select-work" type="button">Select work</button>
|
||||
<button id="cancel-work-selection" type="button" hidden>Cancel selection</button>
|
||||
<button id="select-updates" type="button">Select updates</button>
|
||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="selection-scope-actions" id="selection-scope-actions" hidden>
|
||||
<button id="select-matching-work" type="button">Select all matches</button>
|
||||
<button id="clear-work-selection" type="button">Clear selection</button>
|
||||
</div>
|
||||
<form class="queue-finder" id="queue-finder" role="search">
|
||||
<label for="queue-find-input">Find in <span id="queue-find-label">All</span></label>
|
||||
<div class="queue-finder-row">
|
||||
<input id="queue-find-input" type="search" autocomplete="off" aria-label="Find in active work queue" placeholder="Repository, #number, or title" />
|
||||
<button id="clear-queue-find" type="button" hidden>Clear</button>
|
||||
</div>
|
||||
<div class="small" id="queue-find-status" role="status" aria-live="polite"></div>
|
||||
<button id="search-older-work" type="button" hidden>Search older work</button>
|
||||
</form>
|
||||
<section class="empty-work-start" id="empty-work-start" aria-labelledby="empty-work-start-heading" hidden>
|
||||
<div>
|
||||
<strong id="empty-work-start-heading">Ready for something new?</strong>
|
||||
<p class="small muted">Find an available issue to own, or create the work that needs doing.</p>
|
||||
</div>
|
||||
<div class="empty-work-start-actions">
|
||||
<button id="empty-work-find" type="button">Find and start work</button>
|
||||
<button id="empty-work-create" type="button">Create an issue</button>
|
||||
</div>
|
||||
</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>
|
||||
<button id="start-agenda-replan" type="button">Replan overdue</button>
|
||||
</div>
|
||||
<div id="agenda-replan-controls" hidden>
|
||||
<div class="small" id="agenda-replan-progress" role="status" aria-live="assertive"></div>
|
||||
<div class="agenda-replan-actions">
|
||||
<button id="agenda-replan-keep" type="button">Keep date & next</button>
|
||||
<button id="agenda-replan-tomorrow" type="button">Tomorrow & next</button>
|
||||
<label for="agenda-replan-date">Choose a future date</label>
|
||||
<input id="agenda-replan-date" type="date" />
|
||||
<button id="agenda-replan-choose" type="button">Choose date & next</button>
|
||||
<button id="agenda-replan-cancel" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<p class="small muted">Put overdue and due-today work first, then review what fits.</p>
|
||||
<div class="small" id="protect-today-status" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<button id="protect-today" type="button">Protect Today</button>
|
||||
</section>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||||
@@ -132,7 +257,14 @@
|
||||
<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>
|
||||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||||
<button id="bulk-mark-read" type="button"></button>
|
||||
<div class="my-work-bulk-actions">
|
||||
<button id="batch-add-today" type="button" hidden>Add to Today</button>
|
||||
<button data-batch-defer="today" type="button" hidden>Later today</button>
|
||||
<button data-batch-defer="tomorrow" type="button" hidden>Tomorrow</button>
|
||||
<button id="defer-selected-today" data-defer-selected="today" type="button">Later today</button>
|
||||
<button id="defer-selected-tomorrow" data-defer-selected="tomorrow" type="button">Tomorrow</button>
|
||||
<button id="bulk-mark-read" type="button"></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="sidebar">
|
||||
@@ -208,7 +340,16 @@
|
||||
<label class="plan-today-available" for="plan-today-available">Available today
|
||||
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
|
||||
</label>
|
||||
<button class="plan-today-build" id="build-today-plan" type="button">Build my Today</button>
|
||||
<div class="small plan-today-build-status" id="plan-today-build-status" role="status" aria-live="polite"></div>
|
||||
<section class="plan-today-estimates" id="plan-today-estimates" aria-labelledby="plan-today-estimates-heading" hidden>
|
||||
<h3 id="plan-today-estimates-heading">Estimate to finish your plan</h3>
|
||||
<p class="small muted">Add minutes for ready work and your plan will re-fit immediately.</p>
|
||||
<div id="plan-today-estimate-list"></div>
|
||||
</section>
|
||||
<div class="plan-today-skipped" id="plan-today-skipped"></div>
|
||||
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
|
||||
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
|
||||
<section aria-labelledby="today-plan-heading">
|
||||
<h3 id="today-plan-heading">Today, in order</h3>
|
||||
<div class="plan-today-list" id="plan-today-list"></div>
|
||||
@@ -224,6 +365,41 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-interruption-sheet" id="today-interruption-sheet" role="dialog" aria-modal="true" aria-labelledby="today-interruption-title" hidden>
|
||||
<section class="today-interruption-panel">
|
||||
<div class="small">Today timer</div>
|
||||
<h2 id="today-interruption-title">Were you working while away?</h2>
|
||||
<p id="today-interruption-description" class="small"></p>
|
||||
<p class="small muted">Choose once before the timer continues. You can include the full interval or remove it from your recap.</p>
|
||||
<div class="today-interruption-actions">
|
||||
<button type="button" data-today-interruption="count">Count this time</button>
|
||||
<button type="button" data-today-interruption="exclude">Exclude this time</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-recap-sheet" id="today-recap-sheet" role="dialog" aria-modal="true" aria-labelledby="today-recap-title" hidden>
|
||||
<section class="today-recap-panel">
|
||||
<div class="today-recap-header">
|
||||
<div><div class="small">Estimate feedback</div><h2 id="today-recap-title">Today recap</h2></div>
|
||||
<button id="close-today-recap" type="button">Close</button>
|
||||
</div>
|
||||
<p class="small muted">Review actual time before saving. Gitea time logging is explicit and unchecked by default.</p>
|
||||
<div id="today-recap-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-recap-items" class="today-recap-items"></div>
|
||||
<div id="today-recap-totals" class="today-recap-totals"></div>
|
||||
<section aria-labelledby="today-recap-history-title">
|
||||
<h3 id="today-recap-history-title">Recent recaps</h3>
|
||||
<div id="today-recap-history" class="today-recap-history"></div>
|
||||
</section>
|
||||
<div class="today-recap-actions">
|
||||
<button id="save-today-recap" type="button">Save recap & adjust plan</button>
|
||||
<button id="save-log-today-recap" type="button">Log selected time to Gitea</button>
|
||||
<button id="discard-today-recap" type="button">Keep timer & close</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@@ -272,6 +448,7 @@
|
||||
<div class="search-preview-primary-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
|
||||
</div>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
@@ -408,6 +585,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="issue" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
@@ -418,19 +596,51 @@
|
||||
<section class="find-work-panel">
|
||||
<div class="find-work-header">
|
||||
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
|
||||
<button id="close-find-work" type="button">Close</button>
|
||||
<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>
|
||||
<p class="small">Claim an available issue and continue it in My Work.</p>
|
||||
<form class="find-work-search" id="find-work-search-form" role="search">
|
||||
<label for="find-work-search">Search repository, #number, or title</label>
|
||||
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
|
||||
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
|
||||
<span id="find-work-match-status" class="small" aria-live="polite"></span>
|
||||
</form>
|
||||
<details class="find-work-filters" id="find-work-filters">
|
||||
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
|
||||
<div class="find-work-filter-panel" aria-label="Filter available work">
|
||||
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<button id="clear-find-work-filters" type="button">Clear filters</button>
|
||||
</div>
|
||||
</details>
|
||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
|
||||
aria-describedby="find-work-status" hidden>Resume assigning & queueing</button>
|
||||
<div class="find-work-list" id="find-work-list"></div>
|
||||
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
|
||||
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="find-work-estimate-list"></div>
|
||||
<div class="find-work-estimate-actions">
|
||||
<button id="cancel-find-work-estimates" type="button">Back</button>
|
||||
<button id="confirm-find-work-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
|
||||
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
|
||||
<button id="cancel-find-work-selection" type="button">Cancel</button>
|
||||
<button id="claim-selected-work" type="button" disabled>Assign & queue Today</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
|
||||
<section class="create-issue-panel">
|
||||
<div class="create-issue-header">
|
||||
<h3 id="create-issue-heading">New issue</h3>
|
||||
<h3 id="create-issue-heading">Capture work</h3>
|
||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||
</div>
|
||||
<aside class="shared-content-conflict" id="shared-content-conflict" aria-live="assertive" hidden>
|
||||
@@ -442,23 +652,16 @@
|
||||
</div>
|
||||
</aside>
|
||||
<form class="create-issue-form" id="create-issue-form">
|
||||
<div class="create-issue-repository-picker">
|
||||
<label for="create-issue-repository-search">Repository search
|
||||
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||
</label>
|
||||
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
|
||||
<label for="create-issue-repository">Selected repository
|
||||
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<label for="create-issue-title">Title
|
||||
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
|
||||
</label>
|
||||
<label for="create-issue-body">Description <span class="small">Optional</span>
|
||||
<label for="create-issue-body">Note <span class="small">Optional</span>
|
||||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<div class="create-issue-capture-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
|
||||
<button id="file-new-issue" type="button">File now</button>
|
||||
</div>
|
||||
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
|
||||
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
|
||||
<div class="issue-attachment-controls">
|
||||
@@ -472,6 +675,26 @@
|
||||
</div>
|
||||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
<section class="create-issue-filing" id="create-issue-filing" hidden>
|
||||
<aside class="draft-filing-session" id="draft-filing-session" aria-live="polite" hidden>
|
||||
<strong id="draft-filing-progress"></strong>
|
||||
<span class="small" id="draft-filing-remaining"></span>
|
||||
<div class="draft-filing-session-actions">
|
||||
<button id="previous-draft" type="button">Previous</button>
|
||||
<button id="skip-draft" type="button">Skip</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="create-issue-repository-picker">
|
||||
<label for="create-issue-repository-search">Repository search
|
||||
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||
</label>
|
||||
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
|
||||
<label for="create-issue-repository">Selected repository
|
||||
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
|
||||
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
||||
<option value="">No milestone</option>
|
||||
@@ -494,15 +717,31 @@
|
||||
</section>
|
||||
<div class="small">The issue will be assigned to you.</div>
|
||||
<div class="create-issue-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save for filing</button>
|
||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||
<button id="create-follow-up-next" type="submit" hidden>Create follow-up & next</button>
|
||||
<button id="create-and-start-issue" type="submit">Create & start</button>
|
||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="create-issue-capture-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="draft-capacity-sheet" id="draft-capacity-sheet" role="dialog" aria-modal="true" aria-labelledby="draft-capacity-heading" hidden>
|
||||
<section class="draft-capacity-panel">
|
||||
<h3 id="draft-capacity-heading">Drafts full — nothing was deleted.</h3>
|
||||
<p class="small">Review saved work, keep editing, or explicitly replace the oldest Draft.</p>
|
||||
<div class="draft-capacity-oldest" id="draft-capacity-oldest"></div>
|
||||
<div class="draft-capacity-actions">
|
||||
<button id="review-full-drafts" type="button">Review Drafts</button>
|
||||
<button id="keep-editing-draft" type="button">Keep editing</button>
|
||||
<button id="replace-oldest-draft" type="button">Replace oldest & save</button>
|
||||
</div>
|
||||
<div id="draft-capacity-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||
<section class="update-sheet-panel">
|
||||
<div class="update-sheet-header">
|
||||
@@ -510,8 +749,9 @@
|
||||
<div class="small" id="update-sheet-key"></div>
|
||||
<h3 id="update-sheet-title">Unread update</h3>
|
||||
</div>
|
||||
<button id="keep-update-unread" type="button">Keep unread</button>
|
||||
<button id="keep-update-unread" type="button">Keep unread & next</button>
|
||||
</div>
|
||||
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
||||
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
|
||||
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
||||
<div class="row">
|
||||
@@ -554,6 +794,8 @@
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||
<button id="mute-update-next" type="button" hidden>Mute future updates & next</button>
|
||||
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
@@ -561,12 +803,18 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="update" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="notification-undo" id="notification-undo" role="status" aria-live="assertive" hidden>
|
||||
<span id="notification-undo-status"></span>
|
||||
<button id="undo-notification" type="button" aria-label="Undo marking update read">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">
|
||||
@@ -613,14 +861,26 @@
|
||||
<div id="pull-files"></div>
|
||||
<button id="merge-pull" type="button" disabled>Merge</button>
|
||||
</details>
|
||||
<details class="pull-ownership" id="pull-ownership">
|
||||
<summary>Ownership</summary>
|
||||
<div>
|
||||
<button id="load-pull-handoff" type="button">Choose teammate</button>
|
||||
<label for="pull-handoff-recipient" class="small">Eligible repository assignee</label>
|
||||
<select id="pull-handoff-recipient" disabled><option value="">Select a teammate</option></select>
|
||||
<button id="confirm-pull-handoff" type="button" disabled>Confirm handoff</button>
|
||||
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="pull-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="release-pull" type="button">Release assignment</button>
|
||||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="pull" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
@@ -695,6 +955,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="review" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
@@ -713,24 +974,52 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
|
||||
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
|
||||
<button data-mobile-today-complete type="button" hidden>Done & next</button>
|
||||
<div class="small" data-work-session-progress aria-live="polite"></div>
|
||||
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
|
||||
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
|
||||
</section>
|
||||
<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>
|
||||
<p class="small muted">Choose what to work through next.</p>
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
||||
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span> <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
||||
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||
<button class="mobile-task-action" data-mobile-task="drafts" type="button">Drafts <span class="mobile-task-count" id="mobile-draft-count" aria-label="0 drafts">0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues: no active queues; no upcoming deadlines"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0 active</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/security-center.js"></script>
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/unfiled-captures.js"></script>
|
||||
<script src="static/shared-image-capture.js"></script>
|
||||
<script src="static/draft-filing-session.js"></script>
|
||||
<script src="static/draft-capacity-dialog.js"></script>
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
<script src="static/background-issue-sync.js"></script>
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
@@ -741,23 +1030,34 @@
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/agenda-replan.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-recap.js"></script>
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
<script src="static/update-reply-read-next.js"></script>
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-readiness.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/today-rollover.js"></script>
|
||||
<script src="static/update-ownership.js"></script>
|
||||
<script src="static/update-follow-up.js"></script>
|
||||
<script src="static/later-work.js"></script>
|
||||
<script src="static/later-sync.js"></script>
|
||||
<script src="static/later-and-start.js"></script>
|
||||
<script src="static/detail-defer.js"></script>
|
||||
<script src="static/later-picker.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
<script src="static/batch-find-work.js"></script>
|
||||
<script src="static/conversation.js"></script>
|
||||
<script src="static/comment-actions.js"></script>
|
||||
<script src="static/issue-attachment.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
@@ -769,13 +1069,21 @@
|
||||
<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-task-dock.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/update-triage-session.js"></script>
|
||||
<script src="static/update-triage-launcher.js"></script>
|
||||
<script src="static/agenda-session-launcher.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
<script src="static/mobile-composer-viewport.js"></script>
|
||||
<script src="static/mention-composer.js"></script>
|
||||
<script src="static/push-notifications.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+19
-2
@@ -3,7 +3,8 @@
|
||||
else root.createInstallApp = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
|
||||
const dismissedKey = 'stackchain.install.dismissed.v1';
|
||||
let deferredPrompt = null;
|
||||
let deferredPrompt = options.initialPrompt || null;
|
||||
let installed = false;
|
||||
|
||||
function hide() {
|
||||
options.card.hidden = true;
|
||||
@@ -25,6 +26,7 @@
|
||||
await prompt.prompt();
|
||||
const choice = await prompt.userChoice;
|
||||
if (choice.outcome === 'accepted') {
|
||||
installed = true;
|
||||
hide();
|
||||
options.status.textContent = 'Stackchain was added to your device.';
|
||||
} else {
|
||||
@@ -44,6 +46,7 @@
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
if (deferredPrompt) showNativePrompt(deferredPrompt);
|
||||
if (options.isIosSafari()) {
|
||||
options.installButton.hidden = true;
|
||||
options.guidance.hidden = false;
|
||||
@@ -51,6 +54,7 @@
|
||||
}
|
||||
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
|
||||
options.window.addEventListener('appinstalled', () => {
|
||||
installed = true;
|
||||
deferredPrompt = null;
|
||||
hide();
|
||||
});
|
||||
@@ -58,5 +62,18 @@
|
||||
options.dismissButton.addEventListener('click', dismiss);
|
||||
}
|
||||
|
||||
return {start};
|
||||
function state() {
|
||||
if (installed || options.isStandalone()) {
|
||||
return {state:'complete', detail:'Stackchain is installed on this device.'};
|
||||
}
|
||||
if (options.isIosSafari()) {
|
||||
return {state:'incomplete', detail:'On Safari, tap Share, then Add to Home Screen.'};
|
||||
}
|
||||
if (deferredPrompt) {
|
||||
return {state:'incomplete', detail:'Stackchain is ready to install.'};
|
||||
}
|
||||
return {state:'unavailable', detail:'Installation is available from a supported browser.'};
|
||||
}
|
||||
|
||||
return {start, install, state};
|
||||
});
|
||||
@@ -51,6 +51,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
|
||||
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
if (attachment) item.attachment = attachment;
|
||||
@@ -76,6 +78,9 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
|
||||
function enqueue(draft, mirror = true) {
|
||||
const items = read();
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
const existing = sourceCaptureId && items.find(item => item.sourceCaptureId === sourceCaptureId);
|
||||
if (existing) return { ...existing };
|
||||
const item = prepareItem(draft);
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
@@ -83,6 +88,16 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
|
||||
async function enqueueDurably(draft) {
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
const existing = sourceCaptureId && read().find(item => item.sourceCaptureId === sourceCaptureId);
|
||||
if (existing) {
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item: { ...existing }, background: false, durability: 'foreground-only', reused: true };
|
||||
}
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.requestSync();
|
||||
return { item: { ...existing }, background: true, durability: 'background', reused: true };
|
||||
}
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
const item = enqueue(draft, false);
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
|
||||
+17
-1
@@ -52,6 +52,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function deferMany(items, until) {
|
||||
const key = storageKey();
|
||||
const wake = new Date(until);
|
||||
const entries = (items || []).map(item => [identity(item), item]);
|
||||
if (!key) return 'unavailable';
|
||||
if (!entries.length || entries.some(([id]) => !id) || Number.isNaN(wake.getTime()) || wake <= now()) {
|
||||
return 'invalid';
|
||||
}
|
||||
const records = read();
|
||||
const wakeAt = wake.toISOString();
|
||||
entries.forEach(([id]) => { records[id] = wakeAt; });
|
||||
if (!write(records)) return 'unavailable';
|
||||
entries.forEach(([id]) => onChange('defer', id, wakeAt));
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function presetUntil(preset) {
|
||||
const current = new Date(now());
|
||||
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
|
||||
@@ -127,7 +143,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
return { active, later };
|
||||
}
|
||||
|
||||
return { identity, read, adopt, defer, restore, presetUntil, partition };
|
||||
return { identity, read, adopt, defer, deferMany, restore, presetUntil, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
|
||||
@@ -0,0 +1,78 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.liveDataStatus = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const feeds = [
|
||||
['context', 'Work'],
|
||||
['notifications', 'Updates'],
|
||||
['events', 'Activity'],
|
||||
];
|
||||
|
||||
function boundedSeconds(value) {
|
||||
const seconds = Number(value);
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
|
||||
}
|
||||
|
||||
function describe(freshness = {}) {
|
||||
const sections = freshness.sections || {};
|
||||
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
|
||||
const described = feeds.map(([key, label]) => {
|
||||
const section = sections[key] || {};
|
||||
const state = section.revalidating ? 'refreshing' :
|
||||
(section.stale || section.degraded ? 'delayed' : 'live');
|
||||
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds) };
|
||||
});
|
||||
const delayed = described.filter(feed => feed.state === 'delayed');
|
||||
const refreshing = described.filter(feed => feed.state === 'refreshing');
|
||||
let summary = hasSectionData ? 'Live' : 'Live data unavailable';
|
||||
if (delayed.length === 1) summary = delayed[0].label + ' delayed';
|
||||
else if (delayed.length > 1) summary = delayed.length + ' data feeds delayed';
|
||||
else if (refreshing.length === 1) summary = refreshing[0].label + ' refreshing';
|
||||
else if (refreshing.length > 1) summary = 'Refreshing live data';
|
||||
const retryValues = described.map(feed => {
|
||||
const section = sections[feed.key] || {};
|
||||
return boundedSeconds(section.retry_in_seconds);
|
||||
}).filter(value => value !== null && value > 0);
|
||||
const aggregateRetry = boundedSeconds(freshness.retry_in_seconds);
|
||||
if (aggregateRetry !== null && aggregateRetry > 0) retryValues.push(aggregateRetry);
|
||||
return {
|
||||
summary,
|
||||
feeds: described,
|
||||
nextRetrySeconds: retryValues.length ? Math.min(...retryValues) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function createRefreshController({ button, output, refresh, onState = () => {} }) {
|
||||
let pending = null;
|
||||
function run() {
|
||||
if (pending) return pending;
|
||||
button.disabled = true;
|
||||
output.textContent = 'Refreshing live data…';
|
||||
onState('refreshing');
|
||||
let request;
|
||||
try {
|
||||
request = refresh();
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
pending = Promise.resolve(request).then(result => {
|
||||
if (!result) throw new Error('Live data remains unavailable.');
|
||||
output.textContent = 'Live data refreshed.';
|
||||
onState('success');
|
||||
return result;
|
||||
}).catch(error => {
|
||||
output.textContent = error && error.message ? error.message : 'Live data refresh failed.';
|
||||
onState('error');
|
||||
return null;
|
||||
}).finally(() => {
|
||||
button.disabled = false;
|
||||
pending = null;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
return { run };
|
||||
}
|
||||
|
||||
return { describe, createRefreshController };
|
||||
});
|
||||
+6
-1
@@ -4,7 +4,7 @@
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
function validShareContinuation(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||||
const limits = { title: 200, text: 8000, url: 2048 };
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5 };
|
||||
const params = new URLSearchParams(value.slice(3));
|
||||
const entries = Array.from(params.entries());
|
||||
if (!entries.length) return './';
|
||||
@@ -14,6 +14,11 @@
|
||||
|| content.length > limits[name]
|
||||
));
|
||||
if (invalid) return './';
|
||||
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
|
||||
const launch = params.get('launch');
|
||||
const shared = params.get('shared');
|
||||
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
||||
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,38 @@
|
||||
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Continue work",
|
||||
"short_name": "Continue",
|
||||
"description": "Resume the highest-priority mobile work flow.",
|
||||
"url": "./?launch=continue",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
},
|
||||
{
|
||||
"name": "New issue",
|
||||
"short_name": "New",
|
||||
"description": "Capture work now and file it online or offline.",
|
||||
"url": "./?launch=new",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
},
|
||||
{
|
||||
"name": "Open Agenda",
|
||||
"short_name": "Agenda",
|
||||
"description": "Check every assigned deadline and continue the Agenda session.",
|
||||
"url": "./?launch=agenda",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "./",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"title": "title", "text": "text", "url": "url"}
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.mobileAppShortcuts = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
const ACTIONS = new Set(['continue', 'new', 'agenda']);
|
||||
|
||||
function parse(search) {
|
||||
const action = new URLSearchParams(search || '').get('launch');
|
||||
return ACTIONS.has(action) ? action : null;
|
||||
}
|
||||
|
||||
function createController(options) {
|
||||
const launchAction = parse(options.search);
|
||||
let handled = false;
|
||||
|
||||
function action() { return launchAction; }
|
||||
|
||||
function run() {
|
||||
if (!launchAction || handled) return null;
|
||||
handled = true;
|
||||
if (launchAction === 'continue') return options.continueWork();
|
||||
if (launchAction === 'new') return options.newIssue();
|
||||
return options.agenda();
|
||||
}
|
||||
|
||||
return {action, run};
|
||||
}
|
||||
|
||||
return {parse, createController};
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileDeviceSetup = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileDeviceSetup(options) {
|
||||
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
|
||||
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const steps = [
|
||||
['install', options.installButton, options.installStatus, options.install],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
||||
];
|
||||
let trigger = options.launcher;
|
||||
|
||||
function readinessCounts(readiness) {
|
||||
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
|
||||
const complete = steps.filter(([name]) => readiness[name].state === 'complete').length;
|
||||
return {available, complete};
|
||||
}
|
||||
|
||||
function renderPrompt(readiness) {
|
||||
if (!options.promptCard) return;
|
||||
const {available, complete} = readinessCounts(readiness);
|
||||
let dismissed = false;
|
||||
try {
|
||||
dismissed = Number(options.promptStorage?.getItem(promptDismissKey) || 0) > (options.now?.() ?? Date.now());
|
||||
} catch (_error) {}
|
||||
options.promptSummary.textContent = `${complete} of ${available} steps complete`;
|
||||
options.promptCard.hidden = dismissed || available === 0 || complete === available;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const readiness = await options.getReadiness();
|
||||
let available = 0;
|
||||
let complete = 0;
|
||||
steps.forEach(([name, button, status]) => {
|
||||
const step = readiness[name];
|
||||
status.textContent = step.detail;
|
||||
button.hidden = step.state === 'complete' || step.state === 'unavailable';
|
||||
button.disabled = step.state === 'pending';
|
||||
if (step.state !== 'unavailable') available += 1;
|
||||
if (step.state === 'complete') complete += 1;
|
||||
});
|
||||
options.readyStatus.textContent = complete === available
|
||||
? 'This device is ready.'
|
||||
: `${complete} of ${available} available steps ready.`;
|
||||
renderPrompt(readiness);
|
||||
return readiness;
|
||||
}
|
||||
|
||||
async function open(event) {
|
||||
trigger = event?.currentTarget || options.launcher;
|
||||
await render();
|
||||
options.sheet.hidden = false;
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
options.sheet.hidden = true;
|
||||
trigger?.focus?.();
|
||||
}
|
||||
|
||||
async function start() {
|
||||
options.launcher.addEventListener('click', open);
|
||||
options.promptLauncher?.addEventListener('click', open);
|
||||
options.promptDismiss?.addEventListener('click', () => {
|
||||
try {
|
||||
options.promptStorage?.setItem(promptDismissKey, String((options.now?.() ?? Date.now()) + promptDismissMs));
|
||||
} catch (_error) {}
|
||||
options.promptCard.hidden = true;
|
||||
});
|
||||
options.closeButton.addEventListener('click', close);
|
||||
options.sheet.addEventListener('click', event => {
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) close();
|
||||
});
|
||||
steps.forEach(([_name, button, _status, action]) => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
try { await action(); }
|
||||
finally { await render(); }
|
||||
});
|
||||
});
|
||||
renderPrompt(await options.getReadiness());
|
||||
}
|
||||
|
||||
return {start, open, close, render};
|
||||
});
|
||||
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports.mount = mountMobileDeviceSetup;
|
||||
} else {
|
||||
self.createMobileDeviceSetup.mount = mountMobileDeviceSetup;
|
||||
}
|
||||
|
||||
function mountMobileDeviceSetup(options) {
|
||||
const qs = selector => options.document.querySelector(selector);
|
||||
return createMobileDeviceSetup({
|
||||
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
deadlineButton:qs('#device-setup-deadline'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||
readyStatus:qs('#device-setup-ready-status'),
|
||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
||||
promptStorage:options.promptStorage,
|
||||
escapeTarget:options.document,
|
||||
getReadiness:() => ({
|
||||
install:options.installApp.state(),
|
||||
offline:!options.offlineAvailable()
|
||||
? {state:'unavailable', detail:'Offline saving is unavailable in this browser.'}
|
||||
: options.offlineEnabled()
|
||||
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
|
||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||
push:qs('#push-updates').disabled
|
||||
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
|
||||
: qs('#push-updates').checked
|
||||
? {state:'complete', detail:'New update notifications are enabled.'}
|
||||
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
enablePush:options.enablePush,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
}
|
||||
@@ -4,13 +4,14 @@
|
||||
else root.mobileLaunch = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const allowedFilters = new Set([
|
||||
'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft',
|
||||
'all', 'today', 'attention', 'agenda', 'issue', 'pull', 'review', 'update', 'later', 'draft',
|
||||
]);
|
||||
|
||||
function chooseFilter({ saved, today = 0, attention = 0 } = {}) {
|
||||
function chooseFilter({ saved, today = 0, attention = 0, agenda = 0 } = {}) {
|
||||
if (allowedFilters.has(saved)) return saved;
|
||||
if (today > 0) return 'today';
|
||||
if (attention > 0) return 'attention';
|
||||
if (agenda > 0) return 'agenda';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileQueueLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileQueueLauncher(options) {
|
||||
const emptyMessages = {
|
||||
attention: 'No items need attention.',
|
||||
update: 'No unread updates are ready to open.',
|
||||
later: 'No deferred work is ready to open.',
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
|
||||
function recommend() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const match = continuation.find(([name]) => Number(counts[name]) > 0);
|
||||
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
|
||||
const [name, label] = match;
|
||||
const count = Math.max(0, Number(counts[name]) || 0);
|
||||
return {name, count, label: label + ' (' + count + ')'};
|
||||
}
|
||||
|
||||
function open(name) {
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
if (!action) {
|
||||
options.announce(emptyMessages[name] || 'No work is ready to open.');
|
||||
return 'empty';
|
||||
}
|
||||
action.click();
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
function continueWork() {
|
||||
const next = recommend();
|
||||
if (next.name === 'find') {
|
||||
options.openFindWork();
|
||||
return 'find';
|
||||
}
|
||||
return open(next.name);
|
||||
}
|
||||
|
||||
return { open, recommend, continueWork };
|
||||
});
|
||||
@@ -7,60 +7,123 @@
|
||||
const overlays = options.overlays || [];
|
||||
let launcher = null;
|
||||
let wasHidden = false;
|
||||
let workMode = 'work';
|
||||
|
||||
function select(name) {
|
||||
if (!buttons[name]) return false;
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (key === name) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function refreshVisibility() {
|
||||
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
|
||||
const hidden = overlays.some(overlay => overlay.open || overlay.classList.contains('open'));
|
||||
nav.hidden = hidden;
|
||||
if (options.sessionHud) {
|
||||
if (hidden) options.sessionHud.setAttribute('data-overlay-hidden', 'true');
|
||||
else options.sessionHud.removeAttribute('data-overlay-hidden');
|
||||
}
|
||||
if (wasHidden && !hidden && launcher) launcher.focus();
|
||||
wasHidden = hidden;
|
||||
}
|
||||
|
||||
function openQueues() {
|
||||
if (!options.queueSheet || options.queueSheet.open) return;
|
||||
options.queueSheet.showModal();
|
||||
}
|
||||
|
||||
function closeQueues() {
|
||||
options.queueSheet?.open && options.queueSheet.close();
|
||||
}
|
||||
|
||||
function start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
options.actions[name]();
|
||||
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
|
||||
});
|
||||
});
|
||||
if (options.queueSheet) {
|
||||
options.queueClose?.addEventListener('click', closeQueues);
|
||||
options.queueSheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
closeQueues();
|
||||
});
|
||||
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
|
||||
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
||||
row.addEventListener('click', () => {
|
||||
closeQueues();
|
||||
options.onSelectQueue?.(name, row);
|
||||
});
|
||||
});
|
||||
}
|
||||
options.observe(refreshVisibility, overlays);
|
||||
refreshVisibility();
|
||||
}
|
||||
|
||||
function updateWork(mode, count) {
|
||||
const labels = {
|
||||
continue: ['Continue', 'Continue Today'],
|
||||
resume: ['Resume', 'Resume Today'],
|
||||
start: ['Start', 'Start Today'],
|
||||
work: ['Work', 'Work'],
|
||||
};
|
||||
workMode = labels[mode] ? mode : 'work';
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const badge = options.attentionBadge;
|
||||
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
|
||||
if (badge) {
|
||||
badge.textContent = String(total);
|
||||
badge.hidden = total === 0;
|
||||
}
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute(
|
||||
'aria-label',
|
||||
labels[workMode][1] + (total ? ', ' + total + ' items need attention' : '')
|
||||
);
|
||||
}
|
||||
function updateWork(mode) {
|
||||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Attention', 'Updates', 'Agenda', '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'));
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
updateWork(workMode, count);
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const visible = total > 0;
|
||||
if (options.attentionBadge) {
|
||||
options.attentionBadge.textContent = String(total);
|
||||
options.attentionBadge.hidden = !visible;
|
||||
}
|
||||
if (buttons.attention) {
|
||||
buttons.attention.hidden = !visible;
|
||||
buttons.attention.setAttribute('aria-label', 'Attention, ' + total + ' items');
|
||||
}
|
||||
if (visible) nav.setAttribute('data-attention', 'true');
|
||||
else nav.removeAttribute('data-attention');
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention, updateWork};
|
||||
function updateQueues(counts) {
|
||||
const names = 'today agenda attention update later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const actionableNames = ['today', 'attention', 'update', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
});
|
||||
options.queueRows?.update?.setAttribute(
|
||||
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
|
||||
);
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = active + ' active';
|
||||
options.queueBadge.hidden = active === 0;
|
||||
}
|
||||
if (options.deadlineBadge) {
|
||||
options.deadlineBadge.textContent = normalized.agenda + ' due';
|
||||
options.deadlineBadge.hidden = normalized.agenda === 0;
|
||||
}
|
||||
if (options.queueRows?.agenda) {
|
||||
if (normalized.agenda > 0) options.queueRows.agenda.setAttribute('data-deadlines', 'true');
|
||||
else options.queueRows.agenda.removeAttribute('data-deadlines');
|
||||
}
|
||||
const label = active === 0 && normalized.agenda === 0
|
||||
? 'Queues: no active queues; no upcoming deadlines'
|
||||
: 'Queues: Today ' + normalized.today
|
||||
+ ', Agenda ' + normalized.agenda + ' due'
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Later ' + normalized.later
|
||||
+ ', Drafts ' + normalized.draft
|
||||
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
|
||||
buttons.queues?.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
|
||||
});
|
||||
@@ -4,9 +4,12 @@
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
const recommended = options.queueLauncher && options.queueLauncher.recommend();
|
||||
if (recommended && recommended.name !== 'today') return recommended.name;
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
return 'work';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
return 'find';
|
||||
}
|
||||
|
||||
function open() {
|
||||
@@ -14,7 +17,9 @@
|
||||
if (current === 'continue') options.continueToday();
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else options.openFallback();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else if (current === 'find') options.findWork();
|
||||
else options.queueLauncher.open(current);
|
||||
return current;
|
||||
}
|
||||
|
||||
|
||||
+99
-21
@@ -1,15 +1,26 @@
|
||||
function issueDueState(dueDate, now) {
|
||||
if (!dueDate) return null;
|
||||
const due = new Date(dueDate);
|
||||
if (Number.isNaN(due.getTime())) return null;
|
||||
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
function calendarDay(value) {
|
||||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
|
||||
if (!match) return '';
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
return date.getFullYear() === Number(match[1]) && date.getMonth() === Number(match[2]) - 1 &&
|
||||
date.getDate() === Number(match[3]) ? match.slice(1, 4).join('-') : '';
|
||||
}
|
||||
|
||||
function localDay(value) {
|
||||
return value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(value.getDate()).padStart(2, '0');
|
||||
const dueDay = day(due);
|
||||
const today = day(now);
|
||||
}
|
||||
|
||||
function issueDueState(dueDate, now) {
|
||||
const dueDay = calendarDay(dueDate);
|
||||
if (!dueDay) return null;
|
||||
const today = localDay(now);
|
||||
if (dueDay < today) return { label: 'Overdue', priority: 2 };
|
||||
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
|
||||
return {
|
||||
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
label: 'Due ' + new Date(
|
||||
Number(dueDay.slice(0, 4)), Number(dueDay.slice(5, 7)) - 1, Number(dueDay.slice(8, 10))
|
||||
).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
priority: 4,
|
||||
};
|
||||
}
|
||||
@@ -237,7 +248,8 @@ function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
return {
|
||||
let completing = null;
|
||||
const pager = {
|
||||
reset(next) {
|
||||
pagination = { ...pagination, ...(next || {}) };
|
||||
onPagination(pagination);
|
||||
@@ -271,18 +283,31 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
loadAll(getExisting) {
|
||||
if (completing) return completing;
|
||||
completing = (async () => {
|
||||
while (pagination.has_more) {
|
||||
const loaded = await pager.loadMore(getExisting());
|
||||
if (!loaded) return false;
|
||||
}
|
||||
return true;
|
||||
})().finally(() => { completing = null; });
|
||||
return completing;
|
||||
},
|
||||
};
|
||||
return pager;
|
||||
}
|
||||
|
||||
function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
let pagination = {};
|
||||
const pending = new Set();
|
||||
const completing = new Map();
|
||||
const labels = {
|
||||
issue: 'issues',
|
||||
pull: 'pull requests',
|
||||
review: 'review requests',
|
||||
};
|
||||
return {
|
||||
const pager = {
|
||||
reset(next) {
|
||||
Object.entries(next || {}).forEach(([stream, value]) => {
|
||||
const current = pagination[stream];
|
||||
@@ -331,7 +356,20 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
pending.delete(stream);
|
||||
}
|
||||
},
|
||||
loadAll(stream, getExisting) {
|
||||
if (completing.has(stream)) return completing.get(stream);
|
||||
const completion = (async () => {
|
||||
while (pagination[stream]?.has_more) {
|
||||
const loaded = await pager.loadMore(stream, getExisting());
|
||||
if (!loaded) return false;
|
||||
}
|
||||
return true;
|
||||
})().finally(() => completing.delete(stream));
|
||||
completing.set(stream, completion);
|
||||
return completion;
|
||||
},
|
||||
};
|
||||
return pager;
|
||||
}
|
||||
|
||||
function createNotificationReader({
|
||||
@@ -362,7 +400,7 @@ function createNotificationReader({
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
return { items: updated, item: current, next: next || null };
|
||||
}
|
||||
|
||||
async function open(item, savedDetail = null) {
|
||||
@@ -395,13 +433,17 @@ function createNotificationReader({
|
||||
|
||||
return {
|
||||
open,
|
||||
commentPager() {
|
||||
return conversationPager;
|
||||
},
|
||||
appendReply(comment) {
|
||||
if (!conversationPager) return false;
|
||||
onConversation(conversationPager.append(comment));
|
||||
return true;
|
||||
},
|
||||
acceptReadAndNext(items, item = selected) {
|
||||
if (!item || selected !== item) return false;
|
||||
if (!item || (selected && selected !== item)) return false;
|
||||
selected = item;
|
||||
return advanceAfterRead(items, item);
|
||||
},
|
||||
async loadOlder() {
|
||||
@@ -549,6 +591,29 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
);
|
||||
}
|
||||
|
||||
function agendaMyWork(items, now = new Date()) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const today = localDay(start);
|
||||
const tomorrowDate = new Date(start); tomorrowDate.setDate(tomorrowDate.getDate() + 1);
|
||||
const tomorrow = localDay(tomorrowDate);
|
||||
const afterTomorrowDate = new Date(start); afterTomorrowDate.setDate(afterTomorrowDate.getDate() + 2);
|
||||
const afterTomorrow = localDay(afterTomorrowDate);
|
||||
const horizonDate = new Date(start); horizonDate.setDate(horizonDate.getDate() + 7);
|
||||
const horizon = localDay(horizonDate);
|
||||
return (items || []).flatMap(item => {
|
||||
if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return [];
|
||||
const due = calendarDay(item.due_date);
|
||||
if (!due || due >= horizon) return [];
|
||||
const group = due < today ? 'Overdue' : due < tomorrow ? 'Today' :
|
||||
due < afterTomorrow ? 'Tomorrow' : 'Next 7 days';
|
||||
return [{ ...item, agenda_group: group, _agenda_due: due }];
|
||||
}).sort((left, right) =>
|
||||
left._agenda_due.localeCompare(right._agenda_due) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
).map(({ _agenda_due, ...item }) => item);
|
||||
}
|
||||
|
||||
function milestoneLanes(items) {
|
||||
const lanes = new Map();
|
||||
(items || []).forEach(item => {
|
||||
@@ -623,13 +688,14 @@ function createWorkSessionCheckpoint({
|
||||
|
||||
function createWorkSession({
|
||||
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
|
||||
checkpointEnabled = () => true,
|
||||
checkpointEnabled = () => true, checkpointedEnabled = checkpointEnabled,
|
||||
onOpen, onProgress, onFinish,
|
||||
}) {
|
||||
let currentIdentity = '';
|
||||
let currentIndex = -1;
|
||||
let running = false;
|
||||
let durable = false;
|
||||
const activeCheckpoint = () => typeof checkpoint === 'function' ? checkpoint() : checkpoint;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
@@ -644,7 +710,7 @@ function createWorkSession({
|
||||
durable = false;
|
||||
currentIdentity = '';
|
||||
currentIndex = -1;
|
||||
if (clearCheckpoint) checkpoint?.clear();
|
||||
if (clearCheckpoint) activeCheckpoint()?.clear();
|
||||
onFinish();
|
||||
return false;
|
||||
};
|
||||
@@ -652,7 +718,7 @@ function createWorkSession({
|
||||
if (!items.length || index < 0 || index >= items.length) return finish();
|
||||
currentIndex = index;
|
||||
currentIdentity = workIdentity(items[index]);
|
||||
if (durable) checkpoint?.save(currentIdentity, currentIndex);
|
||||
if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
@@ -660,9 +726,9 @@ function createWorkSession({
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
|
||||
checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(checkpoint?.read()),
|
||||
resumable: () => Boolean(activeCheckpoint()?.read()),
|
||||
reopen(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
@@ -671,13 +737,13 @@ function createWorkSession({
|
||||
if (index < 0) return false;
|
||||
currentIdentity = requestedIdentity;
|
||||
currentIndex = index;
|
||||
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
|
||||
if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
},
|
||||
resume(requested = null) {
|
||||
const saved = checkpoint?.read();
|
||||
const saved = activeCheckpoint()?.read();
|
||||
if (!saved) return false;
|
||||
durable = true;
|
||||
const items = queue();
|
||||
@@ -691,7 +757,7 @@ function createWorkSession({
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
durable = Boolean(checkpoint && checkpointEnabled());
|
||||
durable = Boolean(activeCheckpoint() && checkpointEnabled());
|
||||
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
|
||||
return openAt(items, requested >= 0 ? requested : 0);
|
||||
},
|
||||
@@ -739,7 +805,7 @@ function createWorkSession({
|
||||
if (!items.length) return null;
|
||||
if (action === 'start') return items[0];
|
||||
if (action === 'resume') {
|
||||
const saved = checkpoint?.read();
|
||||
const saved = activeCheckpoint()?.read();
|
||||
if (!saved) return null;
|
||||
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
|
||||
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
|
||||
@@ -810,6 +876,16 @@ function summarizeMyWork(items) {
|
||||
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
|
||||
}
|
||||
|
||||
function findQueueItems(items, query) {
|
||||
const needle = String(query || '').trim().toLowerCase();
|
||||
if (!needle) return (items || []).slice();
|
||||
return (items || []).filter(item => {
|
||||
const number = Number.isInteger(item?.number) ? '#' + item.number : '';
|
||||
return [item?.repository, item?.key, number, item?.title]
|
||||
.some(value => String(value || '').toLowerCase().includes(needle));
|
||||
});
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
@@ -823,6 +899,7 @@ function countMyWork(items) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.agendaMyWork = agendaMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
|
||||
@@ -833,6 +910,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.findQueueItems = findQueueItems;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
function createNotificationUndo({ restore, onItems, onStatus, onOffer = () => {}, onClear = () => {} }) {
|
||||
let offered = null;
|
||||
let pending = false;
|
||||
|
||||
return {
|
||||
offer(item, items) {
|
||||
if (!item || !Number.isInteger(item.notification_id)) return false;
|
||||
offered = { item, items: Array.isArray(items) ? [...items] : [] };
|
||||
const label = item.key || 'Update';
|
||||
onStatus(label + ' marked read. Undo?');
|
||||
onOffer(item);
|
||||
return true;
|
||||
},
|
||||
async run() {
|
||||
if (!offered || pending) return false;
|
||||
const current = offered;
|
||||
const label = current.item.key || 'Update';
|
||||
pending = true;
|
||||
onStatus('Restoring ' + label + '…');
|
||||
try {
|
||||
await restore(current.item.notification_id);
|
||||
if (offered !== current) return false;
|
||||
const restored = [current.item].concat(current.items.filter(
|
||||
item => item?.notification_id !== current.item.notification_id
|
||||
));
|
||||
offered = null;
|
||||
onItems(restored, current.item);
|
||||
onStatus(label + ' is unread again.');
|
||||
onClear();
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not restore ' + label + '. Retry Undo.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
offered = null;
|
||||
onClear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function requestNotificationUnread(notificationId) {
|
||||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/unread', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Restore unread failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
function createDashboardNotificationUndo({ restore, getItems, setItems, getNotifications, setNotifications, refresh, select }) {
|
||||
let timer = null;
|
||||
const controller = createNotificationUndo({
|
||||
restore,
|
||||
onItems: (items, restored) => {
|
||||
setItems(items);
|
||||
const notifications = getNotifications();
|
||||
if (!notifications.some(item => item.id === restored.notification_id)) {
|
||||
setNotifications([{ id:restored.notification_id }].concat(notifications));
|
||||
}
|
||||
refresh();
|
||||
},
|
||||
onStatus: message => {
|
||||
select('#notification-undo-status').textContent = message;
|
||||
select('#my-work-action-status').textContent = message;
|
||||
},
|
||||
onOffer: item => {
|
||||
clearTimeout(timer);
|
||||
select('#notification-undo').hidden = false;
|
||||
select('#undo-notification').disabled = false;
|
||||
select('#undo-notification').setAttribute('aria-label', 'Undo marking ' + (item.key || 'update') + ' read');
|
||||
timer = setTimeout(() => controller.clear(), 10000);
|
||||
},
|
||||
onClear: () => { select('#notification-undo').hidden = true; },
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createNotificationUndo;
|
||||
module.exports.createDashboardNotificationUndo = createDashboardNotificationUndo;
|
||||
}
|
||||
+115
-6
@@ -1,14 +1,26 @@
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
|
||||
let available = [];
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let loadRequest = null;
|
||||
let loadGeneration = 0;
|
||||
let query = '';
|
||||
let filters = { repositories: [], labels: [] };
|
||||
let facetView;
|
||||
let claimRequest = null;
|
||||
const previewed = new Set();
|
||||
let selecting = false;
|
||||
const selected = new Map();
|
||||
|
||||
function itemKey(item) {
|
||||
return String(item?.repository || '') + '#' + String(item?.number || '');
|
||||
}
|
||||
|
||||
function emitSelection() {
|
||||
const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
onSelection(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function apply(result, append) {
|
||||
const incoming = Array.isArray(result?.items) ? result.items : [];
|
||||
if (append) {
|
||||
@@ -31,13 +43,31 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
if (result?.facets) onFacets({
|
||||
repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
|
||||
labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
|
||||
});
|
||||
if (result?.facets && typeof document !== 'undefined') {
|
||||
facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
|
||||
String(value).replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character])),
|
||||
next => { filters = next; loadPage(1, false); });
|
||||
facetView(result.facets);
|
||||
}
|
||||
}
|
||||
|
||||
function loadPage(page, append) {
|
||||
if (loadRequest) return loadRequest;
|
||||
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
|
||||
function loadPage(page, append, requestedQuery = query) {
|
||||
if (loadRequest && requestedQuery === query && append) return loadRequest;
|
||||
const generation = ++loadGeneration;
|
||||
const params = new URLSearchParams();
|
||||
params.set('facets', 'true');
|
||||
if (requestedQuery) params.set('q', requestedQuery);
|
||||
filters.repositories.forEach(value => params.append('repository', value));
|
||||
filters.labels.forEach(value => params.append('label', value));
|
||||
const queryPart = params.toString() ? '&' + params.toString() : '';
|
||||
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (generation !== loadGeneration) return result;
|
||||
apply(result, append);
|
||||
if (result?.refresh_failed === true) {
|
||||
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
|
||||
@@ -45,8 +75,9 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
onStatus('Showing saved available work while the catalog refreshes…');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { loadRequest = null; });
|
||||
return loadRequest;
|
||||
}).finally(() => { if (loadRequest === request) loadRequest = null; });
|
||||
loadRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -60,9 +91,66 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
if (!pagination.has_more) return Promise.resolve(false);
|
||||
return loadPage(pagination.page + 1, true);
|
||||
},
|
||||
search(value) {
|
||||
query = String(value || '').trim();
|
||||
return loadPage(1, false, query);
|
||||
},
|
||||
query() {
|
||||
return query;
|
||||
},
|
||||
setFilters(next) {
|
||||
filters = {
|
||||
repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
|
||||
labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
|
||||
};
|
||||
return loadPage(1, false);
|
||||
},
|
||||
filters() {
|
||||
return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
|
||||
},
|
||||
|
||||
items() {
|
||||
return available.slice();
|
||||
},
|
||||
startSelection() {
|
||||
selecting = true;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
cancelSelection() {
|
||||
selecting = false;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
toggleSelection(item) {
|
||||
if (!selecting || !item) return emitSelection();
|
||||
const key = itemKey(item);
|
||||
if (selected.has(key)) selected.delete(key);
|
||||
else selected.set(key, item);
|
||||
return emitSelection();
|
||||
},
|
||||
fillSelection(limit) {
|
||||
limit = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
if (!limit) return { selected: selected.size, added: 0, limit };
|
||||
selecting = true;
|
||||
const before = selected.size;
|
||||
available.some(item => {
|
||||
if (selected.size >= limit) return true;
|
||||
selected.set(itemKey(item), item);
|
||||
return false;
|
||||
});
|
||||
emitSelection();
|
||||
return { selected: selected.size, added: selected.size - before, limit };
|
||||
},
|
||||
isSelected(item) {
|
||||
return selected.has(itemKey(item));
|
||||
},
|
||||
selectedItems() {
|
||||
return Array.from(selected.values());
|
||||
},
|
||||
selection() {
|
||||
return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
},
|
||||
togglePreview(item) {
|
||||
const key = itemKey(item);
|
||||
if (previewed.has(key)) previewed.delete(key);
|
||||
@@ -91,6 +179,8 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
pagination.total = Math.max(available.length, pagination.total - 1);
|
||||
pagination.has_more = available.length < pagination.total;
|
||||
previewed.delete(itemKey(item));
|
||||
selected.delete(itemKey(item));
|
||||
emitSelection();
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
onStatus('Assigned ' + key + ' to you.');
|
||||
@@ -101,4 +191,23 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
}
|
||||
|
||||
function attachFindWorkFacets(root, escapeHtml, onChange) {
|
||||
const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
|
||||
root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
|
||||
root.querySelector('#clear-find-work-filters').onclick = () => {
|
||||
root.querySelectorAll('input').forEach(input => { input.checked = false; });
|
||||
onChange({ repositories:[], labels:[] });
|
||||
};
|
||||
return facets => {
|
||||
const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
|
||||
const render = (id, choices, selected, name) => {
|
||||
root.querySelector(id).innerHTML = choices.map(value => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').join('');
|
||||
};
|
||||
render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
|
||||
render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
|
||||
const count = active.repositories.length + active.labels.length;
|
||||
root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
|
||||
@@ -0,0 +1,41 @@
|
||||
function createPlanTodayReadiness({ identity, inspect, concurrency = 3 }) {
|
||||
const workerCount = Math.max(1, Math.floor(Number(concurrency) || 1));
|
||||
let generation = 0;
|
||||
|
||||
async function run(items) {
|
||||
const runGeneration = ++generation;
|
||||
const candidates = Array.from(items || []);
|
||||
const results = new Array(candidates.length);
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < candidates.length) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
const item = candidates[index];
|
||||
let result;
|
||||
try {
|
||||
result = await inspect(item);
|
||||
} catch (_error) {
|
||||
result = { status:'unverified' };
|
||||
}
|
||||
results[index] = [identity(item), result];
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from(
|
||||
{ length:Math.min(workerCount, candidates.length) },
|
||||
() => worker(),
|
||||
));
|
||||
if (runGeneration !== generation) return null;
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
generation += 1;
|
||||
}
|
||||
|
||||
return { run, cancel };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanTodayReadiness;
|
||||
+72
-5
@@ -4,7 +4,11 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
let itemsById = new Map();
|
||||
let capacityMinutes = null;
|
||||
let estimates = {};
|
||||
let recommendations = {};
|
||||
let recommendationAware = false;
|
||||
let capacityAware = false;
|
||||
let buildState = null;
|
||||
let buildReadiness = null;
|
||||
|
||||
function cleanItems(items) {
|
||||
const unique = new Map();
|
||||
@@ -15,7 +19,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return unique;
|
||||
}
|
||||
|
||||
function open(selectedItems, candidates, planning = null) {
|
||||
function open(selectedItems, candidates, planning = null, actualMinutes = null) {
|
||||
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
|
||||
draftIds = [];
|
||||
for (const item of selectedItems || []) {
|
||||
@@ -29,6 +33,15 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
|
||||
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
|
||||
}
|
||||
recommendations = {};
|
||||
recommendationAware = actualMinutes !== null;
|
||||
buildState = null;
|
||||
buildReadiness = null;
|
||||
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
|
||||
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
|
||||
estimates[id] !== minutes) recommendations[id] = minutes;
|
||||
}
|
||||
if (Object.keys(recommendations).length) capacityAware = true;
|
||||
openState = true;
|
||||
return snapshot();
|
||||
}
|
||||
@@ -62,7 +75,11 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
itemsById = new Map();
|
||||
capacityMinutes = null;
|
||||
estimates = {};
|
||||
recommendations = {};
|
||||
recommendationAware = false;
|
||||
capacityAware = false;
|
||||
buildState = null;
|
||||
buildReadiness = null;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
@@ -77,13 +94,59 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
}
|
||||
|
||||
function setEstimate(id, minutes) {
|
||||
if (!openState || !draftIds.includes(id)) return false;
|
||||
const resolvingBuildEstimate = buildState?.needs_estimate?.includes(id);
|
||||
if (!openState || (!draftIds.includes(id) && !resolvingBuildEstimate)) return false;
|
||||
capacityAware = true;
|
||||
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
|
||||
else delete estimates[id];
|
||||
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) return false;
|
||||
estimates[id] = minutes;
|
||||
if (resolvingBuildEstimate && buildReadiness) buildRecommendation(buildReadiness);
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyRecommendation(id) {
|
||||
const minutes = recommendations[id];
|
||||
if (!openState || !draftIds.includes(id) || !Number.isInteger(minutes)) return false;
|
||||
estimates[id] = minutes;
|
||||
delete recommendations[id];
|
||||
capacityAware = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecommendation(readiness = {}) {
|
||||
if (!openState) return null;
|
||||
buildReadiness = { ...readiness };
|
||||
const selected = [];
|
||||
const reasons = {};
|
||||
const skipped = [];
|
||||
const needsEstimate = [];
|
||||
let used = 0;
|
||||
for (const [id, item] of itemsById) {
|
||||
if (selected.length >= limit) break;
|
||||
const check = readiness[id] || { status:'unverified' };
|
||||
if (check.status !== 'ready') {
|
||||
skipped.push({
|
||||
id,
|
||||
status:check.status === 'blocked' ? 'blocked' : 'unverified',
|
||||
reason:check.reason || (check.status === 'blocked' ? 'Blocked' : 'Could not verify readiness'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const minutes = estimates[id];
|
||||
if (!Number.isInteger(minutes)) {
|
||||
needsEstimate.push(id);
|
||||
continue;
|
||||
}
|
||||
if (capacityMinutes !== null && used + minutes > capacityMinutes) continue;
|
||||
selected.push(id);
|
||||
reasons[id] = item.reason || item.attention_reason || 'Ranked My Work';
|
||||
used += minutes;
|
||||
}
|
||||
draftIds = selected;
|
||||
capacityAware = true;
|
||||
buildState = { selected:[...selected], reasons, skipped, needs_estimate:needsEstimate };
|
||||
return buildState;
|
||||
}
|
||||
|
||||
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
|
||||
if (!openState) return 'closed';
|
||||
const ids = [...draftIds];
|
||||
@@ -111,10 +174,14 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
...basic,
|
||||
capacity_minutes: capacityMinutes,
|
||||
estimates: selectedEstimates,
|
||||
...(recommendationAware ? { recommendations:Object.fromEntries(
|
||||
draftIds.filter(id => recommendations[id]).map(id => [id, recommendations[id]])
|
||||
) } : {}),
|
||||
planned_minutes: plannedMinutes,
|
||||
remaining_minutes: remainingMinutes,
|
||||
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
|
||||
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
|
||||
...(buildState ? { build:buildState } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,7 +193,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
|
||||
}
|
||||
|
||||
return { open, toggle, move, cancel, setCapacity, setEstimate, commit, snapshot, item, candidates };
|
||||
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, buildRecommendation, commit, snapshot, item, candidates };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
|
||||
@@ -54,6 +54,7 @@
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
function createProtectToday() {
|
||||
function propose({ agenda = [], today = [], identity, limit = 5 }) {
|
||||
const key = item => String(identity?.(item) || '');
|
||||
const urgent = [];
|
||||
const seen = new Set();
|
||||
for (const item of [...agenda, ...today]) {
|
||||
const id = key(item);
|
||||
const eligible = item?.kind === 'issue' && item.is_assigned === true && item.state !== 'closed' &&
|
||||
['Overdue', 'Today'].includes(item.agenda_group);
|
||||
if (!eligible || !id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
urgent.push(item);
|
||||
}
|
||||
urgent.sort((left, right) =>
|
||||
String(left.due_date || '').localeCompare(String(right.due_date || '')) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
);
|
||||
if (!urgent.length) return {
|
||||
selected:[], protected:[], displaced:[],
|
||||
summary:'No overdue or due-today work needs protection.',
|
||||
};
|
||||
const existing = [];
|
||||
for (const item of today) {
|
||||
const id = key(item);
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
existing.push(item);
|
||||
}
|
||||
const combined = [...urgent, ...existing];
|
||||
const selected = combined.slice(0, limit);
|
||||
const selectedIds = new Set(selected.map(key));
|
||||
const displaced = today.filter(item => !selectedIds.has(key(item)) && !urgent.some(candidate => key(candidate) === key(item)));
|
||||
return {
|
||||
selected,
|
||||
protected:urgent,
|
||||
displaced,
|
||||
summary:urgent.length + ' urgent ' + (urgent.length === 1 ? 'item' : 'items') + ' protected · ' +
|
||||
displaced.length + (displaced.length ? ' displaced for review' : ' displaced'),
|
||||
};
|
||||
}
|
||||
return { propose };
|
||||
}
|
||||
|
||||
const protectToday = createProtectToday();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = protectToday;
|
||||
@@ -41,10 +41,137 @@ function renderFile(file, index, reviewed, escapeHtml) {
|
||||
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
|
||||
}
|
||||
|
||||
function focusNextUnreviewed(doc, detail, state, controller) {
|
||||
if (!detail || !state) return;
|
||||
const filename = controller.nextUnreviewed(detail, state);
|
||||
const article = Array.from(doc.querySelectorAll('#pull-files .pull-file'))
|
||||
.find(file => file.dataset.pullFilename === filename);
|
||||
const toggle = article?.querySelector('.pull-file-toggle');
|
||||
const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls'));
|
||||
if (!toggle || !panel) return;
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
panel.hidden = false;
|
||||
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
toggle.focus();
|
||||
}
|
||||
|
||||
function removeFromSnapshot(data, item) {
|
||||
return { ...data, pulls:(data.pulls || []).filter(candidate =>
|
||||
candidate.repository !== item.repository || candidate.number !== item.number) };
|
||||
}
|
||||
|
||||
function sameTarget(left, right) {
|
||||
return Boolean(left && right && left.repository === right.repository && Number(left.number) === Number(right.number));
|
||||
}
|
||||
|
||||
function ownershipSelectors() {
|
||||
return ['#release-pull', '#load-pull-handoff', '#pull-handoff-recipient', '#confirm-pull-handoff'];
|
||||
}
|
||||
|
||||
function renderHandoffCandidates(select, candidates, doc) {
|
||||
select.textContent = '';
|
||||
const placeholder = doc.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
|
||||
select.appendChild(placeholder);
|
||||
candidates.forEach(candidate => {
|
||||
const option = doc.createElement('option');
|
||||
option.value = candidate.login;
|
||||
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
|
||||
select.appendChild(option);
|
||||
});
|
||||
return candidates.length;
|
||||
}
|
||||
|
||||
function ownershipExitMessage(item, action, transitionResult) {
|
||||
return item.key + ' ' + action + (transitionResult === 'opened' ?
|
||||
'. Next work item opened.' : '. Choose the next ready Today item.');
|
||||
}
|
||||
|
||||
function resetOwnershipControls(doc, item, checkpointed) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
qs('#pull-ownership').open = false;
|
||||
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
||||
qs('#pull-handoff-recipient').disabled = true;
|
||||
qs('#confirm-pull-handoff').disabled = true;
|
||||
qs('#confirm-pull-handoff').textContent = checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
|
||||
qs('#load-pull-handoff').disabled = false;
|
||||
qs('#release-pull').disabled = false;
|
||||
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
|
||||
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
|
||||
}
|
||||
|
||||
function bindOwnershipControls(doc, controller, getSelected, finish) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-handoff');
|
||||
if (load.dataset.ownershipBound === 'true') return;
|
||||
load.dataset.ownershipBound = 'true';
|
||||
load.addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
if (!selected) return;
|
||||
const select = qs('#pull-handoff-recipient');
|
||||
load.disabled = true;
|
||||
qs('#pull-handoff-status').textContent = 'Loading eligible teammates…';
|
||||
try {
|
||||
const count = renderHandoffCandidates(select, await controller.loadHandoffCandidates(selected), doc);
|
||||
select.disabled = !count;
|
||||
qs('#confirm-pull-handoff').disabled = true;
|
||||
qs('#pull-handoff-status').textContent = count ?
|
||||
'Choose who should own this pull request next.' : 'No other eligible assignees were found.';
|
||||
if (count) select.focus();
|
||||
} catch (error) {
|
||||
qs('#pull-handoff-status').textContent = error.message + ' Retry loading teammates.';
|
||||
load.disabled = false;
|
||||
load.focus();
|
||||
}
|
||||
});
|
||||
qs('#pull-handoff-recipient').addEventListener('change', event => {
|
||||
qs('#confirm-pull-handoff').disabled = !event.target.value;
|
||||
});
|
||||
qs('#confirm-pull-handoff').addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
const recipient = qs('#pull-handoff-recipient').value;
|
||||
if (!selected || !recipient || !globalThis.confirm('Hand off ' + selected.key + ' to @' + recipient + '?')) return;
|
||||
const button = qs('#confirm-pull-handoff');
|
||||
button.disabled = true;
|
||||
qs('#pull-handoff-status').textContent = 'Confirming handoff…';
|
||||
try {
|
||||
await controller.handoff(selected, recipient);
|
||||
const transition = await finish(selected);
|
||||
qs('#my-work-action-status').textContent = transition === false ?
|
||||
selected.key + ' handed off to @' + recipient + '.' :
|
||||
ownershipExitMessage(selected, 'handed off to @' + recipient, transition);
|
||||
} catch (error) {
|
||||
qs('#pull-handoff-status').textContent = error.message + ' The pull request remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#release-pull').addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
if (!selected || !globalThis.confirm('Release ' + selected.key + ' from your My Work?')) return;
|
||||
const button = qs('#release-pull');
|
||||
button.disabled = true;
|
||||
qs('#pull-sheet-status').textContent = 'Releasing assignment…';
|
||||
try {
|
||||
await controller.release(selected);
|
||||
const transition = await finish(selected);
|
||||
qs('#my-work-action-status').textContent = transition === false ?
|
||||
selected.key + ' released.' : ownershipExitMessage(selected, 'released', transition);
|
||||
} catch (error) {
|
||||
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let mergeRequest = null;
|
||||
let checkRequest = null;
|
||||
let ownershipRequest = null;
|
||||
let candidateRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
const reviewCache = new Map();
|
||||
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
||||
@@ -90,6 +217,29 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}).finally(() => { checkRequest = null; });
|
||||
return checkRequest;
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
if (candidateRequest) return candidateRequest;
|
||||
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { candidateRequest = null; });
|
||||
return candidateRequest;
|
||||
},
|
||||
handoff(item, recipient) {
|
||||
if (ownershipRequest) return ownershipRequest;
|
||||
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient }),
|
||||
}).finally(() => { ownershipRequest = null; });
|
||||
return ownershipRequest;
|
||||
},
|
||||
release(item) {
|
||||
if (ownershipRequest) return ownershipRequest;
|
||||
ownershipRequest = fetchJson(pathFor(item) + '/release', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
}).finally(() => { ownershipRequest = null; });
|
||||
return ownershipRequest;
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
@@ -165,4 +315,12 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.renderFile = renderFile;
|
||||
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
|
||||
createPullSheet.removeFromSnapshot = removeFromSnapshot;
|
||||
createPullSheet.sameTarget = sameTarget;
|
||||
createPullSheet.ownershipSelectors = ownershipSelectors;
|
||||
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
|
||||
createPullSheet.ownershipExitMessage = ownershipExitMessage;
|
||||
createPullSheet.resetOwnershipControls = resetOwnershipControls;
|
||||
createPullSheet.bindOwnershipControls = bindOwnershipControls;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
@@ -0,0 +1,159 @@
|
||||
(function(root, factory) {
|
||||
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,
|
||||
}) {
|
||||
let configuration = null;
|
||||
|
||||
function formattedHour(value) {
|
||||
return `${String(Number(value)).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function enabledDeadlineText(hour, days) {
|
||||
const value = Number(days ?? 2);
|
||||
const horizon = value === 0 ? 'due today' : `next ${value} days`;
|
||||
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
|
||||
}
|
||||
|
||||
function deadlineReadiness() {
|
||||
if (!configuration?.available || deadlineControl?.disabled) {
|
||||
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
||||
}
|
||||
return configuration.deadline_enabled
|
||||
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
|
||||
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
|
||||
}
|
||||
|
||||
function applicationServerKey(value) {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
||||
if (typeof atob === 'function') {
|
||||
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
|
||||
}
|
||||
return Uint8Array.from(Buffer.from(padded, 'base64'));
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
const registration = await serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||
await subscription?.unsubscribe?.();
|
||||
control.checked = false;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
async function ensureSubscription() {
|
||||
const permission = await notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
control.checked = false;
|
||||
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
|
||||
return null;
|
||||
}
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey(configuration.public_key),
|
||||
});
|
||||
}
|
||||
await fetchJson('api/v1/push-subscription', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(subscription.toJSON()),
|
||||
});
|
||||
control.checked = true;
|
||||
status.textContent = 'New update notifications enabled for this device.';
|
||||
configuration.subscribed = true;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
await ensureSubscription();
|
||||
}
|
||||
|
||||
async function change() {
|
||||
control.disabled = true;
|
||||
try {
|
||||
if (control.checked) await enable();
|
||||
else await disable();
|
||||
} catch (error) {
|
||||
control.checked = !control.checked;
|
||||
status.textContent = 'Could not change update notifications. Check your connection and try again.';
|
||||
} finally {
|
||||
control.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeDeadline() {
|
||||
deadlineControl.disabled = true;
|
||||
if (deadlineHour) deadlineHour.disabled = true;
|
||||
if (deadlineDays) deadlineDays.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (deadlineControl.checked && !subscription) {
|
||||
deadlineControl.checked = false;
|
||||
deadlineStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
|
||||
const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2);
|
||||
await fetchJson('api/v1/push-subscription/deadlines', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}),
|
||||
});
|
||||
configuration.deadline_enabled = deadlineControl.checked;
|
||||
configuration.reminder_hour = reminderHour;
|
||||
configuration.reminder_days = reminderDays;
|
||||
configuration.timezone = timezone;
|
||||
deadlineStatus.textContent = deadlineControl.checked
|
||||
? enabledDeadlineText(reminderHour, reminderDays)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
return true;
|
||||
} catch (error) {
|
||||
deadlineControl.checked = !deadlineControl.checked;
|
||||
deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
deadlineControl.disabled = false;
|
||||
if (deadlineHour) deadlineHour.disabled = false;
|
||||
if (deadlineDays) deadlineDays.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDeadline() {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
status.textContent = configuration.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
|
||||
});
|
||||
+19
-12
@@ -3,10 +3,23 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||||
if (root) root.createSearchPreview = createSearchPreview;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, claim, onState }) {
|
||||
return function createSearchPreview({ fetchJson, mutate, onState }) {
|
||||
let generation = 0;
|
||||
let current = null;
|
||||
let claimRequest = null;
|
||||
let mutationRequest = null;
|
||||
|
||||
function run(action, pending, success, detail) {
|
||||
if (mutationRequest) return mutationRequest;
|
||||
onState({ status: pending, item: current, detail });
|
||||
mutationRequest = mutate(detail, action).then(result => {
|
||||
onState({ status: success, item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { mutationRequest = null; });
|
||||
return mutationRequest;
|
||||
}
|
||||
|
||||
return {
|
||||
open(item) {
|
||||
@@ -32,16 +45,10 @@
|
||||
onState({ status: 'closed' });
|
||||
},
|
||||
claim(detail) {
|
||||
if (claimRequest) return claimRequest;
|
||||
onState({ status: 'claiming', item: current, detail });
|
||||
claimRequest = claim(detail).then(result => {
|
||||
onState({ status: 'claimed', item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { claimRequest = null; });
|
||||
return claimRequest;
|
||||
return run('claim', 'claiming', 'claimed', detail);
|
||||
},
|
||||
reopen(detail) {
|
||||
return run('reopen', 'reopening', 'reopened', detail);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
(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 }) {
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
|
||||
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
|
||||
const enrollPasskey = root.document.getElementById('enroll-passkey');
|
||||
const activityList = root.document.getElementById('security-activity-list');
|
||||
const activityStatus = root.document.getElementById('security-activity-status');
|
||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||||
let activityCursor = null;
|
||||
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
try {
|
||||
const devices = await boundary.listActiveDevices();
|
||||
devices.forEach(device => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'active-device';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = device.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||||
details.append(label, timing);
|
||||
if (device.current) {
|
||||
const current = root.document.createElement('span');
|
||||
current.className = 'active-device-current';
|
||||
current.textContent = 'This device';
|
||||
details.append(current);
|
||||
}
|
||||
const revoke = root.document.createElement('button');
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
});
|
||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
|
||||
const renderPasskeys = async () => {
|
||||
if (!passkeysList || !passkeysStatus) return;
|
||||
passkeysStatus.textContent = 'Loading enrolled passkeys…';
|
||||
passkeysList.replaceChildren();
|
||||
try {
|
||||
const enrolled = await boundary.listPasskeys();
|
||||
enrolled.forEach(passkey => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'enrolled-passkey';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = passkey.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
|
||||
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
|
||||
details.append(label, timing);
|
||||
const remove = root.document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Remove passkey';
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
|
||||
try {
|
||||
const outcome = await boundary.revokePasskey(passkey);
|
||||
if (outcome) {
|
||||
passkeysStatus.textContent = outcome.current_session
|
||||
? 'Passkey removed. This session remains active; keep your recovery token available.'
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
|
||||
remove.disabled = false;
|
||||
}
|
||||
});
|
||||
row.append(details, remove);
|
||||
passkeysList.append(row);
|
||||
});
|
||||
passkeysStatus.textContent = enrolled.length
|
||||
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
|
||||
: 'No passkeys enrolled.';
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
|
||||
const renderSecurityActivity = async (append = false) => {
|
||||
if (!activityList || !activityStatus || !loadMoreActivity) return;
|
||||
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
|
||||
loadMoreActivity.hidden = true;
|
||||
if (!append) {
|
||||
activityCursor = null;
|
||||
activityList.replaceChildren();
|
||||
}
|
||||
try {
|
||||
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
||||
if (!append) {
|
||||
page.authentication_alerts.forEach(alert => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event authentication-alert';
|
||||
const formatted = boundary.formatAuthenticationAlert(alert);
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = formatted.title;
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled',
|
||||
passkey_counter_anomaly: 'Passkey counter anomaly',
|
||||
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
};
|
||||
page.events.forEach(event => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event';
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = labels[event.kind] || 'Security event';
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
const context = [event.device_label, event.method, event.target,
|
||||
event.kind === 'passkey_counter_anomaly'
|
||||
? 'Remove and re-enroll this passkey if the alert repeats'
|
||||
: null,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null]
|
||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
activityCursor = page.next_cursor;
|
||||
activityStatus.textContent = activityList.children.length
|
||||
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
|
||||
: 'No security activity yet.';
|
||||
loadMoreActivity.textContent = 'Load older activity';
|
||||
loadMoreActivity.hidden = !activityCursor;
|
||||
} catch (_error) {
|
||||
activityStatus.textContent = 'Security activity could not be loaded.';
|
||||
loadMoreActivity.textContent = 'Retry activity';
|
||||
loadMoreActivity.hidden = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
|
||||
};
|
||||
devicesButton?.addEventListener('click', open);
|
||||
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
|
||||
enrollPasskey?.addEventListener('click', async () => {
|
||||
enrollPasskey.disabled = true;
|
||||
devicesStatus.textContent = 'Waiting for your device passkey…';
|
||||
try {
|
||||
await boundary.enrollPasskey();
|
||||
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
|
||||
await renderPasskeys();
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
|
||||
} finally {
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
});
|
||||
+250
-16
@@ -1,9 +1,10 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v87';
|
||||
const CACHE = 'stackchain-dashboard-shell-v100';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
@@ -13,12 +14,16 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/security-center.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/shared-image-capture.js',
|
||||
BASE + 'static/draft-filing-session.js',
|
||||
BASE + 'static/draft-capacity-dialog.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
@@ -28,23 +33,34 @@ const SHELL = [
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/agenda-replan.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-recap.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
BASE + 'static/update-reply-read-next.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-readiness.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
BASE + 'static/update-follow-up.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/later-sync.js',
|
||||
BASE + 'static/later-and-start.js',
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/batch-find-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/comment-actions.js',
|
||||
BASE + 'static/issue-attachment.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
@@ -56,15 +72,63 @@ const SHELL = [
|
||||
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-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/update-triage-session.js',
|
||||
BASE + 'static/update-triage-launcher.js',
|
||||
BASE + 'static/agenda-session-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/mention-composer.js',
|
||||
BASE + 'static/push-notifications.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
const OPTIONAL_FEATURES = [
|
||||
];
|
||||
|
||||
const SHARED_IMAGE_ID = 'shared-image';
|
||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
|
||||
|
||||
function boundedShareField(form, name, limit) {
|
||||
const value = form.get(name);
|
||||
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
|
||||
}
|
||||
|
||||
async function acceptSharedContent(request) {
|
||||
const form = await request.formData();
|
||||
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
|
||||
let marker = '';
|
||||
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
|
||||
if (images.length > 1) marker = 'multiple';
|
||||
else if (images.length === 1) {
|
||||
const image = images[0];
|
||||
if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) {
|
||||
marker = 'unsupported';
|
||||
} else {
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
});
|
||||
marker = 'image';
|
||||
}
|
||||
}
|
||||
const target = new URL(BASE, self.location.origin);
|
||||
target.searchParams.set('launch', 'new');
|
||||
if (marker) target.searchParams.set('shared', marker);
|
||||
for (const [name, limit] of [['title', 255], ['text', 10000], ['url', 2048]]) {
|
||||
const value = boundedShareField(form, name, limit);
|
||||
if (value) target.searchParams.set(name, value);
|
||||
}
|
||||
return new Response(null, {status:303, headers:{Location:target.pathname + target.search}});
|
||||
}
|
||||
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
@@ -109,8 +173,21 @@ async function withSessionCsrf(work) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePrivateDatabase(name) {
|
||||
if (!indexedDB) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase(name); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function purgeRevokedSessionData() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
@@ -147,6 +224,7 @@ async function offlineLeaseState(cache) {
|
||||
|
||||
async function expiredOfflineResponse() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
@@ -240,10 +318,16 @@ self.addEventListener('install', event => {
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(caches.keys().then(keys => Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
)).then(() => self.clients.claim()));
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
);
|
||||
const cache = await caches.open(CACHE);
|
||||
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('sync', event => {
|
||||
@@ -261,6 +345,7 @@ self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
@@ -268,22 +353,167 @@ self.addEventListener('message', event => {
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
const route = String(event.notification.data?.route || '');
|
||||
if (!route.startsWith('#/my-work/')) return;
|
||||
self.addEventListener('push', event => {
|
||||
let payload;
|
||||
try { payload = event.data?.json?.() || {}; }
|
||||
catch (_error) { return; }
|
||||
const route = String(payload.route || '');
|
||||
const protectRoute = String(payload.protect_route || '');
|
||||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
if (
|
||||
route === '#/my-work/agenda'
|
||||
&& protectRoute === '#/my-work/agenda/protect-today'
|
||||
&& /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag)
|
||||
&& Number.isSafeInteger(deadlineCount)
|
||||
&& deadlineCount > 0
|
||||
&& deadlineCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(
|
||||
deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention',
|
||||
{
|
||||
body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'),
|
||||
tag,
|
||||
actions: [
|
||||
{ action: 'protect-today', title: 'Protect Today' },
|
||||
{ action: 'open-agenda', title: 'Open Agenda' },
|
||||
],
|
||||
data: {route, protectRoute},
|
||||
}
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/updates'
|
||||
&& tag === 'stackchain-update-digest'
|
||||
&& Number.isSafeInteger(updateCount)
|
||||
&& updateCount > 0
|
||||
&& updateCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
|
||||
const options = {
|
||||
body: 'Tap to review it in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
};
|
||||
if (
|
||||
Number.isSafeInteger(notificationId) && notificationId > 0
|
||||
&& route === '#/my-work/update/' + notificationId
|
||||
&& tag === 'stackchain-update-' + notificationId
|
||||
) {
|
||||
options.actions = [
|
||||
{ action: 'mark-read', title: 'Mark read' },
|
||||
{ action: 'tomorrow', title: 'Tomorrow' },
|
||||
];
|
||||
options.data.notificationId = notificationId;
|
||||
}
|
||||
event.waitUntil(self.registration.showNotification('New work update', options));
|
||||
});
|
||||
|
||||
async function openWorkRoute(route) {
|
||||
const target = new URL(BASE + route, self.location.origin).href;
|
||||
event.waitUntil((async () => {
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target);
|
||||
if (client.navigate) await client.navigate(target);
|
||||
return client.focus();
|
||||
})());
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target);
|
||||
if (client.navigate) await client.navigate(target);
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
const route = String(event.notification.data?.route || '');
|
||||
if (event.action === 'protect-today') {
|
||||
const protectRoute = String(event.notification.data?.protectRoute || route);
|
||||
if (
|
||||
protectRoute !== '#/my-work/agenda/protect-today'
|
||||
|| !['#/my-work/agenda', protectRoute].includes(route)
|
||||
) return;
|
||||
event.notification.close();
|
||||
event.waitUntil(openWorkRoute(protectRoute));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'open-agenda') {
|
||||
if (route !== '#/my-work/agenda') return;
|
||||
event.notification.close();
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'tomorrow') {
|
||||
const notificationId = Number(event.notification.data?.notificationId);
|
||||
if (
|
||||
!Number.isSafeInteger(notificationId) || notificationId <= 0
|
||||
|| route !== '#/my-work/update/' + notificationId
|
||||
|| event.notification.tag !== 'stackchain-update-' + notificationId
|
||||
) return;
|
||||
event.waitUntil((async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
||||
try {
|
||||
const wake = new Date(self.__STACKCHAIN_NOW?.() || Date.now());
|
||||
wake.setDate(wake.getDate() + 1);
|
||||
wake.setHours(9, 0, 0, 0);
|
||||
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/later', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wake_at: wake.toISOString() }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
event.notification.close();
|
||||
} catch (_error) {
|
||||
await openWorkRoute(route);
|
||||
event.notification.close();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
if (event.action === 'mark-read') {
|
||||
const notificationId = Number(event.notification.data?.notificationId);
|
||||
if (
|
||||
!Number.isSafeInteger(notificationId) || notificationId <= 0
|
||||
|| route !== '#/my-work/update/' + notificationId
|
||||
) return;
|
||||
event.waitUntil((async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
||||
try {
|
||||
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
|
||||
});
|
||||
event.notification.close();
|
||||
} catch (_error) {
|
||||
await openWorkRoute(route);
|
||||
event.notification.close();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
event.notification.close();
|
||||
if (!route.startsWith('#/my-work/')) return;
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
const requestUrl = new URL(request.url);
|
||||
if (
|
||||
request.method === 'POST' && request.mode === 'navigate'
|
||||
&& requestUrl.origin === self.location.origin && requestUrl.pathname === BASE
|
||||
) {
|
||||
event.respondWith(acceptSharedContent(request));
|
||||
return;
|
||||
}
|
||||
if (request.method !== 'GET' || request.url.includes('/api/')) return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
||||
@@ -313,5 +543,9 @@ self.addEventListener('fetch', event => {
|
||||
}
|
||||
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
}
|
||||
});
|
||||
+29
-177
@@ -36,191 +36,42 @@
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
|
||||
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
|
||||
const closeDevices = root.document.getElementById('close-active-devices');
|
||||
const enrollPasskey = root.document.getElementById('enroll-passkey');
|
||||
const activityList = root.document.getElementById('security-activity-list');
|
||||
const activityStatus = root.document.getElementById('security-activity-status');
|
||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||||
let activityCursor = null;
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
try {
|
||||
const devices = await boundary.listActiveDevices();
|
||||
devices.forEach(device => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'active-device';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = device.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||||
details.append(label, timing);
|
||||
if (device.current) {
|
||||
const current = root.document.createElement('span');
|
||||
current.className = 'active-device-current';
|
||||
current.textContent = 'This device';
|
||||
details.append(current);
|
||||
}
|
||||
const revoke = root.document.createElement('button');
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
});
|
||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
const renderPasskeys = async () => {
|
||||
if (!passkeysList || !passkeysStatus) return;
|
||||
passkeysStatus.textContent = 'Loading enrolled passkeys…';
|
||||
passkeysList.replaceChildren();
|
||||
try {
|
||||
const enrolled = await boundary.listPasskeys();
|
||||
enrolled.forEach(passkey => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'enrolled-passkey';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = passkey.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
|
||||
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
|
||||
details.append(label, timing);
|
||||
const remove = root.document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Remove passkey';
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
|
||||
try {
|
||||
const outcome = await boundary.revokePasskey(passkey);
|
||||
if (outcome) {
|
||||
passkeysStatus.textContent = outcome.current_session
|
||||
? 'Passkey removed. This session remains active; keep your recovery token available.'
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
|
||||
remove.disabled = false;
|
||||
}
|
||||
});
|
||||
row.append(details, remove);
|
||||
passkeysList.append(row);
|
||||
});
|
||||
passkeysStatus.textContent = enrolled.length
|
||||
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
|
||||
: 'No passkeys enrolled.';
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
const renderSecurityActivity = async (append = false) => {
|
||||
if (!activityList || !activityStatus || !loadMoreActivity) return;
|
||||
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
|
||||
loadMoreActivity.hidden = true;
|
||||
if (!append) {
|
||||
activityCursor = null;
|
||||
activityList.replaceChildren();
|
||||
}
|
||||
try {
|
||||
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
||||
if (!append) {
|
||||
page.authentication_alerts.forEach(alert => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event authentication-alert';
|
||||
const formatted = boundary.formatAuthenticationAlert(alert);
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = formatted.title;
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in',
|
||||
sign_out: 'Signed out',
|
||||
passkey_enrolled: 'Passkey enrolled',
|
||||
device_revoked: 'Device access revoked',
|
||||
all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed',
|
||||
pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested',
|
||||
};
|
||||
page.events.forEach(event => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event';
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = labels[event.kind] || 'Security event';
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
const context = [
|
||||
event.device_label,
|
||||
event.method,
|
||||
event.target,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null,
|
||||
].filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
activityCursor = page.next_cursor;
|
||||
activityStatus.textContent = activityList.children.length
|
||||
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
|
||||
: 'No security activity yet.';
|
||||
loadMoreActivity.textContent = 'Load older activity';
|
||||
loadMoreActivity.hidden = !activityCursor;
|
||||
} catch (_error) {
|
||||
activityStatus.textContent = 'Security activity could not be loaded.';
|
||||
loadMoreActivity.textContent = 'Retry activity';
|
||||
loadMoreActivity.hidden = false;
|
||||
}
|
||||
};
|
||||
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
|
||||
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
|
||||
const securityFeatures = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'security-center': root.document.querySelector(
|
||||
'meta[name="stackchain-feature-security-center"]'
|
||||
)?.content || '',
|
||||
},
|
||||
});
|
||||
let loadingSecurityCenter = false;
|
||||
const openSecurityCenter = async () => {
|
||||
if (loadingSecurityCenter) return;
|
||||
loadingSecurityCenter = true;
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
renderDevices();
|
||||
renderPasskeys();
|
||||
renderSecurityActivity();
|
||||
});
|
||||
try {
|
||||
await securityFeatures.run('security-center', {
|
||||
trigger: devicesButton,
|
||||
status: devicesStatus,
|
||||
retryLabel: 'Tap Active devices to retry.',
|
||||
}, () => {
|
||||
devicesButton.removeEventListener('click', openSecurityCenter);
|
||||
root.attachSecurityCenter(boundary).open();
|
||||
});
|
||||
} finally {
|
||||
loadingSecurityCenter = false;
|
||||
}
|
||||
};
|
||||
if (devicesButton && devicesSheet) {
|
||||
devicesButton.addEventListener('click', openSecurityCenter);
|
||||
}
|
||||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
});
|
||||
enrollPasskey?.addEventListener('click', async () => {
|
||||
enrollPasskey.disabled = true;
|
||||
devicesStatus.textContent = 'Waiting for your device passkey…';
|
||||
try {
|
||||
await boundary.enrollPasskey();
|
||||
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
|
||||
await renderPasskeys();
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
|
||||
} finally {
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
boundary.refreshOfflineLease().then(valid => {
|
||||
if (valid) boundary.resumeQueuedWork();
|
||||
});
|
||||
@@ -605,6 +456,7 @@
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
} catch (_error) {
|
||||
const error = new Error('Could not clear private queued work from this device.');
|
||||
onClearError(error);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.sharedImageCapture = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const RECORD_ID = 'shared-image';
|
||||
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
async function consume({marker, store, restore, status = () => {}}) {
|
||||
if (!marker) return false;
|
||||
if (marker !== 'image') {
|
||||
status('Share one PNG, JPEG, or WebP screenshot.');
|
||||
return false;
|
||||
}
|
||||
const value = await store?.get(RECORD_ID);
|
||||
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
|
||||
status('The shared screenshot is unavailable. Share it again.');
|
||||
return false;
|
||||
}
|
||||
restore(value);
|
||||
await store.delete(RECORD_ID);
|
||||
status('Shared screenshot ready to file with this issue.');
|
||||
return true;
|
||||
}
|
||||
|
||||
return {consume, RECORD_ID};
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
function todayRecapFeedbackRows(draft, describe = () => null) {
|
||||
return (draft?.items || []).map(item => {
|
||||
const work = describe(item.identity) || {};
|
||||
const estimate = Number.isInteger(item.estimate_minutes) ? item.estimate_minutes : null;
|
||||
return {
|
||||
identity:item.identity,
|
||||
label:String(work.title || item.identity).slice(0, 180),
|
||||
context:String(work.key || work.repository || 'Work details unavailable').slice(0, 180),
|
||||
estimate_minutes:estimate,
|
||||
actual_minutes:item.actual_minutes,
|
||||
variance_minutes:estimate === null ? null : item.actual_minutes - estimate,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createTodayRecap({ save, saveAndLog = null, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
|
||||
const totals = items => {
|
||||
const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0);
|
||||
const actual = items.reduce((sum, item) => sum + item.actual_minutes, 0);
|
||||
return { estimated_minutes:estimated, actual_minutes:actual, variance_minutes:actual - estimated };
|
||||
};
|
||||
const storageKey = () => {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-recap-draft.v1.' + login : '';
|
||||
};
|
||||
const handoffKey = () => {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-recap-handoff.v1.' + login : '';
|
||||
};
|
||||
const validStoredDraft = saved => {
|
||||
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
|
||||
!Array.isArray(saved.items) || !saved.items.length || saved.items.length > 20) return false;
|
||||
const itemsValid = saved.items.every(item => item && typeof item.identity === 'string' && item.identity.length > 0 &&
|
||||
item.identity.length <= 500 && (item.estimate_minutes === null ||
|
||||
(Number.isInteger(item.estimate_minutes) && item.estimate_minutes >= 5 && item.estimate_minutes <= 1440)) &&
|
||||
Number.isInteger(item.actual_minutes) && item.actual_minutes >= 0 && item.actual_minutes <= 1440);
|
||||
if (!itemsValid) return false;
|
||||
const identities = new Set(saved.items.map(item => item.identity));
|
||||
if (saved.log_identities !== undefined && (!Array.isArray(saved.log_identities) ||
|
||||
saved.log_identities.length > 20 || saved.log_identities.some(identity => !identities.has(identity)))) return false;
|
||||
if (saved.time_logs !== undefined && (!saved.time_logs || typeof saved.time_logs !== 'object' ||
|
||||
Array.isArray(saved.time_logs) || Object.entries(saved.time_logs).some(([identity, status]) =>
|
||||
!identities.has(identity) || !['logged', 'retry', 'verify'].includes(status)))) return false;
|
||||
return true;
|
||||
};
|
||||
const load = () => {
|
||||
const key = storageKey();
|
||||
if (!storage || !key) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (!validStoredDraft(saved)) {
|
||||
if (saved !== null) storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return { ...saved, ...totals(saved.items) };
|
||||
}
|
||||
catch (_error) {
|
||||
try { storage.removeItem(key); } catch (_ignored) {}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const validHandoff = saved => {
|
||||
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
|
||||
!saved.actual_minutes || typeof saved.actual_minutes !== 'object' || Array.isArray(saved.actual_minutes)) return false;
|
||||
const entries = Object.entries(saved.actual_minutes);
|
||||
return entries.length > 0 && entries.length <= 20 && entries.every(([identity, minutes]) =>
|
||||
identity.length > 0 && identity.length <= 500 && Number.isInteger(minutes) && minutes >= 0 && minutes <= 1440
|
||||
);
|
||||
};
|
||||
const pendingReplan = () => {
|
||||
const key = handoffKey();
|
||||
if (!storage || !key) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (!validHandoff(saved)) {
|
||||
if (saved !== null) storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return { session_id:saved.session_id, actual_minutes:{...saved.actual_minutes} };
|
||||
} catch (_error) {
|
||||
try { storage.removeItem(key); } catch (_ignored) {}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const completeReplan = () => {
|
||||
const key = handoffKey();
|
||||
if (storage && key) storage.removeItem(key);
|
||||
};
|
||||
let draftKey = storageKey();
|
||||
let draft = load();
|
||||
const persist = () => {
|
||||
const key = storageKey();
|
||||
if (!storage || !key || !draft) return;
|
||||
const saved = { session_id:draft.session_id, items:draft.items };
|
||||
if (draft.log_identities) saved.log_identities = draft.log_identities;
|
||||
if (draft.time_logs) saved.time_logs = draft.time_logs;
|
||||
storage.setItem(key, JSON.stringify(saved));
|
||||
};
|
||||
const restore = () => {
|
||||
const key = storageKey();
|
||||
if (key !== draftKey) {
|
||||
draft = null;
|
||||
draftKey = key;
|
||||
}
|
||||
if (!draft) draft = load();
|
||||
return Boolean(draft);
|
||||
};
|
||||
const snapshot = () => {
|
||||
restore();
|
||||
return draft ? {
|
||||
...draft, items:draft.items.map(item => ({...item})),
|
||||
...(draft.log_identities ? {log_identities:[...draft.log_identities]} : {}),
|
||||
...(draft.time_logs ? {time_logs:{...draft.time_logs}} : {}),
|
||||
} : null;
|
||||
};
|
||||
const saveConfirmed = async includeActuals => {
|
||||
restore();
|
||||
if (!draft?.items.length) throw new Error('No timed work to save.');
|
||||
const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) };
|
||||
const actualMinutes = includeActuals ? Object.fromEntries(
|
||||
payload.items.map(item => [item.identity, item.actual_minutes])
|
||||
) : null;
|
||||
const result = await save(payload);
|
||||
if (includeActuals && storage && handoffKey()) storage.setItem(handoffKey(), JSON.stringify({
|
||||
session_id:payload.session_id, actual_minutes:actualMinutes,
|
||||
}));
|
||||
clear();
|
||||
if (storage && draftKey) storage.removeItem(draftKey);
|
||||
draft = null;
|
||||
return includeActuals ? { result, actual_minutes:actualMinutes } : result;
|
||||
};
|
||||
const saveWithTime = async identities => {
|
||||
restore();
|
||||
if (!draft?.items.length) throw new Error('No timed work to save.');
|
||||
if (typeof saveAndLog !== 'function') throw new Error('Gitea time logging is unavailable.');
|
||||
const requested = new Set(Array.isArray(identities) ? identities : []);
|
||||
const eligible = new Set(draft.items.filter(item => item.actual_minutes > 0).map(item => item.identity));
|
||||
const intended = [...new Set([...(draft.log_identities || []), ...requested])]
|
||||
.filter(identity => eligible.has(identity));
|
||||
if (!intended.length) throw new Error('Select at least one non-zero time entry.');
|
||||
draft.log_identities = intended;
|
||||
draft.time_logs = draft.time_logs || {};
|
||||
persist();
|
||||
const logIdentities = intended.filter(identity => !['logged', 'verify'].includes(draft.time_logs[identity]));
|
||||
const payload = {
|
||||
session_id:draft.session_id,
|
||||
items:draft.items.map(item => ({...item})),
|
||||
log_identities:logIdentities,
|
||||
};
|
||||
const result = await saveAndLog(payload);
|
||||
Object.assign(draft.time_logs, Object.fromEntries((result.time_logs || []).map(item => [item.identity, item.status])));
|
||||
persist();
|
||||
const logged = intended.filter(identity => draft.time_logs[identity] === 'logged').length;
|
||||
const verify = intended.filter(identity => draft.time_logs[identity] === 'verify').length;
|
||||
const retry = intended.length - logged - verify;
|
||||
if (retry || verify) {
|
||||
if (verify) throw new Error(verify + ' time ' + (verify === 1 ? 'entry needs' : 'entries need') +
|
||||
' verification in Gitea; it will not be posted again automatically.');
|
||||
throw new Error(logged + ' time ' + (logged === 1 ? 'entry' : 'entries') + ' logged; ' + retry + ' needs retry.');
|
||||
}
|
||||
clear();
|
||||
if (storage && draftKey) storage.removeItem(draftKey);
|
||||
draft = null;
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
restore,
|
||||
begin(entries, estimates = {}) {
|
||||
const items = (entries || []).filter(entry => entry?.identity).map(entry => ({
|
||||
identity:String(entry.identity),
|
||||
estimate_minutes:Number.isInteger(estimates[entry.identity]) ? estimates[entry.identity] : null,
|
||||
actual_minutes:Math.min(1440, Math.max(0, Math.round(Number(entry.elapsed_ms || 0) / 60000))),
|
||||
}));
|
||||
draftKey = storageKey();
|
||||
draft = { session_id:makeId(), items, ...totals(items) };
|
||||
persist();
|
||||
return snapshot();
|
||||
},
|
||||
correct(identity, minutes) {
|
||||
restore();
|
||||
if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false;
|
||||
const item = draft.items.find(candidate => candidate.identity === identity);
|
||||
if (!item) return false;
|
||||
item.actual_minutes = minutes;
|
||||
Object.assign(draft, totals(draft.items));
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
snapshot,
|
||||
save:() => saveConfirmed(false),
|
||||
saveForReplan:() => saveConfirmed(true),
|
||||
saveAndLog:saveWithTime,
|
||||
pendingReplan,
|
||||
completeReplan,
|
||||
discardReplan:completeReplan,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveTodayRecap(payload) {
|
||||
const response = await fetch('api/v1/today/recaps', {
|
||||
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.detail || 'Recap could not be saved.');
|
||||
return result;
|
||||
}
|
||||
|
||||
async function saveTodayRecapTime(payload) {
|
||||
const response = await fetch('api/v1/today/recaps/log-time', {
|
||||
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.detail || 'Recap time could not be logged.');
|
||||
return result;
|
||||
}
|
||||
|
||||
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
|
||||
describeWork = () => null, adjustPlan = () => {} }) {
|
||||
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
|
||||
const render = () => {
|
||||
const draft = recap.snapshot();
|
||||
const container = qs('#today-recap-items');
|
||||
const rows = todayRecapFeedbackRows(draft, describeWork);
|
||||
container.innerHTML = rows.map(item => {
|
||||
const variance = item.variance_minutes === null ? 'No estimate comparison' :
|
||||
(item.variance_minutes === 0 ? 'On estimate' : minutes(Math.abs(item.variance_minutes)) +
|
||||
(item.variance_minutes > 0 ? ' over estimate' : ' under estimate'));
|
||||
const canonical = /^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9][0-9]*:$/.test(item.identity);
|
||||
const logStatus = draft?.time_logs?.[item.identity];
|
||||
const logControl = logStatus === 'logged' ?
|
||||
'<span class="today-recap-log today-recap-log-success">Logged to Gitea</span>' :
|
||||
logStatus === 'verify' ? '<span class="today-recap-log today-recap-log-verify">Verify this entry in Gitea</span>' :
|
||||
item.actual_minutes > 0 && canonical ?
|
||||
'<label class="today-recap-log"><input type="checkbox" data-recap-log-identity="' +
|
||||
escapeHtml(item.identity) + '"> ' + (logStatus === 'retry' ? 'Retry ' : 'Log ') +
|
||||
minutes(item.actual_minutes) + ' to Gitea</label>' :
|
||||
'<span class="small muted today-recap-log">No non-zero Gitea time to log</span>';
|
||||
return '<div class="today-recap-row"><span><strong>' + escapeHtml(item.label) + '</strong><span class="small today-recap-context">' +
|
||||
escapeHtml(item.context) + ' · ' + (item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') +
|
||||
'</span></span><span><input type="number" inputmode="numeric" min="0" max="1440" step="1" value="' +
|
||||
item.actual_minutes + '" data-recap-identity="' + escapeHtml(item.identity) + '" aria-label="Actual minutes for ' +
|
||||
escapeHtml(item.label) + '"> min</span><span class="today-recap-variance">' + variance + '</span>' + logControl + '</div>';
|
||||
}).join('') || '';
|
||||
qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' +
|
||||
minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) +
|
||||
(draft.variance_minutes >= 0 ? ' over' : ' under') : '';
|
||||
container.querySelectorAll('[data-recap-identity]').forEach(input => input.addEventListener('change', () => {
|
||||
if (!recap.correct(input.dataset.recapIdentity, Number(input.value))) {
|
||||
qs('#today-recap-status').textContent = 'Actual time must be a whole number from 0 to 1,440 minutes.';
|
||||
}
|
||||
render();
|
||||
}));
|
||||
};
|
||||
const loadHistory = async () => {
|
||||
qs('#today-recap-history').innerHTML = '<div class="small">Loading recent recaps…</div>';
|
||||
try {
|
||||
const result = await api('api/v1/today/recaps');
|
||||
qs('#today-recap-history').innerHTML = (result.recaps || []).map(saved =>
|
||||
'<div class="today-recap-history-row"><span>' + new Date(saved.created_at * 1000).toLocaleString() +
|
||||
'</span><strong>' + minutes(saved.actual_minutes) + ' actual · ' + minutes(Math.abs(saved.variance_minutes)) +
|
||||
(saved.variance_minutes >= 0 ? ' over' : ' under') + '</strong></div>'
|
||||
).join('') || '<div class="small">No saved recaps yet.</div>';
|
||||
} catch (_error) {
|
||||
qs('#today-recap-history').innerHTML = '<div class="small">Recent recaps are unavailable. Try again.</div>';
|
||||
}
|
||||
};
|
||||
const open = ({ begin = false } = {}) => {
|
||||
if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates);
|
||||
else recap.restore();
|
||||
render();
|
||||
qs('#save-today-recap').hidden = !recap.snapshot()?.items.length;
|
||||
qs('#save-log-today-recap').hidden = !recap.snapshot()?.items.length;
|
||||
qs('#today-recap-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
loadHistory();
|
||||
requestAnimationFrame(() => (qs('#today-recap-items input') || qs('#close-today-recap')).focus());
|
||||
};
|
||||
const close = () => {
|
||||
qs('#today-recap-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
qs('#open-today-recaps').focus();
|
||||
};
|
||||
const saveDraft = async button => {
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Saving recap…';
|
||||
try {
|
||||
const handoff = await recap.saveForReplan();
|
||||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(handoff.actual_minutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
||||
} finally { button.disabled = false; }
|
||||
};
|
||||
const saveTimeDraft = async button => {
|
||||
const selected = Array.from(qs('#today-recap-items').querySelectorAll('[data-recap-log-identity]:checked'))
|
||||
.map(input => input.dataset.recapLogIdentity);
|
||||
if (!selected.length) {
|
||||
qs('#today-recap-status').textContent = 'Select at least one non-zero time entry.';
|
||||
return;
|
||||
}
|
||||
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Logging selected time…';
|
||||
try {
|
||||
await recap.saveAndLog(selected);
|
||||
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(actualMinutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
|
||||
render();
|
||||
} finally { button.disabled = false; }
|
||||
};
|
||||
const bind = () => {
|
||||
qs('#open-today-recaps').addEventListener('click', () => open());
|
||||
qs('#close-today-recap').addEventListener('click', close);
|
||||
qs('#discard-today-recap').addEventListener('click', close);
|
||||
qs('#save-today-recap').addEventListener('click', event => saveDraft(event.currentTarget));
|
||||
qs('#save-log-today-recap').addEventListener('click', event => saveTimeDraft(event.currentTarget));
|
||||
};
|
||||
return {
|
||||
open, close, saveDraft, saveTimeDraft, loadHistory, render, bind,
|
||||
pendingReplan:recap.pendingReplan,
|
||||
completeReplan:recap.completeReplan,
|
||||
discardReplan:recap.discardReplan,
|
||||
};
|
||||
}
|
||||
|
||||
function endTodaySession(workSession, qs) {
|
||||
workSession.end();
|
||||
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
|
||||
}
|
||||
|
||||
function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
|
||||
timerView.finish();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
||||
qs('#my-work-action-status').textContent = 'Work session complete.';
|
||||
if (workFilter === 'today' && timer.recapEntries().length) view.open({ begin:true });
|
||||
else qs('#start-work-session').focus();
|
||||
}
|
||||
|
||||
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
|
||||
describeWork, adjustPlan) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
|
||||
const recap = createTodayRecap({
|
||||
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
|
||||
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
|
||||
});
|
||||
const view = createTodayRecapView({ ...options, recap });
|
||||
view.bind();
|
||||
setInterval(timerView.render, 1000);
|
||||
view.finish = workFilter => {
|
||||
closeSheets();
|
||||
updateActions();
|
||||
openTodayRecapAfterSession(view, timer, timerView, workFilter, qs);
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayRecap.createView = createTodayRecapView;
|
||||
createTodayRecap.feedbackRows = todayRecapFeedbackRows;
|
||||
module.exports = createTodayRecap;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
function createTodayRollover(options = {}) {
|
||||
const now = options.now || (() => new Date());
|
||||
const resolvedTimeZone = options.resolvedTimeZone || (() =>
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
|
||||
|
||||
function timeZone() {
|
||||
return options.timeZone?.() || resolvedTimeZone();
|
||||
}
|
||||
|
||||
function localDate() {
|
||||
if (options.localDate) return options.localDate();
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timeZone(), year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).formatToParts(now());
|
||||
const value = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
||||
return `${value.year}-${value.month}-${value.day}`;
|
||||
}
|
||||
|
||||
function reviewState(plan) {
|
||||
if (!Array.isArray(plan?.ids) || !plan.ids.length) return 'empty';
|
||||
if (!plan.plan_date) return 'legacy';
|
||||
return plan.plan_date === localDate() ? 'current' : 'stale';
|
||||
}
|
||||
|
||||
function operation({ operation_id, base_revision, selected_ids, capacity_minutes, estimates = {} }) {
|
||||
const ids = [...new Set((selected_ids || []).filter(id => typeof id === 'string' && id))];
|
||||
const selected = new Set(ids);
|
||||
return {
|
||||
operation_id, action: 'rollover', item_id: 'plan', base_revision,
|
||||
plan_date: localDate(), timezone: timeZone(), ids, capacity_minutes,
|
||||
estimates: Object.fromEntries(Object.entries(estimates).filter(([id]) => selected.has(id))),
|
||||
};
|
||||
}
|
||||
|
||||
return { localDate, timeZone, reviewState, operation };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayRollover;
|
||||
+32
-10
@@ -55,20 +55,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
function adopt(plan, broadcast = true) {
|
||||
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
|
||||
if (plan.revision < savedRevision()) return false;
|
||||
const snapshot = {
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
};
|
||||
if (plan.plan_date) {
|
||||
snapshot.plan_date = plan.plan_date;
|
||||
snapshot.timezone = plan.timezone || null;
|
||||
}
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify({
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
}));
|
||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||
} catch (_error) {
|
||||
// A storage quota failure must not prevent the current tab from using server truth.
|
||||
}
|
||||
onRemoteIds?.(plan.ids);
|
||||
onRemotePlan?.(plan);
|
||||
if (broadcast) channel?.postMessage({
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
});
|
||||
if (broadcast) channel?.postMessage(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -129,7 +131,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||
}))
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
@@ -201,6 +203,26 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueRollover(proposed) {
|
||||
if (!proposed || proposed.action !== 'rollover') return false;
|
||||
const operation = {
|
||||
...proposed, operation_id: operationId(), base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function migrate(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
@@ -281,7 +303,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, enqueueConfiguration, migrate, flush, pending, startLifecycle };
|
||||
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
@@ -0,0 +1,374 @@
|
||||
function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
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,
|
||||
});
|
||||
const read = () => {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey || !storage) return empty();
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(ownerKey) || 'null');
|
||||
if (value?.version !== 1 || typeof value.active_identity !== 'string' ||
|
||||
!value.entries || typeof value.entries !== 'object') return empty();
|
||||
return value;
|
||||
} catch (_error) {
|
||||
return empty();
|
||||
}
|
||||
};
|
||||
const write = state => {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey || !storage) return false;
|
||||
try {
|
||||
storage.setItem(ownerKey, JSON.stringify(state));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const validPending = state => {
|
||||
const pending = state.pending_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
Number.isFinite(pending.away_ms) && pending.away_ms >= 0 ?
|
||||
{ identity:pending.identity, away_ms:pending.away_ms } : null;
|
||||
};
|
||||
const validAttention = state => {
|
||||
const pending = state.attention_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' ?
|
||||
{ identity:pending.identity, resume:pending.resume } : null;
|
||||
};
|
||||
const settle = (state, at = now()) => {
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry?.running) return state;
|
||||
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) || 0) +
|
||||
Math.max(0, at - Number(entry.started_at ?? at));
|
||||
entry.started_at = null;
|
||||
entry.running = false;
|
||||
return state;
|
||||
};
|
||||
const snapshot = (identity = '') => {
|
||||
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) };
|
||||
};
|
||||
return {
|
||||
activate(identity) {
|
||||
if (!key() || typeof identity !== 'string' || !identity) return false;
|
||||
const state = read();
|
||||
if (state.active_identity === identity && state.entries[identity]?.running) return true;
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
state.entries[identity] = entry;
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
return write(state);
|
||||
},
|
||||
pause() {
|
||||
const state = read();
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
return write(state);
|
||||
},
|
||||
resume() {
|
||||
const state = read();
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry) return false;
|
||||
if (!entry.running) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
return write(state);
|
||||
},
|
||||
stop() {
|
||||
const state = read();
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
return write(state);
|
||||
},
|
||||
beginAttention() {
|
||||
const state = read();
|
||||
const existing = validAttention(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.attention_interruption = { identity, resume };
|
||||
return write(state) ? { ...state.attention_interruption } : null;
|
||||
},
|
||||
attentionInterruption() {
|
||||
return validAttention(read());
|
||||
},
|
||||
returnFromAttention() {
|
||||
const state = read();
|
||||
const pending = validAttention(state);
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry) return null;
|
||||
state.active_identity = pending.identity;
|
||||
if (pending.resume && !entry.running) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.attention_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed:pending.resume } : null;
|
||||
},
|
||||
markAway() {
|
||||
const state = read();
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry?.running || validPending(state)) return false;
|
||||
if (Number.isFinite(state.away_at) && state.away_at >= 0) return true;
|
||||
state.away_at = now();
|
||||
return write(state);
|
||||
},
|
||||
reconcileInterruption(thresholdMs = 5 * 60 * 1000) {
|
||||
const state = read();
|
||||
const existing = validPending(state);
|
||||
if (existing) return existing;
|
||||
state.pending_interruption = null;
|
||||
const entry = state.entries[state.active_identity];
|
||||
const awayAt = Number(state.away_at);
|
||||
const detectedAt = now();
|
||||
state.away_at = null;
|
||||
if (!entry?.running || !Number.isFinite(awayAt) || awayAt < 0 || detectedAt < awayAt ||
|
||||
detectedAt - awayAt < thresholdMs) {
|
||||
write(state);
|
||||
return null;
|
||||
}
|
||||
const awayMs = detectedAt - awayAt;
|
||||
settle(state, detectedAt);
|
||||
state.pending_interruption = { identity:state.active_identity, away_ms:awayMs };
|
||||
write(state);
|
||||
return { ...state.pending_interruption };
|
||||
},
|
||||
pendingInterruption() {
|
||||
return validPending(read());
|
||||
},
|
||||
resolveInterruption(decision) {
|
||||
if (decision !== 'count' && decision !== 'exclude') return false;
|
||||
const state = read();
|
||||
const pending = state.pending_interruption;
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!entry || !Number.isFinite(pending.away_ms) || pending.away_ms < 0) return false;
|
||||
if (decision === 'exclude') {
|
||||
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) - pending.away_ms);
|
||||
}
|
||||
state.active_identity = pending.identity;
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
state.pending_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state);
|
||||
},
|
||||
recapEntries() {
|
||||
const state = read();
|
||||
settle(state);
|
||||
write(state);
|
||||
return Object.entries(state.entries).map(([identity, entry]) => ({
|
||||
identity,
|
||||
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0),
|
||||
})).filter(entry => entry.elapsed_ms > 0);
|
||||
},
|
||||
clearRecap() {
|
||||
return write(empty());
|
||||
},
|
||||
totalElapsed() {
|
||||
const state = read();
|
||||
return Object.entries(state.entries).reduce((total, [identity, entry]) => {
|
||||
const live = identity === state.active_identity && entry.running ?
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0;
|
||||
return total + Math.max(0, Number(entry.elapsed_ms) || 0) + live;
|
||||
}, 0);
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
|
||||
let progress = null;
|
||||
let runway = null;
|
||||
queryAll('[data-mobile-today-open]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const identity = timer.snapshot().identity;
|
||||
if (identity) onReopen?.(identity);
|
||||
})
|
||||
);
|
||||
queryAll('[data-mobile-today-toggle]').forEach(button =>
|
||||
button.addEventListener('click', () => view.toggle())
|
||||
);
|
||||
queryAll('[data-mobile-today-complete]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const identity = timer.snapshot().identity;
|
||||
if (identity) onComplete?.(identity);
|
||||
})
|
||||
);
|
||||
if (typeof document !== 'undefined') queryAll('.work-session-nav').forEach(nav => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.hidden = true;
|
||||
button.dataset.workSessionAdjustPlan = '';
|
||||
button.textContent = 'Adjust remaining plan';
|
||||
nav.insertBefore(button, nav.querySelector('[data-work-session-complete]'));
|
||||
});
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const plan = queryAll('#plan-today')[0];
|
||||
const sheet = queryAll('#plan-today-sheet')[0];
|
||||
if (plan && sheet) {
|
||||
const replan = createTodayBudgetReplan({ timer, openPlan:() => plan.click() });
|
||||
queryAll('[data-work-session-adjust-plan]').forEach(button =>
|
||||
button.addEventListener('click', () => replan.open())
|
||||
);
|
||||
new MutationObserver(() => {
|
||||
if (sheet.hidden && replan.restore()) render();
|
||||
}).observe(sheet, { attributes:true, attributeFilter:['hidden'] });
|
||||
}
|
||||
}
|
||||
const elapsed = milliseconds => {
|
||||
const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainder = String(seconds % 60).padStart(2, '0');
|
||||
return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remainder : minutes + ':' + remainder;
|
||||
};
|
||||
const render = () => {
|
||||
const snapshot = timer.snapshot();
|
||||
const active = Boolean(progress && isActive() && snapshot.identity);
|
||||
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
|
||||
queryAll('[data-mobile-today-open]').forEach(element => {
|
||||
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
|
||||
});
|
||||
queryAll('[data-mobile-today-complete]').forEach(button => {
|
||||
const itemTitle = String(getItem?.(snapshot.identity)?.title || 'current Today item');
|
||||
const finalItem = Boolean(progress && progress.index >= progress.total);
|
||||
button.hidden = !active;
|
||||
button.textContent = finalItem ? 'Done & recap' : 'Done & next';
|
||||
button.setAttribute('aria-label', finalItem ?
|
||||
'Complete ' + itemTitle + ' and open Today recap' :
|
||||
'Complete ' + itemTitle + ' and open next Today item');
|
||||
});
|
||||
if (typeof document !== 'undefined') document.body.classList.toggle('mobile-today-active', active);
|
||||
if (!progress) return;
|
||||
const sourceRunway = getRunway?.(snapshot) || runway;
|
||||
const liveRunway = sourceRunway?.future_minutes !== undefined ? (() => {
|
||||
const currentElapsed = Math.max(0, Math.ceil(snapshot.elapsed_ms / 60000));
|
||||
const currentRemaining = sourceRunway.current_minutes === null ? null :
|
||||
Math.max(0, sourceRunway.current_minutes - currentElapsed);
|
||||
const remaining = currentRemaining === null || sourceRunway.future_minutes === null ? null :
|
||||
currentRemaining + sourceRunway.future_minutes;
|
||||
const projected = remaining === null ? null : Math.ceil(timer.totalElapsed() / 60000) + remaining;
|
||||
const capacityRemaining = sourceRunway.capacity_minutes === null || projected === null ? null :
|
||||
sourceRunway.capacity_minutes - projected;
|
||||
return {
|
||||
...sourceRunway,
|
||||
remaining_minutes:remaining,
|
||||
over_estimate_minutes:sourceRunway.current_minutes === null ? 0 :
|
||||
Math.max(0, currentElapsed - sourceRunway.current_minutes),
|
||||
over_capacity_minutes:capacityRemaining === null ? 0 : Math.max(0, -capacityRemaining),
|
||||
};
|
||||
})() : sourceRunway;
|
||||
const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) +
|
||||
(liveRunway?.current_minutes ? ' / ' + formatEstimate(liveRunway.current_minutes) : '') : '';
|
||||
const estimateRisk = liveRunway?.over_estimate_minutes ?
|
||||
' · ' + formatEstimate(liveRunway.over_estimate_minutes) + ' over estimate' : '';
|
||||
const capacityRisk = liveRunway?.over_capacity_minutes ?
|
||||
' · Today projected ' + formatEstimate(liveRunway.over_capacity_minutes) + ' over capacity' : '';
|
||||
queryAll('[data-work-session-progress]').forEach(element => {
|
||||
element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + estimateRisk + capacityRisk +
|
||||
(!estimateRisk && !capacityRisk && liveRunway?.remaining_minutes ?
|
||||
' · ' + formatEstimate(liveRunway.remaining_minutes) + ' remaining' : '');
|
||||
});
|
||||
queryAll('[data-work-session-adjust-plan]').forEach(button => {
|
||||
button.hidden = !(liveRunway?.over_estimate_minutes || liveRunway?.over_capacity_minutes);
|
||||
});
|
||||
queryAll('[data-work-session-timer-toggle]').forEach(button => {
|
||||
button.hidden = !isActive();
|
||||
button.textContent = snapshot.running ? 'Pause timer' : 'Resume timer';
|
||||
button.setAttribute('aria-pressed', String(!snapshot.running));
|
||||
});
|
||||
};
|
||||
return {
|
||||
open(identity, active) {
|
||||
const current = timer.snapshot();
|
||||
if (active && current.identity !== identity) timer.activate(identity);
|
||||
else if (!active) timer.stop();
|
||||
render();
|
||||
},
|
||||
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(); },
|
||||
render,
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayBudgetReplan({ timer, openPlan }) {
|
||||
let active = false;
|
||||
let resume = false;
|
||||
return {
|
||||
open() {
|
||||
if (active) return false;
|
||||
const state = timer.snapshot();
|
||||
if (!state.identity) return false;
|
||||
resume = Boolean(state.running);
|
||||
if (resume && timer.pause() === false) return false;
|
||||
active = true;
|
||||
openPlan?.(state);
|
||||
return true;
|
||||
},
|
||||
restore() {
|
||||
if (!active) return false;
|
||||
active = false;
|
||||
const shouldResume = resume;
|
||||
resume = false;
|
||||
return !shouldResume || timer.resume() !== false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel, onResolved }) {
|
||||
const render = pending => {
|
||||
if (!pending) {
|
||||
sheet.hidden = true;
|
||||
return false;
|
||||
}
|
||||
const label = String(getItemLabel?.(pending.identity) || 'Current Today item');
|
||||
const minutes = Math.max(1, Math.round(pending.away_ms / 60000));
|
||||
description.textContent = label + ' · away for ' + minutes + ' minute' + (minutes === 1 ? '' : 's');
|
||||
sheet.hidden = false;
|
||||
return true;
|
||||
};
|
||||
return {
|
||||
background() { return timer.markAway(); },
|
||||
foreground() { return render(timer.reconcileInterruption()); },
|
||||
restore() { return render(timer.pendingInterruption()); },
|
||||
resolve(decision) {
|
||||
if (!timer.resolveInterruption(decision)) return false;
|
||||
sheet.hidden = true;
|
||||
onResolved?.();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayTimer.createView = createTodayTimerView;
|
||||
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
|
||||
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
|
||||
module.exports = createTodayTimer;
|
||||
}
|
||||
+23
-5
@@ -91,6 +91,21 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
return write(ids) ? 'added' : 'unavailable';
|
||||
}
|
||||
|
||||
function addMany(items) {
|
||||
if (!storageKey()) return { status: 'unavailable', ids: [] };
|
||||
const current = read();
|
||||
const additions = [];
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id) return { status: 'unavailable', ids: [] };
|
||||
if (!current.includes(id) && !additions.includes(id)) additions.push(id);
|
||||
}
|
||||
if (!additions.length) return { status: 'exists', ids: [] };
|
||||
if (current.length + additions.length > limit) return { status: 'full', ids: [] };
|
||||
return write(current.concat(additions)) ?
|
||||
{ status: 'added', ids: additions } : { status: 'unavailable', ids: [] };
|
||||
}
|
||||
|
||||
function replace(ids) {
|
||||
const unique = [];
|
||||
for (const id of ids || []) {
|
||||
@@ -150,16 +165,19 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
}
|
||||
|
||||
function runway(items, currentIndex = 0) {
|
||||
const estimates = planning().estimates;
|
||||
const remaining = (items || []).slice(Math.max(0, currentIndex));
|
||||
const minutes = remaining.map(item => estimates[identity(item)] || null);
|
||||
const plan = planning();
|
||||
const minutes = (items || []).slice(Math.max(0, currentIndex)).map(item => plan.estimates[identity(item)] || null);
|
||||
const total = values => values.some(value => value === null) ? null :
|
||||
values.reduce((sum, value) => sum + value, 0);
|
||||
return {
|
||||
current_minutes: minutes[0] || null,
|
||||
remaining_minutes: minutes.some(value => value === null) ? null : minutes.reduce((total, value) => total + value, 0),
|
||||
remaining_minutes: total(minutes),
|
||||
future_minutes: total(minutes.slice(1)),
|
||||
capacity_minutes: plan.capacity_minutes,
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, remove, move, reconcile, contains, position, limit };
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
@@ -1,5 +1,6 @@
|
||||
function createUnfiledCaptures({
|
||||
storage,
|
||||
attachmentStore = null,
|
||||
getCaptureLogin = () => '',
|
||||
getCurrentLogin = () => '',
|
||||
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
@@ -29,24 +30,101 @@ function createUnfiledCaptures({
|
||||
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
function capacity() {
|
||||
const items = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
|
||||
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
|
||||
}
|
||||
|
||||
function prepare(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
if (!title) throw new Error('Add a title before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
|
||||
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
|
||||
write(items);
|
||||
return item;
|
||||
const attachment = note?.attachment;
|
||||
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
|
||||
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
|
||||
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
|
||||
if (hasAttachment && !attachmentStore) {
|
||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
||||
}
|
||||
return {title, body, ownerLogin, attachment, hasAttachment};
|
||||
}
|
||||
|
||||
function persist(prepared, existing, removed = null) {
|
||||
const item = {
|
||||
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
|
||||
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
|
||||
};
|
||||
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
||||
const writeItems = () => {
|
||||
try { write(items); }
|
||||
catch (error) {
|
||||
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return item;
|
||||
};
|
||||
const stage = prepared.hasAttachment
|
||||
? Promise.resolve(attachmentStore.put(item.id, {
|
||||
filename:String(prepared.attachment.filename).slice(0, 255),
|
||||
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
|
||||
}))
|
||||
: null;
|
||||
if (!stage && !removed?.hasAttachment) return writeItems();
|
||||
return Promise.resolve(stage).then(async () => {
|
||||
if (!removed?.hasAttachment) return writeItems();
|
||||
const removedAttachment = await attachmentStore.get(removed.id);
|
||||
try {
|
||||
await attachmentStore.delete(removed.id);
|
||||
} catch (error) {
|
||||
if (prepared.hasAttachment) await Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
try { return writeItems(); }
|
||||
catch (error) {
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore.put(removed.id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
const prepared = prepare(note);
|
||||
const existing = read();
|
||||
if (existing.length >= maxItems) throw new Error('Drafts full — nothing was deleted.');
|
||||
return persist(prepared, existing);
|
||||
}
|
||||
|
||||
function replaceOldest(note, expectedOldestId) {
|
||||
const prepared = prepare(note);
|
||||
const existing = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
|
||||
const oldest = existing.at(-1);
|
||||
if (existing.length < maxItems || !oldest || oldest.id !== expectedOldestId) {
|
||||
throw new Error('Drafts changed. Review them before replacing anything.');
|
||||
}
|
||||
return persist(prepared, existing.filter(item => item.id !== oldest.id), oldest);
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
const remaining = items.filter(item => item.id !== id);
|
||||
if (remaining.length === items.length) return false;
|
||||
write(remaining);
|
||||
return true;
|
||||
const removed = items.find(item => item.id === id);
|
||||
if (!removed?.hasAttachment) {
|
||||
write(remaining);
|
||||
return true;
|
||||
}
|
||||
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
|
||||
Promise.resolve(attachmentStore?.delete(id)).then(async () => {
|
||||
try { write(remaining); }
|
||||
catch (error) {
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function resume(id, confirmedLogin) {
|
||||
@@ -55,11 +133,20 @@ function createUnfiledCaptures({
|
||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||
throw new Error('Reconnect with the account that saved this capture.');
|
||||
}
|
||||
discard(id);
|
||||
return {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
if (!item.hasAttachment) return draft;
|
||||
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
|
||||
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
|
||||
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
|
||||
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
return {...draft, attachment};
|
||||
});
|
||||
}
|
||||
|
||||
return {list, save, discard, resume};
|
||||
function completeResume(id) { return discard(id); }
|
||||
|
||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
@@ -0,0 +1,87 @@
|
||||
function createUpdateFollowUp({ storage = null, getLogin = () => '' } = {}) {
|
||||
const sourceKey = 'stackchain.update-follow-up-source.v1';
|
||||
const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
|
||||
const safeRepository = value => {
|
||||
const candidate = String(value || '');
|
||||
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate) &&
|
||||
candidate.split('/').every(part => part !== '.' && part !== '..') ? candidate : '';
|
||||
};
|
||||
const safeUrl = value => {
|
||||
try {
|
||||
const parsed = new URL(String(value || ''));
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : '';
|
||||
} catch (_error) { return ''; }
|
||||
};
|
||||
|
||||
function draft(detail = {}) {
|
||||
const source = safeUrl(detail.url || detail.latest_comment?.url);
|
||||
const context = clean(detail.latest_comment?.body || detail.subject_body, 9000);
|
||||
const title = clean(detail.title, 228);
|
||||
const sections = [];
|
||||
if (source) sections.push('Source: ' + source);
|
||||
if (context) sections.push('Latest context:\n> ' + context.replace(/\n/g, '\n> '));
|
||||
return {
|
||||
repository: safeRepository(detail.repository),
|
||||
title: ('Follow up: ' + (title || 'Unread update')).slice(0, 240),
|
||||
body: sections.join('\n\n').slice(0, 9500),
|
||||
};
|
||||
}
|
||||
|
||||
function readSource() {
|
||||
const login = String(getLogin() || '').trim();
|
||||
if (!login) return null;
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(sourceKey) || 'null');
|
||||
return record?.version === 1 && record.ownerLogin === login &&
|
||||
Number.isInteger(record.source?.notification_id) && record.source.notification_id > 0 ? record : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeSource(record) {
|
||||
storage?.setItem(sourceKey, JSON.stringify(record));
|
||||
}
|
||||
|
||||
function stageSource(item = {}) {
|
||||
const ownerLogin = String(getLogin() || '').trim();
|
||||
const notificationId = Number(item.notification_id);
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before continuing this follow-up.');
|
||||
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid source update.');
|
||||
const source = {
|
||||
notification_id: notificationId,
|
||||
repository: safeRepository(item.repository),
|
||||
number: Number.isInteger(item.number) && item.number > 0 ? item.number : null,
|
||||
title: clean(item.title, 240), kind: 'update', has_update: true,
|
||||
};
|
||||
writeSource({ version:1, ownerLogin, source, admission:null });
|
||||
return { ...source };
|
||||
}
|
||||
|
||||
function source() {
|
||||
const record = readSource();
|
||||
return record ? { ...record.source } : null;
|
||||
}
|
||||
|
||||
function discardSource() {
|
||||
if (!readSource()) return false;
|
||||
storage?.removeItem(sourceKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function complete({ admit, queueRead, advance }) {
|
||||
const record = readSource();
|
||||
if (!record) throw new Error('Reopen the source update before continuing.');
|
||||
let admission = record.admission;
|
||||
if (!admission) {
|
||||
admission = await admit();
|
||||
writeSource({ ...record, admission });
|
||||
}
|
||||
await queueRead(record.source.notification_id);
|
||||
const advanced = await advance({ ...record.source });
|
||||
storage?.removeItem(sourceKey);
|
||||
return { admission, advanced:Boolean(advanced) };
|
||||
}
|
||||
|
||||
return { draft, stageSource, source, discardSource, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
|
||||
@@ -0,0 +1,39 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageLauncher(options) {
|
||||
let pending = null;
|
||||
|
||||
function open() {
|
||||
if (pending) return pending;
|
||||
options.selectUpdates();
|
||||
options.announce('Checking all unread updates…');
|
||||
pending = Promise.resolve()
|
||||
.then(() => options.discover())
|
||||
.then(discovery => {
|
||||
if (!options.isUpdatesSelected()) return 'cancelled';
|
||||
if (discovery === false || discovery?.complete === false) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
return 'incomplete';
|
||||
}
|
||||
if (discovery?.complete) options.applySnapshot?.(discovery);
|
||||
if (options.hasMore()) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
return 'incomplete';
|
||||
}
|
||||
const opened = options.hasCheckpoint() ? options.resume() : options.start();
|
||||
if (!opened) options.announce('No unread updates are ready.');
|
||||
return opened ? 'opened' : 'empty';
|
||||
})
|
||||
.catch(() => {
|
||||
if (options.isUpdatesSelected()) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
}
|
||||
return 'incomplete';
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {open};
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageSession = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageSession(options) {
|
||||
const key = options.key || 'stackchain.update-triage.v1';
|
||||
const identity = item => Number.isInteger(item?.notification_id) ? String(item.notification_id) : '';
|
||||
const login = () => String(options.getLogin() || '').trim();
|
||||
let state = null;
|
||||
let running = false;
|
||||
|
||||
function read() {
|
||||
const owner = login();
|
||||
if (!owner) return null;
|
||||
try {
|
||||
const value = JSON.parse(options.storage.getItem(key) || 'null');
|
||||
if (value?.version !== 1 || value.login !== owner || !Array.isArray(value.identities) ||
|
||||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
|
||||
const identities = value.identities.filter(value => typeof value === 'string' && value);
|
||||
const completed = value.completed.filter(value => identities.includes(value));
|
||||
if (!identities.length || !identities.includes(value.current)) return null;
|
||||
return { version:1, login:owner, identities, current:value.current, completed };
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
if (!state) return;
|
||||
options.storage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function available() {
|
||||
const byIdentity = new Map((options.getItems() || []).map(item => [identity(item), item]));
|
||||
return state.identities.filter(id => !state.completed.includes(id) && byIdentity.has(id))
|
||||
.map(id => byIdentity.get(id));
|
||||
}
|
||||
|
||||
function finish() {
|
||||
running = false;
|
||||
state = null;
|
||||
options.storage.removeItem(key);
|
||||
options.onFinish();
|
||||
return false;
|
||||
}
|
||||
|
||||
function openCurrent(preferredIndex = null) {
|
||||
const items = available();
|
||||
if (!items.length) return finish();
|
||||
let index = items.findIndex(item => identity(item) === state.current);
|
||||
if (index < 0) index = Math.min(preferredIndex ?? 0, items.length - 1);
|
||||
state.current = identity(items[index]);
|
||||
persist();
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
options.onProgress({ index:originalIndex + 1, total:state.identities.length });
|
||||
options.onOpen(items[index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function advance(openNext = true) {
|
||||
if (!running || !state) return false;
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
if (!state.completed.includes(state.current)) state.completed.push(state.current);
|
||||
const candidates = available();
|
||||
const next = candidates.find(item => state.identities.indexOf(identity(item)) > originalIndex) || candidates[0];
|
||||
if (!next) return finish();
|
||||
state.current = identity(next);
|
||||
if (openNext) return openCurrent();
|
||||
persist();
|
||||
options.onProgress({ index:state.identities.indexOf(state.current) + 1, total:state.identities.length });
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
resumable: () => Boolean(read()),
|
||||
start() {
|
||||
const identities = (options.getItems() || []).map(identity).filter(Boolean);
|
||||
if (!identities.length) return finish();
|
||||
state = { version:1, login:login(), identities, current:identities[0], completed:[] };
|
||||
if (!state.login) return false;
|
||||
running = true;
|
||||
return openCurrent();
|
||||
},
|
||||
resume() {
|
||||
state = read();
|
||||
if (!state) return false;
|
||||
running = true;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
reconcile() {
|
||||
if (!running || !state) return false;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
completeAndNext: advance,
|
||||
acceptCompleted: () => advance(false),
|
||||
keepUnreadAndNext: advance,
|
||||
items: () => state ? available().slice() : [],
|
||||
end: finish,
|
||||
};
|
||||
});
|
||||
+1
-14
@@ -5,19 +5,6 @@ function renderRepoMix(element, data) {
|
||||
element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>';
|
||||
}
|
||||
|
||||
async function updateRepoMix(element, fetchContext = fetch) {
|
||||
try {
|
||||
const response = await fetchContext('api/v1/context', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
renderRepoMix(element, await response.json());
|
||||
} catch (error) {
|
||||
element.innerHTML = '<div class="muted">Widget unavailable.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
updateRepoMix.renderRepoMix = renderRepoMix;
|
||||
module.exports = updateRepoMix;
|
||||
module.exports = renderRepoMix;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'update', 'later', 'draft'];
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
@@ -15,6 +16,13 @@
|
||||
function parse(fragment) {
|
||||
const parts = String(fragment || '').split('/');
|
||||
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
|
||||
const queue = parts[2]?.replace(/s$/, '');
|
||||
if (queueFilters.includes(queue) && parts.length === 3) {
|
||||
return { kind: 'queue', filter: queue };
|
||||
}
|
||||
if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
|
||||
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
|
||||
}
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
@@ -38,6 +46,7 @@
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
if (route.kind === 'queue') return false;
|
||||
if (route.kind === 'update') {
|
||||
return Number(item.notification_id) === route.notification_id;
|
||||
}
|
||||
@@ -48,6 +57,7 @@
|
||||
|
||||
function createController({
|
||||
location, history, eventTarget, onOpen, onClose, onInvalid,
|
||||
onQueue = function () {},
|
||||
resolve, onResolving = function () {}, onError = function () {},
|
||||
}) {
|
||||
let items = [];
|
||||
@@ -100,6 +110,16 @@
|
||||
active = '';
|
||||
return;
|
||||
}
|
||||
if (route.kind === 'queue') {
|
||||
if (!ready) return;
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
if (active && active !== fragment) onClose();
|
||||
if (active === fragment) return;
|
||||
active = fragment;
|
||||
onQueue(route.filter, route.action || null);
|
||||
return;
|
||||
}
|
||||
const item = items.find(candidate => sameRoute(candidate, route));
|
||||
if (!item) {
|
||||
if (ready) resolveMissing(fragment, route);
|
||||
@@ -134,6 +154,15 @@
|
||||
onOpen(item);
|
||||
return true;
|
||||
},
|
||||
queue(filter) {
|
||||
if (!queueFilters.includes(filter)) return false;
|
||||
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
|
||||
const fragment = '#/my-work/' + name;
|
||||
if (location.hash !== fragment) history.pushState(null, '', fragment);
|
||||
active = fragment;
|
||||
onQueue(filter);
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (parse(location.hash)) history.back();
|
||||
else {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
let active = false;
|
||||
const selected = new Set();
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function changed() {
|
||||
const state = snapshot();
|
||||
onChange(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function start() {
|
||||
active = true;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function select(item) {
|
||||
if (!active) return 'inactive';
|
||||
const id = identity(item);
|
||||
if (!id) return 'invalid';
|
||||
if (selected.has(id)) return 'selected';
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.add(id);
|
||||
changed();
|
||||
return 'selected';
|
||||
}
|
||||
|
||||
function toggle(item) {
|
||||
if (!active) return 'inactive';
|
||||
const id = identity(item);
|
||||
if (!id) return 'invalid';
|
||||
if (selected.has(id)) {
|
||||
selected.delete(id);
|
||||
changed();
|
||||
return 'removed';
|
||||
}
|
||||
return select(item);
|
||||
}
|
||||
|
||||
function selectMany(items) {
|
||||
if (!active) return { status: 'inactive', added: 0, count: selected.size, limit: maximum };
|
||||
let added = 0;
|
||||
let capped = false;
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id || selected.has(id)) continue;
|
||||
if (selected.size >= maximum) {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
selected.add(id);
|
||||
added += 1;
|
||||
}
|
||||
changed();
|
||||
return { status: capped ? 'limit' : 'selected', added, count: selected.size, limit: maximum };
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!active) return snapshot();
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function retain(items) {
|
||||
const retained = new Set((items || []).map(identity).filter(Boolean));
|
||||
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
|
||||
return changed();
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { active, count: selected.size, ids: Array.from(selected) };
|
||||
}
|
||||
|
||||
return { identity, start, cancel, select, selectMany, clear, toggle, retain, snapshot, limit: maximum };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;
|
||||
@@ -3,6 +3,7 @@ httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
python-multipart==0.0.22
|
||||
pytest==9.1.1
|
||||
pywebpush==2.1.2
|
||||
rjsmin==1.2.5
|
||||
uvicorn==0.41.0
|
||||
webauthn==3.0.0
|
||||
+49
-2
@@ -10,6 +10,7 @@ import os
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
@@ -47,6 +48,33 @@ def mode() -> str:
|
||||
return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()
|
||||
|
||||
|
||||
def public_origin() -> str | None:
|
||||
"""Return the canonical HTTPS browser origin, or None when it is invalid."""
|
||||
configured = os.getenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "").strip()
|
||||
if not configured:
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(configured)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.path not in {"", "/"}
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
return None
|
||||
hostname = parsed.hostname.lower()
|
||||
authority = f"[{hostname}]" if ":" in hostname else hostname
|
||||
if port is not None and port != 443:
|
||||
authority += f":{port}"
|
||||
return f"https://{authority}"
|
||||
|
||||
|
||||
def configuration_error() -> str | None:
|
||||
configured_mode = mode()
|
||||
if configured_mode == INSECURE_LOCAL_MODE:
|
||||
@@ -59,6 +87,8 @@ def configuration_error() -> str | None:
|
||||
return "operator mode requires two sufficiently long secrets"
|
||||
if hmac.compare_digest(operator_secret, signing_secret):
|
||||
return "operator and session secrets must be independent"
|
||||
if public_origin() is None:
|
||||
return "operator mode requires a canonical HTTPS public origin"
|
||||
return None
|
||||
|
||||
|
||||
@@ -205,6 +235,23 @@ async def session_management_id(session: Session) -> str:
|
||||
return await asyncio.to_thread(_session_store().management_id, session.session_id)
|
||||
|
||||
|
||||
async def managed_session_active(management_id: str) -> bool:
|
||||
status = await asyncio.to_thread(
|
||||
_session_store().managed_status,
|
||||
management_id,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
return status == "active"
|
||||
|
||||
|
||||
async def managed_session_statuses(management_ids) -> dict[str, str]:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().managed_statuses,
|
||||
management_ids,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
|
||||
|
||||
async def touch_session(session: Session) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().touch,
|
||||
@@ -265,5 +312,5 @@ def application_path(request: Request) -> str:
|
||||
|
||||
def same_origin(request: Request) -> bool:
|
||||
origin = request.headers.get("origin", "")
|
||||
expected = f"{request.url.scheme}://{request.url.netloc}"
|
||||
return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected)
|
||||
expected = public_origin()
|
||||
return bool(origin and expected) and hmac.compare_digest(origin.rstrip("/"), expected)
|
||||
+35
-3
@@ -12,10 +12,27 @@ import rjsmin
|
||||
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
COMMONJS_EXPORT_LINE = re.compile(
|
||||
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$(?!\n\s*else)", re.MULTILINE
|
||||
)
|
||||
COMMONJS_BROWSER_BRANCH = re.compile(
|
||||
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*\n\s*else\s*\{",
|
||||
re.MULTILINE,
|
||||
)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"issue-capture": ("static/create-issue-sheet.js",),
|
||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||
"issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-triage-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.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",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
@@ -49,6 +66,10 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
||||
path = frontend_dir / source.removeprefix("static/")
|
||||
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
|
||||
source = b"".join(chunks)
|
||||
# Node-only export shims support source-level unit tests but are unreachable
|
||||
# in the browser. Strip the simple one-line form from shipped bundles.
|
||||
source = COMMONJS_BROWSER_BRANCH.sub(b"{", source)
|
||||
source = COMMONJS_EXPORT_LINE.sub(b"", source)
|
||||
revision = hashlib.sha256(source).hexdigest()
|
||||
minified = rjsmin.jsmin(source.decode()).encode()
|
||||
return minified + f';"source-sha256:{revision}";'.encode()
|
||||
@@ -86,18 +107,29 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
)
|
||||
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>", f'<script src="{core.runtime_name}"></script>\n</body>'
|
||||
"</body>",
|
||||
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{core.runtime_name}"></script>\n</body>',
|
||||
)
|
||||
|
||||
worker = (frontend_dir / "service-worker.js").read_text()
|
||||
for source in sources:
|
||||
if source != WORKER_RUNTIME_SOURCE:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
eager_feature = feature_bundles["today-timer"]
|
||||
optional_features = {
|
||||
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
|
||||
}
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
" BASE + 'static/dashboard.css',\n"
|
||||
+ f" BASE + '{core.runtime_name}',\n"
|
||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()),
|
||||
+ f" BASE + '{eager_feature.runtime_name}',\n",
|
||||
)
|
||||
worker = worker.replace(
|
||||
"const OPTIONAL_FEATURES = [\n",
|
||||
"const OPTIONAL_FEATURES = [\n"
|
||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
|
||||
)
|
||||
worker = CACHE_DECLARATION.sub(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||
|
||||
+356
-3
@@ -4,7 +4,7 @@ import re
|
||||
import shlex
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
from urllib.parse import quote, urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -20,6 +20,10 @@ class GiteaOverloadedError(RuntimeError):
|
||||
"""Raised when bounded transport admission expires before capacity is available."""
|
||||
|
||||
|
||||
class CommentMutationForbiddenError(RuntimeError):
|
||||
"""Raised when a comment is not owned by the operator or enclosing thread."""
|
||||
|
||||
|
||||
class GiteaTransport:
|
||||
"""Application-lifetime HTTP transport with single-flight concurrent reads."""
|
||||
|
||||
@@ -210,6 +214,35 @@ async def fetch(path: str) -> Any:
|
||||
return r.json()
|
||||
|
||||
|
||||
def issue_time_target(identity: str) -> tuple[str, str]:
|
||||
"""Return the canonical repository and issue number encoded by an identity."""
|
||||
match = re.fullmatch(
|
||||
r"(?:issue|pull):([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+):([1-9][0-9]*):",
|
||||
identity,
|
||||
)
|
||||
if match is None:
|
||||
raise ValueError("time log target is invalid")
|
||||
return match.groups()
|
||||
|
||||
|
||||
async def log_issue_time(identity: str, seconds: int) -> None:
|
||||
"""Log time to the canonical issue endpoint encoded by a dashboard identity."""
|
||||
repository, number = issue_time_target(identity)
|
||||
if not isinstance(seconds, int) or isinstance(seconds, bool) or not 1 <= seconds <= 86400:
|
||||
raise ValueError("time log duration is invalid")
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/times",
|
||||
headers={**_auth(), "Content-Type": "application/json"},
|
||||
json={"time": seconds},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def time_log_failure_is_retryable(error: Exception) -> bool:
|
||||
"""Return true only when failure proves no successful response was lost."""
|
||||
return isinstance(error, (ValueError, GiteaOverloadedError, httpx.ConnectError, httpx.HTTPStatusError))
|
||||
|
||||
|
||||
async def fetch_text(path: str, max_bytes: int) -> tuple[str, bool]:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
@@ -370,6 +403,41 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def assigned_issue_snapshot(
|
||||
*,
|
||||
limit: int = 50,
|
||||
max_pages: int = 20,
|
||||
max_concurrency: int = 4,
|
||||
deadline_seconds: float = 5.0,
|
||||
) -> dict:
|
||||
"""Load a complete, bounded assigned-issue snapshot for deadline dispatch."""
|
||||
async with asyncio.timeout(deadline_seconds):
|
||||
first = await work_page("issue", 1, limit)
|
||||
page_count = max(1, (first["total"] + limit - 1) // limit)
|
||||
if page_count > max_pages:
|
||||
raise ValueError("Assigned issue snapshot exceeds the scan limit")
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def load(page: int) -> dict:
|
||||
async with semaphore:
|
||||
return await work_page("issue", page, limit)
|
||||
|
||||
remaining = await asyncio.gather(
|
||||
*(load(page) for page in range(2, page_count + 1))
|
||||
)
|
||||
pages = [first, *remaining]
|
||||
if any(page.get("total") != first["total"] for page in pages[1:]):
|
||||
raise ValueError("Assigned issue pagination changed during the scan")
|
||||
items = [item for page in pages for item in page["items"]]
|
||||
issue_ids = [
|
||||
item.get("id") for item in items
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), int) and item["id"] > 0
|
||||
]
|
||||
if len(items) != first["total"] or len(set(issue_ids)) != first["total"]:
|
||||
raise ValueError("Assigned issue snapshot has an incomplete issue set")
|
||||
return {"items": items, "complete": True}
|
||||
|
||||
|
||||
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
@@ -472,6 +540,7 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
"assignees": assignee_names,
|
||||
"url": _safe_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),
|
||||
}
|
||||
|
||||
@@ -708,6 +777,41 @@ async def notifications() -> dict:
|
||||
return await notification_page(1)
|
||||
|
||||
|
||||
async def unread_notification_snapshot(
|
||||
*,
|
||||
limit: int = 50,
|
||||
max_pages: int = 20,
|
||||
max_concurrency: int = 4,
|
||||
deadline_seconds: float = 5.0,
|
||||
) -> dict:
|
||||
"""Load one complete, bounded snapshot of unread notification threads."""
|
||||
async with asyncio.timeout(deadline_seconds):
|
||||
first = await notification_page(1, limit)
|
||||
page_count = max(1, (first["total"] + limit - 1) // limit)
|
||||
if page_count > max_pages:
|
||||
raise ValueError("Unread notification snapshot exceeds the scan limit")
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def load(page: int) -> dict:
|
||||
async with semaphore:
|
||||
return await notification_page(page, limit)
|
||||
|
||||
remaining = await asyncio.gather(
|
||||
*(load(page) for page in range(2, page_count + 1))
|
||||
)
|
||||
pages = [first, *remaining]
|
||||
if any(page["total"] != first["total"] for page in pages[1:]):
|
||||
raise ValueError("Unread notification pagination changed during the scan")
|
||||
items = [item for page in pages for item in page["items"]]
|
||||
thread_ids = [
|
||||
item.get("id") for item in items
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), int) and item["id"] > 0
|
||||
]
|
||||
if len(items) != first["total"] or len(set(thread_ids)) != first["total"]:
|
||||
raise ValueError("Unread notification snapshot has an incomplete thread set")
|
||||
return {"items": items, "total": first["total"], "complete": True}
|
||||
|
||||
|
||||
async def notification_page(page: int, limit: int = 50) -> dict:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/notifications?status-types=unread&limit={limit}&page={page}",
|
||||
@@ -735,6 +839,14 @@ async def mark_notification_read(thread_id: int) -> None:
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def mark_notification_unread(thread_id: int) -> None:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/notifications/threads/{thread_id}?to-status=unread",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def acknowledge_notification(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -804,6 +916,14 @@ def _gitea_api_path(value: Any) -> str:
|
||||
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "")
|
||||
|
||||
|
||||
async def _notification_subscription(repository: str, number: str) -> dict:
|
||||
try:
|
||||
value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
|
||||
except Exception:
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
async def notification_detail(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -828,18 +948,20 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
)
|
||||
if supported_conversation:
|
||||
assert conversation_match is not None
|
||||
subject_detail, comment, conversation = await asyncio.gather(
|
||||
subject_detail, comment, conversation, subscription = await asyncio.gather(
|
||||
fetch(subject_path),
|
||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||
issue_conversation_page(
|
||||
conversation_match.group(1), int(conversation_match.group(3))
|
||||
),
|
||||
_notification_subscription(conversation_match.group(1), conversation_match.group(3)),
|
||||
)
|
||||
else:
|
||||
subject_detail, comment, conversation = await asyncio.gather(
|
||||
subject_detail, comment, conversation, subscription = await asyncio.gather(
|
||||
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
|
||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
|
||||
asyncio.sleep(0, result={}),
|
||||
)
|
||||
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
|
||||
comment = comment if isinstance(comment, dict) else {}
|
||||
@@ -900,6 +1022,12 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
and comment_match
|
||||
and comment_match.group(1) == repository_name
|
||||
),
|
||||
"mute_supported": bool(
|
||||
supported_conversation
|
||||
and isinstance(subscription, dict)
|
||||
and subscription.get("subscribed") is True
|
||||
and subscription.get("ignored") is not True
|
||||
),
|
||||
"conversation": conversation,
|
||||
}
|
||||
|
||||
@@ -927,6 +1055,40 @@ async def notification_conversation_page(
|
||||
)
|
||||
|
||||
|
||||
async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
raise ValueError("Gitea notification thread response was not an object")
|
||||
repository = thread.get("repository")
|
||||
subject_value = thread.get("subject")
|
||||
subject = subject_value if isinstance(subject_value, dict) else {}
|
||||
repository_name = repository.get("full_name") if isinstance(repository, dict) else None
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
|
||||
if (
|
||||
not match
|
||||
or match.group(1) != repository_name
|
||||
or subject.get("type") not in {"Issue", "Pull"}
|
||||
):
|
||||
raise ValueError("Notification subject is not a supported conversation")
|
||||
return match.group(1), int(match.group(3))
|
||||
|
||||
|
||||
async def mute_notification(thread_id: int) -> dict:
|
||||
"""Unsubscribe the current operator from the trusted notification conversation."""
|
||||
repository, number = await notification_conversation_target(thread_id)
|
||||
user = await fetch("user")
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("Gitea did not identify the current operator")
|
||||
response = await _get_client().delete(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/subscriptions/{quote(login, safe='')}",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"id": thread_id, "repository": repository, "number": number, "muted": True}
|
||||
|
||||
|
||||
async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -1041,6 +1203,51 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict:
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def _owned_comment(repository: str, number: int, comment_id: int) -> dict:
|
||||
user, comment = await asyncio.gather(
|
||||
current_user(),
|
||||
fetch(f"repos/{repository}/issues/comments/{comment_id}"),
|
||||
)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
author = comment.get("user") if isinstance(comment, dict) else None
|
||||
expected_issue_path = f"repos/{repository}/issues/{number}"
|
||||
if (
|
||||
not isinstance(login, str)
|
||||
or not login
|
||||
or not isinstance(comment, dict)
|
||||
or not isinstance(author, dict)
|
||||
or author.get("login") != login
|
||||
or _gitea_api_path(comment.get("issue_url")) != expected_issue_path
|
||||
):
|
||||
raise CommentMutationForbiddenError("Comment is not editable in this conversation")
|
||||
return comment
|
||||
|
||||
|
||||
async def edit_owned_comment(
|
||||
repository: str, number: int, comment_id: int, body: str
|
||||
) -> dict:
|
||||
await _owned_comment(repository, number, comment_id)
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/comments/{comment_id}",
|
||||
headers=_auth(),
|
||||
json={"body": body},
|
||||
)
|
||||
response.raise_for_status()
|
||||
comment = response.json()
|
||||
if not isinstance(comment, dict):
|
||||
raise ValueError("Gitea comment response was not an object")
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def delete_owned_comment(repository: str, number: int, comment_id: int) -> dict:
|
||||
await _owned_comment(repository, number, comment_id)
|
||||
response = await _get_client().delete(
|
||||
f"/api/v1/repos/{repository}/issues/comments/{comment_id}", headers=_auth()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"id": comment_id, "deleted": True}
|
||||
|
||||
|
||||
async def upload_assigned_issue_attachment(
|
||||
repository: str,
|
||||
number: int,
|
||||
@@ -1272,6 +1479,66 @@ async def claim_available_issue(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def reopen_issue(repository: str, number: int) -> dict:
|
||||
"""Reopen a closed issue, assign it to the current user, and confirm both."""
|
||||
issue, user = await asyncio.gather(
|
||||
fetch(f"repos/{repository}/issues/{number}"), current_user()
|
||||
)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if (
|
||||
not isinstance(issue, dict)
|
||||
or issue.get("number") != number
|
||||
or issue.get("pull_request") is not None
|
||||
or not isinstance(login, str)
|
||||
or not login
|
||||
):
|
||||
raise IssueNotAvailableError("Issue cannot be resumed")
|
||||
|
||||
if issue.get("state") == "open" and _login_in_users(login, issue.get("assignees")):
|
||||
confirmed = issue
|
||||
elif issue.get("state") == "closed":
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"state": "open", "assignee": login},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
else:
|
||||
raise IssueNotAvailableError("Issue is no longer available to resume")
|
||||
assignees_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
|
||||
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||
logins = [
|
||||
assignee["login"] for assignee in assignees
|
||||
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
|
||||
]
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed.get("state") != "open"
|
||||
or login not in logins
|
||||
):
|
||||
raise ValueError("Gitea did not confirm issue reopening and assignment")
|
||||
labels_value = confirmed.get("labels")
|
||||
labels = labels_value if isinstance(labels_value, list) else []
|
||||
return {
|
||||
"id": confirmed.get("id"),
|
||||
"number": number,
|
||||
"title": confirmed.get("title", "")
|
||||
if isinstance(confirmed.get("title"), str) else "",
|
||||
"state": "open",
|
||||
"repository": repository,
|
||||
"labels": [
|
||||
label["name"] for label in labels
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||
],
|
||||
"assignees": logins,
|
||||
"updated_at": confirmed.get("updated_at", "")
|
||||
if isinstance(confirmed.get("updated_at"), str) else "",
|
||||
"url": _safe_web_url(confirmed.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
async def release_assigned_issue(repository: str, number: int) -> dict:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
@@ -1437,6 +1704,92 @@ async def handoff_assigned_issue(
|
||||
}
|
||||
|
||||
|
||||
async def pull_handoff_candidates(repository: str) -> list[dict]:
|
||||
candidates = await issue_handoff_candidates(repository)
|
||||
return [
|
||||
item for item in candidates
|
||||
if re.fullmatch(r"[A-Za-z0-9_.-]+", item["login"])
|
||||
][:25]
|
||||
|
||||
|
||||
async def _change_assigned_pull_owners(
|
||||
repository: str, number: int, recipient: str | None
|
||||
) -> dict:
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
)
|
||||
assignees_value = pull.get("assignees")
|
||||
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||
login_folded = login.casefold()
|
||||
assigned_to_login = any(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get("login"), str)
|
||||
and item["login"].casefold() == login_folded
|
||||
for item in assignees
|
||||
)
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or not assigned_to_login
|
||||
):
|
||||
raise IssueNotAvailableError("Pull request is not assigned to the current user")
|
||||
|
||||
if recipient is not None:
|
||||
eligible = {
|
||||
item["login"] for item in await pull_handoff_candidates(repository)
|
||||
}
|
||||
if recipient not in eligible:
|
||||
raise IssueNotAvailableError("Handoff recipient is not eligible")
|
||||
|
||||
desired = [
|
||||
item["login"] for item in assignees
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("login"), str)
|
||||
and item["login"].casefold() != login_folded
|
||||
]
|
||||
if recipient is not None and recipient not in desired:
|
||||
desired.append(recipient)
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"assignees": desired},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
|
||||
confirmed_assignees = [
|
||||
item["login"] for item in confirmed_value
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
] if isinstance(confirmed_value, list) else []
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or login_folded in {item.casefold() for item in confirmed_assignees}
|
||||
or set(confirmed_assignees) != set(desired)
|
||||
or (recipient is not None and recipient not in confirmed_assignees)
|
||||
):
|
||||
raise ValueError("Gitea did not confirm pull request ownership change")
|
||||
result = {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"assignees": confirmed_assignees,
|
||||
}
|
||||
if recipient is not None:
|
||||
result["recipient"] = recipient
|
||||
return result
|
||||
|
||||
|
||||
async def handoff_assigned_pull(
|
||||
repository: str, number: int, recipient: str
|
||||
) -> dict:
|
||||
return await _change_assigned_pull_owners(repository, number, recipient)
|
||||
|
||||
|
||||
async def release_assigned_pull(repository: str, number: int) -> dict:
|
||||
return await _change_assigned_pull_owners(repository, number, None)
|
||||
|
||||
|
||||
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
|
||||
+10
-2
@@ -22,10 +22,14 @@ class LaterStore:
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
|
||||
connection.close()
|
||||
return
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -63,8 +67,12 @@ class LaterStore:
|
||||
"UPDATE later_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.execute("PRAGMA user_version = 1")
|
||||
connection.commit()
|
||||
return connection
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
|
||||
+971
-41
File diff suppressed because it is too large.
Load diff
+17
-4
@@ -225,13 +225,26 @@ class PasskeyStore:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
|
||||
def advance_counter(
|
||||
self, credential_id: bytes, *, expected: int, new: int
|
||||
) -> bool:
|
||||
if expected == 0 and new == 0:
|
||||
comparison = "sign_count = 0"
|
||||
elif new > expected:
|
||||
comparison = "sign_count = ?"
|
||||
else:
|
||||
return False
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
parameters = (
|
||||
(new, credential_id)
|
||||
if expected == 0 and new == 0
|
||||
else (new, credential_id, expected)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"UPDATE passkey_credentials SET sign_count = ? "
|
||||
"WHERE credential_id = ? AND sign_count <= ?",
|
||||
(new_sign_count, credential_id, new_sign_count),
|
||||
f"UPDATE passkey_credentials SET sign_count = ? "
|
||||
f"WHERE credential_id = ? AND {comparison}",
|
||||
parameters,
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from collections.abc import Awaitable, Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
class UnsafePushEndpoint(ValueError):
|
||||
"""Raised when a Web Push endpoint could reach a non-public service."""
|
||||
|
||||
|
||||
Resolver = Callable[[str, int], Awaitable[list[str]]]
|
||||
|
||||
|
||||
async def _resolve(host: str, port: int) -> list[str]:
|
||||
loop = asyncio.get_running_loop()
|
||||
records = await loop.getaddrinfo(
|
||||
host,
|
||||
port,
|
||||
family=socket.AF_UNSPEC,
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
return sorted({record[4][0] for record in records})
|
||||
|
||||
|
||||
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."""
|
||||
parsed = urlsplit(endpoint)
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as error:
|
||||
raise UnsafePushEndpoint(
|
||||
"Endpoint must be a canonical public Web Push service URL"
|
||||
) from error
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
or port not in {None, 443}
|
||||
):
|
||||
raise UnsafePushEndpoint("Endpoint must be a canonical public Web Push service URL")
|
||||
try:
|
||||
addresses = await asyncio.wait_for(
|
||||
resolver(parsed.hostname, port or 443),
|
||||
timeout=max(0.01, timeout_seconds),
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as error:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
|
||||
try:
|
||||
public = addresses and all(ipaddress.ip_address(address).is_global for address in addresses)
|
||||
except ValueError as error:
|
||||
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
|
||||
@@ -0,0 +1,401 @@
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Awaitable, Callable
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushConfiguration:
|
||||
public_key: str
|
||||
private_key: str
|
||||
subject: str
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.public_key and self.private_key and self.subject)
|
||||
|
||||
|
||||
async def send_web_push(
|
||||
subscription: dict, payload: str, configuration: PushConfiguration
|
||||
) -> None:
|
||||
import requests
|
||||
from pywebpush import webpush
|
||||
|
||||
session = requests.Session()
|
||||
session.max_redirects = 0
|
||||
|
||||
await asyncio.to_thread(
|
||||
webpush,
|
||||
subscription_info=subscription,
|
||||
data=payload,
|
||||
vapid_private_key=configuration.private_key,
|
||||
vapid_claims={"sub": configuration.subject},
|
||||
ttl=300,
|
||||
timeout=10,
|
||||
requests_session=session,
|
||||
)
|
||||
|
||||
|
||||
async def dispatch_unread_updates(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
unread: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
max_individual_notifications: int = 3,
|
||||
endpoint_validator: Callable[[str], Awaitable[str]] | None = None,
|
||||
) -> int:
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
page = await unread()
|
||||
if page.get("complete") is False:
|
||||
return 0
|
||||
thread_revisions = {
|
||||
int(item["id"]): str(item.get("updated_at") or "")
|
||||
for item in page.get("items", [])
|
||||
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
|
||||
}
|
||||
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
|
||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses(
|
||||
[item.session_id for item in deliveries]
|
||||
)
|
||||
except Exception:
|
||||
# Authorization state is mandatory for delivery. Preserve subscriptions
|
||||
# so a temporary registry failure can be retried safely.
|
||||
return 0
|
||||
for delivery in deliveries:
|
||||
if statuses.get(delivery.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, delivery.session_id)
|
||||
deliveries = [
|
||||
delivery
|
||||
for delivery in deliveries
|
||||
if statuses.get(delivery.session_id) == "active"
|
||||
]
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
ownership_lost = asyncio.Event()
|
||||
|
||||
async def dispatch_device(delivery) -> int:
|
||||
async with semaphore:
|
||||
if ownership_lost.is_set():
|
||||
return 0
|
||||
validate_endpoint = endpoint_validator
|
||||
if validate_endpoint is None and send is None:
|
||||
validate_endpoint = validate_public_push_endpoint
|
||||
try:
|
||||
if validate_endpoint is not None:
|
||||
await validate_endpoint(delivery.subscription["endpoint"])
|
||||
except UnsafePushEndpoint:
|
||||
await asyncio.to_thread(store.delete_session, delivery.session_id)
|
||||
return 0
|
||||
count = 0
|
||||
digest_pending = set(delivery.digest_revisions)
|
||||
new_revisions = tuple(
|
||||
thread_revision
|
||||
for thread_revision in delivery.thread_revisions
|
||||
if thread_revision not in digest_pending
|
||||
)
|
||||
individual_revisions = new_revisions[
|
||||
:max(0, max_individual_notifications)
|
||||
]
|
||||
overflow_revisions = (
|
||||
delivery.digest_revisions
|
||||
+ new_revisions[len(individual_revisions):]
|
||||
)
|
||||
can_send_digest = True
|
||||
for thread_id, revision in individual_revisions:
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
if not still_owner:
|
||||
ownership_lost.set()
|
||||
return count
|
||||
payload = json.dumps(
|
||||
{
|
||||
"title": "New work update",
|
||||
"body": "Tap to review it in Stackchain.",
|
||||
"route": f"#/my-work/update/{thread_id}",
|
||||
"tag": f"stackchain-update-{thread_id}",
|
||||
"notification_id": thread_id,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
try:
|
||||
if send is None:
|
||||
operation = send_web_push(
|
||||
delivery.subscription, payload, configuration
|
||||
)
|
||||
else:
|
||||
operation = send(delivery.subscription, payload)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(
|
||||
getattr(error, "response", None), "status_code", None
|
||||
)
|
||||
if status in {404, 410}:
|
||||
await asyncio.to_thread(
|
||||
store.delete_session, delivery.session_id
|
||||
)
|
||||
can_send_digest = False
|
||||
# Leave this device's transient failures unseen for a later
|
||||
# poll instead of paying the endpoint deadline repeatedly.
|
||||
break
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivered,
|
||||
delivery.session_id,
|
||||
((thread_id, revision),),
|
||||
)
|
||||
count += 1
|
||||
if overflow_revisions and can_send_digest:
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
if not still_owner:
|
||||
ownership_lost.set()
|
||||
return count
|
||||
payload = json.dumps(
|
||||
{
|
||||
"title": f"{len(overflow_revisions)} new work updates",
|
||||
"body": "Tap to review them in Stackchain.",
|
||||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-digest",
|
||||
"update_count": len(overflow_revisions),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
try:
|
||||
if send is None:
|
||||
operation = send_web_push(
|
||||
delivery.subscription, payload, configuration
|
||||
)
|
||||
else:
|
||||
operation = send(delivery.subscription, payload)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(
|
||||
getattr(error, "response", None), "status_code", None
|
||||
)
|
||||
if status in {404, 410}:
|
||||
await asyncio.to_thread(
|
||||
store.delete_session, delivery.session_id
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_digest_pending,
|
||||
delivery.session_id,
|
||||
overflow_revisions,
|
||||
)
|
||||
return count
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivered,
|
||||
delivery.session_id,
|
||||
overflow_revisions,
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def dispatch_device_safely(delivery) -> int:
|
||||
try:
|
||||
return await dispatch_device(delivery)
|
||||
except Exception:
|
||||
# Device-specific validation, persistence, or provider failures
|
||||
# must not cancel healthy siblings. Leave any uncheckpointed
|
||||
# revisions unseen so a later poll can retry them.
|
||||
return 0
|
||||
|
||||
counts = await asyncio.gather(
|
||||
*(dispatch_device_safely(delivery) for delivery in deliveries)
|
||||
)
|
||||
return sum(counts)
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="unread")
|
||||
|
||||
|
||||
async def dispatch_deadline_reminders(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
assigned: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
**kwargs,
|
||||
) -> int:
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="deadline",
|
||||
now=time.time(),
|
||||
lease_seconds=max(
|
||||
15.0,
|
||||
float(kwargs.get("lease_seconds", 60.0)),
|
||||
float(kwargs.get("send_timeout_seconds", 10.0)) + 5.0,
|
||||
),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
return await _dispatch_deadline_reminders_unlocked(
|
||||
store, configuration, assigned, send, owner=owner, **kwargs
|
||||
)
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="deadline")
|
||||
|
||||
|
||||
async def _dispatch_deadline_reminders_unlocked(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
assigned: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
lease_seconds: float = 60.0,
|
||||
max_concurrency: int = 8,
|
||||
owner: str,
|
||||
) -> int:
|
||||
"""Send one privacy-safe Agenda digest per eligible device and local day."""
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
devices = await asyncio.to_thread(store.deadline_reminder_devices)
|
||||
if not devices:
|
||||
return 0
|
||||
current = now or datetime.now(timezone.utc)
|
||||
eligible_devices = []
|
||||
for device in devices:
|
||||
try:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
except ZoneInfoNotFoundError:
|
||||
continue
|
||||
if (
|
||||
local_now.hour >= device.reminder_hour
|
||||
and device.delivered_local_day != local_now.date().isoformat()
|
||||
):
|
||||
eligible_devices.append(device)
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
snapshot = await assigned()
|
||||
if snapshot.get("complete") is False:
|
||||
return 0
|
||||
due_days = []
|
||||
for item in snapshot.get("items", []):
|
||||
if not isinstance(item, dict) or not item.get("due_date"):
|
||||
continue
|
||||
raw_due = str(item["due_date"])
|
||||
try:
|
||||
due_day = datetime.strptime(raw_due[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
continue
|
||||
due_days.append(due_day)
|
||||
if not due_days:
|
||||
return 0
|
||||
due_counts = {}
|
||||
for device in eligible_devices:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
local_cutoff = local_now.date() + timedelta(days=device.reminder_days)
|
||||
due_count = sum(due_day <= local_cutoff for due_day in due_days)
|
||||
if due_count:
|
||||
due_counts[device.session_id] = due_count
|
||||
eligible_devices = [
|
||||
device for device in eligible_devices if device.session_id in due_counts
|
||||
]
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses(
|
||||
[device.session_id for device in eligible_devices]
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
for device in eligible_devices:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
eligible_devices = [
|
||||
device
|
||||
for device in eligible_devices
|
||||
if statuses.get(device.session_id) == "active"
|
||||
]
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
try:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
except ZoneInfoNotFoundError:
|
||||
return 0
|
||||
local_day = local_now.date().isoformat()
|
||||
if (
|
||||
local_now.hour < device.reminder_hour
|
||||
or device.delivered_local_day == local_day
|
||||
):
|
||||
return 0
|
||||
due_count = due_counts[device.session_id]
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="deadline",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not still_owner:
|
||||
return 0
|
||||
payload = json.dumps({
|
||||
"title": f"{due_count} deadline{'s' if due_count != 1 else ''} need{'s' if due_count == 1 else ''} attention",
|
||||
"body": f"Open Agenda to review or replan {'it' if due_count == 1 else 'them'}.",
|
||||
"route": "#/my-work/agenda",
|
||||
"protect_route": "#/my-work/agenda/protect-today",
|
||||
"tag": f"stackchain-deadline-digest-{local_day}",
|
||||
"deadline_count": due_count,
|
||||
}, separators=(",", ":"))
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload)
|
||||
if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception:
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_deadline_reminder_delivered, device.session_id, local_day
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device in eligible_devices), return_exceptions=True
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
@@ -0,0 +1,385 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushDelivery:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
thread_revisions: tuple[tuple[int, str], ...]
|
||||
digest_revisions: tuple[tuple[int, str], ...] = ()
|
||||
|
||||
@property
|
||||
def thread_ids(self) -> tuple[int, ...]:
|
||||
return tuple(thread_id for thread_id, _revision in self.thread_revisions)
|
||||
|
||||
@property
|
||||
def digest_ids(self) -> tuple[int, ...]:
|
||||
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeadlineReminderDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
timezone: str
|
||||
reminder_hour: int
|
||||
reminder_days: int
|
||||
delivered_local_day: str | None
|
||||
|
||||
|
||||
def _revisions(
|
||||
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
) -> tuple[tuple[int, str], ...]:
|
||||
if isinstance(values, Mapping):
|
||||
items = list(values.items())
|
||||
else:
|
||||
items = []
|
||||
for value in values:
|
||||
if isinstance(value, tuple):
|
||||
items.append(value)
|
||||
else:
|
||||
items.append((value, ""))
|
||||
normalized = {}
|
||||
for thread_id, revision in items:
|
||||
thread_id = int(thread_id)
|
||||
if thread_id > 0:
|
||||
normalized[thread_id] = str(revision or "")
|
||||
return tuple(sorted(normalized.items()))
|
||||
|
||||
|
||||
class PushSubscriptionStore:
|
||||
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
|
||||
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.path.parent, 0o700)
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
subscription_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_deliveries (
|
||||
session_id TEXT NOT NULL,
|
||||
thread_id INTEGER NOT NULL,
|
||||
revision TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (session_id, thread_id),
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_dispatch_lease (
|
||||
channel TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_digest_pending (
|
||||
session_id TEXT NOT NULL,
|
||||
thread_id INTEGER NOT NULL,
|
||||
revision TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (session_id, thread_id),
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_deadline_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
reminder_days INTEGER NOT NULL DEFAULT 2,
|
||||
delivered_local_day TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(push_deliveries)")
|
||||
}
|
||||
if "revision" not in delivery_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_deliveries ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
|
||||
)
|
||||
digest_columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(push_digest_pending)")
|
||||
}
|
||||
if "revision" not in digest_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_digest_pending ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
|
||||
)
|
||||
lease_columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(push_dispatch_lease)")
|
||||
}
|
||||
if "singleton" in lease_columns:
|
||||
connection.executescript(
|
||||
"""
|
||||
ALTER TABLE push_dispatch_lease RENAME TO push_dispatch_lease_legacy;
|
||||
CREATE TABLE push_dispatch_lease (
|
||||
channel TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
INSERT INTO push_dispatch_lease(channel, owner, expires_at)
|
||||
SELECT 'unread', owner, expires_at
|
||||
FROM push_dispatch_lease_legacy WHERE singleton = 1;
|
||||
DROP TABLE push_dispatch_lease_legacy;
|
||||
"""
|
||||
)
|
||||
preference_columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(push_deadline_preferences)")
|
||||
}
|
||||
if "reminder_days" not in preference_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.path, timeout=2)
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
return connection
|
||||
|
||||
def acquire_dispatch_lease(
|
||||
self, owner: str, *, channel: str = "unread", now: float, lease_seconds: float
|
||||
) -> bool:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current = connection.execute(
|
||||
"SELECT owner, expires_at FROM push_dispatch_lease WHERE channel = ?",
|
||||
(channel,),
|
||||
).fetchone()
|
||||
if current is not None and current[0] != owner and current[1] > now:
|
||||
return False
|
||||
connection.execute(
|
||||
"""INSERT INTO push_dispatch_lease(channel, owner, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(channel) DO UPDATE SET
|
||||
owner = excluded.owner, expires_at = excluded.expires_at""",
|
||||
(channel, owner, now + lease_seconds),
|
||||
)
|
||||
return True
|
||||
|
||||
def release_dispatch_lease(self, owner: str, *, channel: str = "unread") -> bool:
|
||||
with self._connect() as connection:
|
||||
result = connection.execute(
|
||||
"DELETE FROM push_dispatch_lease WHERE channel = ? AND owner = ?",
|
||||
(channel, owner),
|
||||
)
|
||||
return result.rowcount == 1
|
||||
|
||||
def upsert(self, session_id: str, subscription: dict) -> None:
|
||||
endpoint = subscription["endpoint"]
|
||||
encoded = json.dumps(subscription, separators=(",", ":"), sort_keys=True)
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,))
|
||||
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),
|
||||
)
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
||||
|
||||
def delete_all(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions")
|
||||
|
||||
def is_subscribed(self, session_id: str) -> bool:
|
||||
with self._connect() as connection:
|
||||
return connection.execute(
|
||||
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
||||
).fetchone() is not None
|
||||
|
||||
def set_deadline_preferences(
|
||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
|
||||
reminder_days: int = 2,
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_deadline_preferences(
|
||||
session_id, enabled, timezone, reminder_hour, reminder_days
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
timezone = excluded.timezone,
|
||||
reminder_hour = excluded.reminder_hour,
|
||||
reminder_days = excluded.reminder_days""",
|
||||
(session_id, int(enabled), timezone, reminder_hour, reminder_days),
|
||||
)
|
||||
|
||||
def deadline_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT enabled, timezone, reminder_hour, reminder_days
|
||||
FROM push_deadline_preferences WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"enabled": bool(row[0]) if row else False,
|
||||
"timezone": row[1] if row else "UTC",
|
||||
"reminder_hour": row[2] if row else 9,
|
||||
"reminder_days": row[3] if row else 2,
|
||||
}
|
||||
|
||||
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
|
||||
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])
|
||||
for row in rows
|
||||
]
|
||||
|
||||
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 = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(local_day, session_id),
|
||||
)
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||
) -> list[PushDelivery]:
|
||||
candidates = _revisions(thread_revisions)
|
||||
if not candidates:
|
||||
return []
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, subscription_json FROM push_subscriptions ORDER BY session_id"
|
||||
).fetchall()
|
||||
deliveries = []
|
||||
for session_id, encoded in rows:
|
||||
delivered = {
|
||||
row[0]: row[1]
|
||||
for row in connection.execute(
|
||||
"SELECT thread_id, revision FROM push_deliveries WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
}
|
||||
for thread_id, revision in candidates:
|
||||
if delivered.get(thread_id) == "*":
|
||||
connection.execute(
|
||||
"""UPDATE push_deliveries SET revision = ?
|
||||
WHERE session_id = ? AND thread_id = ?""",
|
||||
(revision, session_id, thread_id),
|
||||
)
|
||||
delivered[thread_id] = revision
|
||||
unseen = tuple(
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if (
|
||||
candidate[0] not in delivered
|
||||
or (
|
||||
bool(candidate[1])
|
||||
and (
|
||||
not delivered[candidate[0]]
|
||||
or candidate[1] > delivered[candidate[0]]
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if unseen:
|
||||
pending = {
|
||||
row[0]: row[1]
|
||||
for row in connection.execute(
|
||||
"SELECT thread_id, revision FROM push_digest_pending WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
}
|
||||
digest_revisions = []
|
||||
for thread_id, revision in unseen:
|
||||
if pending.get(thread_id) in {revision, "*"}:
|
||||
digest_revisions.append((thread_id, revision))
|
||||
if pending[thread_id] == "*":
|
||||
connection.execute(
|
||||
"""UPDATE push_digest_pending SET revision = ?
|
||||
WHERE session_id = ? AND thread_id = ?""",
|
||||
(revision, session_id, thread_id),
|
||||
)
|
||||
deliveries.append(
|
||||
PushDelivery(
|
||||
session_id,
|
||||
json.loads(encoded),
|
||||
unseen,
|
||||
tuple(digest_revisions),
|
||||
)
|
||||
)
|
||||
return deliveries
|
||||
|
||||
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}))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"CREATE TEMP TABLE current_unread(thread_id INTEGER PRIMARY KEY)"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO current_unread(thread_id) VALUES (?)",
|
||||
((thread_id,) for thread_id in unread_ids),
|
||||
)
|
||||
connection.execute(
|
||||
"""DELETE FROM push_deliveries
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM current_unread
|
||||
WHERE current_unread.thread_id = push_deliveries.thread_id
|
||||
)"""
|
||||
)
|
||||
connection.execute(
|
||||
"""DELETE FROM push_digest_pending
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM current_unread
|
||||
WHERE current_unread.thread_id = push_digest_pending.thread_id
|
||||
)"""
|
||||
)
|
||||
|
||||
def mark_digest_pending(
|
||||
self,
|
||||
session_id: str,
|
||||
thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
) -> None:
|
||||
values = _revisions(thread_revisions)
|
||||
with self._connect() as connection:
|
||||
connection.executemany(
|
||||
"""INSERT INTO push_digest_pending(session_id, thread_id, revision)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id, thread_id) DO UPDATE SET
|
||||
revision = excluded.revision""",
|
||||
((session_id, thread_id, revision) for thread_id, revision in values),
|
||||
)
|
||||
|
||||
def mark_delivered(
|
||||
self,
|
||||
session_id: str,
|
||||
thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
) -> None:
|
||||
values = _revisions(thread_revisions)
|
||||
with self._connect() as connection:
|
||||
connection.executemany(
|
||||
"""INSERT INTO push_deliveries(session_id, thread_id, revision)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id, thread_id) DO UPDATE SET
|
||||
revision = excluded.revision""",
|
||||
((session_id, thread_id, revision) for thread_id, revision in values),
|
||||
)
|
||||
connection.executemany(
|
||||
"DELETE FROM push_digest_pending WHERE session_id = ? AND thread_id = ?",
|
||||
((session_id, thread_id) for thread_id, _revision in values),
|
||||
)
|
||||
@@ -271,6 +271,43 @@ class SessionStore:
|
||||
raise SessionStoreError("Session is no longer active")
|
||||
return row[0]
|
||||
|
||||
def managed_status(self, management_id: str, *, idle_timeout_seconds: int) -> str:
|
||||
"""Return the authorization state for a durable device identifier."""
|
||||
return self.managed_statuses(
|
||||
[management_id], idle_timeout_seconds=idle_timeout_seconds
|
||||
)[management_id]
|
||||
|
||||
def managed_statuses(
|
||||
self, management_ids, *, idle_timeout_seconds: int
|
||||
) -> dict[str, str]:
|
||||
"""Return authorization states for durable device identifiers in one read."""
|
||||
requested = set(management_ids)
|
||||
if not requested:
|
||||
return {}
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
placeholders = ",".join("?" for _ in requested)
|
||||
rows = connection.execute(
|
||||
"SELECT management_id, expires_at, last_active_at "
|
||||
f"FROM active_sessions WHERE management_id IN ({placeholders})",
|
||||
tuple(requested),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
known = {row[0]: row[1:] for row in rows if row[0] in requested}
|
||||
idle_timeout = max(1, idle_timeout_seconds)
|
||||
statuses = {}
|
||||
for management_id in requested:
|
||||
row = known.get(management_id)
|
||||
if row is None or row[0] <= now:
|
||||
statuses[management_id] = "revoked"
|
||||
elif row[1] + idle_timeout <= now:
|
||||
statuses[management_id] = "idle"
|
||||
else:
|
||||
statuses[management_id] = "active"
|
||||
return statuses
|
||||
|
||||
def revoke_managed(self, management_id: str) -> bool:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
|
||||
+244
-17
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -19,6 +20,7 @@ class TodayStore:
|
||||
timeout: float = 1.0,
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
recap_limit: int = 100,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
@@ -26,11 +28,16 @@ class TodayStore:
|
||||
self.timeout = timeout
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.recap_limit = recap_limit
|
||||
self.clock = clock
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
|
||||
connection.close()
|
||||
return
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -39,7 +46,9 @@ class TodayStore:
|
||||
revision INTEGER NOT NULL,
|
||||
ids TEXT NOT NULL,
|
||||
capacity_minutes INTEGER,
|
||||
estimates TEXT NOT NULL DEFAULT '{}'
|
||||
estimates TEXT NOT NULL DEFAULT '{}',
|
||||
plan_date TEXT,
|
||||
timezone TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -48,6 +57,10 @@ class TodayStore:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER")
|
||||
if "estimates" not in plan_columns:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'")
|
||||
if "plan_date" not in plan_columns:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN plan_date TEXT")
|
||||
if "timezone" not in plan_columns:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN timezone TEXT")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_operations (
|
||||
@@ -65,8 +78,39 @@ class TodayStore:
|
||||
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_recaps (
|
||||
login TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
items TEXT NOT NULL,
|
||||
PRIMARY KEY (login, session_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS today_recaps_recent "
|
||||
"ON today_recaps(login, created_at DESC)"
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_time_logs (
|
||||
login TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
identity TEXT NOT NULL,
|
||||
actual_minutes INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
PRIMARY KEY (login, session_id, identity)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute("PRAGMA user_version = 1")
|
||||
connection.commit()
|
||||
return connection
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
@@ -98,21 +142,146 @@ class TodayStore:
|
||||
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
|
||||
ids = json.loads(row[1])
|
||||
estimates = json.loads(row[3] or "{}")
|
||||
return {
|
||||
snapshot = {
|
||||
"revision": int(row[0]),
|
||||
"ids": ids,
|
||||
"capacity_minutes": row[2],
|
||||
"estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
|
||||
}
|
||||
if len(row) > 4 and row[4]:
|
||||
snapshot["plan_date"] = row[4]
|
||||
snapshot["timezone"] = row[5]
|
||||
return snapshot
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?",
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_recap_items(items: list[dict]) -> list[dict]:
|
||||
if not isinstance(items, list) or not items:
|
||||
raise ValueError("recap requires at least one item")
|
||||
if len(items) > 20:
|
||||
raise ValueError("recap is limited to 20 items")
|
||||
normalized = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
identity = item.get("identity", "") if isinstance(item, dict) else ""
|
||||
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
|
||||
raise ValueError("recap item identity is required and bounded")
|
||||
identity = identity.strip()
|
||||
if identity in seen:
|
||||
raise ValueError("recap item identities must be unique")
|
||||
actual = item.get("actual_minutes")
|
||||
estimate = item.get("estimate_minutes")
|
||||
if not isinstance(actual, int) or isinstance(actual, bool) or actual < 0 or actual > 1440:
|
||||
raise ValueError("actual minutes must be between 0 and 1440")
|
||||
if estimate is not None and (
|
||||
not isinstance(estimate, int) or isinstance(estimate, bool)
|
||||
or estimate < 5 or estimate > 1440
|
||||
):
|
||||
raise ValueError("estimate minutes must be between 5 and 1440")
|
||||
seen.add(identity)
|
||||
normalized.append({
|
||||
"identity": identity,
|
||||
"estimate_minutes": estimate,
|
||||
"actual_minutes": actual,
|
||||
})
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _recap_snapshot(session_id: str, created_at: float, serialized: str) -> dict:
|
||||
items = json.loads(serialized)
|
||||
estimated = sum(item["estimate_minutes"] for item in items if item["estimate_minutes"] is not None)
|
||||
actual = sum(item["actual_minutes"] for item in items)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"created_at": created_at,
|
||||
"items": items,
|
||||
"estimated_minutes": estimated,
|
||||
"actual_minutes": actual,
|
||||
"variance_minutes": actual - estimated,
|
||||
}
|
||||
|
||||
def save_recap(self, login: str, session_id: str, items: list[dict]) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
if not isinstance(session_id, str) or not session_id.strip() or len(session_id) > 100:
|
||||
raise ValueError("session_id is required and bounded")
|
||||
session_id = session_id.strip()
|
||||
normalized = self._normalize_recap_items(items)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT created_at, items FROM today_recaps WHERE login = ? AND session_id = ?",
|
||||
(login, session_id),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return self._recap_snapshot(session_id, existing[0], existing[1])
|
||||
created_at = self.clock()
|
||||
connection.execute(
|
||||
"INSERT INTO today_recaps(login, session_id, created_at, items) VALUES (?, ?, ?, ?)",
|
||||
(login, session_id, created_at, serialized),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM today_recaps WHERE login = ? AND rowid NOT IN "
|
||||
"(SELECT rowid FROM today_recaps WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(login, login, self.recap_limit),
|
||||
)
|
||||
return self._recap_snapshot(session_id, created_at, serialized)
|
||||
|
||||
def list_recaps(self, login: str, *, limit: int = 30) -> list[dict]:
|
||||
bounded_limit = max(1, min(int(limit), self.recap_limit, 100))
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||
(self._normalize_login(login), bounded_limit),
|
||||
).fetchall()
|
||||
return [self._recap_snapshot(*row) for row in rows]
|
||||
|
||||
def begin_time_log(self, login: str, session_id: str, identity: str, actual_minutes: int) -> str:
|
||||
"""Claim one recap item for upstream logging, returning its current state."""
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT actual_minutes, status FROM today_time_logs "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(login, session_id, identity),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_time_logs(login, session_id, identity, actual_minutes, status) "
|
||||
"VALUES (?, ?, ?, ?, 'pending')",
|
||||
(login, session_id, identity, actual_minutes),
|
||||
)
|
||||
return "claimed"
|
||||
if row[0] != actual_minutes:
|
||||
raise ValueError("logged recap time cannot be changed")
|
||||
if row[1] == "failed":
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET status = 'pending' "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(login, session_id, identity),
|
||||
)
|
||||
return "claimed"
|
||||
return row[1]
|
||||
|
||||
def finish_time_log(self, login: str, session_id: str, identity: str, *, succeeded: bool) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET status = ? "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ? AND status = 'pending'",
|
||||
("logged" if succeeded else "failed", self._normalize_login(login), session_id, identity),
|
||||
)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
login: str,
|
||||
@@ -133,7 +302,8 @@ class TodayStore:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
duplicate = connection.execute(
|
||||
@@ -172,8 +342,10 @@ class TodayStore:
|
||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates),
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates,
|
||||
snapshot.get("plan_date"), snapshot.get("timezone")),
|
||||
)
|
||||
elif changed:
|
||||
connection.execute(
|
||||
@@ -181,12 +353,16 @@ class TodayStore:
|
||||
(revision, serialized_ids, serialized_estimates, login),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
return {
|
||||
result = {
|
||||
"revision": revision,
|
||||
"ids": ids,
|
||||
"capacity_minutes": snapshot["capacity_minutes"],
|
||||
"estimates": estimates,
|
||||
}
|
||||
if snapshot.get("plan_date"):
|
||||
result["plan_date"] = snapshot["plan_date"]
|
||||
result["timezone"] = snapshot["timezone"]
|
||||
return result
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
"""Apply an ordered batch with one lock and receipt per operation."""
|
||||
@@ -194,12 +370,15 @@ class TodayStore:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
ids = list(snapshot["ids"])
|
||||
capacity_minutes = snapshot["capacity_minutes"]
|
||||
estimates = dict(snapshot["estimates"])
|
||||
plan_date = snapshot.get("plan_date")
|
||||
timezone = snapshot.get("timezone")
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
@@ -212,7 +391,7 @@ class TodayStore:
|
||||
direction = operation.get("direction")
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"add", "remove", "move", "configure"}:
|
||||
if action not in {"add", "remove", "move", "configure", "rollover"}:
|
||||
raise ValueError("unsupported Today action")
|
||||
if action == "move" and direction not in {"up", "down"}:
|
||||
raise ValueError("move direction must be up or down")
|
||||
@@ -256,7 +435,7 @@ class TodayStore:
|
||||
if index >= 0 and 0 <= target < len(ids):
|
||||
ids[index], ids[target] = ids[target], ids[index]
|
||||
changed = True
|
||||
else:
|
||||
elif action == "configure":
|
||||
proposed_capacity = operation.get("capacity_minutes")
|
||||
if proposed_capacity is not None and (
|
||||
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
|
||||
@@ -276,6 +455,48 @@ class TodayStore:
|
||||
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
|
||||
capacity_minutes = proposed_capacity
|
||||
estimates = normalized_estimates
|
||||
else:
|
||||
proposed_date = operation.get("plan_date")
|
||||
proposed_timezone = operation.get("timezone")
|
||||
try:
|
||||
if date.fromisoformat(proposed_date or "").isoformat() != proposed_date:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("plan_date must be an ISO calendar date") from None
|
||||
if not isinstance(proposed_timezone, str) or not proposed_timezone.strip() or len(proposed_timezone) > 100:
|
||||
raise ValueError("timezone is required and bounded")
|
||||
proposed_ids = operation.get("ids")
|
||||
if not isinstance(proposed_ids, list) or len(proposed_ids) > self.limit or any(
|
||||
not isinstance(candidate, str) or not candidate or len(candidate) > 500
|
||||
for candidate in proposed_ids
|
||||
):
|
||||
raise ValueError("rollover IDs are invalid or exceed the Today limit")
|
||||
if len(set(proposed_ids)) != len(proposed_ids):
|
||||
raise ValueError("rollover IDs must be unique")
|
||||
proposed_capacity = operation.get("capacity_minutes")
|
||||
if proposed_capacity is not None and (
|
||||
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
|
||||
or proposed_capacity < 15 or proposed_capacity > 1440
|
||||
):
|
||||
raise ValueError("capacity_minutes must be between 15 and 1440")
|
||||
proposed_estimates = operation.get("estimates", {})
|
||||
if not isinstance(proposed_estimates, dict):
|
||||
raise ValueError("estimates must be an object")
|
||||
normalized_estimates = {}
|
||||
for estimate_id, minutes in proposed_estimates.items():
|
||||
if estimate_id not in proposed_ids:
|
||||
continue
|
||||
if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
|
||||
raise ValueError("estimate minutes must be between 5 and 1440")
|
||||
normalized_estimates[estimate_id] = minutes
|
||||
changed = (ids != proposed_ids or capacity_minutes != proposed_capacity or
|
||||
estimates != normalized_estimates or plan_date != proposed_date or
|
||||
timezone != proposed_timezone.strip())
|
||||
ids = list(proposed_ids)
|
||||
capacity_minutes = proposed_capacity
|
||||
estimates = normalized_estimates
|
||||
plan_date = proposed_date
|
||||
timezone = proposed_timezone.strip()
|
||||
|
||||
revision += 1 if changed else 0
|
||||
self._record_operation(connection, login, operation_id)
|
||||
@@ -285,15 +506,17 @@ class TodayStore:
|
||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized, capacity_minutes, serialized_estimates),
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone),
|
||||
)
|
||||
elif accepted:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ? WHERE login = ?",
|
||||
(revision, serialized, capacity_minutes, serialized_estimates, login),
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ?, "
|
||||
"plan_date = ?, timezone = ? WHERE login = ?",
|
||||
(revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone, login),
|
||||
)
|
||||
return {
|
||||
result = {
|
||||
"revision": revision,
|
||||
"ids": ids,
|
||||
"capacity_minutes": capacity_minutes,
|
||||
@@ -302,3 +525,7 @@ class TodayStore:
|
||||
"duplicate_operation_ids": duplicates,
|
||||
"rejected_operations": rejected,
|
||||
}
|
||||
if plan_date:
|
||||
result["plan_date"] = plan_date
|
||||
result["timezone"] = timezone
|
||||
return result
|
||||
@@ -1420,5 +1420,6 @@ async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in()
|
||||
assert "await Notification.requestPermission()" in html
|
||||
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
|
||||
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
|
||||
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
|
||||
assert "if (window.location.hash !== '#/my-work/drafts') return;" in html
|
||||
assert "onQueue: openWorkQueueRoute" in html
|
||||
assert "workRoute.queue(filter)" in html
|
||||
assert "openDeliveryReceiptRoute" not in html
|
||||
@@ -0,0 +1,488 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BATCH_FIND_WORK = Path(__file__).parents[1] / "frontend" / "batch-find-work.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_batch_preflights_today_capacity_before_claiming_any_issue():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
onProgress:progress=>calls.push('progress:'+progress.status),
|
||||
}});
|
||||
flow.run([
|
||||
{{repository:'stackchain/dashboard',number:671}},
|
||||
{{repository:'stackchain/dashboard',number:672}},
|
||||
]).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "full",
|
||||
"selected": 2,
|
||||
"available": 1,
|
||||
"queued": [],
|
||||
"failed": [],
|
||||
},
|
||||
"calls": ["progress:full"],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_claims_in_order_continues_after_conflict_and_reports_truthfully():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
claim:item=>{{
|
||||
calls.push('claim:'+item.number);
|
||||
return item.number===672 ? Promise.reject(new Error('already claimed')) :
|
||||
Promise.resolve({{...item,assignees:['timmy']}});
|
||||
}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
onProgress:progress=>calls.push('progress:'+progress.status+':'+progress.processed),
|
||||
}});
|
||||
flow.run([671,672,673].map(number=>({{repository:'stackchain/dashboard',number}})))
|
||||
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete",
|
||||
"selected": 3,
|
||||
"available": 3,
|
||||
"queued": ["stackchain/dashboard#671", "stackchain/dashboard#673"],
|
||||
"failed": [{"key": "stackchain/dashboard#672", "reason": "already claimed"}],
|
||||
},
|
||||
"calls": [
|
||||
"claim:671", "queue:671", "progress:running:1",
|
||||
"claim:672", "progress:running:2",
|
||||
"claim:673", "queue:673", "progress:running:3",
|
||||
"progress:complete:3",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_does_not_reclaim_confirmed_issue_when_today_sync_needs_recovery():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1,
|
||||
claim:item=>{{calls.push('claim');return Promise.resolve({{...item,assignees:['timmy']}});}},
|
||||
queue:item=>{{calls.push('queue');return 'sync-unavailable';}},
|
||||
}});
|
||||
flow.run([{{repository:'stackchain/dashboard',number:671}}])
|
||||
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete",
|
||||
"selected": 1,
|
||||
"available": 1,
|
||||
"queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#671",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"calls": ["claim", "queue"],
|
||||
}
|
||||
|
||||
|
||||
def test_interrupted_batch_resumes_confirmed_assignment_without_claiming_twice():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const values={{}};
|
||||
const storage={{
|
||||
getItem:key=>values[key] || null,
|
||||
setItem:(key,value)=>{{values[key]=value;}},
|
||||
removeItem:key=>{{delete values[key];}},
|
||||
}};
|
||||
const calls=[];
|
||||
const item={{repository:'stackchain/dashboard',number:679,title:'Recover me'}};
|
||||
const first=createBatchFindWork({{
|
||||
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||
claim:value=>{{calls.push('claim');return Promise.resolve({{...value,assignees:['timmy']}});}},
|
||||
queue:()=>{{calls.push('queue-failed');return 'sync-unavailable';}},
|
||||
}});
|
||||
first.run([item]).then(firstResult=>{{
|
||||
const second=createBatchFindWork({{
|
||||
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||
claim:()=>{{calls.push('duplicate-claim');return Promise.resolve(item);}},
|
||||
queue:value=>{{calls.push('queue-resumed:'+value.assignees[0]);return 'queued';}},
|
||||
}});
|
||||
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
|
||||
firstResult, resumed, calls, keys:Object.keys(values),
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"firstResult": {
|
||||
"status": "complete", "selected": 1, "available": 1, "queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#679",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"resumed": {
|
||||
"status": "complete", "selected": 1, "available": 1,
|
||||
"queued": ["stackchain/dashboard#679"], "failed": [],
|
||||
},
|
||||
"calls": ["claim", "queue-failed", "queue-resumed:timmy"],
|
||||
"keys": [],
|
||||
}
|
||||
|
||||
|
||||
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const key='stackchain.find-work-batch.v1.timmy';
|
||||
const values={{[key]:JSON.stringify({{
|
||||
owner:'timmy', available:2, items:[
|
||||
{{item:{{repository:'stackchain/dashboard',number:678}},state:'queued'}},
|
||||
{{item:{{repository:'stackchain/dashboard',number:679}},state:'pending'}},
|
||||
],
|
||||
}})}};
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>2, owner:()=> 'timmy',
|
||||
storage:{{getItem:key=>values[key]||null,setItem:(key,value)=>values[key]=value,removeItem:key=>delete values[key]}},
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
}});
|
||||
flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 2, "available": 2,
|
||||
"queued": ["stackchain/dashboard#678", "stackchain/dashboard#679"],
|
||||
"failed": [],
|
||||
},
|
||||
"calls": ["claim:679", "queue:679"],
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_blocks_claims_until_every_estimate_fits():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
timeBudget:()=>({{capacity_minutes:90,planned_minutes:30}}),
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
}});
|
||||
const items=[701,702].map(number=>({{repository:'stackchain/dashboard',number}}));
|
||||
Promise.all([
|
||||
flow.run(items, {{'stackchain/dashboard#701':30}}),
|
||||
flow.run(items, {{'stackchain/dashboard#701':30,'stackchain/dashboard#702':45}}),
|
||||
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"results": [
|
||||
{
|
||||
"status": "estimates-required", "selected": 2, "available": 3,
|
||||
"queued": [], "failed": [], "remaining_minutes": 60,
|
||||
"requested_minutes": 30,
|
||||
"invalid": ["stackchain/dashboard#702"],
|
||||
},
|
||||
{
|
||||
"status": "over-budget", "selected": 2, "available": 3,
|
||||
"queued": [], "failed": [], "remaining_minutes": 60,
|
||||
"requested_minutes": 75, "over_minutes": 15,
|
||||
},
|
||||
],
|
||||
"calls": [],
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_persists_estimates_only_for_successfully_queued_claims():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
timeBudget:()=>({{capacity_minutes:120,planned_minutes:30}}),
|
||||
claim:item=>item.number===702 ? Promise.reject(new Error('already claimed')) : Promise.resolve(item),
|
||||
queue:item=>'queued',
|
||||
persistEstimate:(item,minutes)=>calls.push(item.number+':'+minutes),
|
||||
}});
|
||||
flow.run([701,702].map(number=>({{repository:'stackchain/dashboard',number}})),{{
|
||||
'stackchain/dashboard#701':30,
|
||||
'stackchain/dashboard#702':45,
|
||||
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 2, "available": 3,
|
||||
"queued": ["stackchain/dashboard#701"],
|
||||
"failed": [{"key": "stackchain/dashboard#702", "reason": "already claimed"}],
|
||||
},
|
||||
"calls": ["701:30"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_has_preclaim_time_review_controls():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-estimate-review"' in html
|
||||
assert 'id="find-work-estimate-list"' in html
|
||||
assert 'id="confirm-find-work-estimates"' in html
|
||||
assert 'inputmode="numeric"' in dashboard
|
||||
assert "timeBudget:" in dashboard
|
||||
assert "persistEstimate:" in dashboard
|
||||
assert ".find-work-estimate-review" in css
|
||||
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_selection_survives_loaded_pages_and_removes_confirmed_claims():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const pages={{
|
||||
1:{{items:[{{id:1,repository:'stackchain/dashboard',number:671}}],page:1,total:2,has_more:true}},
|
||||
2:{{items:[{{id:2,repository:'stackchain/dashboard',number:672}}],page:2,total:2,has_more:false}},
|
||||
}};
|
||||
const controller=createFindWork({{
|
||||
fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
|
||||
Promise.resolve(pages[path.includes('page=2') ? 2 : 1]),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
return controller.loadMore();
|
||||
}}).then(()=>{{
|
||||
controller.toggleSelection(controller.items()[1]);
|
||||
return controller.claim(controller.items()[0]);
|
||||
}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
selecting:controller.selection().active,
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"selected": [672], "selecting": True}
|
||||
|
||||
|
||||
def test_fill_today_selects_ranked_matches_up_to_remaining_capacity():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const states=[];
|
||||
const controller=createFindWork({{
|
||||
fetchJson:()=>Promise.resolve({{items:[
|
||||
{{id:1,repository:'stackchain/dashboard',number:701}},
|
||||
{{id:2,repository:'stackchain/dashboard',number:702}},
|
||||
{{id:3,repository:'stackchain/dashboard',number:703}},
|
||||
{{id:4,repository:'stackchain/dashboard',number:704}},
|
||||
],page:1,total:4,has_more:false}}),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}},
|
||||
onSelection:state=>states.push(state),
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[1]);
|
||||
const outcome=controller.fillSelection(3);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
outcome,
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
lastState:states.at(-1),
|
||||
}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"outcome": {"selected": 3, "added": 2, "limit": 3},
|
||||
"selected": [702, 701, 703],
|
||||
"lastState": {
|
||||
"active": True,
|
||||
"count": 3,
|
||||
"ids": [
|
||||
"stackchain/dashboard#702",
|
||||
"stackchain/dashboard#701",
|
||||
"stackchain/dashboard#703",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_fill_today_with_no_capacity_does_not_change_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const controller=createFindWork({{
|
||||
fetchJson:()=>Promise.resolve({{items:[{{id:1,repository:'stackchain/dashboard',number:701}}]}}),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
const outcome=controller.fillSelection(0);
|
||||
process.stdout.write(JSON.stringify({{outcome,selection:controller.selection()}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"outcome": {"selected": 0, "added": 0, "limit": 0},
|
||||
"selection": {"active": False, "count": 0, "ids": []},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert 'id="select-find-work"' in html
|
||||
assert 'id="batch-find-work-actions"' in html
|
||||
assert 'id="claim-selected-work"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "createBatchFindWork({" in dashboard
|
||||
assert "findWorkController.startSelection()" in dashboard
|
||||
assert "findWorkController.toggleSelection(item)" in dashboard
|
||||
assert ".find-work-batch-actions" in css
|
||||
assert "position:sticky" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert "BASE + 'static/batch-find-work.js'" in worker
|
||||
assert '<script src="static/batch-find-work.js"></script>' in html
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_touch_accessible_batch_recovery():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="resume-find-work-batch"' in html
|
||||
assert 'aria-describedby="find-work-status"' in html
|
||||
assert "mountRecovery(" in BATCH_FIND_WORK.read_text()
|
||||
assert "typeof localStorage === 'undefined'" in BATCH_FIND_WORK.read_text()
|
||||
assert ".find-work-batch-recovery" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="fill-find-work-today"' in html
|
||||
assert 'aria-describedby="find-work-selection-status"' in html
|
||||
assert "findWorkController.fillSelection(remainingSlots)" in dashboard
|
||||
assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard
|
||||
assert ".fill-find-work-today" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_search_ignores_stale_response_and_preserves_cross_query_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const pending=[];
|
||||
const rendered=[];
|
||||
const fetchJson=path=>new Promise(resolve=>pending.push({{path,resolve}}));
|
||||
const controller=createFindWork({{
|
||||
fetchJson,
|
||||
onItems:items=>rendered.push(items.map(item=>item.number)),
|
||||
onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
const first=controller.search('api');
|
||||
const second=controller.search('dashboard');
|
||||
pending[1].resolve({{items:[{{id:2,repository:'stackchain/dashboard',number:673}}],page:1,total:1,has_more:false}});
|
||||
second.then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
const third=controller.search('worker');
|
||||
pending[2].resolve({{items:[{{id:3,repository:'stackchain/worker',number:674}}],page:1,total:1,has_more:false}});
|
||||
return third;
|
||||
}}).then(()=>{{
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
pending[0].resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false}});
|
||||
return first;
|
||||
}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
calls:pending.map(entry=>entry.path),
|
||||
rendered,
|
||||
items:controller.items().map(item=>item.number),
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": [
|
||||
"api/v1/available-issues?page=1&facets=true&q=api",
|
||||
"api/v1/available-issues?page=1&facets=true&q=dashboard",
|
||||
"api/v1/available-issues?page=1&facets=true&q=worker",
|
||||
],
|
||||
"rendered": [[673], [674]],
|
||||
"items": [674],
|
||||
"selected": [673, 674],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_search_has_clear_live_results_and_touch_targets():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-search"' in html
|
||||
assert 'id="clear-find-work-search"' in html
|
||||
assert 'id="find-work-match-status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "findWorkController.search(" in dashboard
|
||||
assert "#find-work-search" in dashboard
|
||||
assert ".find-work-search" in css
|
||||
assert ".find-work-search button" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_facets_serialize_filters_and_preserve_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const calls=[];
|
||||
const controller=createFindWork({{
|
||||
fetchJson:path=>{{ calls.push(path); return Promise.resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false,facets:{{repositories:['stackchain/api'],labels:['critical']}}}}); }},
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, onFacets:()=>{{}},
|
||||
}});
|
||||
controller.reset({{items:[{{id:9,repository:'stackchain/old',number:9}}],page:1,total:1,has_more:false}});
|
||||
controller.startSelection(); controller.toggleSelection(controller.items()[0]);
|
||||
controller.setFilters({{repositories:['stackchain/api'],labels:['critical']}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
calls, filters:controller.filters(), selected:controller.selectedItems().map(item=>item.number)
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": ["api/v1/available-issues?page=1&facets=true&repository=stackchain%2Fapi&label=critical"],
|
||||
"filters": {"repositories": ["stackchain/api"], "labels": ["critical"]},
|
||||
"selected": [9],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_accessible_touch_sized_facets():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-filters"' in html
|
||||
assert 'id="find-work-repository-filters"' in html
|
||||
assert 'id="find-work-label-filters"' in html
|
||||
assert 'id="clear-find-work-filters"' in html
|
||||
assert 'aria-label="Filter available work"' in html
|
||||
assert "setFilters(next)" in PICK_WORK.read_text()
|
||||
assert ".find-work-filters" in css
|
||||
assert ".find-work-filter-option" in css and "min-height:44px" in css
|
||||
@@ -178,7 +178,7 @@ const pending = new Map();
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)),
|
||||
claim: () => Promise.resolve(),
|
||||
mutate: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }});
|
||||
@@ -206,7 +206,7 @@ let resolveClaim;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
claim: () => {{ claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
|
||||
mutate: (_detail, action) => {{ if (action !== 'claim') throw new Error('wrong action'); claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
|
||||
@@ -223,6 +223,32 @@ if (!states.some(state => state.status === 'claimed')) throw new Error('claim co
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_reopen_is_single_flight_and_reports_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let reopens = 0;
|
||||
let resolveReopen;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
mutate: (_detail, action) => {{ if (action !== 'reopen') throw new Error('wrong action'); reopens += 1; return new Promise(resolve => {{ resolveReopen = resolve; }}); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
const detail = {{ repository:'stackchain/api', number:42, reopenable:true }};
|
||||
await preview.open(detail);
|
||||
const first = preview.reopen(detail);
|
||||
const second = preview.reopen(detail);
|
||||
if (reopens !== 1 || first !== second) throw new Error('reopen was not single-flight');
|
||||
resolveReopen({{ state:'open', assignees:['timmy'] }});
|
||||
await first;
|
||||
if (!states.some(state => state.status === 'reopened')) throw new Error('reopen confirmation missing');
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
@@ -248,13 +274,31 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="start-search-result"' in html
|
||||
assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html
|
||||
assert "const searchAssignAndStart = createAssignAndStart({" in html
|
||||
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in html
|
||||
assert "const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail))" in html
|
||||
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
|
||||
assert ".search-preview-primary-actions" in css
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
|
||||
|
||||
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "startButton.textContent = detail.reopenable ? 'Reopen & resume'" in html
|
||||
assert "mutate: (detail, action) => fetchReviewJson(" in html
|
||||
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-whiteboard')", 1
|
||||
)[0]
|
||||
assert "searchReopenAndStart.run(detail)" in handler
|
||||
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
|
||||
"const searchAssignAndStart", 1
|
||||
)[0]
|
||||
assert "available: createAndStart.available" in orchestrator
|
||||
assert "claim," in orchestrator
|
||||
assert "acceptClaimedIssue(confirmed)" in orchestrator
|
||||
assert "const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail))" in html
|
||||
|
||||
|
||||
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
|
||||
script = f"""
|
||||
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
|
||||
|
||||
@@ -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-v87" in worker
|
||||
assert "stackchain-dashboard-shell-v100" in worker
|
||||
@@ -1,10 +1,12 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from src import main
|
||||
from src.session_store import SessionStoreError
|
||||
@@ -14,6 +16,7 @@ from src.views import FRONTEND_BUILD
|
||||
@pytest.fixture
|
||||
def access_control(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
@@ -167,6 +170,102 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
|
||||
assert "correct horse battery staple" not in signed_in.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class StaleAuthentication:
|
||||
new_sign_count = 4
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_authentication",
|
||||
lambda **_kwargs: StaleAuthentication(),
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
options = await client.post("/api/v1/passkeys/authentication/options")
|
||||
denied = await client.post(
|
||||
"/api/v1/passkeys/authentication/verify",
|
||||
json={
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
"device_label": "Phone",
|
||||
"action": "sign_in",
|
||||
"target": "dashboard",
|
||||
},
|
||||
)
|
||||
|
||||
events = main._security_event_store().list(limit=10).events
|
||||
assert denied.status_code == 401
|
||||
assert "stackchain_session=" not in denied.headers.get("set-cookie", "")
|
||||
assert [event.kind for event in events] == ["passkey_counter_anomaly"]
|
||||
assert events[0].device_label == "Phone"
|
||||
assert events[0].target == "sign_in:dashboard"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class StaleAuthentication:
|
||||
new_sign_count = 4
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_authentication",
|
||||
lambda **_kwargs: StaleAuthentication(),
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
options = await client.post(
|
||||
"/api/v1/passkeys/authorization/options",
|
||||
json={"action": "merge_pull", "target": "stackchain/dashboard#42"},
|
||||
headers=headers,
|
||||
)
|
||||
denied = await client.post(
|
||||
"/api/v1/passkeys/authorization/verify",
|
||||
json={
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
"action": "merge_pull",
|
||||
"target": "stackchain/dashboard#42",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
events = main._security_event_store().list(limit=10).events
|
||||
assert options.status_code == 200
|
||||
assert denied.status_code == 401
|
||||
assert "grant" not in denied.json()
|
||||
assert events[0].kind == "passkey_counter_anomaly"
|
||||
assert events[0].target == "merge_pull:stackchain/dashboard#42"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_options_are_source_limited_before_challenge_generation(
|
||||
access_control, monkeypatch
|
||||
@@ -777,6 +876,55 @@ async def test_anonymous_share_target_redirect_preserves_only_bounded_capture_fi
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_app_shortcut_preserves_only_a_known_launch_action(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
valid = await client.get("/", params={"launch": "agenda", "next": "https://evil.example"})
|
||||
invalid = await client.get("/", params={"launch": "delete-account"})
|
||||
|
||||
assert valid.status_code == 303
|
||||
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
||||
"continue": ["./?launch=agenda"]
|
||||
}
|
||||
assert invalid.status_code == 303
|
||||
assert invalid.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
valid = await client.get(
|
||||
"/",
|
||||
params={
|
||||
"launch": "new",
|
||||
"shared": "image",
|
||||
"title": "Broken checkout",
|
||||
"text": "Steps to reproduce",
|
||||
},
|
||||
)
|
||||
wrong_launch = await client.get(
|
||||
"/", params={"launch": "agenda", "shared": "image"}
|
||||
)
|
||||
wrong_marker = await client.get(
|
||||
"/", params={"launch": "new", "shared": "document"}
|
||||
)
|
||||
duplicate_marker = await client.get(
|
||||
"/?launch=new&shared=image&shared=image"
|
||||
)
|
||||
|
||||
assert valid.status_code == 303
|
||||
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
||||
"continue": [
|
||||
"./?title=Broken+checkout&text=Steps+to+reproduce&launch=new&shared=image"
|
||||
]
|
||||
}
|
||||
assert wrong_launch.headers["location"] == "login"
|
||||
assert wrong_marker.headers["location"] == "login"
|
||||
assert duplicate_marker.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_oversized_share_target_is_not_carried_through_login(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -1363,6 +1511,78 @@ async def test_mutation_requires_same_origin_and_session_csrf(access_control, mo
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_operator_mode_rejects_unconfigured_or_foreign_public_origin_before_auth(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
missing = await client.get("/login")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "http://test")
|
||||
insecure = await client.get("/login")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://dashboard.example")
|
||||
foreign_host = await client.get("/login")
|
||||
|
||||
assert missing.status_code == 503
|
||||
assert insecure.status_code == 503
|
||||
assert foreign_host.status_code == 421
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_canonical_origin_drives_csrf_and_passkey_defaults(access_control, monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://dashboard.example")
|
||||
monkeypatch.delenv("STACKCHAIN_PASSKEY_RP_ID", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_PASSKEY_ORIGIN", raising=False)
|
||||
calls = 0
|
||||
|
||||
async def mark_read(notification_id):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": notification_id, "read": True}
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark_read)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://dashboard.example"
|
||||
) as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
valid = await client.patch(
|
||||
"/api/v1/notifications/7/read",
|
||||
headers={
|
||||
"Origin": "https://dashboard.example",
|
||||
"X-CSRF-Token": csrf,
|
||||
},
|
||||
)
|
||||
|
||||
request = Request({
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"scheme": "https",
|
||||
"path": "/",
|
||||
"query_string": b"",
|
||||
"headers": [(b"host", b"attacker.invalid")],
|
||||
"server": ("attacker.invalid", 443),
|
||||
"client": ("127.0.0.1", 1),
|
||||
})
|
||||
assert main._passkey_relying_party(request) == (
|
||||
"dashboard.example",
|
||||
"https://dashboard.example",
|
||||
)
|
||||
assert valid.status_code == 200
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_logout_clears_session_and_blocks_private_routes(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -1617,13 +1837,18 @@ async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
|
||||
async def test_failed_sign_in_recording_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
recording_started = threading.Event()
|
||||
recording_finished = threading.Event()
|
||||
|
||||
class SlowAttempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.02)
|
||||
return 0
|
||||
|
||||
def record_failure(self, source):
|
||||
recording_started.set()
|
||||
time.sleep(0.15)
|
||||
recording_finished.set()
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.11", 1234))
|
||||
@@ -1631,27 +1856,30 @@ async def test_failed_sign_in_recording_does_not_block_the_event_loop(
|
||||
sign_in = asyncio.create_task(
|
||||
client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
)
|
||||
assert await asyncio.to_thread(recording_started.wait, 1)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.04)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
assert not recording_finished.is_set()
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 401
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_sign_in_throttle_clear_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
clear_started = threading.Event()
|
||||
clear_finished = threading.Event()
|
||||
|
||||
class SlowAttempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.02)
|
||||
return 0
|
||||
|
||||
def clear(self, source):
|
||||
clear_started.set()
|
||||
time.sleep(0.15)
|
||||
clear_finished.set()
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.12", 1234))
|
||||
@@ -1662,14 +1890,12 @@ async def test_successful_sign_in_throttle_clear_does_not_block_the_event_loop(
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
)
|
||||
assert await asyncio.to_thread(clear_started.wait, 1)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.04)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
assert not clear_finished.is_set()
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 200
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -1869,6 +2095,7 @@ async def test_public_routes_remain_available_and_readiness_hides_identity(acces
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
await main._check_readiness()
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
health = await client.get("/healthz")
|
||||
|
||||
@@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SESSION_JS = ROOT / "frontend" / "session.js"
|
||||
SECURITY_CENTER_JS = ROOT / "frontend" / "security-center.js"
|
||||
|
||||
|
||||
def run_session_scenario(scenario: str) -> dict:
|
||||
@@ -347,7 +348,8 @@ process.stdout.write(JSON.stringify(state));
|
||||
|
||||
assert result["deletedAtIdle"] == []
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
@@ -372,7 +374,8 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["valid"] is False
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
@@ -502,7 +505,8 @@ process.stdout.write(JSON.stringify(state));
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -524,7 +528,8 @@ process.stdout.write(JSON.stringify(state));
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -619,7 +624,8 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert "stackchain.private" in result["removed"]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
@@ -753,7 +759,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
|
||||
|
||||
def test_security_activity_renders_authentication_alerts_before_journal_events():
|
||||
source = SESSION_JS.read_text()
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
alerts = source.index("page.authentication_alerts.forEach")
|
||||
events = source.index("page.events.forEach")
|
||||
@@ -765,14 +771,14 @@ def test_security_activity_renders_authentication_alerts_before_journal_events()
|
||||
|
||||
|
||||
def test_security_activity_explains_pending_outcome_confirmation():
|
||||
source = SESSION_JS.read_text()
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "Outcome confirmation pending" in source
|
||||
assert "event.status === 'pending'" in source
|
||||
|
||||
|
||||
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
|
||||
source = SESSION_JS.read_text()
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "passkey_enrolled: 'Passkey enrolled'" in source
|
||||
assert "title.textContent = labels[event.kind]" in source
|
||||
@@ -780,7 +786,7 @@ def test_security_activity_labels_passkey_enrollment_without_html_rendering():
|
||||
|
||||
|
||||
def test_security_activity_labels_consequential_pull_review_decisions():
|
||||
source = SESSION_JS.read_text()
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "pull_review_approved: 'Pull request approved'" in source
|
||||
assert "pull_review_changes_requested: 'Changes requested'" in source
|
||||
@@ -815,3 +821,9 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert 'id="security-activity-list"' in html
|
||||
assert '>Load older activity</button>' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
|
||||
|
||||
def test_security_activity_names_confirmed_gitea_time_logging():
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "gitea_time_logged: 'Gitea time logged'" in source
|
||||
@@ -0,0 +1,479 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
from src.push_notifications import PushConfiguration, dispatch_deadline_reminders
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplicates(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="America/New_York", reminder_hour=9
|
||||
)
|
||||
sent = []
|
||||
|
||||
async def assigned():
|
||||
return {
|
||||
"complete": True,
|
||||
"items": [
|
||||
{
|
||||
"id": 42,
|
||||
"title": "Private launch plan",
|
||||
"repository": {"full_name": "private/repo"},
|
||||
"due_date": "2026-08-14T12:00:00Z",
|
||||
},
|
||||
{"id": 43, "due_date": "2026-08-20T12:00:00Z"},
|
||||
],
|
||||
}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
now = datetime(2026, 8, 13, 13, 5, tzinfo=timezone.utc)
|
||||
|
||||
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 1
|
||||
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 0
|
||||
assert sent == [{
|
||||
"title": "1 deadline needs attention",
|
||||
"body": "Open Agenda to review or replan it.",
|
||||
"route": "#/my-work/agenda",
|
||||
"protect_route": "#/my-work/agenda/protect-today",
|
||||
"tag": "stackchain-deadline-digest-2026-08-13",
|
||||
"deadline_count": 1,
|
||||
}]
|
||||
assert "Private launch plan" not in json.dumps(sent)
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for device, timezone_name in (("tokyo", "Asia/Tokyo"), ("la", "America/Los_Angeles")):
|
||||
store.upsert(device, {
|
||||
"endpoint": f"https://push.example/{device}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(device, enabled=True, timezone=timezone_name, reminder_hour=0)
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [
|
||||
{"id": 1, "due_date": "2026-08-15T23:59:59Z"},
|
||||
{"id": 2, "due_date": "2026-08-16T23:59:59Z"},
|
||||
]}
|
||||
|
||||
sent = {}
|
||||
async def send(subscription, payload):
|
||||
device = subscription["endpoint"].rsplit("/", 1)[-1]
|
||||
sent[device] = json.loads(payload)["deadline_count"]
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store, PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned, send, now=datetime(2026, 8, 13, 23, 30, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 2
|
||||
assert sent == {"tokyo": 2, "la": 1}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_uses_each_devices_confirmed_horizon(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for device, reminder_days in (("today", 0), ("week", 7)):
|
||||
store.upsert(device, {
|
||||
"endpoint": f"https://push.example/{device}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
device,
|
||||
enabled=True,
|
||||
timezone="UTC",
|
||||
reminder_hour=9,
|
||||
reminder_days=reminder_days,
|
||||
)
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [
|
||||
{"id": 1, "due_date": "2026-08-13"},
|
||||
{"id": 2, "due_date": "2026-08-15"},
|
||||
{"id": 3, "due_date": "2026-08-20"},
|
||||
{"id": 4, "due_date": "2026-08-21"},
|
||||
]}
|
||||
|
||||
sent = {}
|
||||
|
||||
async def send(subscription, payload):
|
||||
device = subscription["endpoint"].rsplit("/", 1)[-1]
|
||||
sent[device] = json.loads(payload)["deadline_count"]
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
send,
|
||||
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 2
|
||||
assert sent == {"today": 1, "week": 3}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(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="America/Los_Angeles", reminder_hour=9
|
||||
)
|
||||
sent = []
|
||||
|
||||
async def incomplete():
|
||||
return {"complete": False, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
|
||||
|
||||
async def complete():
|
||||
return {"complete": True, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(payload)
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_deadline_reminders(
|
||||
store, config, incomplete, send,
|
||||
now=datetime(2026, 8, 13, 18, 0, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert await dispatch_deadline_reminders(
|
||||
store, config, complete, send,
|
||||
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert sent == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_skips_snapshot_until_a_device_reaches_its_local_hour(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="America/Los_Angeles", reminder_hour=9
|
||||
)
|
||||
snapshot_calls = 0
|
||||
|
||||
async def assigned():
|
||||
nonlocal snapshot_calls
|
||||
snapshot_calls += 1
|
||||
return {"complete": True, "items": []}
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 0
|
||||
assert snapshot_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_skips_snapshot_after_every_device_was_delivered_today(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id, timezone_name in (
|
||||
("device-a", "UTC"),
|
||||
("device-b", "America/New_York"),
|
||||
):
|
||||
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=timezone_name, reminder_hour=9
|
||||
)
|
||||
store.mark_deadline_reminder_delivered(session_id, "2026-08-13")
|
||||
snapshot_calls = 0
|
||||
|
||||
async def assigned():
|
||||
nonlocal snapshot_calls
|
||||
snapshot_calls += 1
|
||||
return {"complete": True, "items": []}
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 0
|
||||
assert snapshot_calls == 0
|
||||
|
||||
|
||||
def test_deadline_preferences_persist_on_the_existing_device_subscription(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="Europe/London", reminder_hour=8
|
||||
)
|
||||
|
||||
assert store.deadline_preferences("device-a") == {
|
||||
"enabled": True,
|
||||
"timezone": "Europe/London",
|
||||
"reminder_hour": 8,
|
||||
"reminder_days": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
|
||||
database = tmp_path / "push.sqlite3"
|
||||
import sqlite3
|
||||
|
||||
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_deadline_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
delivered_local_day TEXT
|
||||
);
|
||||
INSERT INTO push_subscriptions VALUES ('device-a', 'https://push.example/a', '{}');
|
||||
INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL);
|
||||
""")
|
||||
|
||||
store = PushSubscriptionStore(database)
|
||||
|
||||
assert store.deadline_preferences("device-a")["reminder_days"] == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch):
|
||||
pages = {
|
||||
1: {"items": [{"id": 1}], "total": 2, "has_more": True},
|
||||
2: {"items": [{"id": 2}], "total": 2, "has_more": False},
|
||||
}
|
||||
|
||||
async def work_page(stream, page, limit):
|
||||
assert stream == "issue"
|
||||
assert limit == 1
|
||||
return pages[page]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
assert await gitea_proxy.assigned_issue_snapshot(limit=1) == {
|
||||
"items": [{"id": 1}, {"id": 2}],
|
||||
"complete": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_fetches_remaining_pages_concurrently(monkeypatch):
|
||||
active = 0
|
||||
peak = 0
|
||||
remaining_started = asyncio.Event()
|
||||
|
||||
async def work_page(stream, page, limit):
|
||||
nonlocal active, peak
|
||||
assert stream == "issue"
|
||||
assert limit == 1
|
||||
if page == 1:
|
||||
return {"items": [{"id": 1}], "total": 5, "has_more": True}
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
if active == 2:
|
||||
remaining_started.set()
|
||||
await asyncio.wait_for(remaining_started.wait(), timeout=0.2)
|
||||
await asyncio.sleep(0)
|
||||
active -= 1
|
||||
return {"items": [{"id": page}], "total": 5, "has_more": page < 5}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
assert await gitea_proxy.assigned_issue_snapshot(limit=1, max_concurrency=2) == {
|
||||
"items": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}],
|
||||
"complete": True,
|
||||
}
|
||||
assert peak == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"remaining",
|
||||
[
|
||||
{"items": [{"id": 2}], "total": 3, "has_more": True},
|
||||
{"items": [{"id": 1}], "total": 2, "has_more": False},
|
||||
],
|
||||
)
|
||||
async def test_assigned_deadline_snapshot_rejects_changed_or_duplicate_pages(
|
||||
monkeypatch, remaining
|
||||
):
|
||||
async def work_page(_stream, page, _limit):
|
||||
if page == 1:
|
||||
return {"items": [{"id": 1}], "total": 2, "has_more": True}
|
||||
return remaining
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
with pytest.raises(ValueError, match="incomplete|changed"):
|
||||
await gitea_proxy.assigned_issue_snapshot(limit=1)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_enforces_aggregate_deadline(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def work_page(_stream, _page, _limit):
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
await gitea_proxy.assigned_issue_snapshot(deadline_seconds=0.01)
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_competing_workers_send_one_deadline_digest(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
first = PushSubscriptionStore(path)
|
||||
second = PushSubscriptionStore(path)
|
||||
first.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
first.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
sending = __import__("asyncio").Event()
|
||||
release = __import__("asyncio").Event()
|
||||
sent = []
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(payload)
|
||||
sending.set()
|
||||
await release.wait()
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
now = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc)
|
||||
active = __import__("asyncio").create_task(
|
||||
dispatch_deadline_reminders(first, config, assigned, send, now=now)
|
||||
)
|
||||
await sending.wait()
|
||||
competing = await dispatch_deadline_reminders(second, config, assigned, send, now=now)
|
||||
release.set()
|
||||
|
||||
assert competing == 0
|
||||
assert await active == 1
|
||||
assert len(sent) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminders_bound_fanout_and_isolate_failed_devices(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for index in range(4):
|
||||
session_id = f"device-{index}"
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
active = 0
|
||||
peak = 0
|
||||
two_active = asyncio.Event()
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
nonlocal active, peak
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
if active == 2:
|
||||
two_active.set()
|
||||
await asyncio.wait_for(two_active.wait(), timeout=0.5)
|
||||
active -= 1
|
||||
if subscription["endpoint"].endswith("device-1"):
|
||||
raise RuntimeError("provider failed")
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
send,
|
||||
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
|
||||
max_concurrency=2,
|
||||
)
|
||||
|
||||
assert delivered == 3
|
||||
assert peak == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminders_bulk_authorize_due_devices_and_prune_inactive(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("active-device", "revoked-device"):
|
||||
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
|
||||
)
|
||||
authorization_calls = []
|
||||
sent = []
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
|
||||
|
||||
async def session_statuses(management_ids):
|
||||
authorization_calls.append(list(management_ids))
|
||||
return {
|
||||
management_id: "active" if management_id == "active-device" else "revoked"
|
||||
for management_id in management_ids
|
||||
}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
sent.append(subscription["endpoint"])
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
send,
|
||||
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
|
||||
session_statuses=session_statuses,
|
||||
)
|
||||
|
||||
assert delivered == 1
|
||||
assert authorization_calls == [["active-device", "revoked-device"]]
|
||||
assert sent == ["https://push.example/active-device"]
|
||||
assert store.is_subscribed("revoked-device") is False
|
||||
@@ -313,7 +313,7 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
|
||||
assert 'Sending <strong data-delivery-count="sending">' in html
|
||||
assert 'Needs attention <strong data-delivery-count="attention">' in html
|
||||
assert 'Authorize <strong data-delivery-count="authorization">' in html
|
||||
assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html
|
||||
assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html
|
||||
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
|
||||
assert "deliveryCenter.retryable.length" in html
|
||||
assert "item.status === 'sending' ? 'Sending'" in html
|
||||
|
||||
@@ -89,3 +89,22 @@ console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appen
|
||||
"opened": 1,
|
||||
"appends": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_security_center_failure_is_accessible_and_retryable_from_active_devices():
|
||||
result = run_loader("""
|
||||
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
|
||||
const loader=createFeatureLoader({document, urls:{'security-center':'feature-security.js'}, timeoutMs:100});
|
||||
const failed=loader.run('security-center',{trigger,status,retryLabel:'Tap Active devices to retry.'},()=>{opened++;});
|
||||
state.node.onerror(); const first=await failed; const failedStatus=status.textContent;
|
||||
const retry=loader.run('security-center',{trigger,status,retryLabel:'Tap Active devices to retry.'},()=>{opened++;});
|
||||
state.node.onload(); const second=await retry;
|
||||
console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appends}));
|
||||
""")
|
||||
assert result == {
|
||||
"first": False,
|
||||
"second": True,
|
||||
"failedStatus": "Security center could not load. Tap Active devices to retry.",
|
||||
"opened": 1,
|
||||
"appends": 2,
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import gzip
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -25,7 +26,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
|
||||
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
|
||||
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert first.dashboard_html.count("<script src=") == 2
|
||||
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' in first.dashboard_html
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
assert first.runtime_name.startswith("runtime-")
|
||||
assert first.runtime_name.endswith(".js")
|
||||
@@ -45,7 +47,14 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
|
||||
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow"}
|
||||
assert set(first.feature_bundles) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
||||
"today-timer", "security-center",
|
||||
}
|
||||
assert (
|
||||
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{first.runtime_name}"></script>'
|
||||
) in first.dashboard_html
|
||||
capture = first.feature_bundles["issue-capture"]
|
||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||
@@ -54,11 +63,31 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"function createReviewController" not in first.runtime_bytes
|
||||
assert b"function createPullSheet" in pull_workflow.runtime_bytes
|
||||
assert b"function createReviewController" in pull_workflow.runtime_bytes
|
||||
assert len(first.runtime_gzip_bytes) <= 95 * 1024
|
||||
security_center = first.feature_bundles["security-center"]
|
||||
assert b"function attachSecurityCenter" not in first.runtime_bytes
|
||||
assert b"function attachSecurityCenter" in security_center.runtime_bytes
|
||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
||||
assert len(first.runtime_gzip_bytes) <= 96 * 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
|
||||
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
|
||||
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
|
||||
|
||||
shell_block, optional_block = first.service_worker_source.split(
|
||||
"const OPTIONAL_FEATURES = [", 1
|
||||
)
|
||||
optional_block = optional_block.split("];", 1)[0]
|
||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
|
||||
for name, bundle in first.feature_bundles.items():
|
||||
if name == "today-timer":
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
|
||||
else:
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
||||
assert f"BASE + '{bundle.runtime_name}'" in optional_block
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
@@ -75,6 +104,23 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert pull_changed.runtime_name == first.runtime_name
|
||||
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
|
||||
|
||||
security_source = changed_frontend / "security-center.js"
|
||||
security_source.write_text(security_source.read_text() + "\n// security-center-only change\n")
|
||||
security_changed = build_frontend(changed_frontend)
|
||||
assert security_changed.runtime_name == first.runtime_name
|
||||
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
|
||||
|
||||
|
||||
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
|
||||
build = build_frontend(FRONTEND)
|
||||
bundles = {"core.js": build.runtime_bytes}
|
||||
bundles.update({f"{name}.js": bundle.runtime_bytes for name, bundle in build.feature_bundles.items()})
|
||||
for name, source in bundles.items():
|
||||
path = tmp_path / name
|
||||
path.write_bytes(source)
|
||||
result = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
|
||||
@@ -141,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v87';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v100';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -40,6 +40,123 @@ async def test_notification_page_preserves_upstream_total_without_loading_other_
|
||||
assert [item["id"] for item in result["items"]] == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notification_snapshot_loads_every_page_with_bounded_concurrency():
|
||||
active = 0
|
||||
peak = 0
|
||||
requested_pages = []
|
||||
|
||||
async def upstream(request):
|
||||
nonlocal active, peak
|
||||
page = int(request.url.params["page"])
|
||||
requested_pages.append(page)
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
await __import__("asyncio").sleep(0)
|
||||
active -= 1
|
||||
start = (page - 1) * 50 + 1
|
||||
end = min(start + 50, 126)
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "125"},
|
||||
json=[{
|
||||
"id": thread_id,
|
||||
"updated_at": f"2026-08-11T12:{thread_id % 60:02d}:00Z",
|
||||
"subject": {},
|
||||
"repository": {},
|
||||
} for thread_id in range(start, end)],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.unread_notification_snapshot(
|
||||
max_pages=3, max_concurrency=2, deadline_seconds=1
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requested_pages[0] == 1
|
||||
assert sorted(requested_pages) == [1, 2, 3]
|
||||
assert peak == 2
|
||||
assert result["complete"] is True
|
||||
assert result["total"] == 125
|
||||
assert [item["id"] for item in result["items"]] == list(range(1, 126))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notification_snapshot_rejects_pagination_that_changes_mid_scan():
|
||||
def upstream(request):
|
||||
page = int(request.url.params["page"])
|
||||
total = 51 if page == 1 else 50
|
||||
start = (page - 1) * 50 + 1
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": str(total)},
|
||||
json=[{
|
||||
"id": thread_id,
|
||||
"subject": {},
|
||||
"repository": {},
|
||||
} for thread_id in range(start, min(start + 50, total + 1))],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="changed during the scan"):
|
||||
await gitea_proxy.unread_notification_snapshot(deadline_seconds=1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notification_snapshot_rejects_missing_or_duplicate_threads():
|
||||
def upstream(_request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "3"},
|
||||
json=[
|
||||
{"id": 7, "subject": {}, "repository": {}},
|
||||
{"id": 7, "subject": {}, "repository": {}},
|
||||
"malformed",
|
||||
],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="incomplete thread set"):
|
||||
await gitea_proxy.unread_notification_snapshot(deadline_seconds=1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notification_snapshot_fails_before_exceeding_the_scan_limit():
|
||||
requested_pages = []
|
||||
|
||||
def upstream(request):
|
||||
page = int(request.url.params["page"])
|
||||
requested_pages.append(page)
|
||||
start = (page - 1) * 50 + 1
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "101"},
|
||||
json=[
|
||||
{"id": thread_id, "subject": {}, "repository": {}}
|
||||
for thread_id in range(start, min(start + 50, 102))
|
||||
],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="scan limit"):
|
||||
await gitea_proxy.unread_notification_snapshot(
|
||||
max_pages=2, deadline_seconds=1
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requested_pages == [1]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notifications_are_normalized_for_mobile_handoff():
|
||||
result = gitea_proxy._normalize_notifications(
|
||||
@@ -137,6 +254,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"number": 7, "state": "open", "assignees": [],
|
||||
"body": "Deploy fails after **three** retries.",
|
||||
})
|
||||
if request.url.path.endswith("/issues/7/subscriptions/check"):
|
||||
return httpx.Response(200, json={"subscribed": True, "ignored": False})
|
||||
if request.url.path.endswith("/issues/comments/9"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 9,
|
||||
@@ -166,6 +285,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/subscriptions/check",
|
||||
]
|
||||
assert result == {
|
||||
"id": 42,
|
||||
@@ -183,6 +303,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
},
|
||||
"issue": {"number": 7, "assignees": [], "claimable": True},
|
||||
"acknowledge_supported": True,
|
||||
"mute_supported": True,
|
||||
"conversation": {
|
||||
"comments": [{
|
||||
"id": 9,
|
||||
@@ -281,3 +402,37 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
|
||||
"created_at": "2026-08-07T19:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mute_notification_unsubscribes_current_user_from_trusted_thread_target():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path.endswith("/notifications/threads/42"):
|
||||
return httpx.Response(200, json={
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Pull",
|
||||
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/pulls/7",
|
||||
},
|
||||
})
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "DELETE":
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.mute_notification(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {"id": 42, "repository": "stackchain/api", "number": 7, "muted": True}
|
||||
assert requests == [
|
||||
("GET", "/api/v1/notifications/threads/42"),
|
||||
("GET", "/api/v1/user"),
|
||||
("DELETE", "/api/v1/repos/stackchain/api/issues/7/subscriptions/timmy"),
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -7,6 +8,26 @@ from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_log_issue_time_uses_canonical_shared_issue_endpoint_and_seconds():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(201, json={"id": 1})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
await gitea_proxy.log_issue_time("pull:stackchain/stackchain-dashboard:586:", 2520)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].method == "POST"
|
||||
assert requests[0].url.path == "/api/v1/repos/stackchain/stackchain-dashboard/issues/586/times"
|
||||
assert requests[0].read() == b'{"time":2520}'
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_transport_is_reused_across_requests_and_closed():
|
||||
client_ids = []
|
||||
@@ -225,6 +246,7 @@ async def test_application_lifespan_opens_and_closes_gitea_transport(monkeypatch
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
@@ -250,6 +272,7 @@ async def test_application_shutdown_finishes_snapshot_before_closing_transport(m
|
||||
calls.append("snapshot cancelled")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
assert main._live_snapshot_task is not None
|
||||
@@ -286,6 +309,7 @@ async def test_application_shutdown_cancels_available_work_scan_before_transport
|
||||
calls.append("available scan cancelled")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
task = main._available_issue_snapshot_task
|
||||
@@ -321,6 +345,7 @@ async def test_application_shutdown_drains_authored_mutation_before_transport(mo
|
||||
return {"id": 461}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
@@ -362,6 +387,7 @@ async def test_application_shutdown_bounds_and_settles_stalled_authored_mutation
|
||||
|
||||
monkeypatch.setattr(main, "AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS", 0.01)
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
|
||||
@@ -215,6 +215,120 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_search_filters_full_catalog_before_pagination(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{
|
||||
"repository": "stackchain/api",
|
||||
"number": number,
|
||||
"title": "Routine API maintenance",
|
||||
}
|
||||
for number in range(1, 52)
|
||||
] + [{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 673,
|
||||
"title": "Search the full Find Work catalog",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
by_repo = await client.get("/api/v1/available-issues?page=1&q=DASHBOARD")
|
||||
by_number = await client.get("/api/v1/available-issues?page=1&q=%23673")
|
||||
by_title = await client.get("/api/v1/available-issues?page=1&q=full%20find")
|
||||
|
||||
expected = [{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 673,
|
||||
"title": "Search the full Find Work catalog",
|
||||
}]
|
||||
assert by_repo.json() == {"items": expected, "page": 1, "total": 1, "has_more": False}
|
||||
assert by_number.json() == by_repo.json()
|
||||
assert by_title.json() == by_repo.json()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_filter_before_pagination_and_describe_catalog(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]},
|
||||
{"repository": "stackchain/api", "number": 2, "title": "API docs", "labels": ["docs"]},
|
||||
{"repository": "stackchain/dashboard", "number": 3, "title": "Critical UI", "labels": ["critical", "frontend"]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("repository", "stackchain/api"), ("label", "critical")],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]}],
|
||||
"page": 1,
|
||||
"total": 1,
|
||||
"has_more": False,
|
||||
"facets": {
|
||||
"repositories": ["stackchain/api", "stackchain/dashboard"],
|
||||
"labels": ["backend", "critical", "docs", "frontend"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_or_within_each_facet_and_compose_with_search(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{"repository": "stackchain/api", "number": 1, "title": "Repair worker", "labels": ["backend"]},
|
||||
{"repository": "stackchain/web", "number": 2, "title": "Repair mobile", "labels": ["frontend"]},
|
||||
{"repository": "stackchain/docs", "number": 3, "title": "Repair guide", "labels": ["docs"]},
|
||||
{"repository": "stackchain/api", "number": 4, "title": "Routine task", "labels": ["backend"]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("q", "repair"), ("repository", "stackchain/api"), ("repository", "stackchain/web"),
|
||||
("label", "backend"), ("label", "frontend")],
|
||||
)
|
||||
|
||||
assert [item["number"] for item in response.json()["items"]] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_reject_excess_values_before_scanning(monkeypatch):
|
||||
async def must_not_scan():
|
||||
raise AssertionError("invalid facets must be rejected before catalog scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("label", f"label-{index}") for index in range(11)],
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch):
|
||||
async def must_not_scan():
|
||||
raise AssertionError("invalid query must be rejected before catalog scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues", params={"q": "x" * 101})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_reuses_catalog_published_by_another_worker(
|
||||
monkeypatch, tmp_path
|
||||
@@ -240,6 +354,82 @@ async def test_available_issue_endpoint_reuses_catalog_published_by_another_work
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_waits_for_slow_cold_catalog_from_another_worker(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
now = 100.0
|
||||
path = tmp_path / "shared-available.sqlite3"
|
||||
owner_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
|
||||
worker_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
|
||||
owner = owner_store.try_acquire_refresh(lease_seconds=6)
|
||||
monkeypatch.setattr(main, "_available_issue_snapshot_store", worker_store)
|
||||
|
||||
sleep_calls = 0
|
||||
|
||||
async def publish_after_existing_one_second_window(_delay):
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls == 60:
|
||||
owner_store.publish(
|
||||
owner,
|
||||
items=[{"repository": "stackchain/api", "number": 8}],
|
||||
)
|
||||
|
||||
async def must_not_scan():
|
||||
raise AssertionError("a valid cross-worker lease must prevent a duplicate scan")
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", publish_after_existing_one_second_window)
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert sleep_calls > 50
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"] == [
|
||||
{"repository": "stackchain/api", "number": 8}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_takes_over_released_cold_refresh(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
now = 100.0
|
||||
path = tmp_path / "shared-available.sqlite3"
|
||||
owner_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
|
||||
worker_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
|
||||
owner = owner_store.try_acquire_refresh(lease_seconds=6)
|
||||
monkeypatch.setattr(main, "_available_issue_snapshot_store", worker_store)
|
||||
|
||||
sleep_calls = 0
|
||||
scan_calls = 0
|
||||
|
||||
async def release_failed_owner(_delay):
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls == 3:
|
||||
owner_store.release_refresh(owner)
|
||||
|
||||
async def available():
|
||||
nonlocal scan_calls
|
||||
scan_calls += 1
|
||||
return [{"repository": "stackchain/web", "number": 9}]
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", release_failed_owner)
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"] == [
|
||||
{"repository": "stackchain/web", "number": 9}
|
||||
]
|
||||
assert scan_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
@@ -229,6 +229,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
|
||||
"assignees": [],
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
"reopenable": False,
|
||||
"assigned_to_me": False,
|
||||
}
|
||||
|
||||
@@ -255,3 +256,26 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
|
||||
|
||||
assert preview["kind"] == "pull"
|
||||
assert preview["claimable"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_offers_reopen_only_for_closed_issues():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 42,
|
||||
"title": "Resume work",
|
||||
"state": "closed",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/api", "issue", 42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview["reopenable"] is True
|
||||
assert preview["claimable"] is False
|
||||
@@ -11,6 +11,49 @@ def test_health_endpoint_reports_service_liveness_without_gitea_access():
|
||||
assert main.health() == {"status": "ok", "service": "stackchain-dashboard"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_never_contacts_gitea_for_a_request(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def unexpected_user_lookup():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise AssertionError("request-time readiness must not contact Gitea")
|
||||
|
||||
monkeypatch.setattr(main, "_readiness_state", None, raising=False)
|
||||
monkeypatch.setattr(main, "current_user", unexpected_user_lookup)
|
||||
|
||||
first = await main.readiness()
|
||||
second = await main.readiness()
|
||||
|
||||
assert first.status_code == 503
|
||||
assert second.status_code == 503
|
||||
assert calls == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_monitor_refreshes_on_an_interval_and_cancels(monkeypatch):
|
||||
calls = 0
|
||||
refreshed_twice = asyncio.Event()
|
||||
|
||||
async def check():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
refreshed_twice.set()
|
||||
|
||||
monkeypatch.setattr(main, "_check_readiness", check)
|
||||
monkeypatch.setattr(main, "READINESS_INTERVAL_SECONDS", 0.01, raising=False)
|
||||
|
||||
task = asyncio.create_task(main._readiness_monitor())
|
||||
await asyncio.wait_for(refreshed_twice.wait(), timeout=0.2)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert calls == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
|
||||
async def connected_user():
|
||||
@@ -18,6 +61,8 @@ async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(main, "current_user", connected_user)
|
||||
|
||||
await main._check_readiness()
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert any(getattr(route, "path", None) == "/readyz" for route in main.app.routes)
|
||||
@@ -35,6 +80,8 @@ async def test_readiness_endpoint_hides_unexpected_upstream_error_details(monkey
|
||||
|
||||
monkeypatch.setattr(main, "current_user", unavailable_user)
|
||||
|
||||
await main._check_readiness()
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
@@ -53,6 +100,8 @@ async def test_readiness_endpoint_returns_503_for_null_current_user_payload(monk
|
||||
|
||||
monkeypatch.setattr(main, "current_user", null_user)
|
||||
|
||||
await main._check_readiness()
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
@@ -77,6 +126,8 @@ async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monk
|
||||
monkeypatch.setattr(main, "READINESS_TIMEOUT_SECONDS", 0.01, raising=False)
|
||||
monkeypatch.setattr(main, "current_user", hanging_user)
|
||||
|
||||
await main._check_readiness()
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
@@ -87,3 +138,18 @@ async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monk
|
||||
"error": "Gitea readiness check timed out after 0.01s",
|
||||
}
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_expires_a_stale_healthy_snapshot(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_readiness_state",
|
||||
main.ReadinessState(checked_at=10.0, ready=True, login="timmy"),
|
||||
)
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: 76.0)
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert json.loads(response.body)["error"] == "Gitea readiness status is not available"
|
||||
@@ -174,6 +174,53 @@ process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:va
|
||||
assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True}
|
||||
|
||||
|
||||
def test_setup_api_reports_install_state_without_prompting():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card:{hidden:true}, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
const before = app.state();
|
||||
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
|
||||
const after = app.state();
|
||||
process.stdout.write(JSON.stringify({before, after}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"before": {"state": "unavailable", "detail": "Installation is available from a supported browser."},
|
||||
"after": {"state": "incomplete", "detail": "Stackchain is ready to install."},
|
||||
}
|
||||
|
||||
|
||||
def test_captured_install_prompt_remains_available_until_setup_opens():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
let prevented = 0;
|
||||
const prompt = {preventDefault(){prevented += 1;}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})};
|
||||
const card = {hidden:true};
|
||||
const app = createInstallApp({
|
||||
window:new FakeTarget(), initialPrompt:prompt, card,
|
||||
installButton:new FakeButton(), dismissButton:new FakeButton(), guidance:{hidden:true},
|
||||
status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
process.stdout.write(JSON.stringify({state:app.state(), shown:!card.hidden, prevented}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"state": {"state": "incomplete", "detail": "Stackchain is ready to install."},
|
||||
"shown": True,
|
||||
"prevented": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_appinstalled_hides_an_visible_native_promotion():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
|
||||
@@ -8,6 +8,163 @@ from src import gitea_proxy, main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_owned_comment_mutations_verify_thread_and_author_before_writing():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "GET" and request.url.path.endswith("/issues/comments/42"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"body": "Original",
|
||||
"user": {"login": "timmy"},
|
||||
"issue_url": f"{gitea_proxy.GITEA_URL}/api/v1/repos/stackchain/api/issues/17",
|
||||
"html_url": f"{gitea_proxy.GITEA_URL}/stackchain/api/issues/17#issuecomment-42",
|
||||
})
|
||||
if request.method == "PATCH":
|
||||
return httpx.Response(200, json={
|
||||
"id": 42, "body": "Corrected", "user": {"login": "timmy"},
|
||||
"html_url": "https://gitea.example/stackchain/api/issues/17#issuecomment-42",
|
||||
})
|
||||
return httpx.Response(204)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
edited = await gitea_proxy.edit_owned_comment("stackchain/api", 17, 42, "Corrected")
|
||||
deleted = await gitea_proxy.delete_owned_comment("stackchain/api", 17, 42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert edited["body"] == "Corrected"
|
||||
assert deleted == {"id": 42, "deleted": True}
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/comments/42"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/comments/42"),
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/comments/42"),
|
||||
("DELETE", "/api/v1/repos/stackchain/api/issues/comments/42"),
|
||||
]
|
||||
assert json.loads(requests[2].content) == {"body": "Corrected"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"comment",
|
||||
[
|
||||
{"id": 42, "user": {"login": "alex"}, "issue_url": "https://gitea.example/api/v1/repos/stackchain/api/issues/17"},
|
||||
{"id": 42, "user": {"login": "timmy"}, "issue_url": f"{gitea_proxy.GITEA_URL}/api/v1/repos/stackchain/api/issues/99"},
|
||||
{"id": 42, "user": {"login": "timmy"}, "issue_url": "https://evil.example/api/v1/repos/stackchain/api/issues/17"},
|
||||
],
|
||||
)
|
||||
async def test_owned_comment_mutations_reject_other_author_or_thread_without_writing(comment):
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json=comment)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.CommentMutationForbiddenError):
|
||||
await gitea_proxy.edit_owned_comment("stackchain/api", 17, 42, "Nope")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [request.method for request in requests] == ["GET", "GET"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("base_path", "availability_call"),
|
||||
[
|
||||
("/api/v1/repos/stackchain/api/issues/17", "issue"),
|
||||
("/api/v1/repos/stackchain/api/pulls/17", "pull"),
|
||||
("/api/v1/notifications/91", "notification"),
|
||||
],
|
||||
)
|
||||
async def test_comment_mutation_routes_cover_issue_pull_and_unread_update(
|
||||
monkeypatch, base_path, availability_call
|
||||
):
|
||||
calls = []
|
||||
|
||||
async def available_issue(repository, number):
|
||||
calls.append(("issue", repository, number))
|
||||
return True
|
||||
|
||||
async def available_pull(repository, number):
|
||||
calls.append(("pull", repository, number))
|
||||
return True
|
||||
|
||||
async def notification_target(thread_id):
|
||||
calls.append(("notification", thread_id))
|
||||
return "stackchain/api", 17
|
||||
|
||||
async def edit(repository, number, comment_id, body):
|
||||
calls.append(("edit", repository, number, comment_id, body))
|
||||
return {"id": comment_id, "author": "timmy", "body": body}
|
||||
|
||||
async def delete(repository, number, comment_id):
|
||||
calls.append(("delete", repository, number, comment_id))
|
||||
return {"id": comment_id, "deleted": True}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", available_issue)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", available_pull)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "notification_conversation_target", notification_target,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "edit_owned_comment", edit)
|
||||
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
edited = await client.patch(base_path + "/comments/42", json={"body": " Corrected "})
|
||||
deleted = await client.delete(base_path + "/comments/42")
|
||||
|
||||
assert edited.status_code == 200
|
||||
assert edited.json()["body"] == "Corrected"
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json() == {"id": 42, "deleted": True}
|
||||
if availability_call == "notification":
|
||||
assert calls == [
|
||||
("notification", 91), ("edit", "stackchain/api", 17, 42, "Corrected"),
|
||||
("notification", 91), ("delete", "stackchain/api", 17, 42),
|
||||
]
|
||||
else:
|
||||
assert calls == [
|
||||
(availability_call, "stackchain/api", 17),
|
||||
("edit", "stackchain/api", 17, 42, "Corrected"),
|
||||
(availability_call, "stackchain/api", 17),
|
||||
("delete", "stackchain/api", 17, 42),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_comment_mutation_route_returns_forbidden_without_hiding_saved_edit(monkeypatch):
|
||||
async def available(repository, number):
|
||||
return True
|
||||
|
||||
async def forbidden(*args):
|
||||
raise gitea_proxy.CommentMutationForbiddenError("not owned")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", available)
|
||||
monkeypatch.setattr(main.gitea_proxy, "edit_owned_comment", forbidden)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/comments/42",
|
||||
json={"body": "Keep this draft"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "You can only change your own comments"
|
||||
|
||||
|
||||
async def repository_access_from(loader, repository):
|
||||
repositories = await loader()
|
||||
return next(
|
||||
@@ -1094,6 +1251,71 @@ async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_
|
||||
assert result["assignees"] == ["timmy"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_reopen_issue_rechecks_closed_state_and_confirms_self_assignment():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.method == "GET" and request.url.path.endswith("/issues/17"):
|
||||
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",
|
||||
})
|
||||
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",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.reopen_issue("stackchain/api", 17)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
("GET", "/api/v1/user"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
assert requests[2].content == b'{"state":"open","assignee":"timmy"}'
|
||||
assert result == {
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_reopen_issue_retry_accepts_already_open_self_assigned_issue():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if 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"}], "pull_request": None, "labels": [],
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.reopen_issue("stackchain/api", 17)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["state"] == "open"
|
||||
assert result["assignees"] == ["timmy"]
|
||||
assert all(request.method == "GET" for request in requests)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkeypatch):
|
||||
calls = []
|
||||
@@ -1134,6 +1356,30 @@ async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflic
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reopen_issue_endpoint_returns_confirmed_resumable_work_item(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def reopen(repository, number):
|
||||
calls.append((repository, number))
|
||||
return {
|
||||
"id": 81, "number": number, "title": "Resume work", "state": "open",
|
||||
"repository": repository, "labels": ["P1"], "assignees": ["timmy"],
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "reopen_issue", reopen, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch("/api/v1/repos/stackchain/api/issues/17/reopen")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["state"] == "open"
|
||||
assert response.json()["assignees"] == ["timmy"]
|
||||
assert calls == [("stackchain/api", 17)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_peers():
|
||||
requests = []
|
||||
|
||||
@@ -494,7 +494,7 @@ def test_retrying_same_pull_load_preserves_screenshot_but_target_change_clears_i
|
||||
"\n function closePullSheet", 1
|
||||
)[0]
|
||||
|
||||
assert "if (!sameWorkTarget(selectedPull, item)) pullAttachmentController.clear();" in open_body
|
||||
assert "if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();" in open_body
|
||||
|
||||
|
||||
def test_pull_screenshot_comment_uploads_or_durably_admits_before_clearing_draft():
|
||||
|
||||
@@ -744,3 +744,55 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio
|
||||
assert 'Needs attention' in html
|
||||
assert "issueOutbox.discard(item.outbox_id)" in html
|
||||
assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html
|
||||
|
||||
|
||||
|
||||
def test_issue_outbox_reuses_the_original_operation_when_a_source_draft_is_retried():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let sequence = 0;
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'draft-op-' + (++sequence),
|
||||
}});
|
||||
const first = outbox.enqueue({{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-7'}});
|
||||
const retried = outbox.enqueue({{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-7'}});
|
||||
process.stdout.write(JSON.stringify({{first,retried,items:outbox.list(),sequence}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["sequence"] == 1
|
||||
assert len(output["items"]) == 1
|
||||
assert output["first"]["id"] == output["retried"]["id"] == "draft-op-1"
|
||||
assert output["items"][0]["operationId"] == "draft-op-1"
|
||||
assert output["items"][0]["sourceCaptureId"] == "capture-7"
|
||||
|
||||
|
||||
def test_durable_source_draft_retry_reconciles_without_allocating_another_operation():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let sequence = 0; const reconciliations = []; let syncRequests = 0;
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'durable-' + (++sequence),
|
||||
backgroundSync:{{
|
||||
reconcile:async items=>reconciliations.push(items.map(item=>item.operationId)),
|
||||
requestSync:async()=>{{ syncRequests += 1; }},
|
||||
}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const draft={{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-8'}};
|
||||
const first=await outbox.enqueueDurably(draft);
|
||||
const retried=await outbox.enqueueDurably(draft);
|
||||
process.stdout.write(JSON.stringify({{first,retried,reconciliations,syncRequests,items:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert "reused" not in output["first"]
|
||||
assert output["retried"]["reused"] is True
|
||||
assert output["reconciliations"] == [["durable-1"], ["durable-1"]]
|
||||
assert output["syncRequests"] == 2
|
||||
assert len(output["items"]) == 1
|
||||
@@ -65,6 +65,23 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
|
||||
) == {"revision": 2, "records": {}}
|
||||
|
||||
|
||||
def test_initialized_later_reads_remain_available_during_a_planning_write(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path, timeout=0.05)
|
||||
expected = store.apply(
|
||||
"timmy", "seed", "defer", "issue:r:1:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
|
||||
writer = sqlite3.connect(path)
|
||||
writer.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
assert LaterStore(path, timeout=0.05).get("timmy") == expected
|
||||
finally:
|
||||
writer.rollback()
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
operations = [
|
||||
|
||||
@@ -347,5 +347,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-v87" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -0,0 +1,101 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
STATUS = ROOT / "frontend" / "live-data-status.js"
|
||||
HTML = ROOT / "frontend" / "index.html"
|
||||
CSS = ROOT / "frontend" / "dashboard.css"
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.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_live_data_status_summarizes_each_feed_without_claiming_live():
|
||||
script = f"""
|
||||
const status = require({json.dumps(str(STATUS))});
|
||||
const healthy = {{fresh_for_seconds:8, sections:{{
|
||||
context:{{age_seconds:1}}, notifications:{{age_seconds:2}}, events:{{age_seconds:3}}
|
||||
}}}};
|
||||
const oneDelayed = {{fresh_for_seconds:8, retry_in_seconds:30, sections:{{
|
||||
context:{{age_seconds:1}}, notifications:{{age_seconds:14, stale:true, retry_in_seconds:30}}, events:{{age_seconds:3}}
|
||||
}}}};
|
||||
const twoDelayed = {{fresh_for_seconds:8, sections:{{
|
||||
context:{{age_seconds:12, degraded:true}}, notifications:{{age_seconds:14, stale:true}}, events:{{age_seconds:3}}
|
||||
}}}};
|
||||
process.stdout.write(JSON.stringify({{
|
||||
healthy:status.describe(healthy),
|
||||
one:status.describe(oneDelayed),
|
||||
two:status.describe(twoDelayed),
|
||||
unavailable:status.describe({{}}),
|
||||
}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert result["healthy"]["summary"] == "Live"
|
||||
assert [feed["state"] for feed in result["healthy"]["feeds"]] == ["live"] * 3
|
||||
assert result["one"]["summary"] == "Updates delayed"
|
||||
assert result["one"]["nextRetrySeconds"] == 30
|
||||
assert result["one"]["feeds"][1] == {
|
||||
"key": "notifications", "label": "Updates", "state": "delayed", "ageSeconds": 14
|
||||
}
|
||||
assert result["two"]["summary"] == "2 data feeds delayed"
|
||||
assert result["unavailable"]["summary"] == "Live data unavailable"
|
||||
|
||||
|
||||
def test_live_data_status_controller_is_single_flight_and_reports_result():
|
||||
script = f"""
|
||||
const status = require({json.dumps(str(STATUS))});
|
||||
let resolveRefresh;
|
||||
let calls = 0;
|
||||
const states = [];
|
||||
const button = {{disabled:false}};
|
||||
const output = {{textContent:''}};
|
||||
const controller = status.createRefreshController({{
|
||||
button, output,
|
||||
refresh:() => {{ calls += 1; return new Promise(resolve => {{ resolveRefresh = resolve; }}); }},
|
||||
onState:value => states.push(value),
|
||||
}});
|
||||
(async () => {{
|
||||
const first = controller.run();
|
||||
const second = controller.run();
|
||||
const pending = {{calls, disabled:button.disabled, text:output.textContent}};
|
||||
resolveRefresh({{context:{{}}}});
|
||||
await Promise.all([first, second]);
|
||||
process.stdout.write(JSON.stringify({{pending, calls, disabled:button.disabled, text:output.textContent, states}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"pending": {"calls": 1, "disabled": True, "text": "Refreshing live data…"},
|
||||
"calls": 1,
|
||||
"disabled": False,
|
||||
"text": "Live data refreshed.",
|
||||
"states": ["refreshing", "success"],
|
||||
}
|
||||
|
||||
|
||||
def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
|
||||
html = HTML.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="open-live-data-status"' in html
|
||||
assert 'aria-controls="live-data-status-sheet"' in html
|
||||
assert 'id="live-data-status-sheet"' in html
|
||||
assert 'aria-labelledby="live-data-status-heading"' in html
|
||||
assert 'id="live-data-status-feeds"' in html
|
||||
assert 'id="refresh-live-data"' in html
|
||||
assert '<script src="static/live-data-status.js"></script>' in html
|
||||
assert ".live-data-status-panel" in css
|
||||
assert "width:min(560px,100%)" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "liveDataStatus.describe" in dashboard
|
||||
assert "liveDataStatus.createRefreshController" in dashboard
|
||||
assert "contextPoller.getState()" in dashboard
|
||||
@@ -370,6 +370,44 @@ setTimeout(() => process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_login_continues_only_bounded_shared_screenshot_capture():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
async function destination(continuation) {{
|
||||
let replaced = null;
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: value => replaced = value }}, continuation,
|
||||
}});
|
||||
await controller.submit('operator-token');
|
||||
return replaced;
|
||||
}}
|
||||
(async () => {{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
valid: await destination('./?title=Broken&launch=new&shared=image'),
|
||||
wrongLaunch: await destination('./?launch=agenda&shared=image'),
|
||||
missingLaunch: await destination('./?shared=image'),
|
||||
wrongMarker: await destination('./?launch=new&shared=document'),
|
||||
duplicateMarker: await destination('./?launch=new&shared=image&shared=image'),
|
||||
launchWithoutMarker: await destination('./?launch=new'),
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"valid": "./?title=Broken&launch=new&shared=image",
|
||||
"wrongLaunch": "./",
|
||||
"missingLaunch": "./",
|
||||
"wrongMarker": "./",
|
||||
"duplicateMarker": "./",
|
||||
"launchWithoutMarker": "./?launch=new",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_page_loads_rate_limit_controller():
|
||||
html = await login()
|
||||
|
||||
@@ -137,4 +137,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-v87" in worker
|
||||
assert "stackchain-dashboard-shell-v100" in worker
|
||||
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REPLAN = ROOT / "frontend" / "agenda-replan.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_overdue_replan_sweep_orders_items_and_only_advances_after_confirmed_change():
|
||||
script = f"""
|
||||
const createSweep = require({json.dumps(str(REPLAN))});
|
||||
const calls = [];
|
||||
let fail = true;
|
||||
const items = [
|
||||
{{key:'o/r#3', repository:'o/r', number:3, due_date:'2026-08-11T18:00:00Z'}},
|
||||
{{key:'o/r#1', repository:'o/r', number:1, due_date:'2026-08-09T18:00:00Z'}},
|
||||
{{key:'o/r#2', repository:'o/r', number:2, due_date:'2026-08-10T18:00:00Z'}},
|
||||
];
|
||||
const sweep = createSweep({{
|
||||
now: () => new Date('2026-08-12T12:00:00'),
|
||||
update: async (item, due) => {{ calls.push([item.key, due]); if (fail) throw new Error('offline'); return {{due_date:due}}; }},
|
||||
}});
|
||||
async function run() {{
|
||||
const started = sweep.start(items);
|
||||
const failed = await sweep.tomorrow();
|
||||
const afterFailure = sweep.snapshot();
|
||||
fail = false;
|
||||
const changed = await sweep.tomorrow();
|
||||
const afterChange = sweep.snapshot();
|
||||
const kept = await sweep.keep();
|
||||
process.stdout.write(JSON.stringify({{started, failed, afterFailure, changed, afterChange, kept, calls}}));
|
||||
}}
|
||||
run();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"started": {"active": True, "index": 0, "total": 3, "current": "o/r#1", "pending": False},
|
||||
"failed": {"ok": False, "error": "offline"},
|
||||
"afterFailure": {"active": True, "index": 0, "total": 3, "current": "o/r#1", "pending": False},
|
||||
"changed": {"ok": True, "done": False},
|
||||
"afterChange": {"active": True, "index": 1, "total": 3, "current": "o/r#2", "pending": False},
|
||||
"kept": {"ok": True, "done": False},
|
||||
"calls": [["o/r#1", "2026-08-13T23:59:59Z"], ["o/r#1", "2026-08-13T23:59:59Z"]],
|
||||
}
|
||||
|
||||
|
||||
def test_overdue_replan_rejects_non_future_choice_and_prevents_parallel_updates():
|
||||
script = f"""
|
||||
const createSweep = require({json.dumps(str(REPLAN))});
|
||||
let resolveUpdate;
|
||||
let calls = 0;
|
||||
const sweep = createSweep({{
|
||||
now: () => new Date('2026-08-12T12:00:00'),
|
||||
update: async () => {{ calls += 1; await new Promise(resolve => resolveUpdate = resolve); return {{}}; }},
|
||||
}});
|
||||
async function run() {{
|
||||
sweep.start([{{key:'o/r#1', due_date:'2026-08-10T18:00:00Z'}}]);
|
||||
const invalid = await sweep.choose('2026-08-12');
|
||||
const first = sweep.choose('2026-08-14');
|
||||
const duplicate = await sweep.choose('2026-08-15');
|
||||
resolveUpdate();
|
||||
const confirmed = await first;
|
||||
process.stdout.write(JSON.stringify({{invalid, duplicate, confirmed, calls, final:sweep.snapshot()}}));
|
||||
}}
|
||||
run();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"invalid": {"ok": False, "error": "Choose a future date."},
|
||||
"duplicate": {"ok": False, "error": "Deadline update already in progress."},
|
||||
"confirmed": {"ok": True, "done": True},
|
||||
"calls": 1,
|
||||
"final": {"active": False, "index": 1, "total": 1, "current": None, "pending": False},
|
||||
}
|
||||
|
||||
|
||||
def test_overdue_replan_orders_by_calendar_day_without_timezone_drift():
|
||||
script = f"""
|
||||
const createSweep = require({json.dumps(str(REPLAN))});
|
||||
const sweep = createSweep({{update:async()=>({{}})}});
|
||||
const started = sweep.start([
|
||||
{{key:'o/r#2',repository:'o/r',number:2,due_date:'2026-08-13T01:00:00Z'}},
|
||||
{{key:'o/r#1',repository:'o/r',number:1,due_date:'2026-08-12T23:59:59Z'}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(started));
|
||||
"""
|
||||
environment = {**__import__('os').environ, "TZ": "Asia/Tokyo"}
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, env=environment
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout)["current"] == "o/r#1"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_agenda_exposes_thumb_safe_replan_controls_and_wires_existing_mutation():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="start-agenda-replan"' in html
|
||||
assert 'id="agenda-replan-controls"' in html
|
||||
assert 'id="agenda-replan-progress"' in html
|
||||
assert 'id="agenda-replan-keep"' in html
|
||||
assert 'id="agenda-replan-tomorrow"' in html
|
||||
assert 'id="agenda-replan-date"' in html
|
||||
assert 'id="agenda-replan-choose"' in html
|
||||
assert 'id="agenda-replan-cancel"' in html
|
||||
assert "agendaReplan.start(overdue)" in html
|
||||
assert "issueController.updateDueDate(item, dueDate)" in html
|
||||
assert ".agenda-replan-actions button { min-height:44px;" in html
|
||||
assert "@media(max-width:360px)" in html
|
||||
@@ -20,6 +20,7 @@ def test_all_text_entry_flows_are_wired_to_keyboard_safe_panels():
|
||||
("#issue-sheet .issue-sheet-panel", ".issue-comment-composer", "#issue-comment", "#send-issue-comment", "#issue-comment-status"),
|
||||
("#pull-sheet .pull-sheet-panel", ".pull-comment-composer", "#pull-comment", "#send-pull-comment", "#pull-comment-status"),
|
||||
("#update-sheet .update-sheet-panel", ".update-reply", "#update-reply", "#send-update-reply", "#update-reply-status"),
|
||||
("#review-sheet .review-sheet-panel", "#review-inline-composer", "#review-inline-body", "#save-inline-comment", "#review-submit-status"),
|
||||
]:
|
||||
for selector in (panel, workspace, composer, submit, status):
|
||||
assert repr(selector) in dashboard
|
||||
@@ -27,10 +28,13 @@ def test_all_text_entry_flows_are_wired_to_keyboard_safe_panels():
|
||||
assert "workspace:qs('#create-issue-form')" in dashboard
|
||||
assert "focusWithin:true" in dashboard
|
||||
assert "mobileComposerViewport.close(qs('.create-issue-panel'))" in dashboard
|
||||
assert "mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'))" in dashboard
|
||||
assert "height:var(--composer-viewport-height,100dvh)" in css
|
||||
assert "top:var(--composer-viewport-top,0px)" in css
|
||||
assert ".composer-keyboard-active" in css
|
||||
assert ".create-issue-panel.composer-keyboard-active" in css
|
||||
assert ".review-sheet-panel.composer-keyboard-active" in css
|
||||
assert ".composer-keyboard-active .review-inline-composer" in css
|
||||
assert ".composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px;" in css
|
||||
|
||||
|
||||
@@ -41,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-v87" in worker
|
||||
assert "stackchain-dashboard-shell-v100" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "mobile-device-setup.js"
|
||||
|
||||
|
||||
def run_scenario(script: str) -> dict:
|
||||
harness = r"""
|
||||
const createMobileDeviceSetup = require(__MODULE__);
|
||||
class FakeTarget {
|
||||
constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; }
|
||||
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
|
||||
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 launcher = new FakeTarget();
|
||||
const closeButton = new FakeTarget();
|
||||
const sheet = new FakeTarget(); sheet.hidden = true;
|
||||
const installButton = new FakeTarget();
|
||||
const offlineButton = new FakeTarget();
|
||||
const pushButton = new FakeTarget();
|
||||
const deadlineButton = new FakeTarget();
|
||||
const installStatus = new FakeTarget();
|
||||
const offlineStatus = new FakeTarget();
|
||||
const pushStatus = new FakeTarget();
|
||||
const deadlineStatus = new FakeTarget();
|
||||
const readyStatus = new FakeTarget();
|
||||
const escapeTarget = new FakeTarget();
|
||||
const promptCard = new FakeTarget(); promptCard.hidden = true;
|
||||
const promptSummary = new FakeTarget();
|
||||
const promptLauncher = new FakeTarget();
|
||||
const promptDismiss = new FakeTarget();
|
||||
let now = 1000;
|
||||
const promptStorage = {
|
||||
values:{},
|
||||
getItem(key) { return this.values[key] ?? null; },
|
||||
setItem(key, value) { this.values[key] = String(value); },
|
||||
};
|
||||
let readiness = {
|
||||
install:{state:'complete', detail:'Stackchain is installed.'},
|
||||
offline:{state:'incomplete', detail:'Offline work is off.'},
|
||||
push:{state:'unavailable', detail:'Notifications are unavailable.'},
|
||||
deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'},
|
||||
};
|
||||
const setup = createMobileDeviceSetup({
|
||||
launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton,
|
||||
installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
|
||||
promptCard, promptSummary, promptLauncher, promptDismiss,
|
||||
promptStorage, now:() => now,
|
||||
getReadiness:() => readiness,
|
||||
install:async () => { state.installCalls += 1; },
|
||||
enableOffline:async () => { state.offlineCalls += 1; },
|
||||
enablePush:async () => { state.pushCalls += 1; },
|
||||
enableDeadline:async () => { state.deadlineCalls += 1; },
|
||||
});
|
||||
(async () => { await setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
|
||||
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_opening_setup_reports_actual_readiness_without_triggering_permissions():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
process.stdout.write(JSON.stringify({
|
||||
hidden:sheet.hidden,
|
||||
install:installStatus.textContent,
|
||||
offline:offlineStatus.textContent,
|
||||
push:pushStatus.textContent,
|
||||
summary:readyStatus.textContent,
|
||||
calls:[state.installCalls,state.offlineCalls,state.pushCalls],
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"hidden": False,
|
||||
"install": "Stackchain is installed.",
|
||||
"offline": "Offline work is off.",
|
||||
"push": "Notifications are unavailable.",
|
||||
"summary": "1 of 2 available steps ready.",
|
||||
"calls": [0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_incomplete_device_is_discoverable_without_triggering_setup_actions():
|
||||
result = run_scenario("""
|
||||
process.stdout.write(JSON.stringify({
|
||||
hidden:promptCard.hidden,
|
||||
summary:promptSummary.textContent,
|
||||
calls:[state.installCalls,state.offlineCalls,state.pushCalls],
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"hidden": False,
|
||||
"summary": "1 of 2 steps complete",
|
||||
"calls": [0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_readiness_card_opens_setup_and_receives_focus_back_on_close():
|
||||
result = run_scenario("""
|
||||
await promptLauncher.dispatch('click', {currentTarget:promptLauncher});
|
||||
const opened = !sheet.hidden && state.focused === closeButton;
|
||||
await closeButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
opened,
|
||||
closed:sheet.hidden,
|
||||
promptFocused:state.focused === promptLauncher,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {"opened": True, "closed": True, "promptFocused": True}
|
||||
|
||||
|
||||
def test_not_now_hides_prompt_for_seven_days_then_allows_it_to_return():
|
||||
result = run_scenario("""
|
||||
await promptDismiss.dispatch('click');
|
||||
const hiddenAfterDismiss = promptCard.hidden;
|
||||
const dismissedUntil = Number(promptStorage.values['stackchain.device-setup-prompt-dismissed-until']);
|
||||
now = dismissedUntil + 1;
|
||||
await setup.render();
|
||||
process.stdout.write(JSON.stringify({hiddenAfterDismiss, delay:dismissedUntil - 1000, hiddenAfterExpiry:promptCard.hidden}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"hiddenAfterDismiss": True,
|
||||
"delay": 7 * 24 * 60 * 60 * 1000,
|
||||
"hiddenAfterExpiry": False,
|
||||
}
|
||||
|
||||
|
||||
def test_setup_action_rechecks_real_state_before_marking_step_ready():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
readiness.offline = {state:'complete', detail:'Offline work is saved.'};
|
||||
await offlineButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:state.offlineCalls,
|
||||
offline:offlineStatus.textContent,
|
||||
hidden:offlineButton.hidden,
|
||||
promptHidden:promptCard.hidden,
|
||||
summary:readyStatus.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 1,
|
||||
"offline": "Offline work is saved.",
|
||||
"hidden": True,
|
||||
"promptHidden": True,
|
||||
"summary": "This device is ready.",
|
||||
}
|
||||
|
||||
|
||||
def test_escape_closes_setup_and_restores_launcher_focus():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
await escapeTarget.dispatch('keydown', {key:'Escape'});
|
||||
process.stdout.write(JSON.stringify({
|
||||
hidden:sheet.hidden,
|
||||
launcherFocused:state.focused === launcher,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {"hidden": True, "launcherFocused": 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.'};
|
||||
readiness.deadline = {state:'incomplete', detail:'Choose when to receive deadline reminders.'};
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
readiness.deadline = {state:'complete', detail:'Deadline reminders enabled for 08:00 local time.'};
|
||||
await deadlineButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:state.deadlineCalls,
|
||||
detail:deadlineStatus.textContent,
|
||||
hidden:deadlineButton.hidden,
|
||||
summary:readyStatus.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 1,
|
||||
"detail": "Deadline reminders enabled for 08:00 local time.",
|
||||
"hidden": True,
|
||||
"summary": "3 of 4 available steps ready.",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
worker = (root / "frontend" / "service-worker.js").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="open-device-setup"' in html
|
||||
assert 'id="device-readiness-card"' in html
|
||||
assert 'id="finish-device-setup"' in html
|
||||
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 '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-setup-deadline-hour"' in html
|
||||
assert '<script src="static/mobile-device-setup.js"></script>' in html
|
||||
assert "createMobileDeviceSetup.mount({" in dashboard
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v100" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert ".device-setup-action { min-height:44px;" in css
|
||||
assert ".device-readiness-actions button { min-height:44px;" in css
|
||||
@@ -8,16 +8,44 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js"
|
||||
APP_SHORTCUTS = Path(__file__).resolve().parents[1] / "frontend" / "mobile-app-shortcuts.js"
|
||||
|
||||
|
||||
def test_installed_app_shortcut_accepts_only_bounded_actions_and_runs_once():
|
||||
script = f"""
|
||||
const shortcuts = require({json.dumps(str(APP_SHORTCUTS))});
|
||||
const calls = [];
|
||||
const controller = shortcuts.createController({{
|
||||
search:'?launch=continue',
|
||||
continueWork:() => calls.push('continue'),
|
||||
newIssue:() => calls.push('new'),
|
||||
agenda:() => calls.push('agenda'),
|
||||
}});
|
||||
Promise.resolve(controller.run()).then(() => controller.run()).then(() => {{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
action:controller.action(), calls,
|
||||
invalid:shortcuts.parse('?launch=delete-account'),
|
||||
shared:shortcuts.parse('?launch=new&title=Shared'),
|
||||
}}));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"action": "continue", "calls": ["continue"], "invalid": None, "shared": "new"
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_launch_selects_priority_queue_without_overriding_saved_choice():
|
||||
script = f"""
|
||||
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
today: mobileLaunch.chooseFilter({{saved:null, today:2, attention:4}}),
|
||||
attention: mobileLaunch.chooseFilter({{saved:null, today:0, attention:4}}),
|
||||
all: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0}}),
|
||||
saved: mobileLaunch.chooseFilter({{saved:'review', today:2, attention:4}}),
|
||||
today: mobileLaunch.chooseFilter({{saved:null, today:2, attention:4, agenda:6}}),
|
||||
attention: mobileLaunch.chooseFilter({{saved:null, today:0, attention:4, agenda:6}}),
|
||||
agenda: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0, agenda:6}}),
|
||||
all: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0, agenda:0}}),
|
||||
saved: mobileLaunch.chooseFilter({{saved:'agenda', today:2, attention:4, agenda:6}}),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
@@ -26,8 +54,9 @@ process.stdout.write(JSON.stringify({{
|
||||
assert json.loads(result.stdout) == {
|
||||
"today": "today",
|
||||
"attention": "attention",
|
||||
"agenda": "agenda",
|
||||
"all": "all",
|
||||
"saved": "review",
|
||||
"saved": "agenda",
|
||||
}
|
||||
|
||||
|
||||
@@ -61,12 +90,18 @@ async def test_mobile_launch_progressively_discloses_secondary_controls():
|
||||
assert 'id="work-settings-toggle"' in html
|
||||
assert 'id="active-work-queue"' in html
|
||||
assert '<script src="static/mobile-launch.js"></script>' in html
|
||||
assert '<script src="static/mobile-app-shortcuts.js"></script>' in html
|
||||
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "BASE + 'static/mobile-launch.js'" in service_worker
|
||||
assert "BASE + 'static/mobile-app-shortcuts.js'" in service_worker
|
||||
bootstrap = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
assert "mobileAppShortcuts.createController" in bootstrap
|
||||
assert bootstrap.index("await load();\n await appShortcut.run();") < bootstrap.index("contextPoller.start();")
|
||||
assert "mobileLaunch.chooseFilter" in html
|
||||
assert "agenda: counts.agenda" in html
|
||||
assert "mobileLaunch.createDisclosure" in html
|
||||
assert "@media (max-width: 600px)" in html
|
||||
assert "header { min-height:56px; max-height:64px;" in html
|
||||
assert ".work-settings:not([open]) > .work-settings-panel { display:none; }" in html
|
||||
assert ".app-menu:not([open]) > .app-menu-panel { display:none; }" in html
|
||||
assert ".my-work-actions button { min-height:44px;" in html
|
||||
assert ".my-work-actions { display:none; }" in html
|
||||
+502
-37
@@ -9,27 +9,32 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
|
||||
QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-launcher.js"
|
||||
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
|
||||
|
||||
|
||||
def test_mobile_work_entry_prioritizes_continue_resume_start_then_fallback():
|
||||
def test_mobile_work_entry_prioritizes_continue_resume_start_plan_then_find():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const state = {{active:true, resumable:true, today:2}};
|
||||
const state = {{active:true, resumable:true, today:2, eligible:3}};
|
||||
const calls = [];
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => state.active,
|
||||
isTodayResumable: () => state.resumable,
|
||||
getTodayCount: () => state.today,
|
||||
getEligibleCount: () => state.eligible,
|
||||
continueToday: () => calls.push('continue'),
|
||||
resumeToday: () => calls.push('resume'),
|
||||
startToday: () => calls.push('start'),
|
||||
openFallback: () => calls.push('fallback'),
|
||||
planToday: () => calls.push('plan'),
|
||||
findWork: () => calls.push('find'),
|
||||
}});
|
||||
const modes = [];
|
||||
modes.push(entry.open());
|
||||
state.active = false; modes.push(entry.open());
|
||||
state.resumable = false; modes.push(entry.open());
|
||||
state.today = 0; modes.push(entry.open());
|
||||
state.eligible = 0; modes.push(entry.open());
|
||||
process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
@@ -38,8 +43,45 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"modes": ["continue", "resume", "start", "work"],
|
||||
"calls": ["continue", "resume", "start", "fallback"],
|
||||
"modes": ["continue", "resume", "start", "plan", "find"],
|
||||
"calls": ["continue", "resume", "start", "plan", "find"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const calls = [];
|
||||
const state = {{active:true, today:2, resumable:true, eligible:3, next:{{name:'attention'}}}};
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => state.active,
|
||||
isTodayResumable: () => state.resumable,
|
||||
getTodayCount: () => state.today,
|
||||
getEligibleCount: () => state.eligible,
|
||||
queueLauncher: {{recommend: () => state.next, open: name => calls.push('queue:' + name)}},
|
||||
continueToday: () => calls.push('continue'),
|
||||
resumeToday: () => calls.push('resume'),
|
||||
startToday: () => calls.push('start'),
|
||||
planToday: () => calls.push('plan'),
|
||||
|
||||
findWork: () => calls.push('find'),
|
||||
}});
|
||||
const modes = [entry.open()];
|
||||
state.active = false; modes.push(entry.open());
|
||||
state.next = {{name:'today'}}; modes.push(entry.open());
|
||||
state.today = 0; state.eligible = 0; state.next = {{name:'update'}}; modes.push(entry.open());
|
||||
state.next = {{name:'agenda'}}; modes.push(entry.open());
|
||||
state.next = {{name:'later'}}; modes.push(entry.open());
|
||||
state.next = {{name:'draft'}}; modes.push(entry.open());
|
||||
state.next = {{name:'find'}}; 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", "attention", "resume", "update", "agenda", "later", "draft", "find"],
|
||||
"calls": ["continue", "queue:attention", "resume", "queue:update", "queue:agenda", "queue:later", "queue:draft", "find"],
|
||||
}
|
||||
|
||||
|
||||
@@ -61,12 +103,14 @@ class FakeElement {{
|
||||
focus() {{ this.focuses += 1; }}
|
||||
}}
|
||||
const nav = new FakeElement();
|
||||
const buttons = Object.fromEntries(['work','find','new','search','drafts'].map(name => [name, new FakeElement()]));
|
||||
const sessionHud = new FakeElement();
|
||||
const buttons = Object.fromEntries(['work','attention','find','new','search','drafts'].map(name => [name, new FakeElement()]));
|
||||
buttons.attention.hidden = true;
|
||||
const overlay = new FakeElement();
|
||||
const calls = [];
|
||||
let observerCallback;
|
||||
const dock = createDock({{
|
||||
nav, buttons, overlays:[overlay],
|
||||
nav, sessionHud, buttons, overlays:[overlay],
|
||||
actions: Object.fromEntries(Object.keys(buttons).map(name => [name, () => calls.push(name)])),
|
||||
observe(callback) {{ observerCallback = callback; return {{disconnect() {{}}}}; }},
|
||||
}});
|
||||
@@ -75,10 +119,13 @@ buttons.work.click();
|
||||
buttons.find.click();
|
||||
overlay.classList.values.add('open'); observerCallback();
|
||||
const hiddenWhileOpen = nav.hidden;
|
||||
const hudHiddenWhileOpen = sessionHud.attributes['data-overlay-hidden'];
|
||||
overlay.classList.values.delete('open'); observerCallback();
|
||||
buttons.drafts.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden,
|
||||
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden, hudHiddenWhileOpen,
|
||||
hudHiddenAfterClose:sessionHud.attributes['data-overlay-hidden'] || null,
|
||||
attentionHidden:buttons.attention.hidden,
|
||||
findFocuses:buttons.find.focuses,
|
||||
current:Object.fromEntries(Object.entries(buttons).map(([name, button]) => [name, button.attributes['aria-current'] || null])),
|
||||
}}));
|
||||
@@ -92,9 +139,13 @@ process.stdout.write(JSON.stringify({{
|
||||
"calls": ["work", "find", "drafts"],
|
||||
"hiddenWhileOpen": True,
|
||||
"hiddenAfterClose": False,
|
||||
"hudHiddenWhileOpen": "true",
|
||||
"hudHiddenAfterClose": None,
|
||||
"attentionHidden": True,
|
||||
"findFocuses": 1,
|
||||
"current": {
|
||||
"work": None,
|
||||
"attention": None,
|
||||
"find": None,
|
||||
"new": None,
|
||||
"search": None,
|
||||
@@ -103,23 +154,58 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_shows_contextual_work_label_with_attention_count():
|
||||
def test_mobile_task_dock_programmatically_selects_destination_without_running_action():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const calls = [];
|
||||
const makeButton = () => ({{
|
||||
attributes: {{}},
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }},
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }},
|
||||
}});
|
||||
const buttons = {{work:makeButton(), drafts:makeButton()}};
|
||||
const dock = createDock({{
|
||||
nav:{{}}, buttons,
|
||||
actions:{{work:()=>calls.push('work'), drafts:()=>calls.push('drafts')}},
|
||||
}});
|
||||
const selected = dock.select('drafts');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
selected, calls,
|
||||
work:buttons.work.attributes['aria-current'] || null,
|
||||
drafts:buttons.drafts.attributes['aria-current'] || null,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"selected": True,
|
||||
"calls": [],
|
||||
"work": None,
|
||||
"drafts": "page",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_keeps_today_label_separate_from_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||
const workLabel = {{ textContent:'' }};
|
||||
const badge = {{ textContent:'', hidden:true }};
|
||||
const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel, attentionBadge:badge }});
|
||||
const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel }});
|
||||
dock.updateWork('continue', 3);
|
||||
const continuing = {{ text:workLabel.textContent, count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
const continuing = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('resume', 0);
|
||||
const resuming = {{ text:workLabel.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
const resuming = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('start', 0);
|
||||
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('work', 0);
|
||||
dock.updateWork('plan', 0);
|
||||
const planning = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('find', 0);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
continuing, resuming, starting,
|
||||
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
|
||||
continuing, resuming, starting, planning,
|
||||
finding:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
@@ -130,28 +216,89 @@ process.stdout.write(JSON.stringify({{
|
||||
assert json.loads(result.stdout) == {
|
||||
"continuing": {
|
||||
"text": "Continue",
|
||||
"count": "3",
|
||||
"hidden": False,
|
||||
"label": "Continue Today, 3 items need attention",
|
||||
"label": "Continue Today",
|
||||
},
|
||||
"resuming": {"text": "Resume", "hidden": True, "label": "Resume Today"},
|
||||
"resuming": {"text": "Resume", "label": "Resume Today"},
|
||||
"starting": {"text": "Start", "label": "Start Today"},
|
||||
"fallback": {"text": "Work", "label": "Work"},
|
||||
"planning": {"text": "Plan", "label": "Plan Today"},
|
||||
"finding": {"text": "Find", "label": "Find work"},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
|
||||
def test_mobile_today_hud_completes_current_item_and_labels_final_item_for_recap():
|
||||
script = f"""
|
||||
const createView = require({json.dumps(str(TIMER))}).createView;
|
||||
class FakeElement {{
|
||||
constructor() {{ this.listeners = {{}}; this.hidden = true; this.textContent = ''; this.attributes = {{}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
click() {{ return this.listeners.click?.(); }}
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||
}}
|
||||
const hud = new FakeElement();
|
||||
const open = new FakeElement();
|
||||
const toggle = new FakeElement();
|
||||
const complete = new FakeElement();
|
||||
const progress = new FakeElement();
|
||||
const elements = {{
|
||||
'[data-mobile-today-hud]':[hud],
|
||||
'[data-mobile-today-open]':[open],
|
||||
'[data-mobile-today-toggle]':[toggle],
|
||||
'[data-mobile-today-complete]':[complete],
|
||||
'[data-work-session-progress]':[progress],
|
||||
'[data-work-session-adjust-plan]':[],
|
||||
'[data-work-session-timer-toggle]':[toggle],
|
||||
}};
|
||||
let active = true;
|
||||
const snapshot = {{identity:'issue:r:1:', elapsed_ms:0, running:true}};
|
||||
const completed = [];
|
||||
const view = createView({{
|
||||
timer:{{snapshot:()=>snapshot,totalElapsed:()=>0,pause:()=>true,resume:()=>true}},
|
||||
isActive:()=>active,
|
||||
queryAll:selector=>elements[selector] || [],
|
||||
formatEstimate:value=>value + 'm',
|
||||
getItem:()=>({{title:'Ship mobile flow'}}),
|
||||
onComplete:identity=>{{ completed.push(identity); return false; }},
|
||||
}});
|
||||
view.update({{index:1,total:2}}, null);
|
||||
const next = {{hidden:complete.hidden,label:complete.textContent,aria:complete.attributes['aria-label']}};
|
||||
complete.click();
|
||||
const preservedAfterFailure = {{hudHidden:hud.hidden,identity:snapshot.identity}};
|
||||
view.update({{index:2,total:2}}, null);
|
||||
const recap = {{hidden:complete.hidden,label:complete.textContent}};
|
||||
active = false; view.render();
|
||||
process.stdout.write(JSON.stringify({{next,recap,completed,preservedAfterFailure,hiddenWhenInactive:complete.hidden}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"next": {
|
||||
"hidden": False,
|
||||
"label": "Done & next",
|
||||
"aria": "Complete Ship mobile flow and open next Today item",
|
||||
},
|
||||
"recap": {"hidden": False, "label": "Done & recap"},
|
||||
"completed": ["issue:r:1:"],
|
||||
"preservedAfterFailure": {"hudHidden": False, "identity": "issue:r:1:"},
|
||||
"hiddenWhenInactive": True,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_exposes_independent_attention_action_and_six_column_state():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||
const nav = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }}, removeAttribute(name) {{ delete this.attributes[name]; }} }};
|
||||
const attention = {{ attributes: {{}}, hidden:true, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||
const badge = {{ textContent:'', hidden:true }};
|
||||
const dock = createDock({{ nav:{{}}, buttons:{{work}}, attentionBadge:badge }});
|
||||
const dock = createDock({{ nav, buttons:{{attention}}, attentionBadge:badge }});
|
||||
dock.updateAttention(3);
|
||||
const pending = {{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
const pending = {{ count:badge.textContent, badgeHidden:badge.hidden, actionHidden:attention.hidden, label:attention.attributes['aria-label'], layout:nav.attributes['data-attention'] }};
|
||||
dock.updateAttention(0);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
pending,
|
||||
empty:{{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }},
|
||||
empty:{{ count:badge.textContent, badgeHidden:badge.hidden, actionHidden:attention.hidden, label:attention.attributes['aria-label'], layout:nav.attributes['data-attention'] || null }},
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
@@ -160,11 +307,274 @@ process.stdout.write(JSON.stringify({{
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"pending": {"count": "3", "hidden": False, "label": "Work, 3 items need attention"},
|
||||
"empty": {"count": "0", "hidden": True, "label": "Work"},
|
||||
"pending": {"count": "3", "badgeHidden": False, "actionHidden": False, "label": "Attention, 3 items", "layout": "true"},
|
||||
"empty": {"count": "0", "badgeHidden": True, "actionHidden": True, "label": "Attention, 0 items", "layout": None},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_opens_stable_queue_switcher_and_routes_each_queue():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
class FakeElement {{
|
||||
constructor() {{ this.listeners = {{}}; this.attributes = {{}}; this.hidden = false; this.textContent = ''; this.focuses = 0; this.classList = {{contains:()=>false}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
click() {{ return this.listeners.click?.({{currentTarget:this}}); }}
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus() {{ this.focuses += 1; }}
|
||||
}}
|
||||
const nav = new FakeElement();
|
||||
const queues = new FakeElement();
|
||||
const sheet = new FakeElement(); sheet.open = false;
|
||||
sheet.showModal = function () {{ this.open = true; }};
|
||||
sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
|
||||
const close = new FakeElement();
|
||||
const badge = new FakeElement();
|
||||
const deadline = new FakeElement();
|
||||
const rows = Object.fromEntries(['today','agenda','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
||||
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
|
||||
const selected = [];
|
||||
const utilities = [];
|
||||
const dock = createDock({{
|
||||
nav, buttons:{{queues}}, actions:{{queues:()=>{{}}}}, overlays:[sheet],
|
||||
queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge, deadlineBadge:deadline,
|
||||
onSelectQueue:(name,row)=>name === 'recaps' ? utilities.push(row === rows.recaps ? 'recaps:trigger' : 'recaps:missing-trigger') : selected.push(name),
|
||||
observe() {{}},
|
||||
}});
|
||||
dock.start();
|
||||
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, later:3, draft:4}});
|
||||
queues.click();
|
||||
const opened = sheet.open;
|
||||
rows.update.click();
|
||||
const closedAfterSelect = !sheet.open;
|
||||
queues.click(); rows.recaps.click();
|
||||
const closedAfterUtility = !sheet.open;
|
||||
queues.click(); close.click();
|
||||
const populated = {{
|
||||
badge:badge.textContent, badgeHidden:badge.hidden,
|
||||
deadline:deadline.textContent, deadlineHidden:deadline.hidden,
|
||||
agendaDue:rows.agenda.attributes['data-deadlines'] || null,
|
||||
badgeLabel:queues.attributes['aria-label'],
|
||||
}};
|
||||
dock.updateQueues({{today:0, agenda:0, attention:0, update:7, later:0, draft:0}});
|
||||
const updatesOnly = {{
|
||||
badge:badge.textContent, badgeHidden:badge.hidden,
|
||||
deadlineHidden:deadline.hidden,
|
||||
badgeLabel:queues.attributes['aria-label'],
|
||||
}};
|
||||
dock.updateQueues({{today:0, agenda:0, attention:0, update:0, later:0, draft:0}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened, closedAfterSelect, closedAfterUtility, selected, utilities,
|
||||
badge:badge.textContent, populated, updatesOnly,
|
||||
clearedBadgeHidden:badge.hidden,
|
||||
clearedDeadlineHidden:deadline.hidden,
|
||||
clearedAgendaDue:rows.agenda.attributes['data-deadlines'] || null,
|
||||
clearedBadgeLabel:queues.attributes['aria-label'],
|
||||
counts:Object.fromEntries(Object.entries(counts).map(([name, node]) => [name, node.textContent])),
|
||||
updateLabel:rows.update.attributes['aria-label'],
|
||||
queueFocuses:queues.focuses,
|
||||
columnState:nav.attributes['data-attention'] || null,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"closedAfterSelect": True,
|
||||
"closedAfterUtility": True,
|
||||
"selected": ["update"],
|
||||
"utilities": ["recaps:trigger"],
|
||||
"badge": "0 active",
|
||||
"populated": {
|
||||
"badge": "5 active",
|
||||
"badgeHidden": False,
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Later 3, Drafts 4; 5 active queues",
|
||||
"deadline": "5 due",
|
||||
"deadlineHidden": False,
|
||||
"agendaDue": "true",
|
||||
},
|
||||
"updatesOnly": {
|
||||
"badge": "1 active",
|
||||
"badgeHidden": False,
|
||||
"deadlineHidden": True,
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Later 0, Drafts 0; 1 active queue",
|
||||
},
|
||||
"clearedBadgeHidden": True,
|
||||
"clearedDeadlineHidden": True,
|
||||
"clearedAgendaDue": None,
|
||||
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
|
||||
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "later": "0", "draft": "0", "recaps": "0"},
|
||||
"updateLabel": "Updates, 0 unread conversations",
|
||||
"queueFocuses": 3,
|
||||
"columnState": None,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_opens_first_actionable_item_after_selecting_queue():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
const launcher = createLauncher({{
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: name => name === 'attention' ? {{click() {{ calls.push('open:first'); }}}} : null,
|
||||
announce: message => calls.push('announce:' + message),
|
||||
}});
|
||||
const opened = launcher.open('attention');
|
||||
const empty = launcher.open('later');
|
||||
process.stdout.write(JSON.stringify({{opened, empty, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": "opened",
|
||||
"empty": "empty",
|
||||
"calls": [
|
||||
"filter:attention",
|
||||
"open:first",
|
||||
"filter:later",
|
||||
"announce:No deferred work is ready to open.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{attention: 2, today: 1, agenda: 5, later: 4, draft: 3}};
|
||||
const launcher = createLauncher({{
|
||||
openToday: () => calls.push('today'),
|
||||
openAgenda: () => {{ calls.push('agenda'); return 'opened'; }},
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null,
|
||||
announce: message => calls.push('announce:' + message),
|
||||
getCounts: () => counts,
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const first = launcher.recommend();
|
||||
counts = {{attention: 0, today: 0, agenda: 5, later: 4, draft: 3}};
|
||||
const agenda = launcher.recommend();
|
||||
const opened = launcher.continueWork();
|
||||
counts = {{attention: 0, today: 0, agenda: 0, later: 0, draft: 0}};
|
||||
const fallback = launcher.recommend();
|
||||
launcher.continueWork();
|
||||
process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"first": {"name": "attention", "count": 2, "label": "Start Attention (2)"},
|
||||
"agenda": {"name": "agenda", "count": 5, "label": "Open Agenda (5)"},
|
||||
"opened": "opened",
|
||||
"fallback": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"calls": ["agenda", "find"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_prioritizes_updates_before_agenda_and_resumes_launcher():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{attention:0, today:0, update:6, agenda:2, later:1, draft:1}};
|
||||
const launcher = createLauncher({{
|
||||
getCounts: () => counts,
|
||||
openUpdates: () => {{ calls.push('updates'); return 'resumed'; }},
|
||||
openAgenda: () => calls.push('agenda'),
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: () => null,
|
||||
announce: () => {{}},
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const recommendation = launcher.recommend();
|
||||
const opened = launcher.continueWork();
|
||||
process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"recommendation": {"name": "update", "count": 6, "label": "Resume Updates (6)"},
|
||||
"opened": "resumed",
|
||||
"calls": ["updates"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_labels_each_recommended_work_destination_accessibly():
|
||||
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 ['attention','update','agenda','later','draft']) {{
|
||||
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) == {
|
||||
"attention": ["Attention", "Open Attention"],
|
||||
"update": ["Updates", "Resume Updates"],
|
||||
"agenda": ["Agenda", "Open Agenda"],
|
||||
"later": ["Later", "Open Later"],
|
||||
"draft": ["Drafts", "Open Drafts"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_accessible_mobile_queue_completion_handoff():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-queue-heading"' in html
|
||||
assert 'data-mobile-queue="find"' in html
|
||||
assert "mobileQueueLauncher.recommend()" in html
|
||||
assert "showMobileQueueCompletion('Updates')" in html
|
||||
assert "row.setAttribute('data-recommended', 'true')" in html
|
||||
assert "row.focus()" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'data-mobile-task="queues"' in html
|
||||
assert 'id="mobile-queue-sheet"' in html
|
||||
assert 'aria-labelledby="mobile-queue-heading"' in html
|
||||
assert 'data-mobile-queue="today"' in html
|
||||
assert 'data-mobile-queue="agenda"' in html
|
||||
assert '<strong>Agenda</strong><small>Upcoming deadlines</small>' in html
|
||||
assert 'data-work-filter="agenda"' in html
|
||||
assert "const agendaItems = agendaMyWork(activeMyWork);" in html
|
||||
assert "counts.agenda = agendaItems.length;" in html
|
||||
assert 'data-agenda-group' in html
|
||||
assert 'data-mobile-queue="attention"' in html
|
||||
assert 'data-mobile-queue="update"' in html
|
||||
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
|
||||
assert 'data-mobile-queue="later"' in html
|
||||
assert 'data-mobile-queue="draft"' in html
|
||||
assert 'data-mobile-queue="recaps"' in html
|
||||
assert '<strong>Recaps</strong><small>History</small>' in html
|
||||
assert 'id="mobile-queue-count"' in html
|
||||
assert 'id="mobile-deadline-count"' in html
|
||||
assert 'mobileTaskDock.updateQueues(counts)' in html
|
||||
assert '<script src="static/mobile-queue-launcher.js"></script>' in html
|
||||
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
|
||||
assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert '.mobile-queue-sheet' in html
|
||||
assert '.my-work-actions { display:none;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-task-deadline' in html
|
||||
assert '[data-deadlines="true"]' in html
|
||||
assert '.mobile-task-dock[data-attention="true"]' not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||
html = await dashboard()
|
||||
@@ -175,26 +585,39 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||
assert "isTodayActive: () => workSession.checkpointed()" in html
|
||||
assert "isTodayResumable: () => workSession.resumable()" in html
|
||||
assert "getTodayCount: () => todayMyWork.length" in html
|
||||
assert "getEligibleCount: () => activeMyWork.length" in html
|
||||
assert "continueToday: continueTodaySession" in html
|
||||
assert "resumeToday: resumeTodaySession" in html
|
||||
assert "startToday: startTodaySession" in html
|
||||
assert "planToday: () => openPlanToday(mobileTaskButtons.work)" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
||||
assert "workSession.reopen(item)" in html
|
||||
assert "runTodayTransition('continue')" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_work_dock_and_app_shortcut_to_live_queue_recommendation():
|
||||
html = await dashboard()
|
||||
|
||||
assert "queueLauncher: mobileQueueLauncher" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "continueWork: () => mobileWorkEntry.open()" in html
|
||||
assert "mobileQueueCounts = counts;" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
|
||||
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
|
||||
for task in ("work", "find", "new", "search", "drafts"):
|
||||
for task in ("work", "find", "new", "search", "queues"):
|
||||
assert f'data-mobile-task="{task}"' in html
|
||||
assert 'id="mobile-draft-count"' in html
|
||||
assert 'id="mobile-queue-count"' in html
|
||||
assert 'data-work-filter="attention"' in html
|
||||
assert 'id="mobile-attention-count"' in html
|
||||
assert '.mobile-task-dock { display:none;' in html
|
||||
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
|
||||
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
|
||||
@@ -202,11 +625,53 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "const filter = counts.attention ? 'attention' : 'all';" in html
|
||||
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
|
||||
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 "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
|
||||
assert "draftCount.textContent = sourceDraftCount.textContent" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
|
||||
assert "queues: () => {}" in html
|
||||
assert "mobileTaskDock.updateQueues(counts)" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_phone_safe_today_session_hud_above_task_dock():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="mobile-today-hud" data-mobile-today-hud' in html
|
||||
assert 'data-mobile-today-open' in html
|
||||
assert 'data-work-session-progress' in html
|
||||
assert 'data-work-session-timer-toggle' in html
|
||||
assert 'data-work-session-adjust-plan' in html
|
||||
assert 'data-mobile-today-complete' in html
|
||||
assert 'Done & next' in html
|
||||
assert "getItem: identity => [...todayMyWork, ...activeMyWork].find" in html
|
||||
assert "onReopen: identity =>" in html
|
||||
assert "onComplete: identity =>" in html
|
||||
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 'bottom:calc(56px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-today-hud button { min-height:44px;' in html
|
||||
assert '.mobile-task-action[hidden] { display:none;' in html
|
||||
assert 'max-width:100%;' in html
|
||||
assert 'overflow:hidden;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_attention_pauses_today_and_offers_phone_safe_return():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="attention-interruption" id="attention-interruption"' in html
|
||||
assert 'id="return-to-today" type="button">Return to Today</button>' in html
|
||||
assert "if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();" in html
|
||||
assert 'timer.returnFromAttention()' in html
|
||||
assert "workSession.reopen(item)" in html
|
||||
assert "attentionInterruption.hidden = !pending" in html
|
||||
assert '.attention-interruption:not([hidden]) {' in html
|
||||
assert '.attention-interruption button { min-height:44px;' in html
|
||||
assert 'max-width:100%;' in html
|
||||
+1721
-10
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_tomorrow_resolves_unread_update_and_persists_canonical_later_item(
|
||||
monkeypatch,
|
||||
):
|
||||
resolved = []
|
||||
applied = []
|
||||
|
||||
async def confirmed_login():
|
||||
return "timmy"
|
||||
|
||||
async def resolve(kind, repository, number, notification_id):
|
||||
resolved.append((kind, repository, number, notification_id))
|
||||
return {
|
||||
"kind": "update",
|
||||
"notification_id": 42,
|
||||
"repository": "stackchain/api",
|
||||
"title": "Retry failed deploy",
|
||||
}
|
||||
|
||||
class Store:
|
||||
def apply(
|
||||
self,
|
||||
login,
|
||||
operation_id,
|
||||
action,
|
||||
item_id,
|
||||
*,
|
||||
wake_at=None,
|
||||
base_revision=None,
|
||||
):
|
||||
applied.append(
|
||||
(login, operation_id, action, item_id, wake_at, base_revision)
|
||||
)
|
||||
return {"revision": 3, "records": {item_id: wake_at}}
|
||||
|
||||
monkeypatch.setattr(main, "_confirmed_login", confirmed_login)
|
||||
monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve)
|
||||
monkeypatch.setattr(main, "_later_store", lambda: Store())
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/notifications/42/later",
|
||||
json={"wake_at": "2026-08-12T09:00:00.000Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"id": 42,
|
||||
"status": "deferred",
|
||||
"item_id": "update:stackchain/api::42",
|
||||
"wake_at": "2026-08-12T09:00:00.000Z",
|
||||
"revision": 3,
|
||||
}
|
||||
assert resolved == [("update", None, None, 42)]
|
||||
assert applied == [(
|
||||
"timmy",
|
||||
"push-tomorrow:42:2026-08-12T09:00:00.000Z",
|
||||
"defer",
|
||||
"update:stackchain/api::42",
|
||||
"2026-08-12T09:00:00.000Z",
|
||||
None,
|
||||
)]
|
||||
@@ -31,3 +31,55 @@ async def test_notification_page_endpoint_loads_only_requested_page_and_is_not_c
|
||||
"has_more": True,
|
||||
}
|
||||
assert requested == [2]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_snapshot_endpoint_returns_one_complete_atomic_no_store_result(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def snapshot_loader(*, deadline_seconds):
|
||||
requested.append(deadline_seconds)
|
||||
return {
|
||||
"items": [
|
||||
{"id": 1, "title": "Newest update"},
|
||||
{"id": 51, "title": "Older update"},
|
||||
],
|
||||
"total": 2,
|
||||
"complete": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", snapshot_loader)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/snapshot")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"items": [
|
||||
{"id": 1, "title": "Newest update"},
|
||||
{"id": 51, "title": "Older update"},
|
||||
],
|
||||
"total": 2,
|
||||
"complete": True,
|
||||
}
|
||||
assert requested == [main.NOTIFICATION_PAGE_TIMEOUT_SECONDS]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("failure", [TimeoutError(), ValueError("pagination changed")])
|
||||
async def test_notification_snapshot_endpoint_is_bounded_and_retryable(monkeypatch, failure):
|
||||
async def snapshot_loader(*, deadline_seconds):
|
||||
raise failure
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", snapshot_loader)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/snapshot")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.json() == {
|
||||
"error": "Complete unread updates are temporarily unavailable. Please retry."
|
||||
}
|
||||
@@ -41,6 +41,29 @@ async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monk
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_unread_calls_supported_gitea_thread_endpoint(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
class Client:
|
||||
async def patch(self, path, headers):
|
||||
calls.append((path, headers))
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
|
||||
|
||||
await gitea_proxy.mark_notification_unread(42)
|
||||
|
||||
assert calls == [
|
||||
("/api/v1/notifications/threads/42?to-status=unread", {"Authorization": "token test"})
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
|
||||
calls = []
|
||||
@@ -172,6 +195,54 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
|
||||
assert marked == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monkeypatch):
|
||||
restored = []
|
||||
|
||||
async def mark_unread(thread_id):
|
||||
restored.append(thread_id)
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mark_notification_unread", mark_unread, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch("/api/v1/notifications/42/unread")
|
||||
invalid = await client.patch("/api/v1/notifications/0/unread")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": 42, "status": "unread"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert invalid.status_code == 422
|
||||
assert restored == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mute_notification_then_marks_read_and_reports_partial_success(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def mute(thread_id):
|
||||
calls.append(("mute", thread_id))
|
||||
return {"id": thread_id, "muted": True}
|
||||
|
||||
async def fail_read(thread_id):
|
||||
calls.append(("read", thread_id))
|
||||
raise httpx.HTTPError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mute_notification", mute, raising=False)
|
||||
monkeypatch.setattr(main, "mark_notification_read", fail_read)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post("/api/v1/notifications/42/mute")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"id": 42,
|
||||
"muted": True,
|
||||
"status": "unread",
|
||||
"error": "Future updates are muted; current item is still unread. Retry mark read & next.",
|
||||
}
|
||||
assert calls == [("mute", 42), ("read", 42)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
|
||||
calls = []
|
||||
@@ -347,14 +418,14 @@ async def test_bulk_mark_read_cancels_unfinished_work_at_batch_deadline(monkeypa
|
||||
monkeypatch.setattr(main, "_read_notification_ids", set())
|
||||
monkeypatch.setattr(main, "_live_snapshot_value", None)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
started_at = time.monotonic()
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/notifications/read", json={"ids": list(range(1, 11))}
|
||||
response = await asyncio.wait_for(
|
||||
client.patch(
|
||||
"/api/v1/notifications/read", json={"ids": list(range(1, 11))}
|
||||
),
|
||||
timeout=0.5,
|
||||
)
|
||||
elapsed = time.monotonic() - started_at
|
||||
|
||||
assert elapsed < 0.06
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"marked": [], "failed": list(range(1, 11))}
|
||||
assert response.headers["retry-after"] == "1"
|
||||
|
||||
@@ -104,3 +104,44 @@ def test_existing_challenge_registry_is_migrated_without_losing_live_challenges(
|
||||
).fetchall()
|
||||
assert "source_hash" in columns
|
||||
assert sorted(rows) == sorted([("existing",), (store._digest(b"new"),)])
|
||||
|
||||
|
||||
def test_passkey_counter_advancement_is_atomic_across_store_instances(tmp_path):
|
||||
database = tmp_path / "passkeys.sqlite3"
|
||||
first = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
second = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
first.register(
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
|
||||
assert first.advance_counter(b"phone-credential", expected=4, new=5) is True
|
||||
assert second.advance_counter(b"phone-credential", expected=4, new=5) is False
|
||||
assert first.get(b"phone-credential").sign_count == 5
|
||||
|
||||
|
||||
def test_passkey_counter_rejects_non_advancing_values_but_supports_counterless_devices(
|
||||
tmp_path,
|
||||
):
|
||||
store = PasskeyStore(tmp_path / "passkeys.sqlite3", clock=lambda: 1_000.0)
|
||||
store.register(
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
store.register(
|
||||
credential_id=b"counterless-credential",
|
||||
public_key=b"counterless-public-key",
|
||||
sign_count=0,
|
||||
device_label="Security key",
|
||||
management_id="counterless-management-id",
|
||||
)
|
||||
|
||||
assert store.advance_counter(b"phone-credential", expected=4, new=4) is False
|
||||
assert store.advance_counter(b"phone-credential", expected=4, new=3) is False
|
||||
assert store.advance_counter(b"counterless-credential", expected=0, new=0) is True
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user