From 3f559a50eaf803f61a59e763130ba214c321c5ce Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 21:09:25 +0000 Subject: [PATCH] feat: add mobile checklist steps inline (Closes #915) --- README.md | 7 +- frontend/checklist-conflict.js | 19 +++++ frontend/dashboard.css | 7 ++ frontend/dashboard.js | 42 +++++++++++ frontend/index.html | 10 +++ frontend/issue-sheet.js | 30 ++++++++ frontend/service-worker.js | 3 +- tests/test_checklist_conflict.py | 35 +++++++++ tests/test_comment_next.py | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_my_work.py | 91 +++++++++++++++++++++++ tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 31 +++++--- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 19 files changed, 272 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index ca3cc82..c569e3c 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,12 @@ explicit **Add blocked item anyway** override, while an unavailable dependency l as unknown rather than unblocked. Starting a Today work session also stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap. After a reload or installed-app -restart, **Resume Today** reopens the saved item (or the next surviving item if work changed); +restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open +assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps +**Add step** directly beside the plan. A new step is normalized, checked for duplicates, and appended without +exposing or replacing the full issue description. Offline additions enter the same account-bound durable +issue-content queue as checklist toggles; reconnect conflict review applies unique local additions to the +latest remote body while preserving remote prose and task-state changes. **Comment & next** on that current issue or pull request posts the handoff online or admits it to durable account-bound delivery, then removes the item only from Today and opens the next one without closing or merging it. **Reply & next** provides the same one-action continuation diff --git a/frontend/checklist-conflict.js b/frontend/checklist-conflict.js index 3e9f423..5fc9a20 100644 --- a/frontend/checklist-conflict.js +++ b/frontend/checklist-conflict.js @@ -42,6 +42,22 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) { changes.push({ label: remoteMatches[0].label, checked: localMatches[0].checked }); } + for (const [key, localMatches] of local) { + if (base.has(key)) continue; + if (localMatches.length !== 1) { + conflicts.push({ label:localMatches[0].label, reason:'ambiguous' }); + continue; + } + const remoteMatches = remote.get(key) || []; + if (remoteMatches.length > 1) { + conflicts.push({ label: localMatches[0].label, reason: 'ambiguous' }); + continue; + } + if (remoteMatches.length === 0) { + changes.push({ label: localMatches[0].label, checked:localMatches[0].checked, added:true }); + } + } + if (conflicts.length) return { body: null, changes, conflicts }; const desired = new Map(changes.map(change => [ change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked, @@ -52,6 +68,9 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) { const marker = desired.get(entry.key) ? 'x' : ' '; lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4]; }); + changes.filter(change => change.added).forEach(change => { + lines.push('- [' + (change.checked ? 'x' : ' ') + '] ' + change.label); + }); return { body: lines.join('\n'), changes, conflicts: [] }; } diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 3d79b38..be7065e 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -474,6 +474,13 @@ textarea { resize: vertical; min-height: 120px; } .completed-filed-actions button { min-height:44px; min-width:0; } #issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(110px + env(safe-area-inset-bottom)); } .issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } +.checklist-add { display:grid; gap:8px; margin:10px 0 16px; } +.checklist-add form { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; } +.checklist-add form[hidden] { display:none; } +.checklist-add input { min-width:0; width:100%; box-sizing:border-box; } +.checklist-add button, .checklist-add input { min-height:44px; } +#issue-sheet.read-only .checklist-add { display:none; } +@media(max-width:340px) { .checklist-add form { grid-template-columns:1fr 1fr; } .checklist-add input { grid-column:1 / -1; } } .checklist-completion { position:fixed; right:0; bottom:0; z-index:58; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr) repeat(2,minmax(0,auto)); align-items:center; gap:8px; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid #4ade80; background:rgba(11,21,38,.98); overflow-wrap:anywhere; } .checklist-completion[hidden] { display:none; } .checklist-completion button { min-height:44px; min-width:0; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index ac3ba51..7f12d9d 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -7,6 +7,7 @@ mediaQuery: window.matchMedia('(max-width: 600px)'), entries: [ { panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') }, + { panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.checklist-add'), composer:qs('#add-checklist-step'), submit:qs('#save-checklist-step'), status:qs('#add-checklist-step-status') }, { panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') }, { panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') }, { panel:qs('#review-sheet .review-sheet-panel'), workspace:qs('#review-inline-composer'), composer:qs('#review-inline-body'), submit:qs('#save-inline-comment'), status:qs('#review-submit-status') }, @@ -3455,6 +3456,7 @@ function renderIssueBody(detail) { const interactive = !issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at; issueController.renderTasks(qs('#issue-sheet-body'), detail, interactive); + qs('#open-add-checklist-step').disabled = !interactive; renderChecklistCompletion(detail); } @@ -3503,6 +3505,10 @@ qs('#completed-filed-progress').textContent = item.is_completed ? 'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : ''; qs('#issue-sheet-body').textContent = ''; + qs('#open-add-checklist-step').disabled = true; + qs('#add-checklist-step-form').hidden = true; + qs('#add-checklist-step').value = ''; + qs('#add-checklist-step-status').textContent = ''; qs('#checklist-completion').hidden = true; qs('#issue-labels').textContent = ''; qs('#issue-assignees').textContent = ''; @@ -5666,6 +5672,42 @@ confirmed:applyIssueContent, restore:renderIssueBody, }); + function closeAddChecklistStep() { + qs('#add-checklist-step-form').hidden = true; + qs('#add-checklist-step').value = ''; + qs('#open-add-checklist-step').focus(); + } + qs('#open-add-checklist-step').addEventListener('click', () => { + qs('#add-checklist-step-form').hidden = false; + qs('#add-checklist-step-status').textContent = 'Add one required step.'; + qs('#add-checklist-step').focus(); + }); + qs('#cancel-checklist-step').addEventListener('click', closeAddChecklistStep); + qs('#add-checklist-step-form').addEventListener('submit', async event => { + event.preventDefault(); + const state = {item:selectedIssue, detail:selectedIssueDetail, offline:selectedIssueOffline}; + if (!state.item || !state.detail?.updated_at) return; + const submit = qs('#save-checklist-step'); + submit.disabled = true; + qs('#add-checklist-step-status').textContent = state.offline ? 'Queueing checklist step…' : 'Adding checklist step…'; + try { + const result = await (selectedIssueOffline ? issueController.queueAddedTask : issueController.addTask).call( + issueController, state.item, state.detail, qs('#add-checklist-step').value + ); + if (selectedIssue !== state.item) return; + applyIssueContent(state.item, state.detail, state.offline ? result.detail : result); + closeAddChecklistStep(); + qs('#add-checklist-step-status').textContent = state.offline ? + 'Checklist step queued. Pending sync.' : 'Checklist step added.'; + } catch (error) { + if (selectedIssue === state.item) { + qs('#add-checklist-step-status').textContent = error.message; + qs('#add-checklist-step').focus(); + } + } finally { + submit.disabled = false; + } + }); qs('#complete-checklist-issue').addEventListener('click', () => { qs('#close-issue').click(); }); diff --git a/frontend/index.html b/frontend/index.html index 6c0cb3a..fbda043 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -605,6 +605,16 @@
+
+ + +
+