Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a09b5c66 | ||
|
|
8e0389153c | ||
|
|
c3e8ead47b | ||
|
|
5f6dd265a5 |
No files matched your search
@@ -92,6 +92,14 @@ 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.
|
||||
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.
|
||||
|
||||
After one successful online load, the installed dashboard precaches a versioned,
|
||||
subpath-scoped application shell. During a network outage or a dashboard HTTP
|
||||
`500`, `502`, `503`, or `504` response, navigation falls back to that shell when
|
||||
@@ -112,10 +120,12 @@ data** deletes the snapshot, and opting out deletes it 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
|
||||
outboxes when connectivity or a retryable server failure prevents delivery. Issue
|
||||
captures are also mirrored into IndexedDB and registered with Background Sync, so a
|
||||
supporting installed browser can deliver them after every dashboard client has closed.
|
||||
The worker verifies the current Gitea login, shares an atomic delivery claim with the
|
||||
foreground path, and preserves the original idempotency key. Browsers without
|
||||
captures and authored messages are also mirrored into account-bound IndexedDB lanes
|
||||
and registered with Background Sync, so a supporting installed browser can deliver
|
||||
new issues, issue comments, pull-request comments, and unread-update replies after
|
||||
every dashboard client has closed. The worker verifies the current Gitea login, shares
|
||||
an atomic delivery claim with the foreground path, and preserves the original
|
||||
idempotency key. Browsers without
|
||||
IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows
|
||||
queued and needs-attention messages with explicit send/discard controls; reopening the
|
||||
dashboard reconciles worker completions and permanent failures into the visible outbox.
|
||||
|
||||
+44
-11
@@ -1,4 +1,4 @@
|
||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
const storageKey = 'stackchain.authored-outbox.v1';
|
||||
const makeId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
@@ -17,6 +17,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
coordinator?.notify('authored');
|
||||
if (backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items, 'authored'))
|
||||
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
|
||||
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(message) {
|
||||
@@ -84,15 +89,27 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const request = (async () => {
|
||||
try {
|
||||
const result = await fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
});
|
||||
let result;
|
||||
if (backgroundSync?.send) {
|
||||
const delivery = await backgroundSync.send(item, currentLogin);
|
||||
if (delivery.attention) {
|
||||
const error = delivery.error || new Error('Message needs attention');
|
||||
error.status = Number(error.status || 422);
|
||||
throw error;
|
||||
}
|
||||
result = delivery.message;
|
||||
} else {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
});
|
||||
}
|
||||
if (!result) return { blocked: true };
|
||||
discard(item.id);
|
||||
return { result };
|
||||
} catch (error) {
|
||||
@@ -149,7 +166,23 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
|
||||
return retryItem(id, currentLogin);
|
||||
}
|
||||
|
||||
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
|
||||
function reconcileBackground(records) {
|
||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const background = statuses.get(item.id);
|
||||
if (background?.status === 'sent') return [];
|
||||
if (background?.status === 'attention') return [{
|
||||
...item,
|
||||
status: 'attention',
|
||||
error: String(background.error || 'Message needs attention').slice(0, 240),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueue, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
|
||||
@@ -43,11 +43,13 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
|
||||
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
||||
|
||||
async function reconcile(items) {
|
||||
async function reconcile(items, outboxLane = 'issue') {
|
||||
return transact(async records => {
|
||||
const existing = await records.getAll();
|
||||
const incoming = new Map(items.map(item => [item.id, { ...item }]));
|
||||
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
|
||||
for (const current of existing) {
|
||||
const currentLane = current.outboxLane || 'issue';
|
||||
if (currentLane !== outboxLane) continue;
|
||||
const replacement = incoming.get(current.id);
|
||||
if (!replacement) {
|
||||
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
|
||||
@@ -125,8 +127,18 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
}
|
||||
|
||||
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
function issueRequest(item) {
|
||||
function deliveryRequest(item) {
|
||||
if (item.kind === 'update-reply') {
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return authoredRequest(
|
||||
'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
|
||||
item,
|
||||
);
|
||||
}
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues',
|
||||
options: {
|
||||
@@ -147,12 +159,27 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
};
|
||||
}
|
||||
|
||||
function authoredRequest(url, item) {
|
||||
return {
|
||||
url: base + url,
|
||||
options: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function deliver(item) {
|
||||
const request = issueRequest(item);
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const issue = await fetchJson(request.url, request.options);
|
||||
const delivered = await fetchJson(request.url, request.options);
|
||||
await store.complete(item.id);
|
||||
return { issue };
|
||||
return item.kind ? { message: delivered } : { issue: delivered };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status >= 400 && status < 500) {
|
||||
@@ -185,6 +212,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
if (!item) break;
|
||||
const result = await deliver(item);
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.message) confirmed.push(result.message);
|
||||
if (result.attention) attention += 1;
|
||||
}
|
||||
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
||||
@@ -193,7 +221,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
|
||||
return {
|
||||
flush, send,
|
||||
reconcile: items => store.reconcile(items),
|
||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||
snapshot: () => store.snapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
+64
-15
@@ -82,6 +82,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.later-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions button { min-height:44px; width:100%; }
|
||||
.mark-update-read { min-height:44px; width:100%; }
|
||||
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
|
||||
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
||||
@@ -312,6 +314,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
|
||||
<button class="work-filter" data-work-filter="later" aria-pressed="false">Later <span data-work-count="later">0</span></button>
|
||||
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
|
||||
</div>
|
||||
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
|
||||
@@ -746,6 +749,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<script src="static/authored-outbox.js"></script>
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/later-work.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
<script src="static/conversation.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
@@ -785,7 +789,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
);
|
||||
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
|
||||
function openMobileWork() {
|
||||
const counts = countMyWork(lastMyWork);
|
||||
const counts = countMyWork(activeMyWork);
|
||||
const filter = counts.attention ? 'attention' : 'all';
|
||||
qs('[data-work-filter="' + filter + '"]').click();
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
@@ -826,7 +830,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
let selectedWorkMilestone = 'all';
|
||||
try {
|
||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||
if (['all', 'attention', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
if (['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
||||
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
||||
} catch (e) {
|
||||
@@ -866,6 +870,19 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
let editingOutboxId = null;
|
||||
let confirmedOwnerLogin = '';
|
||||
let activeFlushLogin = '';
|
||||
let activeMyWork = [];
|
||||
let laterMyWork = [];
|
||||
const laterWork = createLaterWork({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
onWake: () => {
|
||||
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
|
||||
refreshMyWorkView();
|
||||
},
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) refreshMyWorkView();
|
||||
});
|
||||
|
||||
async function fetchReviewJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
@@ -913,14 +930,17 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
backgroundSync: backgroundIssueSync,
|
||||
getOwnerLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
if (backgroundIssueSync) {
|
||||
backgroundIssueSync.snapshot().then(records => issueOutbox.reconcileBackground(records))
|
||||
.catch(() => { /* The foreground localStorage outbox remains available. */ });
|
||||
}
|
||||
const authoredOutbox = createAuthoredOutbox({
|
||||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||||
backgroundSync: backgroundIssueSync,
|
||||
getOwnerLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
if (backgroundIssueSync) {
|
||||
backgroundIssueSync.snapshot().then(records => {
|
||||
issueOutbox.reconcileBackground(records);
|
||||
authoredOutbox.reconcileBackground(records);
|
||||
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
|
||||
}
|
||||
const shareParams = new URLSearchParams(location.search);
|
||||
const sharedLaunch = {
|
||||
title: shareParams.get('title') || '',
|
||||
@@ -1211,7 +1231,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
|
||||
const workSession = createWorkSession({
|
||||
getItems: () => lastMyWork,
|
||||
getItems: () => activeMyWork,
|
||||
getFilter: () => selectedWorkFilter,
|
||||
getMilestone: () => selectedWorkMilestone,
|
||||
onOpen: openWorkSessionItem,
|
||||
@@ -1306,7 +1326,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
|
||||
function refreshMyWorkView() {
|
||||
lastDrafts = draftInbox.list();
|
||||
const counts = countMyWork(lastMyWork);
|
||||
const partitioned = laterWork.partition(lastMyWork, {
|
||||
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
|
||||
});
|
||||
activeMyWork = partitioned.active;
|
||||
laterMyWork = partitioned.later;
|
||||
const counts = countMyWork(activeMyWork);
|
||||
counts.later = laterMyWork.length;
|
||||
counts.draft = lastDrafts.length;
|
||||
Object.entries(counts).forEach(([filter, count]) => {
|
||||
const element = qs('[data-work-count="' + filter + '"]');
|
||||
@@ -1329,7 +1355,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
qs('#my-work').removeAttribute('data-stale');
|
||||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||||
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
|
||||
summarizeMyWork(activeMyWork) + (laterMyWork.length ? ' · ' + laterMyWork.length + ' deferred' : '') :
|
||||
'No assigned work, review requests, or unread updates.';
|
||||
updateWorkPaginationControls();
|
||||
renderMyWork();
|
||||
if (workSession.active()) workSession.reconcile();
|
||||
@@ -1449,7 +1476,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#load-more-notifications').hidden = true;
|
||||
return;
|
||||
}
|
||||
const visible = filterMyWork(lastMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||
const visible = selectedWorkFilter === 'later' ?
|
||||
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
|
||||
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||
@@ -1462,24 +1491,28 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
(item.milestone?.title ? ' <span class="pill milestone-badge">' + escapeHtml(item.milestone.title) + '</span>' : '') +
|
||||
(item.due_label ? ' <span class="pill due-badge">' + escapeHtml(item.due_label) + '</span>' : '') +
|
||||
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
|
||||
(item.deferred_until ? '<span class="small">Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '</span>' : '') +
|
||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
||||
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
|
||||
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 + '">Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '">Tomorrow</button></div>';
|
||||
if (item.is_review) {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
|
||||
}
|
||||
if (item.kind === 'issue') {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
|
||||
}
|
||||
if (item.kind === 'pull') {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
|
||||
}
|
||||
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + laterActions + '</article>';
|
||||
}).join('') : '<div class="muted">' + (incomplete ?
|
||||
'More work is available. Load the next page.' :
|
||||
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')))) + '.') + '</div>';
|
||||
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
|
||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||||
});
|
||||
@@ -1511,6 +1544,22 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-later-preset]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||||
if (!item || !laterWork.defer(item, laterWork.presetUntil(button.dataset.laterPreset))) return;
|
||||
qs('#my-work-action-status').textContent = 'Deferred work stays unread and unchanged in Gitea.';
|
||||
refreshMyWorkView();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-later-restore]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||||
if (!item || !laterWork.restore(item)) return;
|
||||
qs('#my-work-action-status').textContent = 'Work returned to its priority position.';
|
||||
refreshMyWorkView();
|
||||
});
|
||||
});
|
||||
const allIds = notificationIds(visible);
|
||||
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
|
||||
const bulkBar = qs('#bulk-mark-read-bar');
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {} }) {
|
||||
const prefix = 'stackchain.later-work.v1.';
|
||||
let timer = null;
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function read() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return {};
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || '{}');
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function write(records) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (Object.keys(records).length) storage.setItem(key, JSON.stringify(records));
|
||||
else storage.removeItem(key);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function defer(item, until) {
|
||||
const key = storageKey();
|
||||
const id = identity(item);
|
||||
const wake = new Date(until);
|
||||
if (!key || !id || Number.isNaN(wake.getTime()) || wake <= now()) return false;
|
||||
const records = read();
|
||||
records[id] = wake.toISOString();
|
||||
write(records);
|
||||
return true;
|
||||
}
|
||||
|
||||
function presetUntil(preset) {
|
||||
const current = new Date(now());
|
||||
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
|
||||
if (preset === 'tomorrow') {
|
||||
const wake = new Date(current);
|
||||
wake.setDate(wake.getDate() + 1);
|
||||
wake.setHours(9, 0, 0, 0);
|
||||
return wake;
|
||||
}
|
||||
return new Date(NaN);
|
||||
}
|
||||
|
||||
function restore(item) {
|
||||
const id = identity(item);
|
||||
const records = read();
|
||||
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
|
||||
delete records[id];
|
||||
write(records);
|
||||
return true;
|
||||
}
|
||||
|
||||
function schedule(wakeTimes, current) {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
if (!wakeTimes.length) return;
|
||||
const delay = Math.max(0, Math.min(...wakeTimes) - current);
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
onWake();
|
||||
}, Math.min(delay, 2147483647));
|
||||
}
|
||||
|
||||
function partition(items, { pruneMissing = true } = {}) {
|
||||
const current = now().getTime();
|
||||
const records = read();
|
||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||
const retained = {};
|
||||
const wakeTimes = [];
|
||||
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (!Number.isFinite(wakeTime) || wakeTime <= current) 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);
|
||||
schedule(wakeTimes, current);
|
||||
const deferredIds = new Set(Object.keys(retained));
|
||||
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
|
||||
const later = (items || []).flatMap(item => {
|
||||
const wake = retained[identity(item)];
|
||||
return wake ? [{ ...item, deferred_until: wake }] : [];
|
||||
});
|
||||
return { active, later };
|
||||
}
|
||||
|
||||
return { identity, defer, restore, presetUntil, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v12';
|
||||
const CACHE = 'stackchain-dashboard-shell-v14';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const SHELL = [
|
||||
BASE,
|
||||
@@ -17,6 +17,7 @@ const SHELL = [
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
|
||||
@@ -152,6 +152,63 @@ process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
|
||||
assert len(output["items"]) == 1
|
||||
|
||||
|
||||
def test_authored_outbox_mirrors_to_background_sync_and_reconciles_worker_results():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const mirrors=[]; let requested=0;
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const backgroundSync = {{
|
||||
reconcile: async (items, lane) => mirrors.push({{items:items.map(item=>({{...item}})),lane}}),
|
||||
requestSync: async () => {{requested += 1;}},
|
||||
}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync}});
|
||||
const sent=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Sent',operationId:'sent'}});
|
||||
const failed=outbox.enqueue({{kind:'update-reply',notificationId:2,body:'Fix me',operationId:'failed'}});
|
||||
setTimeout(() => {{
|
||||
const reconciled=outbox.reconcileBackground([
|
||||
{{...sent,status:'sent'}},
|
||||
{{...failed,status:'attention',error:'Reply rejected'}},
|
||||
]);
|
||||
setTimeout(() => process.stdout.write(JSON.stringify({{mirrors,requested,reconciled}})), 0);
|
||||
}}, 0);
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["mirrors"][0]["lane"] == "authored"
|
||||
assert [item["operationId"] for item in output["mirrors"][-1]["items"]] == ["failed"]
|
||||
assert output["requested"] >= 1
|
||||
assert [(item["operationId"], item["status"]) for item in output["reconciled"]] == [
|
||||
("failed", "attention")
|
||||
]
|
||||
|
||||
|
||||
def test_authored_outbox_foreground_send_uses_atomic_background_delivery():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); let backgroundCalls=0; let directCalls=0;
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const backgroundSync = {{
|
||||
reconcile: async()=>{{}}, requestSync:async()=>{{}},
|
||||
send: async (item, owner) => {{backgroundCalls += 1; return {{message:{{id:3,owner}}}};}},
|
||||
}};
|
||||
const outbox=createAuthoredOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',backgroundSync,
|
||||
fetchJson:async()=>{{directCalls += 1; return {{id:4}};}},
|
||||
}});
|
||||
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Review',operationId:'once'}});
|
||||
(async()=>{{
|
||||
const result=await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{backgroundCalls,directCalls,result,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["backgroundCalls"] == 1
|
||||
assert output["directCalls"] == 0
|
||||
assert output["result"]["confirmed"] == [{"id": 3, "owner": "timmy"}]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
html = await dashboard()
|
||||
@@ -164,3 +221,5 @@ async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "authoredOutbox.discard(item.outbox_id)" in html
|
||||
assert "if (result?.queued)" in html
|
||||
assert "backgroundSync: backgroundIssueSync" in html
|
||||
assert "authoredOutbox.reconcileBackground(records)" in html
|
||||
@@ -64,6 +64,83 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("item", "expected_url"),
|
||||
[
|
||||
(
|
||||
{"kind": "issue-comment", "repository": "stackchain/api", "number": 7},
|
||||
"api/v1/repos/stackchain/api/issues/7/comments",
|
||||
),
|
||||
(
|
||||
{"kind": "pull-comment", "repository": "stackchain/web", "number": 8},
|
||||
"api/v1/repos/stackchain/web/pulls/8/comments",
|
||||
),
|
||||
(
|
||||
{"kind": "update-reply", "notificationId": 9},
|
||||
"api/v1/notifications/9/reply",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_closed_app_sync_delivers_each_authored_message_kind(item, expected_url):
|
||||
authored = {
|
||||
"id": "message-op",
|
||||
"operationId": "message-op",
|
||||
"ownerLogin": "timmy",
|
||||
"status": "queued",
|
||||
"body": "Ship this reply",
|
||||
**item,
|
||||
}
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let queued = {json.dumps(authored)};
|
||||
const calls = [];
|
||||
const store = {{
|
||||
claimNext: async owner => queued?.ownerLogin === owner ? (queued = null, {json.dumps(authored)}) : null,
|
||||
complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}},
|
||||
countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async (url, options = {{}}) => {{
|
||||
calls.push({{url, options}});
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
return {{id:42}};
|
||||
}};
|
||||
(async () => {{
|
||||
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{calls,result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
mutation = output["calls"][1]
|
||||
assert mutation["url"] == expected_url
|
||||
assert mutation["options"]["headers"]["Idempotency-Key"] == "message-op"
|
||||
assert json.loads(mutation["options"]["body"]) == {"body": "Ship this reply"}
|
||||
assert output["result"]["confirmed"] == [{"id": 42}]
|
||||
|
||||
|
||||
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
|
||||
await store.reconcile([{{id:'message',kind:'issue-comment',ownerLogin:'timmy',status:'queued'}}], 'authored');
|
||||
await store.reconcile([], 'issue');
|
||||
process.stdout.write(JSON.stringify(await store.snapshot()));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == [
|
||||
{"id": "message", "kind": "issue-comment", "ownerLogin": "timmy", "status": "queued", "outboxLane": "authored"}
|
||||
]
|
||||
|
||||
|
||||
def test_closed_app_sync_leaves_another_accounts_issue_queued():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -255,7 +332,7 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
|
||||
assert output["replay"] is None
|
||||
assert output["snapshot"] == [
|
||||
{"id": "done", "ownerLogin": "timmy", "status": "sent", "claimUntil": 0}
|
||||
{"id": "done", "ownerLogin": "timmy", "status": "sent", "outboxLane": "issue", "claimUntil": 0}
|
||||
]
|
||||
|
||||
|
||||
@@ -311,5 +388,7 @@ async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback():
|
||||
assert "createBackgroundIssueSync({" in html
|
||||
assert "backgroundSync: backgroundIssueSync" in html
|
||||
assert "registration.sync.register('stackchain-issue-outbox-v1')" in html
|
||||
assert "backgroundIssueSync.snapshot().then(records => issueOutbox.reconcileBackground(records))" in html
|
||||
assert "backgroundIssueSync.snapshot().then(records => {" in html
|
||||
assert "issueOutbox.reconcileBackground(records);" in html
|
||||
assert "authoredOutbox.reconcileBackground(records);" in html
|
||||
assert "if ('indexedDB' in window)" in html
|
||||
+183
-1
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
@@ -8,6 +9,7 @@ from src.views import dashboard
|
||||
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.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"
|
||||
@@ -677,6 +679,185 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_defers_work_locally_and_scopes_it_to_confirmed_login():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let login = 'timmy';
|
||||
const item = {{kind:'pull',is_review:true,repository:'stackchain/api',number:17,title:'Review me'}};
|
||||
const store = createLaterWork({{storage,getLogin:() => login,now:() => new Date('2026-08-08T12:00:00Z')}});
|
||||
const deferred = store.defer(item, new Date('2026-08-08T16:00:00Z'));
|
||||
const timmy = store.partition([item]);
|
||||
login = 'alexander';
|
||||
const alexander = store.partition([item]);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
deferred,
|
||||
timmy:{{active:timmy.active.length,later:timmy.later}},
|
||||
alexander:{{active:alexander.active.length,later:alexander.later.length}},
|
||||
keys:Array.from(values.keys()),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"deferred": True,
|
||||
"timmy": {
|
||||
"active": 0,
|
||||
"later": [{
|
||||
"kind": "pull", "is_review": True, "repository": "stackchain/api",
|
||||
"number": 17, "title": "Review me", "deferred_until": "2026-08-08T16:00:00.000Z",
|
||||
}],
|
||||
},
|
||||
"alexander": {"active": 1, "later": 0},
|
||||
"keys": ["stackchain.later-work.v1.timmy"],
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let clock = new Date('2026-08-08T12:00:00Z');
|
||||
let scheduled = null;
|
||||
let wakes = 0;
|
||||
const store = createLaterWork({{
|
||||
storage, getLogin:() => 'timmy', now:() => clock,
|
||||
setTimer:(callback, delay) => {{ scheduled = {{callback,delay}}; return 7; }},
|
||||
clearTimer:() => {{}}, onWake:() => {{ wakes += 1; }},
|
||||
}});
|
||||
const kept = {{kind:'issue',repository:'stackchain/api',number:17,title:'Kept'}};
|
||||
const gone = {{kind:'issue',repository:'stackchain/api',number:18,title:'Gone'}};
|
||||
store.defer(kept, new Date('2026-08-08T13:00:00Z'));
|
||||
store.defer(gone, new Date('2026-08-09T13:00:00Z'));
|
||||
const before = store.partition([kept]);
|
||||
const persisted = JSON.parse(values.get('stackchain.later-work.v1.timmy'));
|
||||
clock = new Date('2026-08-08T13:00:01Z');
|
||||
scheduled.callback();
|
||||
const after = store.partition([kept]);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
before:{{active:before.active.length,later:before.later.length}},
|
||||
persisted:Object.keys(persisted), delay:scheduled.delay, wakes,
|
||||
after:{{active:after.active.map(item => item.title),later:after.later.length}},
|
||||
storageEmpty:!values.has('stackchain.later-work.v1.timmy'),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": {"active": 0, "later": 1},
|
||||
"persisted": ["issue:stackchain/api:17:"],
|
||||
"delay": 3600000,
|
||||
"wakes": 1,
|
||||
"after": {"active": ["Kept"], "later": 0},
|
||||
"storageEmpty": True,
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_preserves_rank_and_retains_unloaded_paginated_work():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
||||
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 store = createLaterWork({{
|
||||
storage,getLogin:() => 'timmy',now:() => new Date('2026-08-08T12:00:00Z'),
|
||||
setTimer:() => 1, clearTimer:() => {{}},
|
||||
}});
|
||||
const first = {{kind:'issue',repository:'stackchain/api',number:1,title:'Higher priority'}};
|
||||
const second = {{kind:'pull',repository:'stackchain/api',number:2,title:'Lower priority'}};
|
||||
store.defer(second, new Date('2026-08-09T12:00:00Z'));
|
||||
store.defer(first, new Date('2026-08-09T12:00:00Z'));
|
||||
store.partition([first], {{pruneMissing:false}});
|
||||
const visible = store.partition([first, second], {{pruneMissing:false}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
titles:visible.later.map(item => item.title),
|
||||
records:Object.keys(JSON.parse(values.get('stackchain.later-work.v1.timmy'))).length,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"titles": ["Higher priority", "Lower priority"],
|
||||
"records": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_presets_and_bring_back_now_preserve_work_identity():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
||||
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 now = new Date('2026-08-08T12:00:00Z');
|
||||
const store = createLaterWork({{storage,getLogin:() => 'timmy',now:() => now}});
|
||||
const item = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
|
||||
const today = store.presetUntil('today');
|
||||
const tomorrow = store.presetUntil('tomorrow');
|
||||
store.defer(item, tomorrow);
|
||||
const removed = store.restore(item);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
today:today.toISOString(), tomorrow:tomorrow.toISOString(), removed,
|
||||
partition:store.partition([item]),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True,
|
||||
env={**os.environ, "TZ": "UTC"},
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"today": "2026-08-08T16:00:00.000Z",
|
||||
"tomorrow": "2026-08-09T09:00:00.000Z",
|
||||
"removed": True,
|
||||
"partition": {"active": [{
|
||||
"kind": "update", "repository": "stackchain/api", "number": 17,
|
||||
"notification_id": 91,
|
||||
}], "later": []},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/later-work.js"></script>' in html
|
||||
assert 'data-work-filter="later"' in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert 'const laterWork = createLaterWork({' in html
|
||||
assert 'getLogin: () => confirmedOwnerLogin' in html
|
||||
assert 'laterWork.partition(lastMyWork,' in html
|
||||
assert 'data-later-preset="today"' in html
|
||||
assert 'data-later-preset="tomorrow"' in html
|
||||
assert 'data-later-restore' in html
|
||||
assert "Deferred until ' + escapeHtml(fmt(item.deferred_until))" in html
|
||||
assert "laterWork.defer(item, laterWork.presetUntil(button.dataset.laterPreset))" in html
|
||||
assert 'laterWork.restore(item)' in html
|
||||
assert '.later-actions button { min-height:44px;' in html
|
||||
assert 'Deferred work stays unread and unchanged in Gitea.' in html
|
||||
|
||||
|
||||
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
||||
payload = {
|
||||
"issues": [
|
||||
@@ -2676,7 +2857,8 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
assert 'data-work-count="pull"' in html
|
||||
assert 'data-work-count="review"' in html
|
||||
assert 'data-work-count="update"' in html
|
||||
assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert 'data-work-count="draft"' in html
|
||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||
|
||||
@@ -70,10 +70,10 @@ async function dispatchSync(tag) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_background_issue_sync_ships_in_a_new_shell_cache():
|
||||
def test_background_authored_sync_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v12" in source
|
||||
assert "stackchain-dashboard-shell-v14" in source
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
@@ -113,6 +113,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
|
||||
Reference in new issue
Block a user