Merge pull request 'Add photo evidence to active Today progress updates' (#1055) from timmy/1054-mobile-today-progress-photo-evidence into main
All checks were successful
CI / lint (push) Successful in 2m41s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m1s
CI / release-candidate (push) Successful in 6s

This commit is contained in:
rockachopa 2026-08-18 01:48:47 +00:00
commit 157d485685
8 changed files with 277 additions and 37 deletions

View File

@ -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;
}

View File

@ -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);

View File

@ -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; } }

View File

@ -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(),
});

View File

@ -1546,6 +1546,30 @@
<p id="today-progress-target" class="small"></p>
<label for="today-progress-body">Update</label>
<textarea id="today-progress-body" rows="5" maxlength="2000" placeholder="What changed, what you learned, or what comes next"></textarea>
<section class="today-progress-evidence" aria-labelledby="today-progress-evidence-label">
<strong id="today-progress-evidence-label">Photo evidence <span class="small">Optional · Up to 5</span></strong>
<div class="issue-attachment-controls conversation-photo-actions">
<label class="issue-attachment-trigger" for="take-today-progress-photo">Take photo</label>
<input class="visually-hidden" id="take-today-progress-photo" type="file" accept="image/*" capture="environment" />
<label class="issue-attachment-trigger" for="today-progress-attachment">Choose existing</label>
<input class="visually-hidden" id="today-progress-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
</div>
<div class="issue-attachment-preview conversation-evidence-review" id="today-progress-attachment-preview" hidden>
<img id="today-progress-attachment-image" alt="Selected progress photo preview" />
<span class="small" id="today-progress-attachment-meta"></span>
<div class="issue-evidence-tray" id="today-progress-attachment-tray" role="toolbar" aria-label="Progress photos" hidden></div>
<label class="issue-evidence-note" for="today-progress-attachment-note">
<span id="today-progress-attachment-note-label">Photo caption (optional)</span>
<textarea id="today-progress-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
<button id="move-today-progress-attachment-earlier" type="button">Move earlier</button>
<button id="edit-today-progress-attachment" type="button">Edit photo</button>
<button id="remove-today-progress-attachment" type="button">Remove selected</button>
<button id="move-today-progress-attachment-later" type="button">Move later</button>
</div>
</div>
</section>
<p class="small muted">Save privately on this device, or post to the exact Gitea item. Today keeps running either way.</p>
<p id="today-progress-status" class="small" role="status" aria-live="polite"></p>
<div class="today-progress-actions">

View File

@ -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;
}

View File

@ -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()

View File

@ -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