diff --git a/frontend/conversation-photo-drafts.js b/frontend/conversation-photo-drafts.js index d1a41aa..ad052d1 100644 --- a/frontend/conversation-photo-drafts.js +++ b/frontend/conversation-photo-drafts.js @@ -35,12 +35,11 @@ await current.pending; const attachments = await store.load(target); if (generation !== current.generation) return false; - current.controller.clear(); - if (attachments?.length) { - current.restoring = true; - try { current.controller.restore(attachments); } - finally { current.restoring = false; } - } + current.restoring = true; + try { + current.controller.clear(); + if (attachments?.length) current.controller.restore(attachments); + } finally { current.restoring = false; } return Boolean(attachments?.length); } @@ -51,9 +50,13 @@ try { await checkpoint(kind); } catch (_error) { return false; } if (generation === current.generation) { - current.controller.clear(); - current.target = null; - current.generation += 1; + current.restoring = true; + try { current.controller.clear(); } + finally { + current.restoring = false; + current.target = null; + current.generation += 1; + } } return true; } @@ -64,9 +67,13 @@ const target = { ...current.target }; await current.pending; await store.remove(target); - current.controller.clear(); - current.target = null; - current.generation += 1; + current.restoring = true; + try { current.controller.clear(); } + finally { + current.restoring = false; + current.target = null; + current.generation += 1; + } return true; } diff --git a/frontend/conversation-reply-draft-store.js b/frontend/conversation-reply-draft-store.js index 0904a69..0d8b0a3 100644 --- a/frontend/conversation-reply-draft-store.js +++ b/frontend/conversation-reply-draft-store.js @@ -73,9 +73,10 @@ } return function createConversationReplyDraftStore({ - indexedDB = globalThis.indexedDB, transaction, getOwnerLogin = () => '', + indexedDB = globalThis.indexedDB, transaction, getOwnerLogin = () => '', scope = 'conversation', } = {}) { const transact = transaction || createTransaction(indexedDB); + const draftScope = String(scope || 'conversation').trim().slice(0, 64) || 'conversation'; function identity(target) { const ownerLogin = String(getOwnerLogin() || '').trim(); @@ -83,26 +84,30 @@ const normalized = normalizedTarget(target); const targetKey = normalized.kind === 'update' ? normalized.notificationId : normalized.repository + ':' + normalized.number; + const identityParts = draftScope === 'conversation' ? [ownerLogin, normalized.kind, targetKey] : + [ownerLogin, draftScope, normalized.kind, targetKey]; return { - ownerLogin, target:normalized, - id:[ownerLogin, normalized.kind, targetKey].map(value => encodeURIComponent(String(value))).join(':'), + ownerLogin, scope:draftScope, target:normalized, + id:identityParts.map(value => encodeURIComponent(String(value))).join(':'), }; } async function save(target, values) { if (!transact) throw new Error('Photo draft storage needs IndexedDB. Your current photos are still here.'); - const { id, ownerLogin, target:normalized } = identity(target); + const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target); const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment); if (!list.length) { await transact('delete', id); return null; } - await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list }); + await transact('put', id, { id, version:1, ownerLogin, scope:recordScope, ...normalized, attachments:list }); return list; } async function load(target) { if (!transact) return null; - const { id, ownerLogin, target:normalized } = identity(target); + const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target); const record = await transact('get', id); - const same = record?.version === 1 && record.ownerLogin === ownerLogin && + const scopeMatches = recordScope === 'conversation' ? (!record?.scope || record.scope === recordScope) : + record?.scope === recordScope; + const same = record?.version === 1 && record.ownerLogin === ownerLogin && scopeMatches && record.kind === normalized.kind && (normalized.kind === 'update' ? Number(record.notificationId) === normalized.notificationId : record.repository === normalized.repository && Number(record.number) === normalized.number); diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 1e527a5..4fc5eb0 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -276,6 +276,9 @@ textarea { resize: vertical; min-height: 120px; } .today-progress-panel h2, .today-progress-panel p { margin-top:0; } .today-progress-panel header button, .today-progress-actions button { min-height:44px; } .today-progress-panel textarea { box-sizing:border-box; width:100%; min-height:120px; resize:vertical; } +.today-progress-evidence { display:grid; gap:8px; min-width:0; margin-top:12px; } +.today-progress-evidence .issue-attachment-preview { width:100%; box-sizing:border-box; } +.today-progress-evidence .issue-evidence-note textarea { min-height:72px; } .today-progress-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; } .today-progress-actions button { width:100%; } @media(max-width:359px) { .today-progress-actions { grid-template-columns:1fr; } } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 5cd31c6..12eacc5 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -833,6 +833,11 @@ update:photoDraftLane(updateReplyAttachmentController, '#update-reply-status'), }, }); + const todayProgressPhotos = createTodayProgressPhotos({ + qs, document, issueAttachment, fetchJson:fetchReviewJson, + createStore:createConversationReplyDraftStore, createDrafts:createConversationPhotoDrafts, + getLogin:() => confirmedOwnerLogin, + }); const voiceTranscriptStore = createVoiceTranscriptStore(); function mountConversationVoice(kind, draftSelector) { return createVoiceConversationCapture({ @@ -1970,7 +1975,7 @@ }; } const todayProgressView = createTodayProgressView({ - progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, + progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos, announce:message => { qs('#my-work-action-status').textContent = message; }, onAdmitted:() => refreshMyWorkView(), }); diff --git a/frontend/index.html b/frontend/index.html index be790f4..fb4b4c2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1546,6 +1546,30 @@

+
+ Photo evidence Optional · Up to 5 +
+ + + + +
+ +

Save privately on this device, or post to the exact Gitea item. Today keeps running either way.

diff --git a/frontend/today-progress.js b/frontend/today-progress.js index 00fe89d..4d98a5d 100644 --- a/frontend/today-progress.js +++ b/frontend/today-progress.js @@ -5,7 +5,7 @@ maxLength = 2000, maxItems = 20 }) { const login = () => String(getLogin() || '').trim().toLowerCase(); const storageKey = () => login() ? prefix + encodeURIComponent(login()) : ''; const validIdentity = identity => typeof identity === 'string' && identity.length > 0 && identity.length <= 500; - const validRecord = record => record && typeof record.body === 'string' && record.body.length > 0 && + const validRecord = record => record && typeof record.body === 'string' && (record.body.length > 0 || record.has_attachments === true) && record.body.length <= maxLength && typeof record.operation_id === 'string' && record.operation_id.length > 0 && record.operation_id.length <= 128; @@ -42,12 +42,12 @@ maxLength = 2000, maxItems = 20 }) { return read()[identity]?.body || ''; } - function save(identity, value) { + function save(identity, value, hasAttachments = false) { if (!validIdentity(identity) || !storageKey()) return false; const body = String(value || '').trim(); if (body.length > maxLength) return false; const drafts = read(); - if (!body) { + if (!body && !hasAttachments) { delete drafts[identity]; return write(drafts); } @@ -56,6 +56,7 @@ maxLength = 2000, maxItems = 20 }) { drafts[identity] = { body, operation_id: previous?.body === body ? previous.operation_id : String(makeId()).slice(0, 128), + ...(hasAttachments ? {has_attachments:true} : {}), }; return write(drafts); } @@ -66,16 +67,21 @@ maxLength = 2000, maxItems = 20 }) { Number.isInteger(target.number) && target.number > 0; } - async function post(target, value) { + async function post(target, value, attachments = [], completeEvidence) { if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.'); - if (value !== undefined && !save(target.identity, value)) throw new Error('Progress update could not be saved on this device.'); + const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5); + if ((value !== undefined || evidence.length) && !save(target.identity, value ?? load(target.identity), evidence.length > 0)) { + throw new Error('Progress update could not be saved on this device.'); + } const record = read()[target.identity]; if (!record) throw new Error('Write a progress update before posting.'); if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.'); const admission = await admit({ kind:target.kind + '-comment', repository:target.repository, number:target.number, body:record.body, operationId:record.operation_id, + ...(evidence.length ? {attachments:evidence} : {}), }); + if (typeof completeEvidence === 'function') await completeEvidence(); const drafts = read(); if (drafts[target.identity]?.operation_id === record.operation_id) { delete drafts[target.identity]; @@ -87,7 +93,7 @@ maxLength = 2000, maxItems = 20 }) { return { load, save, discard:identity => save(identity, ''), post }; } -function createTodayProgressView({ progress, currentTarget, qs, announce = () => {}, onAdmitted = () => {} }) { +function createTodayProgressView({ progress, currentTarget, qs, photos, announce = () => {}, onAdmitted = () => {} }) { const sheet = qs('#today-progress-sheet'); const body = qs('#today-progress-body'); const status = qs('#today-progress-status'); @@ -95,9 +101,12 @@ function createTodayProgressView({ progress, currentTarget, qs, announce = () => let openedTarget = null; const update = () => { launcher.hidden = !currentTarget(); }; - const checkpoint = () => { + const checkpoint = async () => { if (!openedTarget) return false; - if (progress.save(openedTarget.identity, body.value)) return true; + if (progress.save(openedTarget.identity, body.value, photos?.has?.())) { + try { await photos?.checkpoint?.(); return true; } + catch (error) { status.textContent = error.message + ' Your photos remain here; retry.'; return false; } + } status.textContent = 'Update must be 2,000 characters or fewer and device storage must be available.'; return false; }; @@ -106,25 +115,27 @@ function createTodayProgressView({ progress, currentTarget, qs, announce = () => openedTarget = null; }; - launcher.addEventListener('click', () => { + launcher.addEventListener('click', async () => { const target = currentTarget(); if (!target) return; openedTarget = target; qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : ''); body.value = progress.load(target.identity); - status.textContent = ''; + status.textContent = 'Restoring saved photo evidence…'; sheet.showModal(); + try { await photos?.open?.(target); status.textContent = ''; } + catch (error) { status.textContent = error.message + ' You can retry by reopening this update.'; } body.focus(); }); - qs('#cancel-today-progress').addEventListener('click', () => { - if (checkpoint()) close(); + qs('#cancel-today-progress').addEventListener('click', async () => { + if (await checkpoint()) close(); }); - sheet.addEventListener('cancel', event => { + sheet.addEventListener('cancel', async event => { event.preventDefault(); - if (checkpoint()) close(); + if (await checkpoint()) close(); }); - qs('#save-today-progress').addEventListener('click', () => { - if (!checkpoint()) return; + qs('#save-today-progress').addEventListener('click', async () => { + if (!await checkpoint()) return; announce('Progress update saved privately to this Today item.'); close(); }); @@ -138,7 +149,9 @@ function createTodayProgressView({ progress, currentTarget, qs, announce = () => button.disabled = true; status.textContent = 'Saving for delivery…'; try { - const admission = await progress.post(target, body.value); + await photos?.checkpoint?.(); + const attachments = await photos?.serialize?.() || []; + const admission = await progress.post(target, body.value, attachments, () => photos?.complete?.()); onAdmitted(admission); announce(admission.background ? 'Progress update queued for delivery. Today is still on the same item.' : @@ -155,7 +168,62 @@ function createTodayProgressView({ progress, currentTarget, qs, announce = () => return { update }; } +function createTodayProgressPhotos({ qs, document, issueAttachment, fetchJson, createStore, createDrafts, getLogin }) { + let target = null; + let drafts = null; + const controller = issueAttachment.mount({ + maxFiles:5, input:qs('#today-progress-attachment'), + inputs:[qs('#take-today-progress-photo'), qs('#today-progress-attachment')], + preview:qs('#today-progress-attachment-preview'), image:qs('#today-progress-attachment-image'), + meta:qs('#today-progress-attachment-meta'), remove:qs('#remove-today-progress-attachment'), + tray:qs('#today-progress-attachment-tray'), earlier:qs('#move-today-progress-attachment-earlier'), + later:qs('#move-today-progress-attachment-later'), note:qs('#today-progress-attachment-note'), + noteLabel:qs('#today-progress-attachment-note-label'), status:qs('#today-progress-status'), + onChange:() => drafts?.checkpoint('today').catch(() => {}), + onCheckpoint:() => drafts.checkpoint('today'), + readyMessage:'Photo ready to post with this Today update.', + removedMessage:'Photo removed. Your progress text is unchanged.', + editor:{ + document, edit:qs('#edit-today-progress-attachment'), dialog:qs('#issue-evidence-editor'), + canvas:qs('#issue-evidence-editor-canvas'), exportCanvas:qs('#issue-evidence-editor-export'), + crop:qs('#crop-issue-evidence'), redact:qs('#redact-issue-evidence'), + highlight:qs('#highlight-issue-evidence'), arrow:qs('#arrow-issue-evidence'), + undo:qs('#undo-issue-evidence-edit'), reset:qs('#reset-issue-evidence-edit'), + cancel:qs('#cancel-issue-evidence-edit'), apply:qs('#apply-issue-evidence-edit'), + status:qs('#issue-evidence-editor-status'), appliedMessage:'Edited photo ready for this Today update.', + }, + createObjectURL:file => URL.createObjectURL(file), revokeObjectURL:url => URL.revokeObjectURL(url), + upload:payload => { + if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) { + return Promise.reject(new Error('The active Today item changed. Reopen its update before posting.')); + } + const repository = payload.repository.split('/').map(encodeURIComponent).join('/'); + const resource = target.kind === 'pull' ? 'pulls' : 'issues'; + return fetchJson('api/v1/repos/' + repository + '/' + resource + '/' + + encodeURIComponent(payload.number) + '/attachments', { + method:'POST', headers:{Accept:'application/json','Idempotency-Key':payload.operation_id}, + body:issueAttachment.multipart(payload), + }); + }, + }); + const store = createStore({ indexedDB:globalThis.indexedDB, getOwnerLogin:getLogin, scope:'today-progress' }); + drafts = createDrafts({ store, lanes:{ today:{ controller, onError:error => { + qs('#today-progress-status').textContent = error.message + ' Your photos remain here; retry.'; + } } } }); + return { + has:() => Boolean(controller.state()), serialize:() => controller.serialize(), + checkpoint:() => drafts.checkpoint('today'), + open:async value => { + if (drafts.hasTarget('today')) await drafts.switchTo('today', value); + else await drafts.open('today', value); + target = { ...value }; + }, + complete:async () => { await drafts.complete('today'); target = null; }, + }; +} + if (typeof module !== 'undefined' && module.exports) { module.exports = createTodayProgress; module.exports.createView = createTodayProgressView; + module.exports.createPhotos = createTodayProgressPhotos; } diff --git a/tests/test_conversation_photo_drafts.py b/tests/test_conversation_photo_drafts.py index cef7ac1..fc02fbd 100644 --- a/tests/test_conversation_photo_drafts.py +++ b/tests/test_conversation_photo_drafts.py @@ -79,6 +79,36 @@ const photos = [ } +def test_store_isolates_today_progress_photos_from_conversation_photos(): + script = f""" +const createStore = require({json.dumps(str(STORE))}); +const records = new Map(); +const transaction = async (operation, key, value) => {{ + if (operation === 'put') records.set(key, structuredClone(value)); + if (operation === 'get') return records.has(key) ? structuredClone(records.get(key)) : null; + if (operation === 'delete') records.delete(key); +}}; +const conversation = createStore({{transaction,getOwnerLogin:()=> 'timmy'}}); +const progress = createStore({{transaction,getOwnerLogin:()=> 'timmy',scope:'today-progress'}}); +const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}}; +(async()=>{{ + await conversation.save(target,[{{filename:'reply.jpg',contentType:'image/jpeg',blob:new Blob(['reply'])}}]); + await progress.save(target,[{{filename:'progress.jpg',contentType:'image/jpeg',blob:new Blob(['progress'])}}]); + const reply=await conversation.load(target); const today=await progress.load(target); + await progress.remove(target); + process.stdout.write(JSON.stringify({{reply:reply[0].filename,today:today[0].filename, + replyAfter:(await conversation.load(target))[0].filename,progressAfter:await progress.load(target),count:records.size}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + assert json.loads(run_node(script)) == { + "reply": "reply.jpg", + "today": "progress.jpg", + "replyAfter": "reply.jpg", + "progressAfter": None, + "count": 1, + } + + def test_coordinator_ignores_stale_restores_and_clears_only_after_durable_completion(): script = f""" const createCoordinator = require({json.dumps(str(COORDINATOR))}); @@ -138,6 +168,35 @@ const drafts = createCoordinator({{store, lanes:{{issue:{{controller}}}}}}); assert json.loads(run_node(script)) == ["save:1:first.webp", "restore:second.webp"] +def test_coordinator_clear_does_not_delete_the_durable_draft_during_reopen(): + script = f""" +const createCoordinator = require({json.dumps(str(COORDINATOR))}); +const records=new Map(); let current=[]; let drafts; const restored=[]; +const key=target=>target.kind+':'+target.number; +const store={{ + save:async(target,attachments)=>attachments?.length?records.set(key(target),attachments):records.delete(key(target)), + load:async target=>records.get(key(target))||null, + remove:async target=>records.delete(key(target)), +}}; +const controller={{ + serialize:async()=>current, + restore:value=>{{current=value;restored.push(value[0].filename);}}, + clear:()=>{{current=[];drafts.checkpoint('today').catch(()=>{{}});}}, +}}; +drafts=createCoordinator({{store,lanes:{{today:{{controller}}}}}}); +const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}}; +(async()=>{{ + await drafts.open('today',target); + current=[{{filename:'proof.webp',contentType:'image/webp',blob:new Blob(['proof'])}}]; + await drafts.checkpoint('today'); + await drafts.switchTo('today',target); + await Promise.resolve(); + process.stdout.write(JSON.stringify({{restored,saved:(await store.load(target))?.[0]?.filename||null}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + assert json.loads(run_node(script)) == {"restored": ["proof.webp"], "saved": "proof.webp"} + + def test_my_work_wires_durable_photo_drafts_into_all_conversation_boundaries(): dashboard = DASHBOARD.read_text() html = INDEX.read_text() diff --git a/tests/test_today_progress.py b/tests/test_today_progress.py index 54e3117..39d1c80 100644 --- a/tests/test_today_progress.py +++ b/tests/test_today_progress.py @@ -84,6 +84,67 @@ process.stdout.write(JSON.stringify({{error,retained,after:progress.load(target. assert output["admitted"]["background"] is True +def test_photo_only_progress_is_durably_admitted_and_retained_until_success(): + script = f""" +const createProgress = require({json.dumps(str(TODAY_PROGRESS))}); +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 photo = {{filename:'result.jpg',contentType:'image/jpeg',blob:{{size:42}},operationId:'photo-op-1'}}; +const calls=[]; let fail=true; +const progress=createProgress({{ + storage, getLogin:()=> 'timmy', makeId:()=> 'progress-photo-op-1', + admit:async message => {{ calls.push(message); if (fail) throw new Error('photo store unavailable'); return {{background:true}}; }}, +}}); +const target={{identity:'issue:stackchain/dashboard:12:',kind:'issue',repository:'stackchain/dashboard',number:12}}; +let error=''; +try {{ await progress.post(target, '', [photo]); }} catch (caught) {{ error=caught.message; }} +const retained=progress.load(target.identity); +fail=false; +await progress.post(target, undefined, [photo]); +process.stdout.write(JSON.stringify({{error,retained,after:progress.load(target.identity),calls}})); +""" + output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})") + assert output["error"] == "photo store unavailable" + assert output["retained"] == "" + assert output["after"] == "" + assert len(output["calls"]) == 2 + assert output["calls"][0] == output["calls"][1] == { + "kind": "issue-comment", + "repository": "stackchain/dashboard", + "number": 12, + "body": "", + "operationId": "progress-photo-op-1", + "attachments": [{ + "filename": "result.jpg", + "contentType": "image/jpeg", + "blob": {"size": 42}, + "operationId": "photo-op-1", + }], + } + + +def test_photo_cleanup_failure_retries_with_the_same_comment_operation(): + script = f""" +const createProgress = require({json.dumps(str(TODAY_PROGRESS))}); +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 calls=[]; let cleanupFails=true; +const progress=createProgress({{storage,getLogin:()=> 'timmy',makeId:()=> 'stable-progress-op',admit:async message=>{{calls.push(message);return {{background:true}};}}}}); +const target={{identity:'pull:stackchain/dashboard:22:',kind:'pull',repository:'stackchain/dashboard',number:22}}; +const photo={{filename:'proof.webp',contentType:'image/webp',blob:{{size:9}},operationId:'photo-op'}}; +const cleanup=async()=>{{if(cleanupFails)throw new Error('draft cleanup blocked')}}; +let error=''; +try {{await progress.post(target,'',[photo],cleanup);}} catch(caught){{error=caught.message;}} +cleanupFails=false; +await progress.post(target,undefined,[photo],cleanup); +process.stdout.write(JSON.stringify({{error,calls,after:progress.load(target.identity)}})); +""" + output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})") + assert output["error"] == "draft cleanup blocked" + assert [call["operationId"] for call in output["calls"]] == ["stable-progress-op", "stable-progress-op"] + assert output["after"] == "" + + def test_progress_rejects_inactive_or_unsupported_targets_without_mutation(): script = f""" const createProgress = require({json.dumps(str(TODAY_PROGRESS))}); @@ -116,6 +177,9 @@ def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware(): assert 'id="today-progress-sheet"' in html assert 'aria-labelledby="today-progress-title"' in html assert 'id="today-progress-body"' in html + assert 'id="take-today-progress-photo"' in html + assert 'id="today-progress-attachment"' in html + assert 'id="today-progress-attachment-preview"' in html assert 'maxlength="2000"' in html assert 'id="save-today-progress"' in html assert 'id="post-today-progress"' in html @@ -123,6 +187,11 @@ def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware(): assert '"static/today-progress.js"' in bundle assert "workSession.target('continue')" in dashboard assert "authoredOutbox.enqueueDurably" in dashboard + assert "createTodayProgressPhotos" in dashboard + assert "scope:'today-progress'" in TODAY_PROGRESS.read_text() + assert "lanes:{ today:" in TODAY_PROGRESS.read_text() + assert "photos:todayProgressPhotos" in dashboard + assert "const controller = issueAttachment.mount" in TODAY_PROGRESS.read_text() assert ".today-progress-panel" in css assert "env(safe-area-inset-bottom)" in css assert ".today-progress-actions button" in css and "min-height:44px" in css