From a08ce318584a4a12875be16cfd2c70eb2f75b673 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 7 Aug 2026 16:55:00 +0000 Subject: [PATCH] feat: recover mobile drafts from My Work (#211) --- frontend/drafts.js | 144 ++++++++++++++++++++++++++++++++++++++++++ frontend/index.html | 66 +++++++++++++++++-- tests/test_drafts.py | 117 ++++++++++++++++++++++++++++++++++ tests/test_my_work.py | 3 +- 4 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 frontend/drafts.js create mode 100644 tests/test_drafts.py diff --git a/frontend/drafts.js b/frontend/drafts.js new file mode 100644 index 0000000..444817d --- /dev/null +++ b/frontend/drafts.js @@ -0,0 +1,144 @@ +function createDraftInbox({ storage, now = () => Date.now() }) { + const indexKey = 'stackchain.draft-index.v1'; + + function readIndex() { + try { + const value = JSON.parse(storage?.getItem(indexKey) || '{}'); + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + } catch (_error) { return {}; } + } + + function writeIndex(index) { + try { storage?.setItem(indexKey, JSON.stringify(index)); } + catch (_error) { /* Draft discovery remains available without metadata. */ } + } + + function keys() { + try { + return Array.from({ length: Number(storage?.length || 0) }, (_, index) => storage.key(index)) + .filter(Boolean); + } catch (_error) { return []; } + } + + function textPreview(value) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 180); + } + + function parseTarget(value) { + const match = String(value || '').match(/^(.+)#(\d+)$/); + return match ? { repository: match[1], number: Number(match[2]) } : null; + } + + function parse(key, raw) { + let match; + if (key === 'stackchain.issue-capture.v1') { + try { + const value = JSON.parse(raw); + const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — ')); + if (!preview && !value?.repository) return null; + return { + kind: 'new-issue', label: 'New issue', repository: String(value?.repository || ''), + title: textPreview(value?.title) || 'Untitled new issue', preview, + }; + } catch (_error) { return null; } + } + match = key.match(/^stackchain\.(issue|pull)-comment\.v1:(.+#\d+)$/); + if (match) { + const target = parseTarget(match[2]); + const preview = textPreview(raw); + if (!target || !preview) return null; + const routeKind = match[1] === 'issue' ? 'issue' : 'pull'; + return { + kind: routeKind + '-comment', label: routeKind === 'issue' ? 'Issue comment' : 'PR comment', + ...target, title: target.repository + '#' + target.number, preview, + route: { kind: routeKind, ...target }, + }; + } + match = key.match(/^stackchain\.issue-content\.v1:(.+#\d+)$/); + if (match) { + const target = parseTarget(match[1]); + try { + const value = JSON.parse(raw); + const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — ')); + if (!target || !preview || typeof value?.expectedUpdatedAt !== 'string') return null; + return { + kind: 'issue-edit', label: 'Issue edit', ...target, + title: target.repository + '#' + target.number, preview, + route: { kind: 'issue', ...target }, + }; + } catch (_error) { return null; } + } + match = key.match(/^stackchain\.update-reply\.v1\.(\d+)$/); + if (match) { + const preview = textPreview(raw); + if (!preview) return null; + const notificationId = Number(match[1]); + return { + kind: 'update-reply', label: 'Update reply', notification_id: notificationId, + title: 'Update #' + notificationId, preview, + route: { kind: 'update', notification_id: notificationId }, + }; + } + match = key.match(/^stackchain\.review-draft\.v1:(.+#\d+)@([^:]+)$/); + if (match) { + const target = parseTarget(match[1]); + try { + const value = JSON.parse(raw); + const notes = Object.values(value?.notes || {}); + const comments = Array.isArray(value?.comments) ? value.comments.map(item => item?.body) : []; + const preview = textPreview([value?.summary, ...notes, ...comments].filter(Boolean).join(' — ')); + if (!target || !preview) return null; + return { + kind: 'review', label: 'PR review', ...target, head_sha: match[2], + title: target.repository + '#' + target.number, preview, + route: { kind: 'review', ...target }, + }; + } catch (_error) { return null; } + } + return null; + } + + function list() { + const index = readIndex(); + const seen = new Set(); + const drafts = []; + keys().forEach(key => { + if (key === indexKey || key.endsWith(':operation')) return; + let raw; + try { raw = storage.getItem(key); } + catch (_error) { return; } + const parsed = parse(key, raw); + if (!parsed) return; + seen.add(key); + const fingerprint = String(raw); + const previous = index[key]; + if (!previous || previous.fingerprint !== fingerprint) { + index[key] = { fingerprint, updated_at: Number(now()) }; + } + drafts.push({ id: key, ...parsed, updated_at: index[key].updated_at }); + }); + Object.keys(index).forEach(key => { if (!seen.has(key)) delete index[key]; }); + writeIndex(index); + return drafts.sort((left, right) => + Number(right.updated_at || 0) - Number(left.updated_at || 0) || left.id.localeCompare(right.id) + ); + } + + function discard(id) { + if (typeof id !== 'string' || !parse(id, (() => { + try { return storage?.getItem(id); } catch (_error) { return null; } + })())) return false; + try { + storage?.removeItem(id); + storage?.removeItem(id + ':operation'); + const index = readIndex(); + delete index[id]; + writeIndex(index); + return true; + } catch (_error) { return false; } + } + + return { list, discard }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox; diff --git a/frontend/index.html b/frontend/index.html index 57b10e3..b4a47fb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -65,6 +65,10 @@ textarea { resize: vertical; min-height: 120px; } .milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); } .work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); } .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 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; } .my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; } @@ -276,6 +280,7 @@ textarea { resize: vertical; min-height: 120px; } +