feat: sync offline issue captures (#232)
CI / lint (pull_request) Successful in 20s
CI / build-frontend (pull_request) Successful in 4s

This commit is contained in:
timmy committed 2026-08-07 21:58:28 +00:00
1 parent 7110b11c15
commit 51bd9d41f2
8 files changed
+407 -24

No files matched your search

+1 -1
View File
@@ -166,7 +166,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, loadLabels, loadMilestones, submit,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};
}
+25
View File
@@ -98,6 +98,26 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
return null;
}
function parseOutbox(raw) {
try {
const record = JSON.parse(raw);
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item =>
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
).map(item => ({
id: 'stackchain.issue-outbox.v1:' + item.id,
outbox_id: item.id,
kind: 'issue-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.status === 'attention' ? 'Needs attention' : 'Queued issue',
repository: item.repository,
title: textPreview(item.title) || 'Untitled queued issue',
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
updated_at: Number(item.queuedAt || 0),
}));
} catch (_error) { return []; }
}
function list() {
const index = readIndex();
const seen = new Set();
@@ -107,6 +127,11 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
let raw;
try { raw = storage.getItem(key); }
catch (_error) { return; }
if (key === 'stackchain.issue-outbox.v1') {
parseOutbox(raw).forEach(item => drafts.push(item));
seen.add(key);
return;
}
const parsed = parse(key, raw);
if (!parsed) return;
seen.add(key);
+86 -22
View File
@@ -75,7 +75,7 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
@@ -718,6 +718,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/search-preview.js"></script>
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/issue-outbox.js"></script>
<script src="static/offline-work.js"></script>
<script src="static/my-work.js"></script>
<script src="static/pick-work.js"></script>
@@ -798,11 +799,16 @@ textarea { resize: vertical; min-height: 120px; }
let bulkConfirmationPending = false;
let bulkMarkPending = false;
let reviewHandoffPending = false;
let editingOutboxId = null;
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Review request failed.');
if (!response.ok) {
const error = new Error(payload.error || payload.detail || 'Review request failed.');
error.status = response.status;
throw error;
}
return payload;
}
@@ -823,6 +829,7 @@ textarea { resize: vertical; min-height: 120px; }
loadMilestones: item => issueController.loadMilestones(item),
});
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
const shareParams = new URLSearchParams(location.search);
const sharedLaunch = {
title: shareParams.get('title') || '',
@@ -1243,15 +1250,22 @@ textarea { resize: vertical; min-height: 120px; }
function renderDrafts() {
const list = qs('#my-work-list');
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) =>
'<article class="my-work-card draft-card">' +
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' +
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
'<div class="draft-actions"><button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
'<button class="draft-discard" data-draft-id="' + escAttr(item.id) + '" type="button">Discard draft</button></div></article>'
).join('') : '<div class="muted">No unfinished drafts.</div>';
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
const outboxActions = item.kind === 'issue-outbox' ?
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
const state = item.kind === 'issue-outbox' ?
'<span class="pill">' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '</span>' : '';
return '<article class="my-work-card draft-card">' +
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state +
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
'<div class="draft-actions">' + outboxActions + '</div></article>';
}).join('') : '<div class="muted">No unfinished drafts.</div>';
list.querySelectorAll('.draft-resume').forEach(button => {
button.addEventListener('click', () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
@@ -1260,10 +1274,31 @@ textarea { resize: vertical; min-height: 120px; }
else if (item.route) workRoute.open(item.route);
});
});
list.querySelectorAll('.draft-edit').forEach(button => {
button.addEventListener('click', () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id);
if (!queued) return;
editingOutboxId = queued.id;
issueCapture.saveDraft(queued);
openCreateIssueSheet();
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
});
});
list.querySelectorAll('.draft-send').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.outbox_id) return;
button.disabled = true;
applyOutboxResult(await issueOutbox.retry(item.outbox_id));
});
});
list.querySelectorAll('.draft-discard').forEach(button => {
button.addEventListener('click', () => {
if (!window.confirm('Discard this unfinished draft?')) return;
draftInbox.discard(button.dataset.draftId);
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
else if (item) draftInbox.discard(item.id);
lastDrafts = draftInbox.list();
const count = qs('[data-work-count="draft"]');
if (count) count.textContent = lastDrafts.length;
@@ -1909,6 +1944,32 @@ textarea { resize: vertical; min-height: 120px; }
qs('#new-issue').focus();
}
function applyOutboxResult(result, openCreated = false) {
(result.confirmed || []).forEach(confirmed => {
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
});
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
refreshMyWorkView();
if (result.confirmed?.length) {
const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', ');
qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
const confirmed = result.confirmed[result.confirmed.length - 1];
const created = lastMyWork.find(item =>
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
);
if (openCreated && created) openRoutedWork(created, qs('#new-issue'));
} else if ((result.remaining || []).some(item => item.status === 'attention')) {
qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.';
} else {
qs('#my-work-action-status').textContent = 'Queued for sync when the connection returns.';
}
}
async function flushIssueOutbox() {
if (!navigator.onLine || !issueOutbox.list().length) return;
applyOutboxResult(await issueOutbox.flush());
}
function inlineAnchor(target) {
return {
path: target.dataset.reviewFilename,
@@ -2491,7 +2552,10 @@ textarea { resize: vertical; min-height: 120px; }
qs('#create-issue-title').focus();
});
qs('#cancel-new-issue').addEventListener('click', () => {
saveIssueCaptureDraft();
if (editingOutboxId) {
issueCapture.clearDraft();
editingOutboxId = null;
} else saveIssueCaptureDraft();
closeCreateIssueSheet();
});
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
@@ -2521,18 +2585,16 @@ textarea { resize: vertical; min-height: 120px; }
}
const button = qs('#submit-new-issue');
button.disabled = true;
qs('#create-issue-status').textContent = 'Creating issue…';
qs('#create-issue-status').textContent = navigator.onLine ? 'Sending issue…' : 'Saving issue to outbox…';
try {
const confirmed = await issueCapture.submit(captureDraft);
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
lastMyWork = buildMyWork(lastContextSnapshot);
const created = lastMyWork.find(item =>
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
);
const queued = editingOutboxId ? issueOutbox.update(editingOutboxId, captureDraft) :
issueOutbox.enqueue(captureDraft);
editingOutboxId = null;
issueCapture.clearDraft();
closeCreateIssueSheet();
refreshMyWorkView();
qs('#my-work-action-status').textContent = created.key + ' created and assigned to you.';
openRoutedWork(created, qs('#new-issue'));
qs('#my-work-action-status').textContent = 'Queued for sync.';
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id), true);
} catch (error) {
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
button.disabled = false;
@@ -3089,6 +3151,7 @@ textarea { resize: vertical; min-height: 120px; }
if (!navigator.onLine) showOfflineStatus();
window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectLiveData);
window.addEventListener('online', flushIssueOutbox);
qs('#refresh').addEventListener('click', load);
qs('#start-work-session').addEventListener('click', () => {
@@ -3173,6 +3236,7 @@ textarea { resize: vertical; min-height: 120px; }
);
}
contextPoller.start();
flushIssueOutbox();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
});
+123
View File
@@ -0,0 +1,123 @@
function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 20 }) {
const storageKey = 'stackchain.issue-outbox.v1';
const operationId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && typeof item === 'object');
} catch (_error) { return []; }
}
function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
}
function enqueue(draft) {
const items = read();
if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.');
const item = {
id: String(operationId()).slice(0, 128),
operationId: '',
repository: String(draft?.repository || ''),
title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
status: 'queued',
queuedAt: Number(now()),
};
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
}
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
items.push(item);
write(items);
return item;
}
function update(id, draft) {
let updated = null;
write(read().map(item => {
if (item.id !== id) return item;
updated = {
...item,
repository: String(draft?.repository || ''), title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
status: 'queued',
};
delete updated.error;
return updated;
}));
return updated;
}
function discard(id) {
const items = read();
if (!items.some(item => item.id === id)) return false;
write(items.filter(item => item.id !== id));
return true;
}
async function sendItem(item) {
if (pending.has(item.id)) return pending.get(item.id);
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
const request = (async () => {
try {
const issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
discard(item.id);
return { issue };
} catch (error) {
const status = Number(error?.status || 0);
if (status >= 400 && status < 500) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
} : candidate));
}
return { error, transient: !(status >= 400 && status < 500) };
}
})();
pending.set(item.id, request);
try { return await request; }
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
}
async function flush() {
const confirmed = [];
for (const item of read()) {
if (item.status === 'attention') continue;
const result = await sendItem(item);
if (result.issue) confirmed.push(result.issue);
if (result.transient) break;
}
return { confirmed, remaining: read() };
}
async function retry(id) {
const item = read().find(candidate => candidate.id === id);
if (!item) return { confirmed: [], remaining: read() };
update(id, item);
const result = await sendItem({ ...item, status: 'queued' });
return { confirmed: result.issue ? [result.issue] : [], remaining: read() };
}
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
+2 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'stackchain-dashboard-shell-v4';
const CACHE = 'stackchain-dashboard-shell-v5';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const BASE = new URL('./', self.location.href).pathname;
const SHELL = [
@@ -11,6 +11,7 @@ const SHELL = [
BASE + 'static/search-preview.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/my-work.js',
BASE + 'static/pick-work.js',