Compare commits

...
Author SHA1 Message Date
timmy 8f7a836d28 feat: batch queued planning edits (#375)
CI / lint (pull_request) Successful in 44s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 06:05:40 +00:00
rockachopa 5894eafb3f Merge pull request 'Keep Today and Later edits lossless across browser tabs' (#374) from timmy/373-lossless-planning-tabs into main
CI / lint (push) Successful in 39s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 05:38:52 +00:00
timmy 639ce911be fix: keep planning edits lossless across tabs (#373)
CI / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 05:31:14 +00:00
rockachopa 475acbb92f Merge pull request 'Warn before duplicate mobile issue capture' (#372) from timmy/371-duplicate-aware-mobile-capture into main
CI / lint (push) Successful in 40s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-09 04:43:22 +00:00
timmy c0fc4eddd0 feat: warn before duplicate mobile issue capture (#371)
CI / lint (pull_request) Successful in 40s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 04:41:44 +00:00
rockachopa 1bf759dc76 Merge pull request 'Choose an exact return time for Later work' (#370) from timmy/369-exact-later-time into main
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 04:06:14 +00:00
timmy 9441191c18 feat: choose exact Later return times (#369)
CI / lint (pull_request) Successful in 44s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 04:04:12 +00:00
rockachopa 76bea83537 Merge pull request 'Drain Today edits queued during an in-flight sync' (#368) from timmy/367-drain-today-inflight into main
CI / lint (push) Successful in 40s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 03:35:59 +00:00
timmy 7775c3e1f6 fix: drain in-flight Today edits (#367)
CI / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 03:34:23 +00:00
timmy 98249ad9ed Merge pull request 'Retry Today and Later sync automatically after transient failures' (#366) from timmy/365-planning-sync-auto-retry into main
CI / lint (push) Successful in 40s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-09 03:01:55 +00:00
timmy 901691a93b feat: retry planning sync automatically (#365)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 03:00:12 +00:00
rockachopa 6dfc02158d Merge pull request 'Sync Later deferrals across operator devices' (#364) from timmy/363-cross-device-later-sync into main
CI / lint (push) Successful in 39s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 02:39:12 +00:00
timmy 454022ee89 feat: sync Later deferrals across devices (#363)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 02:37:32 +00:00
rockachopa 4bc2881a4b Merge pull request 'Keep Today plans convergent across tabs and devices' (#362) from timmy/361-convergent-today-sync into main
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Keep Today plans convergent across tabs and devices' (#362) from timmy/361-convergent-today-sync into main
2026-08-09 02:03:44 +00:00
timmy 3c743a2b71 feat: converge Today sync across active devices (#361)
CI / lint (pull_request) Successful in 42s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 02:01:55 +00:00
timmy 7ec3980713 Merge pull request 'Retire completed work from the synced Today plan' (#360) from timmy/359-retire-completed-today into main
CI / lint (push) Successful in 39s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 01:18:41 +00:00
timmy d940e0b37b fix: retire completed Today work across devices (#359)
CI / lint (pull_request) Successful in 40s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 01:16:26 +00:00
timmy 4610afd489 Merge pull request 'Sync the ordered Today plan across operator devices' (#358) from timmy/357-cross-device-today into main
CI / lint (push) Successful in 37s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 00:40:47 +00:00
timmy 020106f001 feat: sync Today plan across devices (#357)
CI / lint (pull_request) Successful in 38s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 00:39:08 +00:00
timmy 3384e56cb8 Merge pull request 'Review saved pull request diffs and draft feedback offline' (#356) from timmy/355-offline-review-drafts into main
CI / lint (push) Successful in 37s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 00:22:07 +00:00
timmy 229d112297 feat: draft requested reviews offline (#355)
CI / lint (pull_request) Successful in 37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 00:20:30 +00:00
timmy 34029e624f Merge pull request 'Open cached dashboard when mobile navigation stalls' (#354) from timmy/353-stalled-navigation-deadline into main
CI / lint (push) Successful in 34s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-08 23:56:15 +00:00
timmy 0ff6b5c8fd fix: bound stalled dashboard navigations (#353)
CI / lint (pull_request) Successful in 35s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 23:54:41 +00:00
timmy 391e8964c4 Merge pull request 'Finish unread-update triage offline with queued acknowledgements' (#352) from timmy/351-offline-update-acknowledgements into main
CI / lint (push) Successful in 35s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-08 23:40:19 +00:00
timmy 990f0f04d6 feat: finish unread update triage offline (#351)
CI / lint (pull_request) Successful in 35s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 23:38:52 +00:00
rockachopa a995e3b45f Merge pull request 'Keep the Today queue automatically ready offline' (#350) from timmy/349-offline-today-readiness into main
CI / lint (push) Successful in 36s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-08 23:17:49 +00:00
timmy ac3d1f32d1 feat: keep Today work ready offline (#349)
CI / lint (pull_request) Successful in 37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 23:16:22 +00:00
timmy e11e55149c Merge pull request 'Continue unread-update conversations offline' (#348) from timmy/347-offline-update-conversations into main
CI / lint (push) Successful in 36s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-08 22:43:48 +00:00
timmy ae6cde29c2 feat: continue unread updates offline (#347)
CI / lint (pull_request) Successful in 36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 22:42:20 +00:00
timmy 0539b0a035 Merge pull request 'Open saved Today details and queue replies offline' (#346) from timmy/345-offline-today-details into main
CI / lint (push) Successful in 39s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-08 22:04:56 +00:00
timmy a2ba624dc2 feat: open saved Today details offline (#345)
CI / lint (pull_request) Successful in 36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 22:03:28 +00:00
rockachopa e78ceb1bf7 Merge pull request #344: Render safe mobile Markdown across My Work details
CI / lint (push) Successful in 35s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-08 21:40:14 +00:00
timmy ac7a716e5c feat: render safe mobile Markdown across My Work (#343)
CI / lint (pull_request) Successful in 33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 21:38:59 +00:00
timmy fa4ad28961 Merge pull request 'Require fresh authorization for high-impact actions' (#342) from timmy/341-fresh-authorization into main
CI / lint (push) Successful in 34s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-08 21:06:04 +00:00
37 changed files with 4460 additions and 115 deletions

No files matched your search

+55 -11
View File
@@ -21,7 +21,11 @@ inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
creation and assignment permission. The New issue sheet can optionally select an open
creation and assignment permission. Once a repository and meaningful title are selected,
the New issue sheet checks for similar open issues in that repository. Candidate links keep
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
when search is unavailable. The sheet can also optionally select an open
repository milestone and due date; the dashboard validates both and sends them with
self-assignment in the single create request, so planned work appears in its release
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
@@ -32,7 +36,28 @@ and durable delivery flow. A different or unconfirmed account can only copy or d
the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. Closed-app
another worker replays a confirmed result instead of posting duplicate content. The ordered,
five-item Today plan syncs across the operator's devices. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
offline operations. After a healthy, fully paginated
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
queues an idempotent retirement before removing it locally; partial and degraded refreshes
leave the plan unchanged, and offline retirements replay after reconnect. When
**Keep My Work available offline** is enabled, every issue or pull request in the bounded
Today queue is warmed automatically after a healthy authenticated refresh and as soon as
it is added. The readiness indicator reports saved, pending, and retryable items; unchanged
`updated_at` revisions make no detail request, transient failures retain the prior copy,
and pull-request diffs remain online-only. Saved unread-update conversations remain
triageable offline: **Queue read & next** writes an account-bound, notification-ID-
deduplicated acknowledgement to the durable background delivery system, removes the
update from the local queue immediately, and opens the next saved conversation. A cold
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
authenticated notification-read endpoint and keeps transient failures queued. Installed-app
navigations are also deadline-bounded: after four seconds without a network response,
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
Closed-app
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
with the unchanged idempotency key. Device purge cancels an active drain before closing
@@ -190,13 +215,17 @@ filter, preserves the selected release lane, shows the current draft count, resp
the device safe area, and moves out of the way while a full-screen task is open.
Desktop layout is unchanged.
My Work also has a local **Later** queue. **Later today** defers an item for four
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone.
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
accepts a valid future local date and time and returns the item at that exact instant; the picker
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
Deferred items leave normal and Attention queues without marking notifications read
or changing any Gitea issue or pull request. They automatically return to their
existing priority position at the wake time, and **Bring back now** restores them
early. Later state is stored only in this browser, scoped to the confirmed Gitea
login, and removed when fully loaded work confirms that an item no longer exists.
early. Later wake times are scoped to the confirmed Gitea login and synchronize
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
and replay after reconnect; `STACKCHAIN_LATER_DB` can override the default durable
store at `.stackchain-state/later.sqlite3`.
After one successful online load, the installed dashboard precaches a versioned,
subpath-scoped application shell. During a network outage or a dashboard HTTP
@@ -209,11 +238,26 @@ dashboard keeps polling until it can restore its live snapshot automatically.
Users can explicitly enable **Keep My Work available offline**. Each healthy live
refresh then stores a seven-day, versioned snapshot containing only the signed-in
user identity and queue-card metadata for issues, pull requests, unread updates,
and pagination totals. Bodies, comments, diffs, credentials, repository catalogs,
events, and complete API responses are excluded. A cold offline launch labels the
saved time and renders this snapshot read-only; opening live details, pagination,
and server mutations remain disabled until reconnection. **Clear offline work
data** deletes the snapshot, and opting out deletes it automatically.
and pagination totals. For each opened Today issue or pull request, Stackchain additionally retains an allowlisted detail record with its body and
newest 20 comments. Requested-review records also retain the head SHA, CI state,
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
per file. A previously opened unread update is retained by notification
identity with allowlisted subject context and its newest 20 conversation messages.
This account-bound cache is limited to ten records across all detail kinds; credentials,
repository catalogs, events, raw patches, and complete API responses are excluded.
A cold offline launch labels the saved time. Cached Today details open in the existing
phone sheet, where comments can enter the account-bound durable outbox. For issue and
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
until reconnection. Cached
requested reviews open in the existing review sheet so file progress, notes, summary,
decision, and inline-comment drafts remain usable under the saved head SHA. Review
submission is never queued: reconnect reloads the current head before enabling submit;
a changed head starts clean SHA-scoped progress while retaining the prior-head draft.
Cached unread updates use the same phone conversation sheet and replies enter the
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
Cards without a saved detail explain that reconnection is required. **Clear offline
work data** deletes both stores, and opting out or seven-day expiry deletes them
automatically.
API responses and mutations are never cached by the service worker. New issue captures,
issue comments, pull-request comments, and unread-update replies use bounded local
+9
View File
@@ -232,6 +232,9 @@ function createBackgroundIssueSync({
if (status === 'attention') {
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
}
if (item.kind === 'notification-read') {
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
}
if (item.kind === 'update-reply') {
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
}
@@ -244,6 +247,12 @@ function createBackgroundIssueSync({
}
function deliveryRequest(item) {
if (item.kind === 'notification-read') {
return {
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
};
}
if (item.kind === 'update-reply') {
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
}
+50 -1
View File
@@ -23,6 +23,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
const storageKey = 'stackchain.issue-capture.v1';
const sharedStorageKey = 'stackchain.issue-share.v1';
let pending = null;
let duplicateRequest = 0;
let duplicateState = {status: 'idle', key: '', candidates: []};
let acknowledgedDuplicateKey = '';
const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
@@ -140,6 +143,51 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
function duplicateKey(draft) {
const repository = String(draft?.repository || '').trim();
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
return { repository, title, key: repository + '\n' + title.toLowerCase() };
}
async function findDuplicates(draft) {
const input = duplicateKey(draft);
const request = ++duplicateRequest;
if (!input.repository || input.title.length < 4) {
duplicateState = {status: 'idle', key: input.key, candidates: []};
return duplicateState;
}
try {
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(input.title) + '&limit=10');
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
duplicateState = {
status: 'ready',
key: input.key,
partial: payload?.partial === true,
candidates: (Array.isArray(payload?.items) ? payload.items : []).filter(item =>
item?.kind === 'issue' && item?.state === 'open' && item?.repository === input.repository
).slice(0, 3),
};
return duplicateState;
} catch (error) {
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
duplicateState = {status: 'failed', key: input.key, candidates: [], error};
return duplicateState;
}
}
function needsDuplicateAcknowledgement(draft) {
const {key} = duplicateKey(draft);
return duplicateState.status === 'ready' && duplicateState.partial !== true && duplicateState.key === key &&
duplicateState.candidates.length > 0 && acknowledgedDuplicateKey !== key;
}
function acknowledgeDuplicates(draft) {
const {key} = duplicateKey(draft);
if (duplicateState.status === 'ready' && duplicateState.key === key && duplicateState.candidates.length) {
acknowledgedDuplicateKey = key;
}
}
function submit(draft) {
if (pending) return pending;
const saved = saveDraft(draft);
@@ -166,7 +214,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};
}
+29
View File
@@ -70,6 +70,9 @@ textarea { resize: vertical; min-height: 120px; }
.offline-work-controls label { display:flex; gap:8px; align-items:center; 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; }
.offline-today-readiness[hidden] { display:none; }
.offline-today-readiness button { min-height:44px; }
.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.install-app-card[hidden] { display:none; }
.install-app-card p { margin:0; flex:1 1 240px; }
@@ -106,6 +109,14 @@ textarea { resize: vertical; min-height: 120px; }
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; }
.later-picker { box-sizing:border-box; width:100%; height:100%; max-width:none; max-height:none; margin:0; padding:0; border:0; position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
.later-picker[hidden] { display:none; }
.later-picker-panel { box-sizing:border-box; width:min(560px,100%); max-height:100%; overflow:auto; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.later-picker-header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.later-picker-header h2 { margin:0; }
.later-picker input, .later-picker button { box-sizing:border-box; width:100%; min-height:44px; }
.later-picker input { padding:10px; border:1px solid #2a496e; border-radius:10px; color:var(--text); background:#08111f; color-scheme:dark; font:inherit; }
.later-picker-error { min-height:1.4em; color:#fca5a5; }
.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.mark-update-read { min-height:44px; width:100%; }
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
@@ -238,6 +249,18 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
.markdown-content > :first-child { margin-top:0; }
.markdown-content > :last-child { margin-bottom:0; }
.markdown-content pre { max-width:100%; overflow-x:auto; padding:10px; border-radius:8px; background:#07101d; white-space:pre; -webkit-overflow-scrolling:touch; }
.markdown-content code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
.markdown-content :not(pre) > code { padding:2px 4px; border-radius:4px; background:#07101d; }
.markdown-content blockquote { margin:10px 0; padding-left:12px; border-left:3px solid #60a5fa; color:#b9c9dd; }
.markdown-content ul { padding-left:24px; }
.markdown-content .task-list { padding-left:0; list-style:none; }
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.create-issue-sheet.open { display:flex; }
@@ -251,6 +274,12 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.create-issue-label-option input { width:20px; height:20px; margin:0; }
.create-issue-duplicates { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2b210f; }
.create-issue-duplicates[hidden] { display:none; }
.create-issue-duplicate-list { display:grid; gap:8px; min-width:0; }
.create-issue-duplicate-card { display:grid; gap:4px; min-width:0; padding:10px; border:1px solid #6b5220; border-radius:10px; background:#171d29; overflow-wrap:anywhere; }
.create-issue-duplicate-card a, #create-issue-anyway { display:flex; align-items:center; min-height:44px; max-width:100%; }
.create-issue-duplicate-card a { color:#93c5fd; }
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; }
.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
+407 -39
View File
@@ -105,6 +105,7 @@
let hasContextSnapshot = false;
let selectedReview = null;
let reviewTrigger = null;
let offlineReview = false;
let selectedUpdate = null;
let updateTrigger = null;
let selectedIssue = null;
@@ -134,18 +135,63 @@
let activeMyWork = [];
let laterMyWork = [];
let todayMyWork = [];
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
const todayWork = createTodayWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
});
const todaySync = createTodaySync({
storage: localStorage,
getLogin: () => planningOwnerLogin,
fetchJson: fetchReviewJson,
coordinator: outboxCoordinator,
onRemoteIds: ids => {
if (!planningOwnerLogin || !todayWork.replace(ids)) return;
refreshMyWorkView();
warmTodayOffline();
},
onStatus: (state, detail = {}) => {
const status = qs('#today-sync-status');
status.textContent = state === 'saved' ? 'Today saved to account.' :
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Today saved on this device · sync pending.' :
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
'Today sync unavailable · changes stay on this device.')));
},
});
todaySync.startLifecycle({ window, document });
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
onChange: (action, itemId, wakeAt) => {
if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush();
},
onExpire: ids => {
const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
if (queued) laterSync.flush();
},
onWake: () => {
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
refreshMyWorkView();
},
});
const laterSync = createLaterSync({
storage: localStorage,
getLogin: () => planningOwnerLogin,
fetchJson: fetchReviewJson,
coordinator: outboxCoordinator,
onRemoteRecords: records => {
if (!planningOwnerLogin || !laterWork.adopt(records)) return;
refreshMyWorkView();
},
onStatus: (state, detail = {}) => {
qs('#later-sync-status').textContent = state === 'saved' ? 'Later saved to account.' :
(state === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Later saved on this device · sync pending.' :
'Later sync unavailable · changes stay on this device.'));
},
});
laterSync.startLifecycle({ window, document });
document.addEventListener('visibilitychange', () => {
if (!document.hidden) refreshMyWorkView();
});
@@ -157,6 +203,8 @@
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
error.status = response.status;
error.code = payload.detail?.code;
const retryAfter = response.headers.get('Retry-After');
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
throw error;
}
return payload;
@@ -197,7 +245,6 @@
await registration.sync.register('stackchain-issue-outbox-v1');
};
}
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
const issueOutbox = createIssueOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
@@ -208,10 +255,16 @@
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
const notificationReadOutbox = createNotificationReadOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
if (backgroundIssueSync) {
backgroundIssueSync.snapshot().then(records => {
issueOutbox.reconcileBackground(records);
authoredOutbox.reconcileBackground(records);
notificationReadOutbox.reconcileBackground(records);
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
}
const shareParams = new URLSearchParams(location.search);
@@ -226,6 +279,33 @@
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
function renderOfflineTodayStatus(status) {
const container = qs('#offline-today-readiness');
const label = qs('#offline-today-status');
const retry = qs('#retry-offline-today');
const visible = offlineWorkStore.enabled() && status.total > 0;
container.hidden = !visible;
if (!visible) return;
label.textContent = 'Today offline: ' + status.ready + ' of ' + status.total + ' ready' +
(status.pending ? ' · saving ' + status.pending : '') +
(status.failed ? ' · retry ' + status.failed : '');
retry.hidden = status.failed === 0;
}
const offlineToday = createOfflineToday({
loadDetail: item => item.is_review ? reviewController.load(item) :
item.kind === 'pull' ? pullController.load(item) : issueController.load(item),
loadSavedDetail: (login, item) => offlineWorkStore.loadDetail(login, item),
saveDetail: (login, item, detail) => offlineWorkStore.saveDetail(login, item, detail),
onStatus: renderOfflineTodayStatus,
});
function warmTodayOffline() {
if (!offlineWorkStore.enabled() || !confirmedOwnerLogin || offlineWorkMode) {
offlineToday.cancel();
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
return Promise.resolve();
}
return offlineToday.warm(confirmedOwnerLogin, todayMyWork);
}
const findWorkController = createFindWork({
fetchJson: fetchReviewJson,
onItems: renderAvailableIssues,
@@ -390,6 +470,11 @@
addToday: item => {
const result = todayWork.add(item);
refreshMyWorkView();
if (result === 'added') {
todaySync.enqueue('add', todayWork.identity(item));
todaySync.flush();
warmTodayOffline();
}
return result;
},
onClaimed: item => {
@@ -412,6 +497,8 @@
load: fetchNotificationDetail,
loadConversation: fetchNotificationConversation,
markRead: markNotificationRead,
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
onOpen: item => {
selectedUpdate = item;
qs('#update-sheet').classList.add('open');
@@ -429,15 +516,23 @@
qs('#send-update-reply').disabled = false;
qs('#update-ownership-action').hidden = true;
qs('#retry-update-load').hidden = true;
setOfflineUpdateControls(false);
qs('#keep-update-unread').focus();
},
onDetail: detail => {
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
qs('#update-subject-state').textContent = detail.state || '';
qs('#update-subject-body').textContent = detail.subject_body || 'No subject context was provided.';
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
updateOwnership.open(detail, selectedUpdate);
if (offlineWorkMode) {
setOfflineUpdateControls(true);
} else {
updateOwnership.open(detail, selectedUpdate);
if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
}
}
qs('#retry-update-load').hidden = true;
},
onConversation: renderUpdateConversation,
@@ -467,7 +562,34 @@
function openRoutedWork(item, trigger, options = {}) {
if (!item) return;
if (offlineWorkMode) {
qs('#my-work-action-status').textContent = 'This saved item is read-only. Reconnect to open live details.';
const offlineLogin = planningOwnerLogin || confirmedOwnerLogin ||
String(offlineWorkStore.load()?.user?.login || '').trim();
const savedDetail = offlineWorkStore.loadDetail(offlineLogin, item);
const savedUpdate = item.kind === 'update' && item.has_update;
if ((!savedUpdate && !todayWork.contains(item)) || !savedDetail) {
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
return;
}
if (item.kind === 'issue') {
issueTrigger = trigger;
openIssueSheet(item, trigger, savedDetail);
} else if (item.is_review) {
reviewTrigger = trigger;
openReviewSheet(item, trigger, savedDetail);
} else if (item.kind === 'pull') {
pullTrigger = trigger;
openPullSheet(item, trigger, savedDetail);
} else if (savedUpdate) {
updateTrigger = trigger;
notificationReader.open(item, savedDetail).then(opened => {
if (opened && selectedUpdate === item) {
qs('#update-sheet-status').textContent = 'Offline update · saved ' + fmt(savedDetail.saved_at) +
' · replies and read acknowledgements queue for sync. Reconnect to take ownership, defer, or load older messages.';
}
});
} else {
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
}
return;
}
if (item.has_update && item.kind === 'update') updateTrigger = trigger;
@@ -576,6 +698,54 @@
announce: message => { qs('#my-work-action-status').textContent = message; },
formatTime: fmt,
});
const laterPickerElement = qs('#later-picker');
const laterPickerInput = qs('#later-picker-time');
const laterPicker = createLaterPicker({
history: window.history,
eventTarget: window,
onState: state => {
qs('#later-picker-error').textContent = state.message || '';
if (state.open) {
laterPickerInput.value = state.value || laterPickerInput.value;
qs('#later-picker-timezone').textContent = 'Times use ' +
(Intl.DateTimeFormat().resolvedOptions().timeZone || 'your device timezone') + '.';
laterPickerElement.hidden = false;
if (!laterPickerElement.open) laterPickerElement.showModal();
laterPickerInput.focus();
} else {
if (laterPickerElement.open) laterPickerElement.close();
laterPickerElement.hidden = true;
}
},
onConfirm: (item, until, context) => {
if (context === 'detail') {
const inSession = workSession.active();
const deferred = detailDefer.deferUntil(item, until, {
closeSheet:false,
restoreFocus:false,
});
if (!deferred) return false;
return inSession ? true : () => workRoute.close();
}
const result = laterWork.defer(item, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
'Deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
if (result !== 'deferred') return false;
refreshMyWorkView();
return true;
},
});
laterPicker.start();
qs('#later-picker-form').addEventListener('submit', event => {
event.preventDefault();
laterPicker.submit(laterPickerInput.value);
});
qs('#cancel-later-picker').addEventListener('click', () => laterPicker.close());
laterPickerElement.addEventListener('cancel', event => {
event.preventDefault();
laterPicker.close();
});
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
button.addEventListener('click', () => {
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
@@ -583,6 +753,13 @@
detailDefer.defer(item, button.dataset.detailDeferPreset);
});
});
document.querySelectorAll('[data-detail-defer-custom]').forEach(button => {
button.addEventListener('click', () => {
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
button.closest('.detail-defer').open = false;
laterPicker.open(item, button, 'detail');
});
});
document.querySelectorAll('[data-detail-defer-cancel]').forEach(button => {
button.addEventListener('click', () => {
const chooser = button.closest('.detail-defer');
@@ -592,12 +769,41 @@
});
function updatePlanningAvailability() {
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
document.querySelectorAll('[data-detail-defer-preset], [data-detail-defer-custom]').forEach(button => {
button.disabled = !planningOwnerLogin;
button.toggleAttribute('data-planning-disabled', !planningOwnerLogin);
});
}
function setOfflineDetailControls(kind) {
const selectors = kind === 'issue' ? [
'#edit-issue-content', '#close-issue', '#release-issue', '#load-issue-handoff',
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
] : [
'#merge-pull', '#pull-review-retry', '#next-unreviewed-pull-file', '#load-older-pull-comments',
];
selectors.forEach(selector => {
const control = qs(selector);
if (control) control.disabled = true;
});
if (kind === 'issue') {
qs('#issue-planning').inert = true;
qs('#issue-handoff').inert = true;
} else {
qs('#pull-review').inert = true;
}
}
function setOfflineUpdateControls(offline) {
qs('#mark-update-read-next').disabled = false;
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
qs('#update-ownership-action').disabled = offline;
qs('#load-older-update-comments').disabled = offline;
qs('#update-sheet .detail-defer').inert = offline;
}
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
@@ -689,8 +895,16 @@
});
activeMyWork = partitioned.active;
laterMyWork = partitioned.later;
const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&
!lastContextSnapshot?.error &&
!Object.values(workPagination).some(page => page?.has_more);
todayMyWork = todayWork.reconcile(lastMyWork, {
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
pruneMissing: authoritativeTodayReconciliation,
onPrune: retiredIds => {
const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean);
if (queued) todaySync.flush();
return queued;
},
});
const counts = countMyWork(activeMyWork);
counts.today = todayMyWork.length;
@@ -888,7 +1102,7 @@
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
const laterActions = selectedWorkFilter === 'later' ?
'<div class="later-actions"><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '"' + planningDisabled + '>Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '"' + planningDisabled + '>Tomorrow</button></div>';
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '"' + planningDisabled + '>Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '"' + planningDisabled + '>Tomorrow</button><button type="button" data-later-custom data-work-index="' + index + '"' + planningDisabled + '>Choose date &amp; time</button></div>';
const alreadyToday = todayWork.contains(item);
const todayPosition = todayWork.position(item);
const todayActions = selectedWorkFilter === 'today' ?
@@ -923,7 +1137,7 @@
const item = lastMyWork[Number(button.dataset.updateIndex)];
if (!item) return;
updateTrigger = button;
workRoute.open({ ...item, kind:'update' });
openRoutedWork(item, button);
});
});
document.querySelectorAll('[data-notification-id]').forEach(button => {
@@ -956,6 +1170,17 @@
refreshMyWorkView();
});
});
document.querySelectorAll('[data-later-custom]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item) return;
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
laterPicker.open(item, button, 'card');
});
});
document.querySelectorAll('[data-later-restore]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
@@ -976,18 +1201,32 @@
(result === 'added' ? 'Added to Today without changing Gitea.' :
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
refreshMyWorkView();
if (result === 'added') {
const item = lastMyWork[Number(button.dataset.workIndex)];
todaySync.enqueue('add', todayWork.identity(item));
todaySync.flush();
warmTodayOffline();
}
});
});
document.querySelectorAll('[data-today-remove]').forEach(button => {
button.addEventListener('click', () => {
todayWork.remove(lastMyWork[Number(button.dataset.workIndex)]);
const item = lastMyWork[Number(button.dataset.workIndex)];
if (todayWork.remove(item)) {
todaySync.enqueue('remove', todayWork.identity(item));
todaySync.flush();
}
qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.';
refreshMyWorkView();
});
});
document.querySelectorAll('[data-today-move]').forEach(button => {
button.addEventListener('click', () => {
todayWork.move(lastMyWork[Number(button.dataset.workIndex)], button.dataset.todayMove);
const item = lastMyWork[Number(button.dataset.workIndex)];
if (todayWork.move(item, button.dataset.todayMove)) {
todaySync.enqueue('move', todayWork.identity(item), button.dataset.todayMove);
todaySync.flush();
}
refreshMyWorkView();
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
});
@@ -1046,8 +1285,8 @@
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
'</div><div class="issue-sheet-content">' +
escapeHtml(comment.body || 'No comment body provided.') + '</div></div>';
'</div><div class="issue-sheet-content markdown-content">' +
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
}
function renderIssueConversation(state) {
@@ -1135,8 +1374,10 @@
}
}
async function openIssueSheet(item, trigger) {
async function openIssueSheet(item, trigger, offlineDetail = null) {
if (!item) return;
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
selectedIssue = item;
selectedIssueDetail = null;
issueConversation = null;
@@ -1182,12 +1423,12 @@
qs('#close-issue').disabled = false;
qs('#close-issue-sheet').focus();
try {
const detail = await issueController.load(item);
const detail = offlineDetail || await issueController.load(item);
if (selectedIssue !== item) return;
selectedIssueDetail = detail;
issueConversation = issueController.conversation(item, detail.conversation);
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
qs('#issue-sheet-body').textContent = detail.body || 'No description provided.';
qs('#issue-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
'<span class="pill">' + escapeHtml(label) + '</span>'
).join(' ');
@@ -1204,7 +1445,14 @@
qs('#clear-issue-due-date').disabled = !detail.due_date;
qs('#issue-due-status').textContent = detail.due_date ?
'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.';
if (qs('#issue-planning').open) loadIssuePlanning();
if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();
if (offlineDetail) {
setOfflineDetailControls('issue');
qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
}
} catch (error) {
if (selectedIssue !== item) return;
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
@@ -1297,8 +1545,9 @@
}
}
async function openPullSheet(item, trigger) {
async function openPullSheet(item, trigger, offlineDetail = null) {
if (!item) return;
qs('#pull-review').inert = false;
selectedPull = item;
pullTrigger = trigger;
selectedPullDetail = null;
@@ -1327,15 +1576,22 @@
qs('#open-pull-gitea').href = item.url || '#';
qs('#close-pull-sheet').focus();
try {
const detail = await pullController.load(item);
const detail = offlineDetail || await pullController.load(item);
if (selectedPull !== item) return;
selectedPullDetail = detail;
pullConversation = pullController.conversation(item, detail.conversation);
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
qs('#pull-sheet-body').textContent = detail.body || 'No description provided.';
qs('#pull-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderPullConversation(pullConversation.snapshot());
qs('#open-pull-gitea').href = detail.url || item.url || '#';
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
if (offlineDetail) {
setOfflineDetailControls('pull');
qs('#pull-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
}
} catch (error) {
if (selectedPull !== item) return;
qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
@@ -1369,7 +1625,7 @@
const expanded = findWorkController.isPreviewed(item);
const detailId = 'find-work-detail-' + index;
const detail = '<div id="' + detailId + '" class="find-work-detail"' + (expanded ? '' : ' hidden') +
'><p class="find-work-description">' + escapeHtml(item.body || 'No description provided.') + '</p>' +
'><div class="find-work-description markdown-content">' + renderMarkdown(item.body || 'No description provided.') + '</div>' +
(item.url ? '<a href="' + escapeHtml(item.url) + '" target="_blank" rel="noopener noreferrer">Open in Gitea</a>' : '') +
'</div>';
return '<article class="find-work-card"><div class="small">' + escapeHtml(item.repository) + '#' +
@@ -1463,6 +1719,67 @@
});
}
function currentIssueCaptureDraft() {
return {
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
labelIds: selectedIssueLabelIds(),
milestoneId: Number(qs('#create-issue-milestone').value) || null,
dueDate: qs('#create-issue-due-date').value,
};
}
let duplicateCheckTimer = null;
function renderIssueDuplicates(state) {
if (state.status === 'stale') return;
const panel = qs('#create-issue-duplicates');
const list = qs('#create-issue-duplicate-list');
const status = qs('#create-issue-duplicate-status');
const anyway = qs('#create-issue-anyway');
list.replaceChildren();
anyway.hidden = true;
if (state.status === 'idle') {
panel.hidden = true;
return;
}
panel.hidden = false;
if (state.status === 'failed') {
status.textContent = 'Could not check for existing issues. You can still create this issue.';
return;
}
if (!state.candidates.length) {
status.textContent = 'No similar open issues found in this repository.';
return;
}
status.textContent = (state.partial ? 'Search was incomplete. ' : '') + state.candidates.length +
' similar open issue' + (state.candidates.length === 1 ? '' : 's') +
' found. ' + (state.partial ? 'You can continue without waiting.' : 'Review before creating another.');
state.candidates.forEach(item => {
const card = document.createElement('article');
card.className = 'create-issue-duplicate-card';
const key = document.createElement('span');
key.className = 'small';
key.textContent = item.repository + ' #' + item.number;
const link = document.createElement('a');
link.textContent = item.title || 'Review existing issue';
link.href = safeSearchUrl(item.url) || '#';
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.addEventListener('click', saveIssueCaptureDraft);
card.append(key, link);
list.appendChild(card);
});
}
function scheduleIssueDuplicateCheck() {
clearTimeout(duplicateCheckTimer);
const draft = currentIssueCaptureDraft();
duplicateCheckTimer = setTimeout(async () => {
renderIssueDuplicates(await issueCapture.findDuplicates(draft));
}, 350);
}
async function loadIssueLabels(repository, selectedIds = []) {
const list = qs('#create-issue-label-list');
const status = qs('#create-issue-label-status');
@@ -1548,6 +1865,7 @@
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
qs('#submit-new-issue').disabled = !repositories.length;
qs('#create-issue-sheet').classList.add('open');
@@ -1563,6 +1881,8 @@
return;
}
qs('#create-issue-sheet').classList.remove('open');
clearTimeout(duplicateCheckTimer);
qs('#create-issue-duplicates').hidden = true;
creatingIssue = false;
qs('#new-issue').focus();
}
@@ -1609,6 +1929,15 @@
applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin));
}
async function flushNotificationReadOutbox() {
if (!navigator.onLine || !activeFlushLogin || !notificationReadOutbox.list().length) return;
const result = await notificationReadOutbox.flush(activeFlushLogin);
if (result.confirmed?.length) {
qs('#my-work-action-status').textContent = result.confirmed.length +
' queued update' + (result.confirmed.length === 1 ? '' : 's') + ' marked read.';
}
}
function canQueueMessage(error) {
const status = Number(error?.status || 0);
return !status || status >= 500;
@@ -1646,9 +1975,10 @@
qs('#review-inline-body').focus();
}
async function openReviewSheet(item, trigger) {
async function openReviewSheet(item, trigger, cachedDetail = null) {
selectedReview = item;
reviewTrigger = trigger;
offlineReview = Boolean(cachedDetail);
qs('#review-sheet').classList.add('open');
qs('#review-sheet-key').textContent = item.key;
qs('#review-sheet-title').textContent = item.title;
@@ -1670,15 +2000,16 @@
qs('#review-submit-status').textContent = '';
qs('#continue-review-to-merge').hidden = true;
qs('#submit-review').disabled = true;
qs('#submit-review').textContent = offlineReview ? 'Reconnect to validate & submit' : 'Submit review';
closeInlineComposer();
draft = null;
reviewFiles = [];
selectedReviewHead = '';
qs('#close-review-sheet').focus();
try {
const detail = await reviewController.load(selectedReview);
const detail = cachedDetail || await reviewController.load(selectedReview);
if (selectedReview !== item) return;
qs('#review-sheet-body').textContent = detail.body || 'No description provided.';
qs('#review-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
qs('#review-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
createReviewController.renderDiffFile(file, index, escapeHtml)
@@ -1731,10 +2062,12 @@
openNextUnreviewed(progressSnapshot);
qs('#review-history').innerHTML = (detail.reviews || []).length ? detail.reviews.map(review =>
'<div class="review-history"><strong>' + escapeHtml(review.user?.login || 'Reviewer') + '</strong> · ' +
escapeHtml(review.state || 'commented') + (review.body ? '<div class="small">' + escapeHtml(review.body) + '</div>' : '') + '</div>'
escapeHtml(review.state || 'commented') + (review.body ? '<div class="small markdown-content">' + renderMarkdown(review.body) + '</div>' : '') + '</div>'
).join('') : '<div>No prior reviews.</div>';
qs('#review-sheet-status').textContent = 'Ready to review · by ' + (detail.author || 'unknown author');
qs('#submit-review').disabled = false;
qs('#review-sheet-status').textContent = offlineReview ?
'Offline review · saved ' + fmt(detail.saved_at) + ' · draft feedback stays on this device.' :
'Ready to review · by ' + (detail.author || 'unknown author');
qs('#submit-review').disabled = offlineReview;
} catch (error) {
if (selectedReview !== item) return;
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
@@ -1750,6 +2083,7 @@
}
qs('#review-sheet').classList.remove('open');
selectedReview = null;
offlineReview = false;
progress = null;
draft = null;
reviewFiles = [];
@@ -1838,6 +2172,12 @@
String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin;
updatePlanningAvailability();
if (planningOwnerLogin) {
todaySync.migrate(todayWork.read());
todaySync.flush();
laterSync.migrate(laterWork.read());
laterSync.flush();
}
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating;
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
@@ -1856,9 +2196,11 @@
notification_pagination: snapshot.notification_pagination,
});
updateOfflineWorkControls();
warmTodayOffline();
}
flushIssueOutbox();
flushAuthoredOutbox();
flushNotificationReadOutbox();
} else if (!snapshot.context) handleContextError(new Error('Context section unavailable'));
if (eventsChanged && Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
if (eventsFreshness?.revalidating) {
@@ -2009,7 +2351,7 @@
(detail.author ? ' · by ' + detail.author : '') +
(detail.labels?.length ? ' · ' + detail.labels.join(', ') : '') +
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
qs('#search-preview-body').textContent = detail.body || 'No description provided.';
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
@@ -2289,30 +2631,39 @@
closeCreateIssueSheet(true, !discardEditedDraft);
});
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
qs(selector).addEventListener('input', saveIssueCaptureDraft)
qs(selector).addEventListener('input', () => {
saveIssueCaptureDraft();
if (selector === '#create-issue-title') scheduleIssueDuplicateCheck();
})
);
qs('#create-issue-repository').addEventListener('change', event => {
loadIssueLabels(event.target.value);
loadIssueMilestones(event.target.value);
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
});
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-anyway').addEventListener('click', () => {
const captureDraft = currentIssueCaptureDraft();
issueCapture.acknowledgeDuplicates(captureDraft);
qs('#create-issue-anyway').hidden = true;
qs('#create-issue-form').requestSubmit();
});
qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault();
const captureDraft = {
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
labelIds: selectedIssueLabelIds(),
milestoneId: Number(qs('#create-issue-milestone').value) || null,
dueDate: qs('#create-issue-due-date').value,
};
const captureDraft = currentIssueCaptureDraft();
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
qs('#create-issue-title').focus();
return;
}
if (issueCapture.needsDuplicateAcknowledgement(captureDraft)) {
qs('#create-issue-status').textContent = 'Review the possible existing issues, or choose Create anyway.';
qs('#create-issue-anyway').hidden = false;
qs('#create-issue-anyway').focus();
return;
}
const button = qs('#submit-new-issue');
button.disabled = true;
qs('#create-issue-status').textContent = 'Saving for background delivery…';
@@ -2403,7 +2754,7 @@
selectedIssue = { ...editing, ...confirmed, key: editing.key };
selectedIssueDetail = { ...selectedIssueDetail, ...confirmed };
qs('#issue-sheet-title').textContent = confirmed.title;
qs('#issue-sheet-body').textContent = confirmed.body || 'No description provided.';
qs('#issue-sheet-body').innerHTML = renderMarkdown(confirmed.body || 'No description provided.');
paintMyWork(lastContextSnapshot);
qs('#issue-edit-form').hidden = true;
qs('#issue-sheet-status').textContent = 'Issue saved.';
@@ -2965,12 +3316,20 @@
offlineWorkMode = value;
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
if (value) {
document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')
.forEach(button => { button.disabled = true; });
}
}
function hydrateOfflineWork(mode = 'offline') {
const saved = offlineWorkStore.load();
if (!saved) return false;
const outage = mode === 'outage';
lastNotifications = saved.notifications || [];
confirmedOwnerLogin = String(saved.user?.login || '').trim();
planningOwnerLogin = confirmedOwnerLogin;
updatePlanningAvailability();
saved.notifications = notificationReadOutbox.suppress(saved.notifications || []);
lastNotifications = saved.notifications;
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
workPagination = saved.work_pagination || {};
lastContextSnapshot = saved;
@@ -3002,7 +3361,9 @@
offlineStatus.hidden = true;
setOfflineWorkMode(false);
setStatus('Reconnecting…');
contextPoller.refresh({ force: true });
contextPoller.refresh({ force: true }).then(() => {
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
});
}
keepWorkOffline.addEventListener('change', () => {
offlineWorkStore.setEnabled(keepWorkOffline.checked);
@@ -3011,6 +3372,8 @@
notification_pagination:notificationPagination });
}
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
if (keepWorkOffline.checked) warmTodayOffline();
else offlineToday.cancel();
});
deliveryReceipts.addEventListener('change', async () => {
let enabled = deliveryReceipts.checked;
@@ -3026,8 +3389,13 @@
});
qs('#clear-offline-work').addEventListener('click', () => {
offlineWorkStore.clear();
offlineToday.cancel();
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
updateOfflineWorkControls('Offline work data cleared.');
});
qs('#retry-offline-today').addEventListener('click', () =>
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
);
updateOfflineWorkControls();
updateDeliveryReceiptControls();
if (!navigator.onLine) showOfflineStatus();
+6 -4
View File
@@ -8,21 +8,23 @@ function createDetailDefer({
formatTime = value => new Date(value).toLocaleString(),
}) {
return {
defer(item, preset) {
deferUntil(item, until, { closeSheet = true, restoreFocus = true } = {}) {
if (!item) return false;
const until = laterWork.presetUntil(preset);
const result = laterWork.defer(item, until);
if (result !== 'deferred') {
announce(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
return false;
}
const inSession = session.active();
if (!inSession) close();
if (!inSession && closeSheet) close();
refresh();
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
if (!inSession) focus();
if (!inSession && restoreFocus) focus();
return true;
},
defer(item, preset) {
return this.deferUntil(item, laterWork.presetUntil(preset));
},
};
}
+38 -9
View File
@@ -77,6 +77,10 @@
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<button id="clear-offline-work" type="button">Clear offline work data</button>
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
<span class="offline-today-readiness" id="offline-today-readiness" hidden>
<span class="small" id="offline-today-status" role="status" aria-live="polite"></span>
<button id="retry-offline-today" type="button" hidden>Retry offline items</button>
</span>
<span class="small" id="delivery-receipt-status" role="status" aria-live="polite"></span>
<div class="install-app-card" id="install-app-card" hidden>
<p><strong>Install Stackchain</strong><br><span class="small">Keep mobile work one tap away and launch the saved app shell during an outage.</span></p>
@@ -95,6 +99,8 @@
<div class="small" id="notification-page-status" aria-live="polite"></div>
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
<div class="small" id="later-sync-status" aria-live="polite">Later is saved on this device.</div>
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
<div class="small" id="work-route-share-status" aria-live="polite"></div>
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
@@ -184,7 +190,7 @@
</div>
<div id="search-preview-status" class="small" aria-live="polite">Choose a search result.</div>
<div id="search-preview-meta" class="row"></div>
<p id="search-preview-body" class="search-preview-body"></p>
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
<div class="search-preview-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button>
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
@@ -221,7 +227,7 @@
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
<p class="issue-sheet-content" id="issue-sheet-body"></p>
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
<h2>Full conversation</h2>
<div id="issue-comments"></div>
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
@@ -288,7 +294,7 @@
<button id="release-issue" type="button">Release assignment</button>
<button id="close-issue" type="button">Close issue</button>
<a id="open-issue-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-cancel>Cancel</button></div></details>
<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 &amp; 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>
@@ -349,6 +355,12 @@
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
<div class="create-issue-label-list" id="create-issue-label-list"></div>
</fieldset>
<section class="create-issue-duplicates" id="create-issue-duplicates" aria-live="polite" hidden>
<strong>Possible existing issues</strong>
<div id="create-issue-duplicate-status" class="small"></div>
<div id="create-issue-duplicate-list" class="create-issue-duplicate-list"></div>
<button id="create-issue-anyway" type="button" hidden>Create anyway</button>
</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>
@@ -380,7 +392,7 @@
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
<details>
<summary><h2>Subject context</h2></summary>
<p class="update-sheet-content muted" id="update-subject-body"></p>
<div class="update-sheet-content muted markdown-content" id="update-subject-body"></div>
</details>
<section class="update-reply" aria-labelledby="update-reply-title">
<h2 id="update-reply-title">Reply</h2>
@@ -394,7 +406,7 @@
<button class="share-work-route" type="button">Share</button>
<button id="mark-update-read-next" type="button">Mark read &amp; 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-cancel>Cancel</button></div></details>
<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 &amp; 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>
@@ -412,7 +424,7 @@
</div>
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
<p class="pull-sheet-content" id="pull-sheet-body"></p>
<div class="pull-sheet-content markdown-content" id="pull-sheet-body"></div>
<h2>Full conversation</h2><div id="pull-comments"></div>
<button class="conversation-more" id="load-older-pull-comments" type="button" hidden>Load older messages</button>
<div id="pull-conversation-status" class="small" aria-live="assertive"></div>
@@ -434,7 +446,7 @@
<div class="pull-sheet-actions">
<button class="share-work-route" type="button">Share</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-cancel>Cancel</button></div></details>
<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 &amp; 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>
@@ -455,7 +467,7 @@
</div>
<div id="review-sheet-status" class="small" aria-live="polite">Choose a review request.</div>
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
<p class="review-sheet-body" id="review-sheet-body"></p>
<div class="review-sheet-body markdown-content" id="review-sheet-body"></div>
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><button class="share-work-route" type="button">Share</button><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
<h2>Changed files</h2>
<div id="review-files" class="muted"></div>
@@ -499,7 +511,7 @@
</section>
<h2>Review history</h2>
<div id="review-history" class="muted"></div>
<div class="review-sheet-actions"><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-cancel>Cancel</button></div></details></div>
<div class="review-sheet-actions"><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 &amp; 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>
@@ -508,6 +520,18 @@
</section>
</div>
<dialog class="later-picker" id="later-picker" role="dialog" aria-modal="true" aria-labelledby="later-picker-heading" hidden>
<form class="later-picker-panel" id="later-picker-form">
<div class="later-picker-header"><h2 id="later-picker-heading">Choose return time</h2><button id="cancel-later-picker" type="button">Cancel</button></div>
<p class="small">The item stays unread and unchanged in Gitea.</p>
<label for="later-picker-time">Return this work at</label>
<input id="later-picker-time" type="datetime-local" required />
<div class="small" id="later-picker-timezone"></div>
<div class="small later-picker-error" id="later-picker-error" aria-live="assertive"></div>
<button id="confirm-later-picker" type="submit">Defer until…</button>
</form>
</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">Work <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
@@ -529,12 +553,17 @@
<script src="static/background-issue-sync.js"></script>
<script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.js"></script>
<script src="static/notification-read-outbox.js"></script>
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/update-ownership.js"></script>
<script src="static/later-work.js"></script>
<script src="static/later-sync.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/conversation.js"></script>
<script src="static/issue-sheet.js"></script>
+104
View File
@@ -0,0 +1,104 @@
function createLaterPicker({
now = () => new Date(),
history = null,
eventTarget = null,
onState = () => {},
onConfirm = () => false,
} = {}) {
let active = false;
let item = null;
let trigger = null;
let context = null;
let afterClose = null;
let committed = false;
let started = false;
function parse(input) {
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(String(input || ''));
if (!match) return { ok:false, message:'Choose a valid local date and time.' };
const parts = match.slice(1).map(Number);
const value = new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], 0, 0);
const sameLocalTime = value.getFullYear() === parts[0] &&
value.getMonth() === parts[1] - 1 && value.getDate() === parts[2] &&
value.getHours() === parts[3] && value.getMinutes() === parts[4];
if (!sameLocalTime) return { ok:false, message:'Choose a valid local date and time.' };
if (value <= now()) return { ok:false, message:'Choose a future date and time.' };
return { ok:true, value };
}
function formatLocal(value) {
const pad = part => String(part).padStart(2, '0');
return value.getFullYear() + '-' + pad(value.getMonth() + 1) + '-' + pad(value.getDate()) +
'T' + pad(value.getHours()) + ':' + pad(value.getMinutes());
}
function deactivate() {
if (!active) return false;
const restore = trigger;
active = false;
item = null;
trigger = null;
context = null;
committed = false;
onState({ open:false, message:'' });
restore?.focus?.();
const cleanup = afterClose;
afterClose = null;
cleanup?.();
return true;
}
function start() {
if (started || !eventTarget) return;
started = true;
eventTarget.addEventListener('popstate', event => {
if (active && !event.state?.laterPicker) deactivate();
});
eventTarget.addEventListener('keydown', event => {
if (active && event.key === 'Escape') {
event.preventDefault?.();
close();
}
});
}
function open(nextItem, nextTrigger, nextContext = 'card') {
if (!nextItem || active) return false;
active = true;
item = nextItem;
trigger = nextTrigger || null;
context = nextContext;
committed = false;
const suggested = new Date(now().getTime() + 60 * 60 * 1000);
suggested.setMinutes(Math.ceil(suggested.getMinutes() / 15) * 15, 0, 0);
if (history) history.pushState({ ...(history.state || {}), laterPicker:true }, '');
onState({ open:true, message:'', value:formatLocal(suggested) });
return true;
}
function close() {
if (!active) return false;
if (history?.state?.laterPicker) history.back();
else deactivate();
return true;
}
function submit(input) {
if (!active || committed) return false;
const result = parse(input);
if (!result.ok) {
onState({ open:true, message:result.message, value:String(input || '') });
return false;
}
const confirmed = onConfirm(item, result.value, context);
if (confirmed !== true && typeof confirmed !== 'function') return false;
committed = true;
afterClose = typeof confirmed === 'function' ? confirmed : null;
close();
return true;
}
return { parse, start, open, close, submit, current:() => active };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterPicker;
+247
View File
@@ -0,0 +1,247 @@
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
const prefix = 'stackchain.later-sync.v1.';
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
let flushing = null;
let channel = null;
let channelKey = '';
let retryTimer = null;
let retryAttempt = 0;
const knownOperationKeys = new Set();
function cancelRetry() {
if (retryTimer !== null) clearTimer?.(retryTimer);
retryTimer = null;
}
function scheduleRetry(error, ownerKey) {
if (retryTimer !== null || !pending().length || !ownerKey) return;
const advised = Number(error?.retryAfter);
const delayMs = Number.isFinite(advised) && advised >= 0
? advised * 1000
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
retryAttempt += 1;
onStatus?.('retrying', { delayMs });
retryTimer = setTimer?.(async () => {
retryTimer = null;
if (key() !== ownerKey) return false;
return flush();
}, delayMs);
retryTimer?.unref?.();
}
function key() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function snapshotKey() {
const storageKey = key();
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
}
function savedRevision() {
try {
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
} catch (_error) {
return -1;
}
}
function validRecords(records) {
return records && typeof records === 'object' && !Array.isArray(records);
}
function adopt(plan, broadcast = true) {
if (!Number.isInteger(plan?.revision) || !validRecords(plan?.records)) return false;
if (plan.revision < savedRevision()) return false;
const snapshot = { revision: plan.revision, records: plan.records };
try {
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
} catch (_error) {
// Server truth remains usable in this tab when storage is unavailable.
}
onRemoteRecords?.(plan.records);
if (broadcast) channel?.postMessage(snapshot);
return true;
}
function ensureChannel() {
const storageKey = key();
if (!storageKey || channelKey === storageKey) return;
channel?.close?.();
const factory = createChannel || (globalThis.window?.BroadcastChannel
? name => new globalThis.window.BroadcastChannel(name)
: null);
channelKey = storageKey;
channel = factory?.('stackchain-later-' + storageKey.slice(prefix.length)) || null;
channel?.addEventListener?.('message', event => {
if (key() === storageKey) adopt(event.data, false);
});
}
function pending() {
const storageKey = key();
if (!storageKey || !storage) return [];
const recordPrefix = storageKey + '.operation.';
try {
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
if (Array.isArray(legacy)) {
legacy.forEach((operation, index) => {
if (!operation?.operation_id) return;
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
knownOperationKeys.add(recordKey);
});
if (legacy.length) storage.removeItem(storageKey);
}
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
for (let index = 0; index < Number(storage.length || 0); index += 1) {
const candidate = storage.key?.(index);
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
}
const records = [...keys].map(recordKey => {
const record = JSON.parse(storage.getItem(recordKey) || 'null');
return record ? { ...record, recordKey } : null;
}).filter(record => record?.operation)
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
left.operation.operation_id.localeCompare(right.operation.operation_id));
const latestByItem = new Map();
records.forEach(record => latestByItem.set(record.operation.item_id, record));
records.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
storage.removeItem(record.recordKey);
knownOperationKeys.delete(record.recordKey);
});
return records.filter(record => latestByItem.get(record.operation.item_id) === record)
.map(record => record.operation).filter(operation =>
operation && typeof operation.operation_id === 'string' &&
['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' &&
(operation.action === 'restore' || typeof operation.wake_at === 'string'));
} catch (_error) {
return [];
}
}
function removeOperation(operationId) {
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
try {
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
return true;
} catch (_error) {
return false;
}
}
function operationId() {
if (createOperationId) return createOperationId();
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function enqueue(action, itemId, wakeAt = null) {
if (!['defer', 'restore'].includes(action) || !itemId ||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
pending().filter(operation => operation.item_id === itemId)
.forEach(operation => removeOperation(operation.operation_id));
const operation = { operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt };
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false;
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('later');
} catch (_error) { /* Report the persistence failure below. */ }
onStatus?.(saved ? 'pending' : 'error');
return saved;
}
function migrate(records) {
const storageKey = key();
if (!storageKey || !storage) return false;
const marker = migrationPrefix + storageKey.slice(prefix.length);
try {
if (storage.getItem(marker)) return false;
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
enqueue('defer', itemId, wakeAt)
);
storage.setItem(marker, '1');
return true;
} catch (_error) {
return false;
}
}
async function run() {
const ownerKey = key();
if (!ownerKey) return false;
ensureChannel();
try {
let operations = pending();
let plan;
if (!operations.length) plan = await fetchJson('api/v1/later');
while (operations.length) {
if (key() !== ownerKey) return false;
const batch = operations.slice(0, 50);
plan = await fetchJson('api/v1/later', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations: batch }),
});
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
const received = hasReceipts ? [
...(plan.accepted_operation_ids || []),
...(plan.duplicate_operation_ids || []),
...(plan.rejected_operations || []).map(item => item.operation_id),
] : batch.map(item => item.operation_id);
for (const operationId of received) {
if (pending().some(candidate => candidate.operation_id === operationId) &&
!removeOperation(operationId)) {
throw new Error('Could not persist Later delivery receipt');
}
}
operations = pending();
}
adopt(plan);
onStatus?.(pending().length ? 'pending' : 'saved');
retryAttempt = 0;
cancelRetry();
return true;
} catch (error) {
if (pending().length) scheduleRetry(error, ownerKey);
else onStatus?.('error');
return false;
}
}
function flush() {
if (!flushing) {
const delivery = coordinator ? coordinator.runExclusive('later', run) : run();
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
}
return flushing;
}
function startLifecycle({ window: windowObject, document: documentObject }) {
windowObject?.addEventListener?.('online', flush);
documentObject?.addEventListener?.('visibilitychange', () =>
documentObject.hidden ? false : flush()
);
}
coordinator?.subscribe(change => {
if (change.queue === 'later' && pending().length) flush();
});
return { enqueue, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterSync;
+27 -7
View File
@@ -1,4 +1,4 @@
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {} }) {
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {}, onChange = () => {}, onExpire = () => {} }) {
const prefix = 'stackchain.later-work.v1.';
let timer = null;
@@ -45,8 +45,11 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
if (!key) return 'unavailable';
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
const records = read();
records[id] = wake.toISOString();
return write(records) ? 'deferred' : 'unavailable';
const wakeAt = wake.toISOString();
records[id] = wakeAt;
if (!write(records)) return 'unavailable';
onChange('defer', id, wakeAt);
return 'deferred';
}
function presetUntil(preset) {
@@ -66,10 +69,21 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const records = read();
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
delete records[id];
write(records);
if (!write(records)) return false;
onChange('restore', id, null);
return true;
}
function adopt(records) {
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
const normalized = {};
Object.entries(records).forEach(([id, wake]) => {
const wakeTime = new Date(wake).getTime();
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
});
return write(normalized);
}
function schedule(wakeTimes, current) {
if (timer !== null) clearTimer(timer);
timer = null;
@@ -87,16 +101,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const available = new Map((items || []).map(item => [identity(item), item]));
const retained = {};
const wakeTimes = [];
const expired = [];
Object.entries(records).forEach(([id, wake]) => {
const wakeTime = new Date(wake).getTime();
if (!Number.isFinite(wakeTime) || wakeTime <= current) return;
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
if (Number.isFinite(wakeTime)) expired.push(id);
return;
}
if (pruneMissing && !available.has(id)) return;
retained[id] = new Date(wakeTime).toISOString();
wakeTimes.push(wakeTime);
});
if (JSON.stringify(retained) !== JSON.stringify(records)) write(retained);
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
onExpire(expired);
}
schedule(wakeTimes, current);
const deferredIds = new Set(Object.keys(retained));
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
@@ -107,7 +127,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
return { active, later };
}
return { identity, defer, restore, presetUntil, partition };
return { identity, read, adopt, defer, restore, presetUntil, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
+88 -10
View File
@@ -13,17 +13,95 @@
})[character]);
}
return function renderMarkdown(raw) {
return escapeHtml(raw)
.replace(/^#{1,6}\s.*$/gm, (line) => {
const level = line.match(/^(#{1,6})/)[1].length;
return '<h' + level + '>' + line.replace(/^#{1,6}\s/, '') + '</h' + level + '>';
function renderInline(value) {
const code = [];
let rendered = escapeHtml(value).replace(/`([^`\n]+)`/g, (_match, body) => {
const marker = '\u0000CODE' + code.length + '\u0000';
code.push('<code>' + body + '</code>');
return marker;
});
rendered = rendered
.replace(/\[([^\]\n]+)\]\(([^\s)]+)(?:\s+&quot;[^&]*?&quot;)?\)/g, (_match, label, escapedUrl) => {
const url = escapedUrl.replace(/&amp;/g, '&');
const safe = /^(https?:|mailto:)/i.test(url) || /^(\/|#|\.\/|\.\.\/)/.test(url);
if (!safe) return label;
return '<a href="' + escapedUrl + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
})
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/^-\s?(.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n/g, '<br>');
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
}
function renderList(lines) {
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
const items = lines.map(line => {
let body = line.replace(/^[-*+]\s+/, '');
if (!taskList) return '<li>' + renderInline(body) + '</li>';
const checked = /^\[[xX]\]\s+/.test(body);
body = body.replace(/^\[[ xX]\]\s+/, '');
return '<li class="task-list-item"><input type="checkbox" disabled' +
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
}).join('');
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
}
return function renderMarkdown(raw) {
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
const output = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
if (fence) {
const body = [];
index += 1;
while (index < lines.length && !/^```\s*$/.test(lines[index])) {
body.push(lines[index]);
index += 1;
}
if (index < lines.length) index += 1;
const language = fence[1] ? ' class="language-' + fence[1] + '"' : '';
output.push('<pre><code' + language + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
const level = heading[1].length;
output.push('<h' + level + '>' + renderInline(heading[2]) + '</h' + level + '>');
index += 1;
continue;
}
if (/^>\s?/.test(line)) {
const quote = [];
while (index < lines.length && /^>\s?/.test(lines[index])) {
quote.push(lines[index].replace(/^>\s?/, ''));
index += 1;
}
output.push('<blockquote><p>' + renderInline(quote.join('\n')).replace(/\n/g, '<br>') + '</p></blockquote>');
continue;
}
if (/^[-*+]\s+/.test(line)) {
const list = [];
while (index < lines.length && /^[-*+]\s+/.test(lines[index])) {
list.push(lines[index]);
index += 1;
}
output.push(renderList(list));
continue;
}
const paragraph = [];
while (index < lines.length && lines[index].trim() &&
!/^```/.test(lines[index]) && !/^(#{1,6})\s+/.test(lines[index]) &&
!/^>\s?/.test(lines[index]) && !/^[-*+]\s+/.test(lines[index])) {
paragraph.push(lines[index]);
index += 1;
}
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
}
return output.join('');
};
});
+16 -11
View File
@@ -280,6 +280,8 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
function createNotificationReader({
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
queueRead = null,
loadSaved = () => null,
loadConversation = null,
onConversation = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
@@ -288,14 +290,16 @@ function createNotificationReader({
let loadVersion = 0;
let marking = false;
let conversationPager = null;
let offlineHydrated = false;
async function open(item) {
async function open(item, savedDetail = null) {
selected = item;
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
const version = ++loadVersion;
onOpen(item);
onStatus('Loading update…');
try {
const detail = await load(item.notification_id);
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false;
onDetail(detail);
if (createPager && loadConversation && detail.conversation) {
@@ -324,7 +328,7 @@ function createNotificationReader({
return true;
},
async loadOlder() {
if (!conversationPager || !selected) return false;
if (!conversationPager || !selected || offlineHydrated) return false;
const pager = conversationPager;
const version = loadVersion;
onStatus('Loading older messages…');
@@ -342,20 +346,21 @@ function createNotificationReader({
}
},
async markReadAndNext(items) {
if (!selected || marking) return false;
if (!selected || marking || (offlineHydrated && !queueRead)) return false;
const current = selected;
const queueing = offlineHydrated;
marking = true;
onStatus('Marking update read…');
onStatus(queueing ? 'Queueing update read…' : 'Marking update read…');
try {
await markRead(current.notification_id);
if (queueing) await queueRead(current.notification_id);
else await markRead(current.notification_id);
const updated = acknowledgeNotification(items, current.notification_id);
onItems(updated);
const currentIndex = items.indexOf(current);
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
const next = remaining.find(item =>
item && item.has_update && Number.isInteger(item.notification_id)
);
if (next) await open(next);
const next = remaining.find(item => item && item.has_update &&
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
if (next) await open(next, queueing ? loadSaved(next) : null);
else {
selected = null;
onClose();
@@ -363,7 +368,7 @@ function createNotificationReader({
}
return { items: updated, next: next || null };
} catch (_error) {
onStatus('Could not mark update read. Retry.');
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
return false;
} finally {
marking = false;
+125
View File
@@ -0,0 +1,125 @@
function createNotificationReadOutbox({
storage, fetchJson, backgroundSync, coordinator, getOwnerLogin = () => '', now = () => Date.now(), maxItems = 100,
}) {
const storageKey = 'stackchain.notification-read-outbox.v1';
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item => item?.kind === 'notification-read' &&
Number.isInteger(item.notificationId) && item.notificationId > 0 && item.ownerLogin);
} catch (_error) { return []; }
}
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
coordinator?.notify('notification-read');
if (mirror && backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items, 'notification-read'))
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
.catch(() => { /* Foreground reconnect remains available. */ });
}
}
function itemId(ownerLogin, notificationId) {
return 'notification-read:' + ownerLogin + ':' + notificationId;
}
async function enqueueDurably(notificationId) {
notificationId = Number(notificationId);
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing this update.');
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid update.');
const items = read();
const id = itemId(ownerLogin, notificationId);
let item = items.find(candidate => candidate.id === id);
if (!item) {
if (items.length >= maxItems) throw new Error('Update acknowledgement queue is full. Reconnect before clearing more updates.');
item = {
id, kind: 'notification-read', notificationId, ownerLogin,
status: 'queued', queuedAt: Number(now()),
};
items.push(item);
write(items, false);
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item: { ...item }, background: false, durability: 'foreground-only' };
}
try {
await backgroundSync.reconcile(read(), 'notification-read');
await backgroundSync.requestSync();
return { item: { ...item }, background: true, durability: 'background' };
} catch (error) {
return { item: { ...item }, background: false, durability: 'foreground-only', error };
}
}
function suppress(items, login = getOwnerLogin()) {
const pending = new Set(read().filter(item => item.ownerLogin === String(login || '').trim())
.map(item => item.notificationId));
return (items || []).filter(item => !pending.has(Number(item?.notification_id ?? item?.id)));
}
async function send(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (backgroundSync?.send) return backgroundSync.send(item, currentLogin);
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
method: 'PATCH', headers: { Accept: 'application/json' },
});
return { confirmed: true };
}
async function flush(currentLogin) {
currentLogin = String(currentLogin || '').trim();
const confirmed = [];
let items = read();
for (const item of items) {
if (item.ownerLogin !== currentLogin || item.status === 'attention') continue;
try {
const result = await send(item, currentLogin);
if (result?.blocked || result?.busy) continue;
if (result?.attention) {
items = items.map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(result.error?.message || 'Update needs attention').slice(0, 240),
} : candidate);
write(items);
continue;
}
confirmed.push(item.notificationId);
items = items.filter(candidate => candidate.id !== item.id);
write(items);
} catch (error) {
const status = Number(error?.status || 0);
if (status >= 400 && status < 500) {
items = items.map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(error?.message || 'Update needs attention').slice(0, 240),
} : candidate);
write(items);
continue;
}
break;
}
}
return { confirmed, remaining: read() };
}
function reconcileBackground(records) {
const states = new Map((records || []).filter(item => item?.kind === 'notification-read')
.map(item => [item.id, item]));
const items = read().flatMap(item => {
const state = states.get(item.id);
if (state?.status === 'sent') return [];
if (state?.status === 'attention') return [{
...item, status: 'attention', error: String(state.error || 'Update needs attention').slice(0, 240),
}];
return [item];
});
write(items);
return items;
}
return { enqueueDurably, flush, suppress, reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createNotificationReadOutbox;
+83
View File
@@ -0,0 +1,83 @@
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.createOfflineToday = api;
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const itemKey = item => [item?.kind, item?.repository, Number(item?.number || 0)].join(':');
function createOfflineToday({
loadDetail, loadSavedDetail, saveDetail, onStatus = () => {}, concurrency = 2, maxItems = 5,
}) {
let failed = new Set();
let generation = 0;
function bounded(items) {
return (Array.isArray(items) ? items : []).filter(item =>
['issue', 'pull'].includes(item?.kind) && item?.repository && Number(item?.number) > 0
).slice(0, Math.max(1, maxItems));
}
async function run(login, sourceItems, onlyFailed) {
login = String(login || '').trim();
const items = bounded(sourceItems);
const runGeneration = ++generation;
if (!login || !items.length) {
failed = new Set();
const empty = { total: items.length, ready: 0, failed: 0, pending: 0 };
onStatus(empty);
return empty;
}
const previousFailed = failed;
const readyKeys = new Set(items.filter(item => loadSavedDetail(login, item)).map(itemKey));
const snapshot = pending => ({
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
});
const candidates = items.filter(item => {
const key = itemKey(item);
if (onlyFailed && !previousFailed.has(key)) return false;
const saved = loadSavedDetail(login, item);
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
});
failed = new Set();
onStatus(snapshot(candidates.length));
let cursor = 0;
async function worker() {
while (cursor < candidates.length && runGeneration === generation) {
const item = candidates[cursor++];
try {
const detail = await loadDetail(item);
if (runGeneration !== generation) return;
const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
if (saved !== false) readyKeys.add(itemKey(item));
} catch (_error) {
if (runGeneration === generation) failed.add(itemKey(item));
}
if (runGeneration === generation) {
onStatus(snapshot(Math.max(0, candidates.length - cursor)));
}
}
}
await Promise.all(Array.from(
{ length: Math.min(Math.max(1, concurrency), candidates.length) }, worker
));
if (runGeneration !== generation) return snapshot(0);
const status = snapshot(0);
onStatus(status);
return status;
}
return {
warm: (login, items) => run(login, items, false),
retry: (login, items) => run(login, items, true),
cancel() { generation += 1; failed = new Set(); },
failedKeys: () => Array.from(failed),
};
}
return createOfflineToday;
});
+97 -3
View File
@@ -7,6 +7,7 @@
const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
const DETAILS_KEY = 'stackchain.offline-work.details.v1';
const VERSION = 1;
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const ITEM_FIELDS = [
@@ -16,6 +17,15 @@
const NOTIFICATION_FIELDS = [
'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url',
];
const DETAIL_FIELDS = [
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
'id', 'repository', 'subject_type', 'subject_body', 'source_updated_at', 'head_sha', 'ci_state',
];
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];
const REVIEW_FILE_FIELDS = [
'filename', 'status', 'additions', 'deletions', 'diff_available', 'diff_binary', 'diff_truncated',
];
const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at'];
function pick(source, fields) {
const output = {};
@@ -25,18 +35,102 @@
return output;
}
function createOfflineWorkStore({ storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS }) {
function createOfflineWorkStore({
storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10,
}) {
function enabled() {
try { return storage.getItem(ENABLED_KEY) === 'true'; }
catch (_) { return false; }
}
function clear() {
try { storage.removeItem(SNAPSHOT_KEY); }
try {
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
}
catch (_) { return false; }
return true;
}
function detailKey(item) {
if (item?.kind === 'update') {
const notificationId = Number(item?.notification_id || 0);
return Number.isInteger(notificationId) && notificationId > 0 ? 'update:' + notificationId : '';
}
const kind = item?.kind === 'review' ? 'review' :
item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : '';
const repository = String(item?.repository || '');
const number = Number(item?.number || 0);
return kind && repository && number > 0 ? [kind, repository, number].join(':') : '';
}
function readDetails() {
try {
const value = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
return Array.isArray(value) ? value : [];
} catch (_) {
try { storage.removeItem(DETAILS_KEY); } catch (_error) { /* Best effort purge. */ }
return [];
}
}
function saveDetail(login, item, detail) {
const key = detailKey(item);
login = String(login || '').trim();
if (!enabled() || !login || !key || !detail) return false;
const conversation = detail.conversation || {};
const data = {
...pick(detail, DETAIL_FIELDS),
conversation: {
comments: (conversation.comments || []).slice(-20).map(comment => pick(comment, COMMENT_FIELDS)),
page: Number(conversation.page || 1),
older_page: conversation.older_page ?? null,
total: Number(conversation.total || 0),
},
};
if (item?.is_review || item?.kind === 'review') {
data.files = (detail.files || []).slice(0, 50).map(file => ({
...pick(file, REVIEW_FILE_FIELDS),
diff_lines: (file?.diff_lines || []).slice(0, 400).map(line => String(line)),
}));
data.reviews = (detail.reviews || []).slice(-20).map(review => ({
...pick(review, REVIEW_FIELDS),
user: pick(review?.user, ['login']),
}));
}
const record = {
key,
user_login: login,
saved_at: now().toISOString(),
data,
};
const records = readDetails().filter(candidate =>
!(candidate?.key === key && candidate?.user_login === login)
);
records.push(record);
try { storage.setItem(DETAILS_KEY, JSON.stringify(records.slice(-Math.max(1, maxDetails)))); }
catch (_) { return false; }
return true;
}
function loadDetail(login, item) {
const key = detailKey(item);
login = String(login || '').trim();
if (!login || !key) return null;
const records = readDetails();
const currentTime = now().getTime();
const valid = records.filter(record => {
const savedAt = Date.parse(record?.saved_at || '');
return record?.key && record?.user_login && record?.data && Number.isFinite(savedAt) &&
currentTime - savedAt <= maxAgeMs;
});
if (valid.length !== records.length) {
try { storage.setItem(DETAILS_KEY, JSON.stringify(valid)); } catch (_) { /* Best effort purge. */ }
}
const record = valid.find(candidate => candidate.key === key && candidate.user_login === login);
return record ? { ...record.data, saved_at: record.saved_at } : null;
}
function setEnabled(value) {
try {
storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
@@ -81,7 +175,7 @@
return { ...record.data, saved_at: record.saved_at };
}
return { enabled, setEnabled, save, load, clear };
return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear };
}
return createOfflineWorkStore;
+1 -1
View File
@@ -15,7 +15,7 @@ function createOutboxCoordinator({
let sequence = 0;
function validChange(value) {
return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null;
return value && ['issue', 'authored', 'today', 'later'].includes(value.queue) ? value : null;
}
function publish(change) {
+32 -3
View File
@@ -1,7 +1,8 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v37';
const CACHE = 'stackchain-dashboard-shell-v54';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
@@ -19,12 +20,17 @@ const SHELL = [
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/today-work.js',
BASE + 'static/today-sync.js',
BASE + 'static/update-ownership.js',
BASE + 'static/later-work.js',
BASE + 'static/later-sync.js',
BASE + 'static/detail-defer.js',
BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',
BASE + 'static/issue-sheet.js',
@@ -42,6 +48,25 @@ const SHELL = [
BASE + 'static/background-issue-sync.js',
];
async function fetchNavigation(request) {
const controller = new AbortController();
let timeout;
const deadline = new Promise((resolve, reject) => {
timeout = setTimeout(() => {
controller.abort();
reject(new Error('Dashboard navigation timed out.'));
}, NAVIGATION_TIMEOUT_MS);
});
try {
return await Promise.race([
fetch(request, { signal: controller.signal }),
deadline,
]);
} finally {
clearTimeout(timeout);
}
}
async function sessionCsrf(signal) {
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
headers: { Accept: 'application/json' },
@@ -180,7 +205,7 @@ self.addEventListener('fetch', event => {
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).then(async response => {
fetchNavigation(request).then(async response => {
const cache = await caches.open(CACHE);
const responseUrl = new URL(response.url || request.url);
const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE;
@@ -193,7 +218,11 @@ self.addEventListener('fetch', event => {
return response;
}).catch(async () => {
const cache = await caches.open(CACHE);
return cache.match(BASE);
const cached = await cache.match(BASE);
return cached || new Response(
'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.',
{ status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
);
})
);
return;
+235
View File
@@ -0,0 +1,235 @@
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
const prefix = 'stackchain.today-sync.v1.';
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
let flushing = null;
let channel = null;
let channelKey = '';
let retryTimer = null;
let retryAttempt = 0;
const knownOperationKeys = new Set();
function cancelRetry() {
if (retryTimer !== null) clearTimer?.(retryTimer);
retryTimer = null;
}
function scheduleRetry(error, ownerKey) {
if (retryTimer !== null || !pending().length || !ownerKey) return;
const advised = Number(error?.retryAfter);
const delayMs = Number.isFinite(advised) && advised >= 0
? advised * 1000
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
retryAttempt += 1;
onStatus?.('retrying', { delayMs });
retryTimer = setTimer?.(async () => {
retryTimer = null;
if (key() !== ownerKey) return false;
return flush();
}, delayMs);
retryTimer?.unref?.();
}
function key() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function snapshotKey() {
const storageKey = key();
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
}
function savedRevision() {
try {
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
} catch (_error) {
return -1;
}
}
function adopt(plan, broadcast = true) {
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
if (plan.revision < savedRevision()) return false;
try {
storage?.setItem(snapshotKey(), JSON.stringify({ revision: plan.revision, ids: plan.ids }));
} catch (_error) {
// A storage quota failure must not prevent the current tab from using server truth.
}
onRemoteIds?.(plan.ids);
if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids });
return true;
}
function ensureChannel() {
const storageKey = key();
if (!storageKey || channelKey === storageKey) return;
channel?.close?.();
const factory = createChannel || (globalThis.window?.BroadcastChannel
? name => new globalThis.window.BroadcastChannel(name)
: null);
channelKey = storageKey;
channel = factory?.('stackchain-today-' + storageKey.slice(prefix.length)) || null;
channel?.addEventListener?.('message', event => {
if (key() === storageKey) adopt(event.data, false);
});
}
function pending() {
const storageKey = key();
if (!storageKey || !storage) return [];
const recordPrefix = storageKey + '.operation.';
try {
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
if (Array.isArray(legacy)) {
legacy.forEach((operation, index) => {
if (!operation?.operation_id) return;
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
knownOperationKeys.add(recordKey);
});
if (legacy.length) storage.removeItem(storageKey);
}
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
for (let index = 0; index < Number(storage.length || 0); index += 1) {
const candidate = storage.key?.(index);
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
}
return [...keys].map(recordKey => JSON.parse(storage.getItem(recordKey) || 'null'))
.filter(record => record?.operation)
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
left.operation.operation_id.localeCompare(right.operation.operation_id))
.map(record => record.operation)
.filter(operation => operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
} catch (_error) {
return [];
}
}
function removeOperation(operationId) {
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
try {
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
return true;
} catch (_error) {
return false;
}
}
function operationId() {
if (createOperationId) return createOperationId();
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function enqueue(action, itemId, direction = null) {
const operations = pending();
if (action === 'remove' && operations.some(operation =>
operation.action === 'remove' && operation.item_id === itemId
)) {
onStatus?.('pending');
return true;
}
const operation = { operation_id: operationId(), action, item_id: itemId, direction };
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false;
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('today');
} catch (_error) { /* Report the persistence failure below. */ }
onStatus?.(saved ? 'pending' : 'error');
return saved;
}
function migrate(ids) {
const storageKey = key();
if (!storageKey || !storage) return false;
const marker = migrationPrefix + storageKey.slice(prefix.length);
try {
if (storage.getItem(marker)) return false;
for (const id of ids || []) enqueue('add', id);
storage.setItem(marker, '1');
return true;
} catch (_error) {
return false;
}
}
async function run() {
const ownerKey = key();
if (!ownerKey) return false;
ensureChannel();
try {
let operations = pending();
let plan;
let hadConflict = false;
if (!operations.length) plan = await fetchJson('api/v1/today');
while (operations.length) {
if (key() !== ownerKey) return false;
const batch = operations.slice(0, 50);
plan = await fetchJson('api/v1/today', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations: batch }),
});
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
const received = hasReceipts ? [
...(plan.accepted_operation_ids || []),
...(plan.duplicate_operation_ids || []),
...(plan.rejected_operations || []).map(item => item.operation_id),
] : batch.map(item => item.operation_id);
hadConflict = hadConflict || Boolean(plan.rejected_operations?.length);
for (const operationId of received) {
if (pending().some(candidate => candidate.operation_id === operationId) &&
!removeOperation(operationId)) {
throw new Error('Could not persist Today delivery receipt');
}
}
operations = pending();
}
adopt(plan);
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' : 'saved');
retryAttempt = 0;
cancelRetry();
return !hadConflict;
} catch (error) {
if (pending().length) scheduleRetry(error, ownerKey);
else onStatus?.('error');
return false;
}
}
function flush() {
if (!flushing) {
const delivery = coordinator ? coordinator.runExclusive('today', run) : run();
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
}
return flushing;
}
function startLifecycle({ window: windowObject, document: documentObject }) {
windowObject?.addEventListener?.('online', flush);
documentObject?.addEventListener?.('visibilitychange', () =>
documentObject.hidden ? false : flush()
);
}
coordinator?.subscribe(change => {
if (change.queue === 'today' && pending().length) flush();
});
return { enqueue, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
+17 -3
View File
@@ -48,6 +48,16 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return write(ids) ? 'added' : 'unavailable';
}
function replace(ids) {
const unique = [];
for (const id of ids || []) {
if (typeof id === 'string' && id && !unique.includes(id) && unique.length < limit) {
unique.push(id);
}
}
return write(unique);
}
function remove(item) {
const id = identity(item);
const ids = read();
@@ -64,11 +74,15 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return write(ids);
}
function reconcile(items, { pruneMissing = false } = {}) {
function reconcile(items, { pruneMissing = false, onPrune } = {}) {
const available = new Map((items || []).map(item => [identity(item), item]));
const ids = read();
const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids;
if (retained.length !== ids.length) write(retained);
const retired = ids.filter(id => !retained.includes(id));
if (retired.length) {
const reported = onPrune?.(retired);
if (reported !== false) write(retained);
}
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
}
@@ -85,7 +99,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
};
}
return { identity, add, remove, move, reconcile, contains, position, limit };
return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
+190
View File
@@ -0,0 +1,190 @@
"""Durable, account-scoped Later deferrals."""
import json
import sqlite3
from datetime import datetime
from pathlib import Path
class LaterStore:
def __init__(self, path: str | Path, *, timeout: float = 1.0):
self.path = Path(path)
self.timeout = timeout
def _connect(self) -> sqlite3.Connection:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=self.timeout)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS later_plans (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
records TEXT NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS later_operations (
login TEXT NOT NULL,
operation_id TEXT NOT NULL,
PRIMARY KEY (login, operation_id)
)
"""
)
return connection
@staticmethod
def _normalize_login(login: str) -> str:
normalized = login.strip().lower()
if not normalized:
raise ValueError("login is required")
return normalized
@staticmethod
def _snapshot(row) -> dict:
if row is None:
return {"revision": 0, "records": {}}
return {"revision": int(row[0]), "records": json.loads(row[1])}
@staticmethod
def _validate_wake_at(wake_at: str | None) -> str:
if not wake_at:
raise ValueError("wake_at is required for defer")
try:
datetime.fromisoformat(wake_at.replace("Z", "+00:00"))
except ValueError as error:
raise ValueError("wake_at must be an ISO timestamp") from error
return wake_at
def get(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = ?",
(self._normalize_login(login),),
).fetchone()
return self._snapshot(row)
def apply(
self,
login: str,
operation_id: str,
action: str,
item_id: str,
*,
wake_at: str | None = None,
) -> dict:
login = self._normalize_login(login)
if not operation_id or not item_id:
raise ValueError("operation_id and item_id are required")
if action not in {"defer", "restore"}:
raise ValueError("unsupported Later action")
if action == "defer":
wake_at = self._validate_wake_at(wake_at)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
duplicate = connection.execute(
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
(login, operation_id),
).fetchone()
if duplicate:
return snapshot
records = dict(snapshot["records"])
before = records.get(item_id)
if action == "defer":
records[item_id] = wake_at
changed = before != wake_at
else:
changed = item_id in records
records.pop(item_id, None)
revision = snapshot["revision"] + (1 if changed else 0)
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
if row is None:
connection.execute(
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
(login, revision, serialized),
)
elif changed:
connection.execute(
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
(revision, serialized, login),
)
connection.execute(
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
(login, operation_id),
)
return {"revision": revision, "records": records}
def apply_batch(self, login: str, operations: list[dict]) -> dict:
"""Apply ordered deferrals with one SQLite write transaction."""
login = self._normalize_login(login)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
records = dict(snapshot["records"])
revision = snapshot["revision"]
accepted: list[str] = []
duplicates: list[str] = []
for operation in operations:
operation_id = operation.get("operation_id", "")
action = operation.get("action", "")
item_id = operation.get("item_id", "")
if not operation_id or not item_id:
raise ValueError("operation_id and item_id are required")
if action not in {"defer", "restore"}:
raise ValueError("unsupported Later action")
wake_at = operation.get("wake_at")
if action == "defer":
wake_at = self._validate_wake_at(wake_at)
duplicate = connection.execute(
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
(login, operation_id),
).fetchone()
if duplicate:
duplicates.append(operation_id)
continue
before = records.get(item_id)
if action == "defer":
records[item_id] = wake_at
changed = before != wake_at
else:
changed = item_id in records
records.pop(item_id, None)
revision += 1 if changed else 0
connection.execute(
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
(login, operation_id),
)
accepted.append(operation_id)
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
if row is None:
connection.execute(
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
(login, revision, serialized),
)
elif accepted:
connection.execute(
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
(revision, serialized, login),
)
return {
"revision": revision,
"records": records,
"accepted_operation_ids": accepted,
"duplicate_operation_ids": duplicates,
"rejected_operations": [],
}
+143 -1
View File
@@ -2,6 +2,7 @@ import asyncio
import hmac
import math
import os
import sqlite3
import time
from collections.abc import Awaitable, Coroutine
from contextlib import asynccontextmanager
@@ -34,6 +35,8 @@ from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, c
from src.models import Issue, Milestone, PullRequest, Repo, User
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.suggestion_engine import compute
from src.later_store import LaterStore
from src.today_store import TodayPlanFull, TodayStore
from src.views import router as frontend_router
@asynccontextmanager
@@ -199,6 +202,36 @@ class NotificationReadBatch(BaseModel):
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
class TodayOperation(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
action: Literal["add", "remove", "move"]
item_id: str = Field(min_length=1, max_length=500)
direction: Literal["up", "down"] | None = None
@model_validator(mode="after")
def require_move_direction(self):
if self.action == "move" and self.direction is None:
raise ValueError("move requires a direction")
if self.action != "move" and self.direction is not None:
raise ValueError("direction is only valid for move")
return self
class LaterOperation(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
action: Literal["defer", "restore"]
item_id: str = Field(min_length=1, max_length=500)
wake_at: str | None = Field(default=None, max_length=100)
class TodayOperationBatch(BaseModel):
operations: list[TodayOperation] = Field(min_length=1, max_length=50)
class LaterOperationBatch(BaseModel):
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
class NotificationReply(BaseModel):
body: str = Field(min_length=1, max_length=10_000)
@@ -616,7 +649,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
path = dashboard_auth.application_path(request)
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route"} or path.startswith("/api/v1/work/") or (
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@@ -838,6 +871,115 @@ async def session_status(request: Request):
}
def _today_store() -> TodayStore:
return TodayStore(
os.getenv("STACKCHAIN_TODAY_DB", str(_state_dir / "today.sqlite3")), limit=5
)
def _later_store() -> LaterStore:
return LaterStore(
os.getenv("STACKCHAIN_LATER_DB", str(_state_dir / "later.sqlite3"))
)
async def _confirmed_login() -> str:
try:
user = await asyncio.wait_for(
current_user(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except Exception as exc:
raise HTTPException(
status_code=503, detail="Operator identity is unavailable"
) from exc
login = user.get("login", "") if isinstance(user, dict) else ""
if not isinstance(login, str) or not login.strip():
raise HTTPException(status_code=503, detail="Operator identity is unavailable")
return login.strip().lower()
@app.get("/api/v1/today")
async def get_today_plan():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_today_store().get, login)
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Today synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.patch("/api/v1/today")
async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
login = await _confirmed_login()
try:
if isinstance(payload, TodayOperationBatch):
return await asyncio.to_thread(
_today_store().apply_batch,
login,
[operation.model_dump() for operation in payload.operations],
)
return await asyncio.to_thread(
_today_store().apply,
login,
payload.operation_id,
payload.action,
payload.item_id,
direction=payload.direction,
)
except TodayPlanFull:
raise HTTPException(status_code=409, detail="Today is limited to 5 items")
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Today synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/later")
async def get_later_plan():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_later_store().get, login)
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Later synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.patch("/api/v1/later")
async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
login = await _confirmed_login()
try:
if isinstance(payload, LaterOperationBatch):
return await asyncio.to_thread(
_later_store().apply_batch,
login,
[operation.model_dump() for operation in payload.operations],
)
return await asyncio.to_thread(
_later_store().apply,
login,
payload.operation_id,
payload.action,
payload.item_id,
wake_at=payload.wake_at,
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Later synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.delete("/api/v1/session")
async def sign_out(request: Request, response: Response):
session = request.state.dashboard_session
+211
View File
@@ -0,0 +1,211 @@
"""Durable, account-scoped ordered Today plans."""
import json
import sqlite3
from pathlib import Path
class TodayPlanFull(ValueError):
"""Raised when an add would exceed the bounded Today plan."""
class TodayStore:
def __init__(self, path: str | Path, *, limit: int = 5, timeout: float = 1.0):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
def _connect(self) -> sqlite3.Connection:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=self.timeout)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_plans (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
ids TEXT NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_operations (
login TEXT NOT NULL,
operation_id TEXT NOT NULL,
PRIMARY KEY (login, operation_id)
)
"""
)
return connection
@staticmethod
def _normalize_login(login: str) -> str:
normalized = login.strip().lower()
if not normalized:
raise ValueError("login is required")
return normalized
@staticmethod
def _snapshot(row) -> dict:
if row is None:
return {"revision": 0, "ids": []}
return {"revision": int(row[0]), "ids": json.loads(row[1])}
def get(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, ids FROM today_plans WHERE login = ?",
(self._normalize_login(login),),
).fetchone()
return self._snapshot(row)
def apply(
self,
login: str,
operation_id: str,
action: str,
item_id: str,
*,
direction: str | None = None,
) -> dict:
login = self._normalize_login(login)
if not operation_id or not item_id:
raise ValueError("operation_id and item_id are required")
if action not in {"add", "remove", "move"}:
raise ValueError("unsupported Today action")
if action == "move" and direction not in {"up", "down"}:
raise ValueError("move direction must be up or down")
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
duplicate = connection.execute(
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
(login, operation_id),
).fetchone()
if duplicate:
return snapshot
ids = list(snapshot["ids"])
changed = False
if action == "add":
if item_id not in ids:
if len(ids) >= self.limit:
raise TodayPlanFull("Today is limited to five items")
ids.append(item_id)
changed = True
elif action == "remove":
if item_id in ids:
ids.remove(item_id)
changed = True
else:
try:
index = ids.index(item_id)
except ValueError:
index = -1
target = index - 1 if direction == "up" else index + 1
if index >= 0 and 0 <= target < len(ids):
ids[index], ids[target] = ids[target], ids[index]
changed = True
revision = snapshot["revision"] + (1 if changed else 0)
if row is None:
connection.execute(
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
(login, revision, json.dumps(ids, separators=(",", ":"))),
)
elif changed:
connection.execute(
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
(revision, json.dumps(ids, separators=(",", ":")), login),
)
connection.execute(
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
(login, operation_id),
)
return {"revision": revision, "ids": ids}
def apply_batch(self, login: str, operations: list[dict]) -> dict:
"""Apply an ordered batch with one lock and receipt per operation."""
login = self._normalize_login(login)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
ids = list(snapshot["ids"])
revision = snapshot["revision"]
accepted: list[str] = []
duplicates: list[str] = []
rejected: list[dict[str, str]] = []
for operation in operations:
operation_id = operation.get("operation_id", "")
action = operation.get("action", "")
item_id = operation.get("item_id", "")
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"}:
raise ValueError("unsupported Today action")
if action == "move" and direction not in {"up", "down"}:
raise ValueError("move direction must be up or down")
duplicate = connection.execute(
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
(login, operation_id),
).fetchone()
if duplicate:
duplicates.append(operation_id)
continue
changed = False
if action == "add":
if item_id not in ids:
if len(ids) >= self.limit:
rejected.append({"operation_id": operation_id, "reason": "today_full"})
continue
ids.append(item_id)
changed = True
elif action == "remove":
if item_id in ids:
ids.remove(item_id)
changed = True
else:
try:
index = ids.index(item_id)
except ValueError:
index = -1
target = index - 1 if direction == "up" else index + 1
if index >= 0 and 0 <= target < len(ids):
ids[index], ids[target] = ids[target], ids[index]
changed = True
revision += 1 if changed else 0
connection.execute(
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
(login, operation_id),
)
accepted.append(operation_id)
serialized = json.dumps(ids, separators=(",", ":"))
if row is None:
connection.execute(
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
(login, revision, serialized),
)
elif accepted:
connection.execute(
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
(revision, serialized, login),
)
return {
"revision": revision,
"ids": ids,
"accepted_operation_ids": accepted,
"duplicate_operation_ids": duplicates,
"rejected_operations": rejected,
}
+134
View File
@@ -0,0 +1,134 @@
import json
import subprocess
from pathlib import Path
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.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_exact_local_time_is_validated_and_converted_to_an_instant():
script = f"""
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
const picker = createLaterPicker({{
now: () => new Date(2026, 7, 9, 14, 0, 0),
}});
const valid = picker.parse('2026-08-09T16:30');
const past = picker.parse('2026-08-09T13:59');
const invalid = picker.parse('2026-02-30T12:00');
process.stdout.write(JSON.stringify({{
valid: {{ok:valid.ok, local:[valid.value.getFullYear(), valid.value.getMonth() + 1, valid.value.getDate(), valid.value.getHours(), valid.value.getMinutes()]}},
past, invalid,
}}));
"""
output = run_node(script)
assert output["valid"] == {"ok": True, "local": [2026, 8, 9, 16, 30]}
assert output["past"] == {
"ok": False,
"message": "Choose a future date and time.",
}
assert output["invalid"] == {
"ok": False,
"message": "Choose a valid local date and time.",
}
def test_picker_uses_history_for_cancel_and_confirms_only_valid_future_time():
script = f"""
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
const listeners = {{}};
const states = [];
const confirmed = [];
let focused = 0;
const history = {{
state: {{workRoute:'issue'}},
pushState(state) {{ this.state = state; states.push(['push', state.laterPicker]); }},
back() {{ states.push(['back']); const previous = {{workRoute:'issue'}}; this.state = previous; listeners.popstate({{state:previous}}); }},
}};
const picker = createLaterPicker({{
now: () => new Date(2026, 7, 9, 14, 0, 0), history,
eventTarget: {{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
onState: state => states.push([state.open, state.message || '']),
onConfirm: (item, value, context) => {{ confirmed.push([item.number, value.toISOString(), context]); return true; }},
}});
picker.start();
picker.open({{number:17}}, {{focus:() => {{ focused += 1; }}}}, 'detail');
const invalid = picker.submit('2026-08-09T13:00');
const valid = picker.submit('2026-08-09T16:30');
picker.submit('2026-08-09T17:30');
process.stdout.write(JSON.stringify({{invalid, valid, confirmed, focused, current:picker.current(), states}}));
"""
output = run_node(script)
assert output["invalid"] is False
assert output["valid"] is True
assert output["confirmed"] == [[17, "2026-08-09T16:30:00.000Z", "detail"]]
assert output["focused"] == 1
assert output["current"] is False
assert [False, "Choose a future date and time."] not in output["states"]
assert [True, "Choose a future date and time."] in output["states"]
assert ["back"] in output["states"]
def test_picker_runs_detail_cleanup_only_after_its_history_entry_closes():
script = f"""
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
const listeners = {{}};
const order = [];
const history = {{
state: {{workRoute:'issue'}},
pushState(state) {{ this.state = state; }},
back() {{
order.push('picker-back');
this.state = {{workRoute:'issue'}};
listeners.popstate({{state:this.state}});
}},
}};
const picker = createLaterPicker({{
now:() => new Date(2026, 7, 9, 14, 0), history,
eventTarget:{{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
onConfirm:() => () => order.push('detail-close'),
}});
picker.start();
picker.open({{number:17}}, null, 'detail');
const saved = picker.submit('2026-08-09T16:30');
process.stdout.write(JSON.stringify({{saved, order}}));
"""
output = run_node(script)
assert output == {"saved": True, "order": ["picker-back", "detail-close"]}
def test_picker_ignores_a_second_submit_while_browser_back_is_pending():
script = f"""
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
const listeners = {{}};
let popstate;
let confirmations = 0;
const history = {{
state:null,
pushState(state) {{ this.state = state; }},
back() {{ popstate = () => {{ this.state = {{}}; listeners.popstate({{state:this.state}}); }}; }},
}};
const picker = createLaterPicker({{
now:() => new Date(2026, 7, 9, 14, 0), history,
eventTarget:{{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
onConfirm:() => {{ confirmations += 1; return true; }},
}});
picker.start();
picker.open({{number:17}});
const first = picker.submit('2026-08-09T16:30');
const second = picker.submit('2026-08-09T16:30');
popstate();
process.stdout.write(JSON.stringify({{first, second, confirmations}}));
"""
output = run_node(script)
assert output == {"first": True, "second": False, "confirmations": 1}
+150
View File
@@ -0,0 +1,150 @@
import sqlite3
import httpx
import pytest
from src import main
from src.later_store import LaterStore
@pytest.mark.anyio
async def test_transient_later_store_failure_tells_clients_when_to_retry(monkeypatch):
async def user():
return {"login": "timmy"}
class BusyStore:
def apply(self, *_args, **_kwargs):
raise sqlite3.OperationalError("database is busy")
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_later_store", lambda: BusyStore())
payload = main.LaterOperation(
operation_id="retry-me",
action="defer",
item_id="issue:r:1:",
wake_at="2026-08-10T09:00:00.000Z",
)
with pytest.raises(main.HTTPException) as raised:
await main.update_later_plan(payload)
assert raised.value.status_code == 503
assert raised.value.headers == {"Retry-After": "1"}
def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path):
path = tmp_path / "later.sqlite3"
store = LaterStore(path)
deferred = store.apply(
"Timmy",
"op-1",
"defer",
"issue:stackchain/dashboard:363:",
wake_at="2026-08-10T09:00:00.000Z",
)
duplicate = store.apply(
"timmy",
"op-1",
"defer",
"issue:stackchain/dashboard:363:",
wake_at="2026-08-11T09:00:00.000Z",
)
assert duplicate == deferred == {
"revision": 1,
"records": {
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
},
}
assert LaterStore(path).get("timmy") == deferred
assert store.get("alexander") == {"revision": 0, "records": {}}
assert store.apply(
"timmy", "op-2", "restore", "issue:stackchain/dashboard:363:"
) == {"revision": 2, "records": {}}
def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
store = LaterStore(tmp_path / "later.sqlite3")
operations = [
{"operation_id": "first", "action": "defer", "item_id": "issue:r:1:", "wake_at": "2026-08-10T09:00:00.000Z"},
{"operation_id": "second", "action": "restore", "item_id": "issue:r:1:"},
]
result = store.apply_batch("timmy", operations)
assert result == {
"revision": 2,
"records": {},
"accepted_operation_ids": ["first", "second"],
"duplicate_operation_ids": [],
"rejected_operations": [],
}
replay = store.apply_batch("timmy", operations)
assert replay["revision"] == 2
assert replay["accepted_operation_ids"] == []
assert replay["duplicate_operation_ids"] == ["first", "second"]
@pytest.mark.anyio
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
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.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LATER_DB", str(tmp_path / "later.sqlite3"))
async def user():
return {"id": 1, "login": "Timmy"}
monkeypatch.setattr(main, "current_user", user)
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"}
)
forbidden = await client.patch(
"/api/v1/later",
json={
"operation_id": "mobile-1",
"action": "defer",
"item_id": "issue:stackchain/dashboard:363:",
"wake_at": "2026-08-10T09:00:00.000Z",
},
)
changed = await client.patch(
"/api/v1/later",
json={
"operations": [
{"operation_id": "mobile-1", "action": "defer", "item_id": "issue:stackchain/dashboard:363:", "wake_at": "2026-08-10T09:00:00.000Z"},
{"operation_id": "mobile-2", "action": "defer", "item_id": "issue:stackchain/dashboard:365:", "wake_at": "2026-08-11T09:00:00.000Z"},
],
},
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
fetched = await client.get("/api/v1/later")
assert forbidden.status_code == 403
assert changed.status_code == 200
assert changed.json() == {
"revision": 2,
"records": {
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
},
"accepted_operation_ids": ["mobile-1", "mobile-2"],
"duplicate_operation_ids": [],
"rejected_operations": [],
}
assert fetched.json() == {"revision": 2, "records": {
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
}}
assert fetched.headers["cache-control"] == "no-store"
+292
View File
@@ -0,0 +1,292 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js"
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js"
def run_node(script):
return json.loads(
subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
)
def test_two_tabs_cannot_erase_each_others_later_edits_or_double_drain():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const createCoordinator=require({json.dumps(str(COORDINATOR))});
const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const channels=[];const channelFactory=()=>{{const channel={{onmessage:null,postMessage:data=>channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data}})),close(){{}}}};channels.push(channel);return channel}};
const held=new Set();const locks={{request:async(name,_options,work)=>{{if(held.has(name))return work(null);held.add(name);try{{return await work({{name}})}}finally{{held.delete(name)}}}}}};
let sequence=0,releaseFirst;const delivered=[];
const make=tab=>createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=>tab+'-'+(++sequence),coordinator:createCoordinator({{storage,locks,channelFactory,tabId:tab}}),fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};const operation=JSON.parse(options.body).operations[0];delivered.push(operation);if(delivered.length===1)await new Promise(resolve=>releaseFirst=resolve);return {{revision:delivered.length,records:{{[operation.item_id]:operation.wake_at}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
const first=make('first'),second=make('second');first.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
(async()=>{{const draining=first.flush();while(!releaseFirst)await Promise.resolve();second.enqueue('defer','issue:r:2:','2026-08-11T09:00:00.000Z');const competing=second.flush();releaseFirst();await Promise.all([draining,competing]);await first.flush();process.stdout.write(JSON.stringify({{delivered,pending:first.pending(),keys:[...values.keys()]}}));}})();
"""
result = run_node(script)
assert sorted(operation["operation_id"] for operation in result["delivered"]) == ["first-1", "second-2"]
assert len(result["delivered"]) == 2
assert result["pending"] == []
assert not any(".operation." in key for key in result["keys"])
def test_concurrent_later_records_for_one_item_deliver_only_the_newest_intent():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const prefix='stackchain.later-sync.v1.timmy.operation.';
values.set(prefix+'old',JSON.stringify({{queued_at:100,operation:{{operation_id:'old',action:'defer',item_id:'issue:r:2:',wake_at:'2026-08-10T09:00:00.000Z'}}}}));
values.set(prefix+'new',JSON.stringify({{queued_at:101,operation:{{operation_id:'new',action:'restore',item_id:'issue:r:2:',wake_at:null}}}}));
const delivered=[];const sync=createLaterSync({{storage,getLogin:()=> 'timmy',fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};delivered.push(...JSON.parse(options.body).operations);return {{revision:1,records:{{}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,pending:sync.pending(),keys:[...values.keys()]}}));}})();
"""
result = run_node(script)
assert [operation["operation_id"] for operation in result["delivered"]] == ["new"]
assert result["pending"] == []
assert not any(".operation." in key for key in result["keys"])
def test_offline_deferral_replays_once_and_adopts_server_records():
script = f"""
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
const values = new Map();
const requests = [];
let remote = {{revision:1,records:{{'issue:r:9:':'2026-08-11T09:00:00.000Z'}}}};
const sync = createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy', createOperationId:()=> 'offline-op',
fetchJson:async (url,options={{}})=>{{
requests.push({{url,body:options.body&&JSON.parse(options.body)}});
if (!options.method) return remote;
const operation=JSON.parse(options.body).operations[0];
remote={{revision:2,records:{{...remote.records,[operation.item_id]:operation.wake_at}}}};
return remote;
}},
onRemoteRecords:records=>{{globalThis.records=records}},
onStatus:status=>{{globalThis.status=status}},
}});
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
(async()=>{{await sync.flush();await sync.flush();process.stdout.write(JSON.stringify({{
requests,records:globalThis.records,status:globalThis.status,pending:sync.pending()
}}));}})();
"""
result = run_node(script)
assert [request["url"] for request in result["requests"]] == [
"api/v1/later",
"api/v1/later",
]
assert result["requests"][0]["body"] == {"operations": [{
"operation_id": "offline-op",
"action": "defer",
"item_id": "issue:r:2:",
"wake_at": "2026-08-10T09:00:00.000Z",
}]}
assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z"
assert result["status"] == "saved"
assert result["pending"] == []
def test_pending_later_edits_are_sent_as_one_batch_without_a_preflight_get():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map();const requests=[];let sequence=0;
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
fetchJson:async(_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:2,records:{{}},accepted_operation_ids:body.operations.map(x=>x.operation_id),duplicate_operation_ids:[],rejected_operations:[]}}}},
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
}});
sync.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
sync.enqueue('restore','issue:r:2:');
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,pending:sync.pending()}}));}})();
"""
result=run_node(script)
assert len(result["requests"]) == 1
assert [item["operation_id"] for item in result["requests"][0]["operations"]] == ["op-1", "op-2"]
assert result["pending"] == []
def test_latest_offline_intent_wins_and_failed_delivery_stays_pending():
script = f"""
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
const values=new Map(); let sequence=0;
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
fetchJson:async()=>{{throw new Error('offline')}},onRemoteRecords:()=>{{}},onStatus:s=>{{globalThis.status=s}},
}});
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
sync.enqueue('restore','issue:r:2:');
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}}));}})();
"""
assert run_node(script) == {
"pending": [
{
"operation_id": "op-2",
"action": "restore",
"item_id": "issue:r:2:",
"wake_at": None,
}
],
"status": "retrying",
}
def test_retry_is_single_flight_and_does_not_cross_account_boundary():
script = f"""
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
const values=new Map(); const timers=[]; const requests=[]; let login='timmy';
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=>login,createOperationId:()=> 'stable-later-op',
setTimer:(callback,delay)=>{{timers.push({{callback,delay}});return timers.length}},clearTimer:()=>{{}},
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');throw new Error('offline')}},
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
}});
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
(async()=>{{
const first=sync.flush(); const same=sync.flush(); await Promise.all([first,same]);
const scheduledBeforeSwitch=timers.length;
login='alexander'; await timers[0].callback();
process.stdout.write(JSON.stringify({{scheduledBeforeSwitch,timers:timers.length,requests,pendingForAlexander:sync.pending()}}));
}})();
"""
assert run_node(script) == {
"scheduledBeforeSwitch": 1,
"timers": 1,
"requests": ["PATCH"],
"pendingForAlexander": [],
}
def test_change_queued_during_delivery_is_drained_before_flush_settles():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map(); const actions=[]; let sequence=0; let releaseFirst;
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
fetchJson:async (_url,options={{}})=>{{
if (!options.method) return {{revision:0,records:{{}}}};
const operation=JSON.parse(options.body).operations[0]; actions.push(operation.action);
if (operation.action==='defer') await new Promise(resolve=>releaseFirst=resolve);
return {{revision:actions.length,records:operation.action==='defer'?{{[operation.item_id]:operation.wake_at}}:{{}}}};
}},onRemoteRecords:r=>{{globalThis.records=r}},onStatus:()=>{{}},
}});
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
(async()=>{{const flushing=sync.flush();while(!releaseFirst) await Promise.resolve();
sync.enqueue('restore','issue:r:2:');releaseFirst();await flushing;
process.stdout.write(JSON.stringify({{actions,pending:sync.pending(),records:globalThis.records}}));}})();
"""
assert run_node(script) == {
"actions": ["defer", "restore"],
"pending": [],
"records": {},
}
def test_tabs_reject_an_older_snapshot_after_a_newer_revision():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const listeners=[];
const createChannel=()=>({{addEventListener:(_n,h)=>listeners.push(h),postMessage:data=>listeners.forEach(h=>h({{data}}))}});
let resolveOld,resolveNew; const oldHistory=[],newHistory=[];
const oldTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveOld=r),onRemoteRecords:r=>oldHistory.push(r),onStatus:()=>{{}}}});
const newTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveNew=r),onRemoteRecords:r=>newHistory.push(r),onStatus:()=>{{}}}});
(async()=>{{const oldFlush=oldTab.flush();const newFlush=newTab.flush();await Promise.resolve();
resolveNew({{revision:2,records:{{new:'2026-08-11T09:00:00.000Z'}}}});await newFlush;
resolveOld({{revision:1,records:{{old:'2026-08-10T09:00:00.000Z'}}}});await oldFlush;
process.stdout.write(JSON.stringify({{oldHistory,newHistory}}));}})();
"""
result = run_node(script)
assert result["oldHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
assert result["newHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
def test_existing_browser_records_migrate_once_and_lifecycle_replays():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map(); const handlers={{}}; let sequence=0; const requests=[];
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'migration-'+(++sequence),
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:options.method?1:0,records:{{}}}}}},
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
}});
const records={{'issue:r:1:':'2026-08-10T09:00:00.000Z'}};
const first=sync.migrate(records),second=sync.migrate(records);
sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:{{hidden:false,addEventListener:()=>{{}}}}}});
(async()=>{{await handlers.online();process.stdout.write(JSON.stringify({{first,second,requests,pending:sync.pending()}}));}})();
"""
assert run_node(script) == {
"first": True,
"second": False,
"requests": ["PATCH"],
"pending": [],
}
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
script = f"""
const createLaterWork=require({json.dumps(str(LATER_WORK))});
const values=new Map(); const changes=[]; const expired=[];
let clock=new Date('2026-08-08T12:00:00Z');
const item={{kind:'issue',repository:'stackchain/api',number:17}};
const work=createLaterWork({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
onChange:(action,id,wakeAt)=>changes.push([action,id,wakeAt]),
onExpire:ids=>expired.push(ids),
}});
work.defer(item,new Date('2026-08-08T16:00:00Z'));
work.restore(item);
work.adopt({{'issue:stackchain/api:17:':'2026-08-09T09:00:00.000Z'}});
const remote=work.partition([item]);
clock=new Date('2026-08-09T09:00:01Z');
const awake=work.partition([item]);
process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:awake.active}}));
"""
result = run_node(script)
assert result["changes"] == [
["defer", "issue:stackchain/api:17:", "2026-08-08T16:00:00.000Z"],
["restore", "issue:stackchain/api:17:", None],
]
assert result["remote"][0]["deferred_until"] == "2026-08-09T09:00:00.000Z"
assert result["awake"] == [{"kind": "issue", "repository": "stackchain/api", "number": 17}]
assert result["expired"] == [["issue:stackchain/api:17:"]]
@pytest.mark.anyio
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
html = await dashboard()
assert '<script src="static/later-sync.js"></script>' in html
assert 'id="later-sync-status"' in html
assert "const laterSync = createLaterSync({" in html
assert "onRemoteRecords: records =>" in html
assert "onChange: (action, itemId, wakeAt) =>" in html
assert "onExpire: ids =>" in html
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
assert "laterSync.migrate(laterWork.read());" in html
assert "laterSync.flush();" in html
assert "Later saved to account." in html
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-v54" in source
assert "BASE + 'static/later-sync.js'" in source
+88
View File
@@ -1,4 +1,5 @@
import json
import re
import subprocess
from html.parser import HTMLParser
from pathlib import Path
@@ -9,6 +10,19 @@ FRONTEND = Path(__file__).parent.parent / "frontend"
RENDERER = FRONTEND / "markdown.js"
def render_markdown(payload):
script = (
f"const render = require({json.dumps(str(RENDERER))});"
f"process.stdout.write(render({json.dumps(payload)}));"
)
return subprocess.run(
["node", "-e", script],
check=True,
capture_output=True,
text=True,
).stdout
class ScriptSourceParser(HTMLParser):
def __init__(self):
super().__init__()
@@ -50,3 +64,77 @@ def test_markdown_script_resolves_inside_dashboard_subpath():
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/", markdown_source
) == "https://forge.alexanderwhitestone.com/dashboard/static/markdown.js"
def test_markdown_renderer_preserves_mobile_reading_structure():
rendered = render_markdown(
"## Plan\n\n> Ship this\n\n- [x] Tested\n- [ ] Released\n\n"
"```python\nprint('ready')\n```"
)
assert rendered == (
"<h2>Plan</h2><blockquote><p>Ship this</p></blockquote>"
'<ul class="task-list"><li class="task-list-item">'
'<input type="checkbox" disabled checked> Tested</li>'
'<li class="task-list-item"><input type="checkbox" disabled> Released</li></ul>'
'<pre><code class="language-python">print(&#39;ready&#39;)</code></pre>'
)
def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
rendered = render_markdown(
"[Forge](https://forge.example/work?q=1&safe=yes) "
"[relative](/git/org/repo/issues/1) "
"[attack](javascript:alert(1)) <img src=x onerror=alert(2)>"
)
assert (
'<a href="https://forge.example/work?q=1&amp;safe=yes" target="_blank" '
'rel="noopener noreferrer">Forge</a>' in rendered
)
assert (
'<a href="/git/org/repo/issues/1" target="_blank" '
'rel="noopener noreferrer">relative</a>' in rendered
)
assert "javascript:" not in rendered
assert "<img" not in rendered
assert "&lt;img src=x onerror=alert(2)&gt;" in rendered
def test_all_read_only_work_bodies_use_the_shared_markdown_renderer():
dashboard = (FRONTEND / "dashboard.js").read_text()
expected_paths = (
"renderMarkdown(comment.body || 'No comment body provided.')",
"renderMarkdown(detail.subject_body || 'No subject context was provided.')",
"renderMarkdown(detail.body || 'No description provided.')",
"renderMarkdown(item.body || 'No description provided.')",
"renderMarkdown(review.body)",
"renderMarkdown(confirmed.body || 'No description provided.')",
)
for path in expected_paths:
assert path in dashboard
assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 4
def test_markdown_work_bodies_are_mobile_safe_block_containers():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
worker = (FRONTEND / "service-worker.js").read_text()
for body_id in (
"issue-sheet-body",
"pull-sheet-body",
"review-sheet-body",
"update-subject-body",
"search-preview-body",
):
body_tag = re.search(rf'<div\b[^>]*\bid="{body_id}"[^>]*>', html)
assert body_tag
assert "markdown-content" in body_tag.group(0)
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-v54" in worker
+1 -1
View File
@@ -35,4 +35,4 @@ 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-v37" in worker
assert "stackchain-dashboard-shell-v54" in worker
+244 -2
View File
@@ -11,6 +11,7 @@ from tests.dashboard_bundle import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
@@ -829,7 +830,7 @@ async def test_opening_issue_defers_planning_requests_until_disclosure_expands()
assert 'loadIssueLabelEditor(' not in open_handler
assert 'loadIssueMilestoneEditor(' not in open_handler
assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler
assert "if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();" in open_handler
assert "qs('#issue-planning').addEventListener('toggle'" in html
assert 'planningLoader.open(selectedIssue)' in html
assert 'id="retry-issue-planning"' in html
@@ -1176,6 +1177,29 @@ async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
assert "'Deferred until ' + fmt(until)" in html
@pytest.mark.anyio
async def test_mobile_later_actions_open_one_keyboard_safe_exact_time_dialog():
html = await dashboard()
assert '<script src="static/later-picker.js"></script>' in html
assert html.count('<button type="button" data-detail-defer-custom') == 4
assert 'data-later-custom' in html
assert 'id="later-picker"' in html
assert 'role="dialog"' in html
assert 'type="datetime-local"' in html
assert 'id="later-picker-timezone"' in html
assert 'id="later-picker-error"' in html
assert 'createLaterPicker({' in html
assert "laterPicker.open(item, button, 'detail')" in html
assert "laterPicker.open(item, button, 'card')" in html
assert "detailDefer.deferUntil(item, until, {" in html
assert "laterWork.defer(item, until)" in html
assert "fetch(" not in LATER_PICKER.read_text()
assert '.later-picker-panel' in html
assert '.later-picker { box-sizing:border-box; width:100%; height:100%;' in html
assert 'env(safe-area-inset-bottom)' in html
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
payload = {
"issues": [
@@ -1667,7 +1691,7 @@ async def test_mobile_find_work_cards_preview_escaped_context_without_extra_requ
assert 'aria-controls="' in html
assert "const detailId = 'find-work-detail-' + index" in html
assert 'class="find-work-detail"' in html
assert "escapeHtml(item.body || 'No description provided.')" in html
assert "renderMarkdown(item.body || 'No description provided.')" in html
assert '>Open in Gitea</a>' in html
assert '.find-work-card a, .find-work-more { min-height:44px;' in html
assert '.find-work-detail { min-width:0;' in html
@@ -1942,6 +1966,113 @@ Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.lo
assert output["stored"]["number"] == 221
def test_issue_capture_finds_only_open_issue_duplicates_in_the_selected_repository():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const calls = [];
const capture = createIssueCapture({{
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
fetchJson: url => {{
calls.push(url);
return Promise.resolve({{items:[
{{kind:'issue', state:'open', repository:'stackchain/api', number:7, title:'Investigate checkout latency'}},
{{kind:'pull', state:'open', repository:'stackchain/api', number:8, title:'Investigate checkout latency'}},
{{kind:'issue', state:'closed', repository:'stackchain/api', number:9, title:'Investigate checkout latency'}},
{{kind:'issue', state:'open', repository:'stackchain/web', number:10, title:'Investigate checkout latency'}},
{{kind:'issue', state:'open', repository:'stackchain/api', number:11, title:'Checkout latency alert'}},
{{kind:'issue', state:'open', repository:'stackchain/api', number:12, title:'Checkout latency follow-up'}},
{{kind:'issue', state:'open', repository:'stackchain/api', number:13, title:'Fourth match is bounded'}},
]}});
}},
}});
capture.findDuplicates({{repository:'stackchain/api', title:' Investigate checkout latency '}}).then(state =>
process.stdout.write(JSON.stringify({{calls, state}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == [
"api/v1/search?q=Investigate%20checkout%20latency&limit=10"
]
assert output["state"]["status"] == "ready"
assert [item["number"] for item in output["state"]["candidates"]] == [7, 11, 12]
def test_issue_capture_ignores_stale_duplicate_results_and_resets_create_anyway_acknowledgement():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const pending = [];
const capture = createIssueCapture({{
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
fetchJson: url => new Promise((resolve, reject) => pending.push({{url, resolve, reject}})),
}});
const firstDraft = {{repository:'stackchain/api', title:'Checkout latency'}};
const latestDraft = {{repository:'stackchain/api', title:'Checkout latency alert'}};
const first = capture.findDuplicates(firstDraft);
const latest = capture.findDuplicates(latestDraft);
pending[1].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:22,title:'Checkout latency alert'}}]}});
latest.then(latestState => {{
const blocked = capture.needsDuplicateAcknowledgement(latestDraft);
capture.acknowledgeDuplicates(latestDraft);
const allowed = capture.needsDuplicateAcknowledgement(latestDraft);
const changedDraft = {{...latestDraft,title:'Checkout latency alert today'}};
const changedSearch = capture.findDuplicates(changedDraft);
pending[2].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:23,title:'Checkout latency alert today'}}]}});
changedSearch.then(() => {{
const changed = capture.needsDuplicateAcknowledgement(changedDraft);
pending[0].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:21,title:'Old result'}}]}});
first.then(staleState => {{
const failure = capture.findDuplicates({{repository:'stackchain/api',title:'Network failure'}});
pending[3].reject(new Error('offline'));
failure.then(failedState => process.stdout.write(JSON.stringify({{
latestState, staleState, blocked, allowed, changed, failedState,
failureBlocks:capture.needsDuplicateAcknowledgement({{repository:'stackchain/api',title:'Network failure'}}),
}})));
}});
}});
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["latestState"]["status"] == "ready"
assert output["staleState"]["status"] == "stale"
assert output["blocked"] is True
assert output["allowed"] is False
assert output["changed"] is True
assert output["failedState"]["status"] == "failed"
assert output["failureBlocks"] is False
def test_issue_capture_keeps_partial_duplicate_search_advisory():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const draft = {{repository:'stackchain/api', title:'Checkout latency'}};
const capture = createIssueCapture({{
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
fetchJson: () => Promise.resolve({{partial:true, items:[
{{kind:'issue',state:'open',repository:'stackchain/api',number:24,title:'Checkout latency'}},
]}}),
}});
capture.findDuplicates(draft).then(state => process.stdout.write(JSON.stringify({{
state, blocked:capture.needsDuplicateAcknowledgement(draft),
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["state"]["partial"] is True
assert [item["number"] for item in output["state"]["candidates"]] == [24]
assert output["blocked"] is False
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
payload = {
"user": {"login": "timmy"},
@@ -2359,6 +2490,98 @@ reader.open(firstItem).then(async () => {{
assert output["states"] == [[41], [90]]
def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const item = {{kind:'update', notification_id:42, has_update:true}};
const saved = {{
id:42, title:'Saved update', saved_at:'2026-08-07T12:00:00Z',
conversation:{{comments:[{{id:41, body:'Cached'}}], page:2, older_page:1, total:21}},
}};
let detailLoads = 0;
let conversationLoads = 0;
let markReads = 0;
const details = [];
const conversations = [];
const reader = build.createNotificationReader({{
load: async () => {{ detailLoads += 1; throw new Error('network must not run'); }},
loadConversation: async () => {{ conversationLoads += 1; return {{}}; }},
markRead: async () => {{ markReads += 1; }}, createPager,
onOpen: () => {{}}, onDetail: detail => details.push(detail.title),
onConversation: state => conversations.push(state.comments.map(comment => comment.id)),
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
(async () => {{
const opened = await reader.open(item, saved);
const older = await reader.loadOlder();
const marked = await reader.markReadAndNext([item]);
process.stdout.write(JSON.stringify({{
opened, older, marked, detailLoads, conversationLoads, markReads, details, conversations,
}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"opened": True,
"older": False,
"marked": False,
"detailLoads": 0,
"conversationLoads": 0,
"markReads": 0,
"details": ["Saved update"],
"conversations": [[41]],
}
def test_notification_reader_queues_saved_update_read_offline_and_opens_next_saved_update():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const items = [
{{kind:'update', notification_id:42, has_update:true, title:'First'}},
{{kind:'update', notification_id:43, has_update:true, title:'Second'}},
];
const saved = new Map([
[42, {{id:42, title:'Saved first', conversation:{{comments:[],page:1,total:0}}}}],
[43, {{id:43, title:'Saved second', conversation:{{comments:[],page:1,total:0}}}}],
]);
const events = [];
const reader = build.createNotificationReader({{
load: async () => {{ throw new Error('network must not run'); }},
markRead: async () => {{ throw new Error('network must not run'); }},
queueRead: async id => events.push(['queued', id]),
loadSaved: item => saved.get(item.notification_id),
onOpen: item => events.push(['open', item.notification_id]),
onDetail: detail => events.push(['detail', detail.id]),
onItems: next => events.push(['items', next.map(item => item.notification_id)]),
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
}});
(async () => {{
await reader.open(items[0], saved.get(42));
events.length = 0;
const result = await reader.markReadAndNext(items);
process.stdout.write(JSON.stringify({{result, events}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["result"]["next"]["notification_id"] == 43
assert output["events"] == [
["status", "Queueing update read…"],
["queued", 42],
["items", [43]],
["open", 43],
["status", "Loading update…"],
["detail", 43],
["status", "Update ready."],
]
def test_notification_reader_appends_a_confirmed_reply_exactly_once():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
@@ -3032,6 +3255,25 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
assert '.create-issue-label-option' in html and 'min-height:44px' in html
@pytest.mark.anyio
async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
html = await dashboard()
assert 'id="create-issue-duplicates"' in html
assert 'id="create-issue-duplicate-list"' in html
assert 'aria-live="polite"' in html
assert 'id="create-issue-anyway"' in html and 'Create anyway' in html
assert "issueCapture.findDuplicates(draft)" in html
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index(
"issueOutbox.enqueueDurably(captureDraft)"
)
assert ".create-issue-duplicate-card" in html
assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
assert "overflow-wrap:anywhere" in html
@pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard()
+135
View File
@@ -0,0 +1,135 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).resolve().parents[1]
OUTBOX = ROOT / "frontend" / "notification-read-outbox.js"
BACKGROUND_SYNC = ROOT / "frontend" / "background-issue-sync.js"
WORKER = ROOT / "frontend" / "service-worker.js"
def run_node(script):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_offline_read_admission_is_account_bound_deduplicated_and_suppresses_saved_updates():
script = f"""
const createOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
}};
const reconciled = [];
const backgroundSync = {{
reconcile: async (items, lane) => reconciled.push([lane, items]),
requestSync: async () => true,
}};
const outbox = createOutbox({{
storage, backgroundSync, getOwnerLogin:() => 'timmy', now:() => 1234,
}});
(async () => {{
const first = await outbox.enqueueDurably(42);
const duplicate = await outbox.enqueueDurably(42);
const visible = outbox.suppress([
{{notification_id:42, title:'Queued'}}, {{notification_id:43, title:'Visible'}},
]);
process.stdout.write(JSON.stringify({{
first, duplicate, items:outbox.list(), visible, reconciled,
}}));
}})();
"""
output = run_node(script)
assert output["first"]["item"]["notificationId"] == 42
assert output["duplicate"]["item"]["id"] == output["first"]["item"]["id"]
assert len(output["items"]) == 1
assert output["items"][0]["ownerLogin"] == "timmy"
assert [item["notification_id"] for item in output["visible"]] == [43]
assert output["reconciled"][-1][0] == "notification-read"
assert len(output["reconciled"][-1][1]) == 1
def test_foreground_flush_patches_each_unique_read_and_keeps_transient_failures_queued():
script = f"""
const createOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:key => values.get(key) || null,setItem:(key,value) => values.set(key,value)}};
const calls = [];
const outbox = createOutbox({{
storage, getOwnerLogin:() => 'timmy',
fetchJson: async (url, options) => {{
calls.push([url, options.method]);
if (url.includes('/43/')) {{ const error = new Error('offline'); error.status = 503; throw error; }}
return {{ok:true}};
}},
}});
(async () => {{
await outbox.enqueueDurably(42);
await outbox.enqueueDurably(43);
const result = await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{calls, result, items:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output["calls"] == [
["api/v1/notifications/42/read", "PATCH"],
["api/v1/notifications/43/read", "PATCH"],
]
assert output["result"]["confirmed"] == [42]
assert [item["notificationId"] for item in output["items"]] == [43]
assert output["items"][0]["status"] == "queued"
def test_background_delivery_uses_patch_and_reports_read_receipt():
script = f"""
const createSync = require({json.dumps(str(BACKGROUND_SYNC))});
const requests = [];
const store = {{
upsert:async () => {{}}, claim:async () => ({{
id:'notification-read:timmy:42', kind:'notification-read', notificationId:42,
ownerLogin:'timmy', status:'sending',
}}), complete:async () => {{}}, release:async () => {{}}, fail:async () => {{}},
}};
const sync = createSync({{
store,
fetchJson:async (url, options) => {{ requests.push([url, options.method, options.body || null]); return {{ok:true}}; }},
}});
(async () => {{
const result = await sync.send({{
id:'notification-read:timmy:42', kind:'notification-read', notificationId:42,
ownerLogin:'timmy', status:'queued',
}}, 'timmy');
process.stdout.write(JSON.stringify({{requests, result}}));
}})();
"""
output = run_node(script)
assert output["requests"] == [["api/v1/notifications/42/read", "PATCH", None]]
assert output["result"]["receipt"] == {
"id": "notification-read:timmy:42",
"status": "confirmed",
"kind": "notification-read",
"route": "#/my-work/updates",
}
@pytest.mark.anyio
async def test_dashboard_wires_offline_queue_read_next_through_precached_background_outbox():
html = await dashboard()
worker = WORKER.read_text()
assert '<script src="static/notification-read-outbox.js"></script>' in html
assert "const notificationReadOutbox = createNotificationReadOutbox({" in html
assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html
assert "notificationReadOutbox.suppress(saved.notifications || [])" in html
assert "notificationReadOutbox.flush(activeFlushLogin)" in html
assert "Queue read & next" in html
assert "BASE + 'static/notification-read-outbox.js'" in worker
+150
View File
@@ -0,0 +1,150 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
OFFLINE_TODAY = Path(__file__).parents[1] / "frontend" / "offline-today.js"
def run_scenario(scenario: str) -> dict:
script = f"""
const createOfflineToday = require({json.dumps(str(OFFLINE_TODAY))});
(async () => {{
{scenario}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(result.stdout)
def test_warms_bounded_today_queue_with_two_concurrent_requests():
result = run_scenario("""
let active = 0;
let peak = 0;
const loaded = [];
const saved = [];
const items = Array.from({length:7}, (_, index) => ({
kind:index % 2 ? 'pull' : 'issue', repository:'stackchain/dashboard', number:index + 1,
updated_at:'2026-08-08T12:0' + index + ':00Z',
}));
const warmer = createOfflineToday({
loadDetail: async item => {
active += 1;
peak = Math.max(peak, active);
loaded.push(item.number);
await new Promise(resolve => setTimeout(resolve, 5));
active -= 1;
return {title:'Work ' + item.number, body:'Context ' + item.number};
},
loadSavedDetail: () => null,
saveDetail: (login, item, detail) => saved.push({login, number:item.number, detail}),
});
const status = await warmer.warm('timmy', items);
process.stdout.write(JSON.stringify({peak, loaded, saved, status}));
""")
assert result["peak"] == 2
assert result["loaded"] == [1, 2, 3, 4, 5]
assert [entry["number"] for entry in result["saved"]] == [1, 2, 3, 4, 5]
assert result["saved"][0]["detail"]["source_updated_at"] == "2026-08-08T12:00:00Z"
assert result["status"] == {"total": 5, "ready": 5, "failed": 0, "pending": 0}
def test_refreshes_only_changed_revisions_and_keeps_prior_copy_on_failure():
result = run_scenario("""
const items = [
{kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'new'},
{kind:'pull', repository:'stackchain/dashboard', number:2, updated_at:'same'},
];
const old = new Map([
['issue:stackchain/dashboard:1', {title:'Prior 1', source_updated_at:'old'}],
['pull:stackchain/dashboard:2', {title:'Prior 2', source_updated_at:'same'}],
]);
const loaded = [];
const warmer = createOfflineToday({
loadDetail: async item => { loaded.push(item.number); throw new Error('temporary outage'); },
loadSavedDetail: (_login, item) => old.get(item.kind + ':' + item.repository + ':' + item.number),
saveDetail: () => { throw new Error('must not replace a prior copy after failure'); },
});
const status = await warmer.warm('timmy', items);
process.stdout.write(JSON.stringify({loaded, status, failed:warmer.failedKeys()}));
""")
assert result == {
"loaded": [1],
"status": {"total": 2, "ready": 2, "failed": 1, "pending": 0},
"failed": ["issue:stackchain/dashboard:1"],
}
def test_retry_fetches_only_failed_items():
result = run_scenario("""
const items = [
{kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'v2'},
{kind:'issue', repository:'stackchain/dashboard', number:2, updated_at:'v1'},
];
let fail = true;
const calls = [];
const saved = new Map([['issue:stackchain/dashboard:2', {source_updated_at:'v1'}]]);
const warmer = createOfflineToday({
loadDetail: async item => {
calls.push(item.number);
if (fail) throw new Error('offline');
return {title:'Fresh'};
},
loadSavedDetail: (_login, item) => saved.get(item.kind + ':' + item.repository + ':' + item.number),
saveDetail: (_login, item, detail) => saved.set(item.kind + ':' + item.repository + ':' + item.number, detail),
});
await warmer.warm('timmy', items);
fail = false;
const status = await warmer.retry('timmy', items);
process.stdout.write(JSON.stringify({calls, status}));
""")
assert result["calls"] == [1, 1]
assert result["status"] == {"total": 2, "ready": 2, "failed": 0, "pending": 0}
def test_requested_reviews_are_warmed_as_distinct_today_items():
result = run_scenario("""
const loaded = [];
const saved = [];
const review = {
kind:'pull', is_review:true, repository:'stackchain/dashboard', number:12,
updated_at:'2026-08-08T12:00:00Z',
};
const warmer = createOfflineToday({
loadDetail: async item => {
loaded.push(item.is_review ? 'review' : item.kind);
return {head_sha:'abc123', files:[{filename:'src/app.js'}]};
},
loadSavedDetail: () => null,
saveDetail: (_login, item, detail) => saved.push({item, detail}),
});
const status = await warmer.warm('timmy', [review]);
process.stdout.write(JSON.stringify({loaded, saved, status}));
""")
assert result["loaded"] == ["review"]
assert result["saved"][0]["item"]["is_review"] is True
assert result["saved"][0]["detail"]["head_sha"] == "abc123"
assert result["status"] == {"total": 1, "ready": 1, "failed": 0, "pending": 0}
@pytest.mark.anyio
async def test_dashboard_warms_today_after_fresh_snapshot_and_exposes_mobile_retry():
html = await dashboard()
assert '<script src="static/offline-today.js"></script>' in html
assert 'id="offline-today-status"' in html
assert 'id="retry-offline-today"' in html
assert "offlineToday.warm(confirmedOwnerLogin, todayMyWork)" in html
assert "offlineToday.retry(confirmedOwnerLogin, todayMyWork)" in html
assert "Today offline: " in html
assert ".offline-today-readiness button { min-height:44px;" in html
+161
View File
@@ -87,6 +87,115 @@ process.stdout.write(JSON.stringify({enabled:store.enabled(), loaded:store.load(
assert result == {"enabled": False, "loaded": None}
def test_opted_in_detail_cache_is_allowlisted_account_bound_and_bounded():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z'), maxDetails:2});
store.setEnabled(true);
const issue = {kind:'issue', repository:'stackchain/dashboard', number:8};
store.saveDetail('timmy', issue, {
title:'Ship offline work', body:'Safe description', state:'open', labels:['P0'], assignees:['timmy'],
url:'https://forge.example/issues/8', token:'secret', files:[{patch:'private diff'}],
conversation:{comments:[{id:1, author:'alexander', body:'Newest message', created_at:'2026-08-07T11:00:00Z',
token:'comment-secret'}], page:1, older_page:null, total:1},
});
store.saveDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}, {title:'PR 9', body:'Body 9'});
store.saveDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:10}, {title:'Issue 10', body:'Body 10'});
const raw = [...values.values()].join(' ');
process.stdout.write(JSON.stringify({
evicted:store.loadDetail('timmy', issue),
loaded:store.loadDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}),
wrongUser:store.loadDetail('alexander', {kind:'pull', repository:'stackchain/dashboard', number:9}),
raw,
}));
""")
assert result["evicted"] is None
assert result["wrongUser"] is None
assert result["loaded"]["title"] == "PR 9"
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
assert "secret" not in result["raw"]
assert "private diff" not in result["raw"]
def test_unread_update_conversation_detail_is_private_bounded_and_account_bound():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
store.setEnabled(true);
const update = {kind:'update', notification_id:42};
store.saveDetail('timmy', update, {
id:42, repository:'stackchain/dashboard', title:'Deployment blocked',
subject_type:'Issue', state:'open', subject_body:'Safe subject context',
url:'https://forge.example/issues/8#issuecomment-9', token:'secret',
issue:{number:8, assignees:[], claimable:true}, files:[{patch:'private diff'}],
conversation:{
comments:Array.from({length:25}, (_, index) => ({
id:index + 1, author:'alexander', body:'Message ' + (index + 1),
created_at:'2026-08-07T11:00:00Z', token:'comment-secret',
})),
page:1, older_page:2, total:40,
},
});
const raw = [...values.values()].join(' ');
process.stdout.write(JSON.stringify({
loaded:store.loadDetail('timmy', update),
wrongUser:store.loadDetail('alexander', update),
raw,
}));
""")
assert result["wrongUser"] is None
assert result["loaded"]["title"] == "Deployment blocked"
assert result["loaded"]["subject_body"] == "Safe subject context"
assert result["loaded"]["subject_type"] == "Issue"
assert len(result["loaded"]["conversation"]["comments"]) == 20
assert result["loaded"]["conversation"]["comments"][0]["body"] == "Message 6"
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
assert "secret" not in result["raw"]
assert "private diff" not in result["raw"]
def test_requested_review_cache_is_sha_scoped_allowlisted_and_bounded():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
store.setEnabled(true);
const review = {kind:'pull', repository:'stackchain/dashboard', number:12, is_review:true};
store.saveDetail('timmy', review, {
title:'Review offline work', body:'Review description', author:'alexander', head_sha:'abc123',
ci_state:'success', token:'top-secret', source_updated_at:'2026-08-07T11:00:00Z',
files:Array.from({length:55}, (_, fileIndex) => ({
filename:'src/file-' + fileIndex + '.js', status:'modified', additions:2, deletions:1,
diff_available:true, diff_binary:false, diff_truncated:false, patch:'must-not-persist',
diff_lines:Array.from({length:405}, (_, lineIndex) => '+' + fileIndex + ':' + lineIndex),
})),
reviews:Array.from({length:25}, (_, index) => ({
id:index + 1, state:'COMMENT', body:'Review ' + (index + 1),
submitted_at:'2026-08-07T10:00:00Z', user:{login:'reviewer-' + index, email:'private@example.test'},
token:'review-secret',
})),
});
const loaded = store.loadDetail('timmy', review);
process.stdout.write(JSON.stringify({
loaded,
wrongUser:store.loadDetail('alexander', review),
raw:[...values.values()].join(' '),
}));
""")
assert result["wrongUser"] is None
assert result["loaded"]["head_sha"] == "abc123"
assert result["loaded"]["ci_state"] == "success"
assert len(result["loaded"]["files"]) == 50
assert len(result["loaded"]["files"][0]["diff_lines"]) == 400
assert result["loaded"]["files"][0]["filename"] == "src/file-0.js"
assert len(result["loaded"]["reviews"]) == 20
assert result["loaded"]["reviews"][0]["body"] == "Review 6"
assert result["loaded"]["reviews"][0]["user"] == {"login": "reviewer-5"}
assert "top-secret" not in result["raw"]
assert "must-not-persist" not in result["raw"]
assert "private@example.test" not in result["raw"]
assert "review-secret" not in result["raw"]
@pytest.mark.anyio
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
html = await dashboard()
@@ -114,3 +223,55 @@ async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snap
assert "Live details and actions will return automatically." in html
assert "function renderLiveSnapshot(snapshot, changedSections" in html
assert "setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
@pytest.mark.anyio
async def test_saved_today_details_open_offline_without_enabling_server_state_actions():
html = await dashboard()
assert "offlineWorkStore.loadDetail(offlineLogin, item)" in html
assert "Details not saved—reconnect to open this item." in html
assert "openIssueSheet(item, trigger, savedDetail)" in html
assert "openPullSheet(item, trigger, savedDetail)" in html
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail)" in html
assert "todayWork.contains(item)" in html
assert "Offline copy · saved " in html
assert "setOfflineDetailControls('issue')" in html
assert "setOfflineDetailControls('pull')" in html
assert "qs('#issue-planning').inert = true;" in html
assert "qs('#issue-handoff').inert = true;" in html
assert "qs('#pull-review').inert = true;" in html
assert "qs('#issue-planning').inert = false;" in html
assert "qs('#pull-review').inert = false;" in html
assert "confirmedOwnerLogin = String(saved.user?.login || '').trim();" in html
@pytest.mark.anyio
async def test_saved_unread_update_opens_offline_with_queued_reply_and_read_controls():
html = await dashboard()
assert "notificationReader.open(item, savedDetail)" in html
assert "openRoutedWork(item, button);" in html
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail)" in html
assert "Offline update · saved " in html
assert "setOfflineUpdateControls(true)" in html
assert "qs('#mark-update-read-next').disabled = false;" in html
assert "offline ? 'Queue read & next' : 'Mark read & next'" in html
assert "qs('#update-ownership-action').disabled = offline;" in html
assert "qs('#load-older-update-comments').disabled = offline;" in html
assert "document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')" in html
assert "replies and read acknowledgements queue for sync" in html
@pytest.mark.anyio
async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only():
html = await dashboard()
assert "item.is_review ? reviewController.load(item)" in html
assert "openReviewSheet(item, trigger, savedDetail)" in html
assert "async function openReviewSheet(item, trigger, cachedDetail = null)" in html
assert "cachedDetail || await reviewController.load(selectedReview)" in html
assert "Offline review · saved " in html
assert "Reconnect to validate & submit" in html
assert "qs('#submit-review').disabled = offlineReview;" in html
assert "if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);" in html
+3 -1
View File
@@ -110,13 +110,15 @@ const seen=[];
const unsubscribe=second.subscribe(change=>seen.push(change));
first.notify('issue');
first.notify('authored');
first.notify('today');
first.notify('later');
unsubscribe();
first.notify('issue');
process.stdout.write(JSON.stringify({{seen,signal:JSON.parse(storage.getItem('stackchain.outbox-change.v1'))}}));
"""
output = run_node(script)
assert [change["queue"] for change in output["seen"]] == ["issue", "authored"]
assert [change["queue"] for change in output["seen"]] == ["issue", "authored", "today", "later"]
assert output["signal"]["queue"] == "issue"
assert output["signal"]["tabId"] == "first"
+25
View File
@@ -32,3 +32,28 @@ def test_readme_documents_liveness_and_gitea_readiness_checks():
assert "`/readyz`" in text
assert "does not contact Gitea" in text
assert "HTTP 503" in text
def test_readme_documents_exact_later_return_times():
text = " ".join(README.read_text().split())
assert "Choose date & time" in text
assert "valid future local date and time" in text
assert "exact instant" in text
def test_readme_documents_bounded_offline_today_details_and_safe_actions():
text = " ".join(README.read_text().split())
assert "opened Today issue or pull request" in text
assert "newest 20 comments" in text
assert "comments can enter the account-bound durable outbox" in text
assert "planning, assignment, review, merge, and close controls remain disabled" in text
def test_readme_documents_offline_unread_update_conversations_and_boundaries():
text = " ".join(README.read_text().split())
assert "previously opened unread update" in text
assert "replies enter the account-bound durable outbox" in text
assert "mark read, ownership, deferral, and older-message loading remain disabled" in text
+118 -7
View File
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const cache = {{
addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
@@ -21,9 +21,10 @@ const cache = {{
}};
const context = {{
URL, Request, Response, Headers, AbortController,
console,
setTimeout, clearTimeout, console,
self: {{
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{
@@ -45,9 +46,19 @@ const context = {{
delete: async key => {{ state.deleted.push(key); return true; }},
match: async request => cache.match(request),
}},
fetch: async request => {{
fetch: async (request, options = {{}}) => {{
state.fetches.push(String(request.url || request));
if (state.failFetch) throw new Error('offline');
if (state.stallFetch) return new Promise((resolve, reject) => {{
options.signal?.addEventListener('abort', () => {{
state.fetchAborted = true;
reject(new Error('aborted'));
}}, {{ once: true }});
}});
if (state.lateFetch) return new Promise(resolve => {{
options.signal?.addEventListener('abort', () => {{ state.fetchAborted = true; }}, {{ once: true }});
setTimeout(() => resolve(new Response('late network')), 50);
}});
const response = new Response('network', {{ status: state.fetchStatus }});
Object.defineProperty(response, 'redirected', {{ value: state.fetchRedirected }});
return response;
@@ -94,26 +105,48 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout)
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v37" in source
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v37" in source
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v37" in source
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -303,12 +336,17 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
"/dashboard/static/notification-read-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/update-ownership.js",
"/dashboard/static/later-work.js",
"/dashboard/static/later-sync.js",
"/dashboard/static/detail-defer.js",
"/dashboard/static/later-picker.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/conversation.js",
"/dashboard/static/issue-sheet.js",
@@ -358,6 +396,79 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
]
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
result = run_worker_scenario(
"""
state.stallFetch = true;
state.cachedBody = 'cached dashboard';
const response = await Promise.race([
dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}),
new Promise(resolve => setTimeout(() => resolve(null), 80)),
]);
process.stdout.write(JSON.stringify({
timedOut: response === null,
body: response ? await response.text() : null,
state,
}));
"""
)
assert result["timedOut"] is False
assert result["body"] == "cached dashboard"
assert result["state"]["fetchAborted"] is True
assert result["state"]["puts"] == []
def test_stalled_navigation_without_cached_shell_returns_deterministic_504():
result = run_worker_scenario(
"""
state.stallFetch = true;
const response = await Promise.race([
dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}),
new Promise(resolve => setTimeout(() => resolve(null), 80)),
]);
process.stdout.write(JSON.stringify({
status: response?.status || null,
body: response ? await response.text() : null,
contentType: response?.headers.get('Content-Type') || null,
state,
}));
"""
)
assert result["status"] == 504
assert result["body"] == "Stackchain is offline and the dashboard is not cached yet. Reconnect and try again."
assert result["contentType"] == "text/plain; charset=utf-8"
assert result["state"]["fetchAborted"] is True
def test_navigation_deadline_wins_when_fetch_ignores_abort_and_prevents_late_cache_write():
result = run_worker_scenario(
"""
state.lateFetch = true;
state.cachedBody = 'cached dashboard';
const response = await Promise.race([
dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}),
new Promise(resolve => setTimeout(() => resolve(null), 35)),
]);
const body = response ? await response.text() : null;
await new Promise(resolve => setTimeout(resolve, 60));
process.stdout.write(JSON.stringify({timedOut: response === null, body, state}));
"""
)
assert result["timedOut"] is False
assert result["body"] == "cached dashboard"
assert result["state"]["fetchAborted"] is True
assert result["state"]["puts"] == []
def test_redirected_login_navigation_does_not_replace_cached_dashboard_shell():
result = run_worker_scenario(
"""
+164
View File
@@ -0,0 +1,164 @@
import sqlite3
import pytest
import httpx
from src import main
from src.today_store import TodayPlanFull, TodayStore
@pytest.mark.anyio
async def test_transient_today_store_failure_tells_clients_when_to_retry(monkeypatch):
async def user():
return {"login": "timmy"}
class BusyStore:
def apply(self, *_args, **_kwargs):
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_today_store", lambda: BusyStore())
payload = main.TodayOperation(
operation_id="retry-me", action="add", item_id="issue:r:1:"
)
with pytest.raises(main.HTTPException) as raised:
await main.update_today_plan(payload)
assert raised.value.status_code == 503
assert raised.value.headers == {"Retry-After": "1"}
def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, limit=3)
assert store.apply("timmy", "op-1", "add", "issue:stackchain/dashboard:1:") == {
"revision": 1,
"ids": ["issue:stackchain/dashboard:1:"],
}
store.apply("timmy", "op-2", "add", "issue:stackchain/dashboard:2:")
store.apply("timmy", "op-3", "add", "issue:stackchain/dashboard:3:")
moved = store.apply(
"timmy", "op-4", "move", "issue:stackchain/dashboard:3:", direction="up"
)
duplicate = store.apply(
"timmy", "op-4", "move", "issue:stackchain/dashboard:3:", direction="up"
)
assert moved == duplicate == {
"revision": 4,
"ids": [
"issue:stackchain/dashboard:1:",
"issue:stackchain/dashboard:3:",
"issue:stackchain/dashboard:2:",
],
}
assert TodayStore(path, limit=3).get("timmy") == moved
assert store.get("alexander") == {"revision": 0, "ids": []}
def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3", limit=2)
store.apply("timmy", "one", "add", "issue:r:1:")
store.apply("timmy", "two", "add", "issue:r:2:")
with pytest.raises(TodayPlanFull):
store.apply("timmy", "three", "add", "issue:r:3:")
assert store.get("timmy") == {
"revision": 2,
"ids": ["issue:r:1:", "issue:r:2:"],
}
store.apply("timmy", "remove", "remove", "issue:r:1:")
assert store.apply("timmy", "retry-three", "add", "issue:r:3:")["ids"] == [
"issue:r:2:",
"issue:r:3:",
]
def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflicts(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3", limit=2)
result = store.apply_batch(
"timmy",
[
{"operation_id": "one", "action": "add", "item_id": "issue:r:1:"},
{"operation_id": "two", "action": "add", "item_id": "issue:r:2:"},
{"operation_id": "full", "action": "add", "item_id": "issue:r:3:"},
{"operation_id": "remove", "action": "remove", "item_id": "issue:r:1:"},
{"operation_id": "three", "action": "add", "item_id": "issue:r:3:"},
],
)
assert result == {
"revision": 4,
"ids": ["issue:r:2:", "issue:r:3:"],
"accepted_operation_ids": ["one", "two", "remove", "three"],
"duplicate_operation_ids": [],
"rejected_operations": [{"operation_id": "full", "reason": "today_full"}],
}
replay = store.apply_batch("timmy", [
{"operation_id": "two", "action": "add", "item_id": "issue:r:2:"},
{"operation_id": "three", "action": "add", "item_id": "issue:r:3:"},
])
assert replay["revision"] == 4
assert replay["accepted_operation_ids"] == []
assert replay["duplicate_operation_ids"] == ["two", "three"]
@pytest.mark.anyio
async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
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.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
async def user():
return {"id": 1, "login": "Timmy"}
monkeypatch.setattr(main, "current_user", user)
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"}
)
forbidden = await client.patch(
"/api/v1/today",
json={
"operation_id": "mobile-1",
"action": "add",
"item_id": "issue:stackchain/dashboard:357:",
},
)
changed = await client.patch(
"/api/v1/today",
json={
"operations": [
{"operation_id": "mobile-1", "action": "add", "item_id": "issue:stackchain/dashboard:357:"},
{"operation_id": "mobile-2", "action": "add", "item_id": "issue:stackchain/dashboard:359:"},
],
},
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
fetched = await client.get("/api/v1/today")
assert forbidden.status_code == 403
assert changed.status_code == 200
assert changed.json() == {
"revision": 2,
"ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
"accepted_operation_ids": ["mobile-1", "mobile-2"],
"duplicate_operation_ids": [],
"rejected_operations": [],
}
assert fetched.json() == {"revision": 2, "ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"]}
assert fetched.headers["cache-control"] == "no-store"
+491
View File
@@ -0,0 +1,491 @@
import json
import subprocess
from pathlib import Path
TODAY_SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js"
COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js"
def test_two_tabs_cannot_erase_each_others_today_edits_or_double_drain():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const values = new Map();
const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const channels=[]; const channelFactory=()=>{{const channel={{onmessage:null,postMessage:data=>channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data}})),close(){{}}}};channels.push(channel);return channel}};
const held=new Set(); const locks={{request:async(name,_options,work)=>{{if(held.has(name))return work(null);held.add(name);try{{return await work({{name}})}}finally{{held.delete(name)}}}}}};
let sequence=0,releaseFirst;const delivered=[];
const make=tab=>createTodaySync({{storage,getLogin:()=> 'timmy',createOperationId:()=>tab+'-'+(++sequence),coordinator:createCoordinator({{storage,locks,channelFactory,tabId:tab}}),
fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,ids:[]}};const operation=JSON.parse(options.body).operations[0];delivered.push(operation.operation_id);if(delivered.length===1)await new Promise(resolve=>releaseFirst=resolve);return {{revision:delivered.length,ids:delivered}}}},onRemoteIds:()=>{{}},onStatus:()=>{{}}}});
const first=make('first'),second=make('second');first.enqueue('add','issue:r:1:');
(async()=>{{const draining=first.flush();while(!releaseFirst)await Promise.resolve();second.enqueue('add','issue:r:2:');const competing=second.flush();releaseFirst();await Promise.all([draining,competing]);await first.flush();process.stdout.write(JSON.stringify({{delivered,pending:first.pending(),keys:[...values.keys()]}}));}})();
"""
result = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
assert sorted(result["delivered"]) == ["first-1", "second-2"]
assert len(result["delivered"]) == 2
assert result["pending"] == []
assert not any(".operation." in key for key in result["keys"])
def test_operations_enqueued_during_an_inflight_flush_are_drained_before_it_settles():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map();
const patches = [];
let sequence = 0;
let releaseFirstPatch;
const firstPatchBlocked = new Promise(resolve => {{ globalThis.firstPatchStarted = resolve; }});
const sync = createTodaySync({{
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin: () => 'timmy', createOperationId: () => 'op-' + (++sequence),
fetchJson: async (_url, options={{}}) => {{
if (!options.method) return {{revision:0, ids:[]}};
const operation = JSON.parse(options.body).operations[0];
patches.push(operation);
if (patches.length === 1) {{
globalThis.firstPatchStarted();
await new Promise(resolve => {{ releaseFirstPatch = resolve; }});
}}
return {{revision:patches.length, ids:patches.map(item => item.item_id)}};
}},
onRemoteIds: ids => {{ globalThis.ids = ids; }},
onStatus: status => {{ globalThis.status = status; }},
}});
sync.enqueue('add', 'issue:r:1:');
(async () => {{
const firstFlush = sync.flush();
await firstPatchBlocked;
sync.enqueue('add', 'issue:r:2:');
const sharedFlush = sync.flush();
const sharesInflight = firstFlush === sharedFlush;
releaseFirstPatch();
await sharedFlush;
process.stdout.write(JSON.stringify({{
sharesInflight, patches, pending:sync.pending(), ids:globalThis.ids, status:globalThis.status,
}}));
}})();
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result == {
"sharesInflight": True,
"patches": [
{"operation_id": "op-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
{"operation_id": "op-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
],
"pending": [],
"ids": ["issue:r:1:", "issue:r:2:"],
"status": "saved",
}
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/today-sync.js'" in source
def test_local_operations_replay_once_then_adopt_server_order():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map();
const storage = {{
getItem: key => values.get(key) || null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const requests = [];
let remote = {{revision: 1, ids:['issue:r:9:']}};
const sync = createTodaySync({{
storage,
getLogin: () => 'timmy',
createOperationId: () => 'fixed-op',
fetchJson: async (url, options={{}}) => {{
requests.push({{url, body: options.body && JSON.parse(options.body)}});
if (!options.method) return remote;
remote = {{revision: remote.revision + 1, ids:['issue:r:9:', JSON.parse(options.body).operations[0].item_id]}};
return remote;
}},
onRemoteIds: ids => {{ globalThis.adopted = ids; }},
onStatus: status => {{ globalThis.status = status; }},
}});
sync.enqueue('add', 'issue:r:2:');
(async () => {{
await sync.flush();
await sync.flush();
process.stdout.write(JSON.stringify({{
requests, adopted: globalThis.adopted, status: globalThis.status,
pending: sync.pending(),
}}));
}})();
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert [request["url"] for request in result["requests"]] == [
"api/v1/today",
"api/v1/today",
]
assert result["requests"][0]["body"] == {"operations": [{
"operation_id": "fixed-op",
"action": "add",
"item_id": "issue:r:2:",
"direction": None,
}]}
assert result["adopted"] == ["issue:r:9:", "issue:r:2:"]
assert result["status"] == "saved"
assert result["pending"] == []
def test_pending_today_edits_are_sent_as_one_batch_without_a_preflight_get():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map(); const requests=[]; let sequence=0;
const sync=createTodaySync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
fetchJson:async (_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:2,ids:['issue:r:1:','issue:r:2:'],accepted_operation_ids:body.operations.map(x=>x.operation_id),duplicate_operation_ids:[],rejected_operations:[]}}}},
onRemoteIds:()=>{{}},onStatus:()=>{{}},
}});
sync.enqueue('add','issue:r:1:');sync.enqueue('add','issue:r:2:');
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,pending:sync.pending()}}));}})();
"""
result = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
assert len(result["requests"]) == 1
assert [item["operation_id"] for item in result["requests"][0]["operations"]] == ["op-1", "op-2"]
assert result["pending"] == []
def test_failed_delivery_stays_pending_for_offline_replay():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map();
const sync = createTodaySync({{
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin: () => 'timmy', createOperationId: () => 'offline-op',
fetchJson: async () => {{ throw new Error('offline'); }},
onRemoteIds: () => {{}}, onStatus: value => {{ globalThis.status = value; }},
}});
sync.enqueue('move', 'issue:r:2:', 'up');
(async () => {{ await sync.flush(); process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}})); }})();
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result == {
"pending": [{
"operation_id": "offline-op",
"action": "move",
"item_id": "issue:r:2:",
"direction": "up",
}],
"status": "retrying",
}
def test_retryable_failure_replays_automatically_once_with_same_operation_id():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map(); const timers = []; const statuses = []; const patches = [];
let attempts = 0;
const sync = createTodaySync({{
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy', createOperationId:()=> 'stable-op',
setTimer:(callback,delay)=>{{timers.push({{callback,delay}});return timers.length}}, clearTimer:()=>{{}},
fetchJson:async (_url,options={{}})=>{{
attempts += 1;
if (attempts === 1) {{ const error = new Error('busy'); error.status=503; error.retryAfter=2; throw error; }}
if (options.method) patches.push(...JSON.parse(options.body).operations);
return options.method ? {{revision:1,ids:['issue:r:2:']}} : {{revision:0,ids:[]}};
}},
onRemoteIds:()=>{{}}, onStatus:(state,detail)=>statuses.push([state,detail?.delayMs||null]),
}});
sync.enqueue('add','issue:r:2:');
(async()=>{{
await sync.flush();
const scheduled = timers.map(timer=>timer.delay);
await timers[0].callback();
process.stdout.write(JSON.stringify({{scheduled,attempts,patches,pending:sync.pending(),statuses}}));
}})();
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result["scheduled"] == [2000]
assert result["attempts"] == 2
assert result["patches"] == [{
"operation_id": "stable-op", "action": "add", "item_id": "issue:r:2:", "direction": None,
}]
assert result["pending"] == []
assert ["retrying", 2000] in result["statuses"]
assert result["statuses"][-1] == ["saved", None]
def test_duplicate_pending_retirements_collapse_to_one_effective_remove():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map();
let sequence = 0;
const statuses = [];
const sync = createTodaySync({{
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin: () => 'timmy', createOperationId: () => 'remove-' + (++sequence),
fetchJson: async () => ({{revision:0,ids:[]}}), onRemoteIds:()=>{{}}, onStatus:value=>statuses.push(value),
}});
const first = sync.enqueue('remove', 'issue:r:1:');
const duplicate = sync.enqueue('remove', 'issue:r:1:');
process.stdout.write(JSON.stringify({{first, duplicate, pending:sync.pending(), statuses}}));
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result == {
"first": True,
"duplicate": True,
"pending": [{
"operation_id": "remove-1",
"action": "remove",
"item_id": "issue:r:1:",
"direction": None,
}],
"statuses": ["pending", "pending"],
}
def test_existing_device_queue_is_migrated_only_once():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map();
let sequence = 0;
const sync = createTodaySync({{
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin: () => 'timmy', createOperationId: () => 'migration-' + (++sequence),
fetchJson: async () => ({{revision:0,ids:[]}}), onRemoteIds:()=>{{}}, onStatus:()=>{{}},
}});
const first = sync.migrate(['issue:r:1:', 'issue:r:2:']);
const second = sync.migrate(['issue:r:1:', 'issue:r:2:']);
process.stdout.write(JSON.stringify({{first, second, pending:sync.pending()}}));
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result == {
"first": True,
"second": False,
"pending": [
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
{"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
],
}
def test_server_limit_conflict_drops_rejected_add_and_adopts_server_truth():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map();
let patch = false;
const sync = createTodaySync({{
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin: () => 'timmy', createOperationId: () => 'sixth',
fetchJson: async (_url, options={{}}) => {{
patch = true;
return {{revision:5, ids:['1','2','3','4','5'], accepted_operation_ids:[], duplicate_operation_ids:[], rejected_operations:[{{operation_id:'sixth',reason:'today_full'}}]}};
}},
onRemoteIds:ids=>{{globalThis.ids=ids}}, onStatus:value=>{{globalThis.status=value}},
}});
sync.enqueue('add', '6');
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{patch,pending:sync.pending(),ids:globalThis.ids,status:globalThis.status}}));}})();
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result == {
"patch": True,
"pending": [],
"ids": ["1", "2", "3", "4", "5"],
"status": "full",
}
def test_server_limit_conflict_drops_only_the_rejected_operation_and_drains_later_edits():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map();
const patches = [];
let sequence = 0;
const statuses = [];
const sync = createTodaySync({{
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin: () => 'timmy', createOperationId: () => 'op-' + (++sequence),
fetchJson: async (_url, options={{}}) => {{
const operations = JSON.parse(options.body).operations;
patches.push(...operations);
return {{revision:6, ids:['1','2','3','4'], accepted_operation_ids:['op-2'], duplicate_operation_ids:[], rejected_operations:[{{operation_id:'op-1',reason:'today_full'}}]}};
}},
onRemoteIds: ids => {{ globalThis.ids = ids; }},
onStatus: status => statuses.push(status),
}});
sync.enqueue('add', '6');
sync.enqueue('remove', '5');
(async()=>{{
const result = await sync.flush();
process.stdout.write(JSON.stringify({{result, patches, pending:sync.pending(), ids:globalThis.ids, statuses}}));
}})();
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result == {
"result": False,
"patches": [
{"operation_id": "op-1", "action": "add", "item_id": "6", "direction": None},
{"operation_id": "op-2", "action": "remove", "item_id": "5", "direction": None},
],
"pending": [],
"ids": ["1", "2", "3", "4"],
"statuses": ["pending", "pending", "full"],
}
def test_tabs_ignore_an_older_response_after_a_newer_revision_is_broadcast():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const listeners = [];
const createChannel = () => ({{
addEventListener: (_name, listener) => listeners.push(listener),
postMessage: data => listeners.forEach(listener => listener({{data}})),
}});
let resolveOld;
let resolveNew;
const oldHistory = [];
const newHistory = [];
const oldTab = createTodaySync({{
storage, getLogin:()=> 'timmy', createChannel,
fetchJson:()=>new Promise(resolve=>{{resolveOld=resolve}}),
onRemoteIds:ids=>oldHistory.push(ids), onStatus:()=>{{}},
}});
const newTab = createTodaySync({{
storage, getLogin:()=> 'timmy', createChannel,
fetchJson:()=>new Promise(resolve=>{{resolveNew=resolve}}),
onRemoteIds:ids=>newHistory.push(ids), onStatus:()=>{{}},
}});
(async()=>{{
const oldFlush = oldTab.flush();
const newFlush = newTab.flush();
await Promise.resolve();
resolveNew({{revision:2,ids:['new']}});
await newFlush;
resolveOld({{revision:1,ids:['old']}});
await oldFlush;
process.stdout.write(JSON.stringify({{oldHistory,newHistory}}));
}})();
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result["oldHistory"][-1] == ["new"]
assert result["newHistory"][-1] == ["new"]
def test_returning_online_replays_pending_operations():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map();
const handlers = {{}};
const requests = [];
let revision = 1;
const sync = createTodaySync({{
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy', createOperationId:()=> 'offline-add',
fetchJson:async (_url, options={{}})=>{{
requests.push(options.method || 'GET');
if (options.method) return {{revision:++revision,ids:['issue:r:1:']}};
return {{revision,ids:[]}};
}},
onRemoteIds:()=>{{}}, onStatus:()=>{{}},
}});
sync.enqueue('add', 'issue:r:1:');
sync.startLifecycle({{
window: {{addEventListener:(name, handler)=>{{handlers[name]=handler}}}},
document: {{addEventListener:()=>{{}}, hidden:false}},
}});
(async()=>{{
await handlers.online();
process.stdout.write(JSON.stringify({{requests,pending:sync.pending()}}));
}})();
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result == {"requests": ["PATCH"], "pending": []}
def test_foregrounding_a_stale_tab_refreshes_the_saved_plan():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const handlers = {{}};
const adopted = [];
const documentObject = {{
hidden:true,
addEventListener:(name, handler)=>{{handlers[name]=handler}},
}};
const sync = createTodaySync({{
storage: {{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
getLogin:()=> 'timmy',
fetchJson:async ()=>({{revision:7,ids:['issue:r:7:']}}),
onRemoteIds:ids=>adopted.push(ids), onStatus:()=>{{}},
}});
sync.startLifecycle({{
window: {{addEventListener:()=>{{}}}}, document:documentObject,
}});
(async()=>{{
await handlers.visibilitychange();
documentObject.hidden = false;
await handlers.visibilitychange();
process.stdout.write(JSON.stringify(adopted));
}})();
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result == [["issue:r:7:"]]
def test_old_account_broadcasts_are_ignored_after_identity_changes():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
let login = 'timmy';
let listener;
const adopted = [];
const sync = createTodaySync({{
storage: {{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
getLogin:()=>login,
createChannel:()=>({{addEventListener:(_name, handler)=>{{listener=handler}},postMessage:()=>{{}},close:()=>{{}}}}),
fetchJson:async ()=>({{revision:1,ids:['timmy-plan']}}),
onRemoteIds:ids=>adopted.push(ids), onStatus:()=>{{}},
}});
(async()=>{{
await sync.flush();
login = 'alexander';
listener({{data:{{revision:2,ids:['timmy-secret']}}}});
process.stdout.write(JSON.stringify(adopted));
}})();
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result == [["timmy-plan"]]
+94 -1
View File
@@ -16,6 +16,15 @@ def run_node(script):
).stdout
@pytest.mark.anyio
async def test_dashboard_surfaces_automatic_planning_retry_guidance():
html = await dashboard()
assert "error.retryAfter = retryAfter === null ? undefined : Number(retryAfter)" in html
assert "Today saved on this device · retrying" in html
assert "Later saved on this device · retrying" in html
def test_today_queue_is_account_scoped_ordered_unique_and_bounded():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
@@ -78,11 +87,77 @@ process.stdout.write(JSON.stringify([
]
def test_authoritative_reconciliation_reports_retired_ids_before_local_prune():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
const item = number => ({{kind:'issue', repository:'r', number}});
queue.add(item(1));
queue.add(item(2));
const reports = [];
const visible = queue.reconcile([item(2)], {{
pruneMissing: true,
onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}),
}});
queue.reconcile([item(2)], {{
pruneMissing: true,
onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}),
}});
process.stdout.write(JSON.stringify({{reports, visible:visible.map(entry => entry.number), stored:queue.read()}}));
"""
assert json.loads(run_node(script)) == {
"reports": [{
"retiredIds": ["issue:r:1:"],
"idsDuringReport": ["issue:r:1:", "issue:r:2:"],
}],
"visible": [2],
"stored": ["issue:r:2:"],
}
def test_failed_retirement_queue_keeps_local_identity_for_retry():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
const item = number => ({{kind:'issue', repository:'r', number}});
queue.add(item(1));
const visible = queue.reconcile([], {{pruneMissing:true, onPrune:() => false}});
process.stdout.write(JSON.stringify({{visible, stored:queue.read()}}));
"""
assert json.loads(run_node(script)) == {
"visible": [],
"stored": ["issue:r:1:"],
}
def test_today_queue_adopts_bounded_server_order():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const queue = createTodayWork({{storage, getLogin: () => 'timmy', limit: 2}});
queue.add({{kind:'issue', repository:'r', number:1}});
const adopted = queue.replace(['issue:r:3:', 'issue:r:2:', 'issue:r:2:', '', 'issue:r:1:']);
process.stdout.write(JSON.stringify({{adopted, ids:queue.read()}}));
"""
assert json.loads(run_node(script)) == {
"adopted": True,
"ids": ["issue:r:3:", "issue:r:2:"],
}
@pytest.mark.anyio
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
html = await dashboard()
assert '<script src="static/today-work.js"></script>' in html
assert '<script src="static/today-sync.js"></script>' in html
assert 'data-work-filter="today"' in html
assert 'data-work-count="today"' in html
assert "const todayWork = createTodayWork({" in html
@@ -94,6 +169,24 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
assert 'data-today-move="down"' in html
assert 'Today is limited to 5 items' in html
assert '.today-actions button' in html and 'min-height:44px' in html
assert 'id="today-sync-status"' in html
assert "todaySync.enqueue('add'" in html
assert "todaySync.enqueue('remove'" in html
assert "todaySync.enqueue('move'" in html
assert "const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&" in html
assert "!lastContextSnapshot?.error &&" in html
assert "onPrune: retiredIds =>" in html
assert "retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean)" in html
assert "if (queued) todaySync.flush();" in html
assert "todaySync.flush();" in html
assert "Another device filled Today · showing its saved plan." in html
@pytest.mark.anyio
async def test_dashboard_activates_today_lifecycle_convergence():
html = await dashboard()
assert "todaySync.startLifecycle({ window, document });" in html
@pytest.mark.anyio
@@ -101,7 +194,7 @@ async def test_retained_authenticated_context_keeps_local_planning_separate_from
html = await dashboard()
assert "let planningOwnerLogin = '';" in html
assert html.count("getLogin: () => planningOwnerLogin") == 2
assert html.count("getLogin: () => planningOwnerLogin") == 4
assert "const retainedPlanningLogin = !snapshot.context.error ?" in html
assert "planningOwnerLogin = retainedPlanningLogin;" in html
assert "button.disabled = !planningOwnerLogin;" in html