feat: sync offline issue captures (#232)
This commit is contained in:
1 parent
7110b11c15
commit
51bd9d41f2
8 files changed
+407
-24
No files matched your search
+86
-22
@@ -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);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user