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
25 changed files with 1186 additions and 127 deletions

No files matched your search

+8 -2
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
@@ -212,7 +216,9 @@ 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 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.
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
+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,
};
}
+14
View File
@@ -109,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; }
@@ -266,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; }
+153 -12
View File
@@ -135,6 +135,7 @@
let activeMyWork = [];
let laterMyWork = [];
let todayMyWork = [];
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
const todayWork = createTodayWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
@@ -143,6 +144,7 @@
storage: localStorage,
getLogin: () => planningOwnerLogin,
fetchJson: fetchReviewJson,
coordinator: outboxCoordinator,
onRemoteIds: ids => {
if (!planningOwnerLogin || !todayWork.replace(ids)) return;
refreshMyWorkView();
@@ -177,6 +179,7 @@
storage: localStorage,
getLogin: () => planningOwnerLogin,
fetchJson: fetchReviewJson,
coordinator: outboxCoordinator,
onRemoteRecords: records => {
if (!planningOwnerLogin || !laterWork.adopt(records)) return;
refreshMyWorkView();
@@ -242,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,
@@ -696,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;
@@ -703,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');
@@ -712,7 +769,7 @@
});
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);
});
@@ -1045,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' ?
@@ -1113,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)];
@@ -1651,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');
@@ -1736,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');
@@ -1751,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();
}
@@ -2499,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…';
+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));
},
};
}
+23 -4
View File
@@ -294,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>
@@ -355,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>
@@ -400,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>
@@ -440,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>
@@ -505,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>
@@ -514,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>
@@ -545,6 +563,7 @@
<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;
+76 -22
View File
@@ -1,4 +1,4 @@
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel,
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.';
@@ -8,6 +8,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
let channelKey = '';
let retryTimer = null;
let retryAttempt = 0;
const knownOperationKeys = new Set();
function cancelRetry() {
if (retryTimer !== null) clearTimer?.(retryTimer);
@@ -84,25 +85,52 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
function pending() {
const storageKey = key();
if (!storageKey || !storage) return [];
const recordPrefix = storageKey + '.operation.';
try {
const value = JSON.parse(storage.getItem(storageKey) || '[]');
return Array.isArray(value) ? value.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')
) : [];
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 save(operations) {
function removeOperation(operationId) {
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
try {
if (operations.length) storage.setItem(storageKey, JSON.stringify(operations));
else storage.removeItem(storageKey);
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
return true;
} catch (_error) {
return false;
@@ -118,9 +146,19 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
function enqueue(action, itemId, wakeAt = null) {
if (!['defer', 'restore'].includes(action) || !itemId ||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
const operations = pending().filter(operation => operation.item_id !== itemId);
operations.push({ operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt });
const saved = save(operations);
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;
}
@@ -146,20 +184,29 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
if (!ownerKey) return false;
ensureChannel();
try {
let plan = await fetchJson('api/v1/later');
let operations = pending();
let plan;
if (!operations.length) plan = await fetchJson('api/v1/later');
while (operations.length) {
if (key() !== ownerKey) return false;
const operation = operations[0];
const batch = operations.slice(0, 50);
plan = await fetchJson('api/v1/later', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(operation),
body: JSON.stringify({ operations: batch }),
});
const remaining = pending();
const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id);
if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) {
throw new Error('Could not persist Later delivery receipt');
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();
}
@@ -176,7 +223,10 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
}
function flush() {
if (!flushing) flushing = run().finally(() => { flushing = null; });
if (!flushing) {
const delivery = coordinator ? coordinator.runExclusive('later', run) : run();
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
}
return flushing;
}
@@ -187,6 +237,10 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
);
}
coordinator?.subscribe(change => {
if (change.queue === 'later' && pending().length) flush();
});
return { enqueue, migrate, flush, pending, startLifecycle };
}
+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) {
+2 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v50';
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 = [
@@ -30,6 +30,7 @@ const SHELL = [
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',
+68 -34
View File
@@ -1,4 +1,4 @@
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel,
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.';
@@ -8,6 +8,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
let channelKey = '';
let retryTimer = null;
let retryAttempt = 0;
const knownOperationKeys = new Set();
function cancelRetry() {
if (retryTimer !== null) clearTimer?.(retryTimer);
@@ -79,24 +80,42 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
function pending() {
const storageKey = key();
if (!storageKey || !storage) return [];
const recordPrefix = storageKey + '.operation.';
try {
const value = JSON.parse(storage.getItem(storageKey) || '[]');
return Array.isArray(value) ? value.filter(operation =>
operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move'].includes(operation.action) &&
typeof operation.item_id === 'string'
) : [];
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 save(operations) {
function removeOperation(operationId) {
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
try {
if (operations.length) storage.setItem(storageKey, JSON.stringify(operations));
else storage.removeItem(storageKey);
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
return true;
} catch (_error) {
return false;
@@ -117,8 +136,17 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
onStatus?.('pending');
return true;
}
operations.push({ operation_id: operationId(), action, item_id: itemId, direction });
const saved = save(operations);
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;
}
@@ -142,32 +170,31 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
if (!ownerKey) return false;
ensureChannel();
try {
let plan = await fetchJson('api/v1/today');
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 operation = operations[0];
try {
plan = await fetchJson('api/v1/today', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(operation),
});
} catch (error) {
if (error?.status !== 409) throw error;
const rejected = pending();
if (!save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id))) {
throw new Error('Could not persist rejected Today operation');
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');
}
hadConflict = true;
operations = pending();
continue;
}
const remaining = pending();
const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id);
if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) {
throw new Error('Could not persist Today delivery receipt');
}
operations = pending();
}
@@ -184,7 +211,10 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
}
function flush() {
if (!flushing) flushing = run().finally(() => { flushing = null; });
if (!flushing) {
const delivery = coordinator ? coordinator.runExclusive('today', run) : run();
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
}
return flushing;
}
@@ -195,6 +225,10 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
);
}
coordinator?.subscribe(change => {
if (change.queue === 'today' && pending().length) flush();
});
return { enqueue, migrate, flush, pending, startLifecycle };
}
+66
View File
@@ -122,3 +122,69 @@ class LaterStore:
(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": [],
}
+22 -2
View File
@@ -224,6 +224,14 @@ class LaterOperation(BaseModel):
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)
@@ -904,9 +912,15 @@ async def get_today_plan():
@app.patch("/api/v1/today")
async def update_today_plan(payload: TodayOperation):
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,
@@ -939,9 +953,15 @@ async def get_later_plan():
@app.patch("/api/v1/later")
async def update_later_plan(payload: LaterOperation):
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,
+81
View File
@@ -128,3 +128,84 @@ class TodayStore:
(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}
+36 -7
View File
@@ -65,6 +65,27 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
) == {"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")
@@ -98,10 +119,10 @@ async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypat
changed = 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",
"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",
@@ -112,10 +133,18 @@ async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypat
assert forbidden.status_code == 403
assert changed.status_code == 200
assert changed.json() == fetched.json() == {
"revision": 1,
assert changed.json() == {
"revision": 2,
"records": {
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
"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"
+63 -8
View File
@@ -9,6 +9,7 @@ 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):
@@ -19,6 +20,41 @@ def run_node(script):
)
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))});
@@ -31,7 +67,7 @@ const sync = createLaterSync({{
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);
const operation=JSON.parse(options.body).operations[0];
remote={{revision:2,records:{{...remote.records,[operation.item_id]:operation.wake_at}}}};
return remote;
}},
@@ -48,19 +84,38 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
assert [request["url"] for request in result["requests"]] == [
"api/v1/later",
"api/v1/later",
"api/v1/later",
]
assert result["requests"][1]["body"] == {
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))});
@@ -109,7 +164,7 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
assert run_node(script) == {
"scheduledBeforeSwitch": 1,
"timers": 1,
"requests": ["GET"],
"requests": ["PATCH"],
"pendingForAlexander": [],
}
@@ -123,7 +178,7 @@ const sync=createLaterSync({{
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
fetchJson:async (_url,options={{}})=>{{
if (!options.method) return {{revision:0,records:{{}}}};
const operation=JSON.parse(options.body); actions.push(operation.action);
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:()=>{{}},
@@ -178,7 +233,7 @@ sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:
assert run_node(script) == {
"first": True,
"second": False,
"requests": ["GET", "PATCH"],
"requests": ["PATCH"],
"pending": [],
}
@@ -233,5 +288,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v50" in source
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v50" in worker
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-v50" in worker
assert "stackchain-dashboard-shell-v54" in worker
+150
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"
@@ -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": [
@@ -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"},
@@ -3124,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()
+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"
+8
View File
@@ -34,6 +34,14 @@ def test_readme_documents_liveness_and_gitea_readiness_checks():
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())
+20 -4
View File
@@ -105,10 +105,25 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout)
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
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-v50" in source
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
@@ -117,21 +132,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v50" in source
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-v50" 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-v50" in source
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -331,6 +346,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/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",
+41 -6
View File
@@ -77,6 +77,36 @@ def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
]
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")
@@ -109,9 +139,10 @@ async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypat
changed = await client.patch(
"/api/v1/today",
json={
"operation_id": "mobile-1",
"action": "add",
"item_id": "issue:stackchain/dashboard:357:",
"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",
@@ -122,8 +153,12 @@ async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypat
assert forbidden.status_code == 403
assert changed.status_code == 200
assert changed.json() == fetched.json() == {
"revision": 1,
"ids": ["issue:stackchain/dashboard:357:"],
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"
+55 -16
View File
@@ -4,6 +4,29 @@ 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():
@@ -19,7 +42,7 @@ const sync = createTodaySync({{
getLogin: () => 'timmy', createOperationId: () => 'op-' + (++sequence),
fetchJson: async (_url, options={{}}) => {{
if (!options.method) return {{revision:0, ids:[]}};
const operation = JSON.parse(options.body);
const operation = JSON.parse(options.body).operations[0];
patches.push(operation);
if (patches.length === 1) {{
globalThis.firstPatchStarted();
@@ -63,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
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-v50" in source
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/today-sync.js'" in source
@@ -85,7 +108,7 @@ const sync = createTodaySync({{
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:', options.body && JSON.parse(options.body).item_id]}};
remote = {{revision: remote.revision + 1, ids:['issue:r:9:', JSON.parse(options.body).operations[0].item_id]}};
return remote;
}},
onRemoteIds: ids => {{ globalThis.adopted = ids; }},
@@ -108,19 +131,37 @@ sync.enqueue('add', 'issue:r:2:');
assert [request["url"] for request in result["requests"]] == [
"api/v1/today",
"api/v1/today",
"api/v1/today",
]
assert result["requests"][1]["body"] == {
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))});
@@ -160,7 +201,7 @@ const sync = createTodaySync({{
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));
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]),
@@ -178,7 +219,7 @@ sync.enqueue('add','issue:r:2:');
)
assert result["scheduled"] == [2000]
assert result["attempts"] == 3
assert result["attempts"] == 2
assert result["patches"] == [{
"operation_id": "stable-op", "action": "add", "item_id": "issue:r:2:", "direction": None,
}]
@@ -254,8 +295,8 @@ 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={{}}) => {{
if (options.method) {{ patch = true; const error = new Error('full'); error.status = 409; throw error; }}
return {{revision:5, ids:['1','2','3','4','5']}};
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}},
}});
@@ -284,11 +325,9 @@ 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:5, ids:['1','2','3','4','5']}};
const operation = JSON.parse(options.body);
patches.push(operation);
if (operation.action === 'add') {{ const error = new Error('full'); error.status = 409; throw error; }}
return {{revision:6, ids:['1','2','3','4']}};
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),
@@ -390,7 +429,7 @@ sync.startLifecycle({{
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result == {"requests": ["GET", "PATCH"], "pending": []}
assert result == {"requests": ["PATCH"], "pending": []}
def test_foregrounding_a_stale_tab_refreshes_the_saved_plan():