Make mobile issue sheets read-first with on-demand planning (#216)
All checks were successful
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s

Closes #215
This commit is contained in:
rockachopa 2026-08-07 17:39:18 +00:00
commit ea1f4ad837
3 changed files with 182 additions and 76 deletions

View File

@ -142,6 +142,9 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.issue-sheet-header button { min-height:44px; }
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
.issue-planning-retry { min-height:44px; width:100%; }
#edit-issue-content { min-height:44px; width:100%; }
.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; }
.issue-edit-form label { display:grid; gap:6px; min-width:0; }
@ -417,44 +420,7 @@ textarea { resize: vertical; min-height: 120px; }
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
<fieldset class="issue-label-editor" id="issue-label-editor" aria-describedby="issue-label-status">
<legend>Labels</legend>
<div class="issue-label-list" id="issue-label-list"></div>
<div class="small" id="issue-label-status" aria-live="polite">Load an issue to edit labels.</div>
<button id="save-issue-labels" type="button" disabled>Save labels</button>
</fieldset>
<fieldset class="issue-due-editor" aria-describedby="issue-due-status">
<legend>Due date</legend>
<label for="issue-due-date" class="small">Schedule this assigned issue</label>
<input id="issue-due-date" type="date" disabled />
<div class="issue-due-actions">
<button id="save-issue-due-date" type="button" disabled>Save due date</button>
<button id="clear-issue-due-date" type="button" disabled>Clear due date</button>
</div>
<div id="issue-due-status" class="small" aria-live="assertive">Load an issue to schedule it.</div>
</fieldset>
<fieldset class="issue-milestone-editor" aria-describedby="issue-milestone-status">
<legend>Milestone</legend>
<label for="issue-milestone" class="small">Plan this issue into a release</label>
<select id="issue-milestone" disabled><option value="">No milestone</option></select>
<button id="save-issue-milestone" type="button" disabled>Save milestone</button>
<div id="issue-milestone-status" class="small" aria-live="assertive">Load an issue to plan it.</div>
</fieldset>
<p class="issue-sheet-content" id="issue-sheet-body"></p>
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
<form class="issue-edit-form" id="issue-edit-form" hidden>
<label for="issue-edit-title">Title
<input id="issue-edit-title" type="text" maxlength="255" required autocomplete="off" />
</label>
<label for="issue-edit-body">Description
<textarea id="issue-edit-body" maxlength="10000"></textarea>
</label>
<div class="issue-edit-actions">
<button id="save-issue-content" type="submit">Save issue</button>
<button id="cancel-issue-content" type="button">Cancel</button>
</div>
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
</form>
<h2>Full conversation</h2>
<div id="issue-comments"></div>
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
@ -465,6 +431,47 @@ textarea { resize: vertical; min-height: 120px; }
<button id="send-issue-comment" type="button">Post comment</button>
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
</section>
<details class="issue-planning" id="issue-planning">
<summary>Plan &amp; edit</summary>
<button class="issue-planning-retry" id="retry-issue-planning" type="button" hidden>Retry planning controls</button>
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
<form class="issue-edit-form" id="issue-edit-form" hidden>
<label for="issue-edit-title">Title
<input id="issue-edit-title" type="text" maxlength="255" required autocomplete="off" />
</label>
<label for="issue-edit-body">Description
<textarea id="issue-edit-body" maxlength="10000"></textarea>
</label>
<div class="issue-edit-actions">
<button id="save-issue-content" type="submit">Save issue</button>
<button id="cancel-issue-content" type="button">Cancel</button>
</div>
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
</form>
<fieldset class="issue-label-editor" id="issue-label-editor" aria-describedby="issue-label-status">
<legend>Labels</legend>
<div class="issue-label-list" id="issue-label-list"></div>
<div class="small" id="issue-label-status" aria-live="polite">Expand planning controls to load labels.</div>
<button id="save-issue-labels" type="button" disabled>Save labels</button>
</fieldset>
<fieldset class="issue-due-editor" aria-describedby="issue-due-status">
<legend>Due date</legend>
<label for="issue-due-date" class="small">Schedule this assigned issue</label>
<input id="issue-due-date" type="date" disabled />
<div class="issue-due-actions">
<button id="save-issue-due-date" type="button" disabled>Save due date</button>
<button id="clear-issue-due-date" type="button" disabled>Clear due date</button>
</div>
<div id="issue-due-status" class="small" aria-live="assertive">Load an issue to schedule it.</div>
</fieldset>
<fieldset class="issue-milestone-editor" aria-describedby="issue-milestone-status">
<legend>Milestone</legend>
<label for="issue-milestone" class="small">Plan this issue into a release</label>
<select id="issue-milestone" disabled><option value="">No milestone</option></select>
<button id="save-issue-milestone" type="button" disabled>Save milestone</button>
<div id="issue-milestone-status" class="small" aria-live="assertive">Expand planning controls to load milestones.</div>
</fieldset>
</details>
<div class="issue-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="release-issue" type="button">Release assignment</button>
@ -764,6 +771,10 @@ textarea { resize: vertical; min-height: 120px; }
}
const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const planningLoader = createIssueSheet.createPlanningLoader({
loadLabels: item => issueController.loadLabels(item),
loadMilestones: item => issueController.loadMilestones(item),
});
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const draftInbox = createDraftInbox({ storage: localStorage });
@ -1356,29 +1367,21 @@ textarea { resize: vertical; min-height: 120px; }
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
async function loadIssueLabelEditor(item, confirmedNames) {
function renderIssueLabelEditor(item, confirmedNames, labels) {
const list = qs('#issue-label-list');
const status = qs('#issue-label-status');
list.textContent = '';
qs('#save-issue-labels').disabled = true;
status.textContent = 'Loading labels…';
try {
const labels = await issueController.loadLabels(item);
if (selectedIssue !== item) return;
const draftIds = issueController.loadLabelDraft(item);
const selectedIds = draftIds.length ? new Set(draftIds) : new Set(
labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id))
);
list.innerHTML = labels.map(label =>
'<label class="issue-label-option"><input type="checkbox" name="issue-label" value="' +
Number(label.id) + '"' + (selectedIds.has(Number(label.id)) ? ' checked' : '') + '><span>' +
escapeHtml(label.name) + '</span></label>'
).join('');
status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.';
qs('#save-issue-labels').disabled = false;
} catch (_error) {
status.textContent = 'Labels could not be loaded. Retry by reopening this issue.';
}
const draftIds = issueController.loadLabelDraft(item);
const selectedIds = draftIds.length ? new Set(draftIds) : new Set(
labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id))
);
list.innerHTML = labels.map(label =>
'<label class="issue-label-option"><input type="checkbox" name="issue-label" value="' +
Number(label.id) + '"' + (selectedIds.has(Number(label.id)) ? ' checked' : '') + '><span>' +
escapeHtml(label.name) + '</span></label>'
).join('');
status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.';
qs('#save-issue-labels').disabled = false;
}
function selectedEditIssueLabelIds() {
@ -1386,27 +1389,38 @@ textarea { resize: vertical; min-height: 120px; }
.map(input => Number(input.value)).filter(Number.isInteger);
}
async function loadIssueMilestoneEditor(item, confirmedMilestone) {
function renderIssueMilestoneEditor(item, confirmedMilestone, milestones) {
const select = qs('#issue-milestone');
const status = qs('#issue-milestone-status');
select.innerHTML = '<option value="">No milestone</option>';
select.disabled = true;
qs('#save-issue-milestone').disabled = true;
status.textContent = 'Loading milestones…';
select.innerHTML += milestones.map(milestone =>
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
).join('');
const draft = issueController.loadMilestoneDraft(item);
select.value = String(draft ?? confirmedMilestone?.id ?? '');
select.disabled = false;
qs('#save-issue-milestone').disabled = false;
status.textContent = confirmedMilestone ?
'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.';
}
async function loadIssuePlanning() {
const item = selectedIssue;
const detail = selectedIssueDetail;
if (!item || !detail) return;
qs('#retry-issue-planning').hidden = true;
qs('#issue-label-status').textContent = 'Loading labels…';
qs('#issue-milestone-status').textContent = 'Loading milestones…';
try {
const milestones = await issueController.loadMilestones(item);
const planning = await planningLoader.open(selectedIssue);
if (selectedIssue !== item) return;
select.innerHTML += milestones.map(milestone =>
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
).join('');
const draft = issueController.loadMilestoneDraft(item);
select.value = String(draft ?? confirmedMilestone?.id ?? '');
select.disabled = false;
qs('#save-issue-milestone').disabled = false;
status.textContent = confirmedMilestone ?
'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.';
renderIssueLabelEditor(item, detail.labels || [], planning.labels);
renderIssueMilestoneEditor(item, detail.milestone, planning.milestones);
} catch (_error) {
status.textContent = 'Milestones could not be loaded. Retry by reopening this issue.';
if (selectedIssue !== item) return;
qs('#issue-label-status').textContent = 'Labels could not be loaded.';
qs('#issue-milestone-status').textContent = 'Milestones could not be loaded.';
qs('#retry-issue-planning').hidden = false;
}
}
@ -1428,8 +1442,10 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
qs('#issue-comment').value = issueController.loadDraft(item);
qs('#issue-comment-status').textContent = '';
qs('#issue-planning').open = false;
qs('#retry-issue-planning').hidden = true;
qs('#issue-label-list').textContent = '';
qs('#issue-label-status').textContent = 'Loading labels…';
qs('#issue-label-status').textContent = 'Expand planning controls to load labels.';
qs('#save-issue-labels').disabled = true;
qs('#issue-due-date').value = '';
qs('#issue-due-date').disabled = true;
@ -1439,7 +1455,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-milestone').innerHTML = '<option value="">No milestone</option>';
qs('#issue-milestone').disabled = true;
qs('#save-issue-milestone').disabled = true;
qs('#issue-milestone-status').textContent = 'Loading milestones…';
qs('#issue-milestone-status').textContent = 'Expand planning controls to load milestones.';
qs('#retry-issue-load').hidden = true;
qs('#open-issue-gitea').href = item.url || '#';
qs('#send-issue-comment').disabled = false;
@ -1458,8 +1474,6 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
'<span class="pill">' + escapeHtml(label) + '</span>'
).join(' ');
loadIssueLabelEditor(item, detail.labels || []);
loadIssueMilestoneEditor(item, detail.milestone);
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
renderIssueConversation(issueConversation.snapshot());
@ -1473,6 +1487,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#clear-issue-due-date').disabled = !detail.due_date;
qs('#issue-due-status').textContent = detail.due_date ?
'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.';
if (qs('#issue-planning').open) loadIssuePlanning();
} catch (error) {
if (selectedIssue !== item) return;
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
@ -2346,6 +2361,10 @@ textarea { resize: vertical; min-height: 120px; }
qs('#retry-issue-load').addEventListener('click', () => {
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
});
qs('#issue-planning').addEventListener('toggle', event => {
if (event.currentTarget.open) loadIssuePlanning();
});
qs('#retry-issue-planning').addEventListener('click', loadIssuePlanning);
qs('#edit-issue-content').addEventListener('click', () => {
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
const draft = issueController.loadEditDraft(selectedIssue) || {

View File

@ -1,3 +1,23 @@
function createPlanningLoader({ loadLabels, loadMilestones }) {
const requests = new Map();
const keyFor = item => item.repository + '#' + item.number;
return {
open(item) {
const key = keyFor(item);
if (requests.has(key)) return requests.get(key);
const request = Promise.all([loadLabels(item), loadMilestones(item)])
.then(([labels, milestones]) => ({ labels, milestones }))
.catch(error => {
if (requests.get(key) === request) requests.delete(key);
throw error;
});
requests.set(key, request);
return request;
},
};
}
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
@ -213,4 +233,6 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
};
}
createIssueSheet.createPlanningLoader = createPlanningLoader;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;

View File

@ -535,6 +535,71 @@ async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor
assert 'buildMyWork.replaceIssueMilestone(' in html
@pytest.mark.anyio
async def test_mobile_issue_sheet_puts_reading_before_collapsed_planning_controls():
html = await dashboard()
body = html.index('id="issue-sheet-body"')
conversation = html.index('<h2>Full conversation</h2>', body)
planning = html.index('id="issue-planning"', conversation)
labels = html.index('id="issue-label-editor"', planning)
milestone = html.index('id="issue-milestone"', planning)
assert '<summary>Plan &amp; edit</summary>' in html[planning:labels]
assert '<details class="issue-planning" id="issue-planning">' in html
assert body < conversation < planning < labels < milestone
def test_issue_planning_metadata_is_lazy_cached_and_retryable_after_failure():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const calls = [];
let fail = true;
const planning = createIssueSheet.createPlanningLoader({{
loadLabels: async item => {{ calls.push('labels:' + item.number); return [{{id:1,name:'P0'}}]; }},
loadMilestones: async item => {{
calls.push('milestones:' + item.number);
if (fail) throw new Error('offline');
return [{{id:9,title:'RC'}}];
}},
}});
const item = {{repository:'stackchain/api', number:17}};
(async () => {{
const before = calls.slice();
let failed = false;
try {{ await planning.open(item); }} catch (_error) {{ failed = true; }}
fail = false;
const first = await planning.open(item);
const second = await planning.open(item);
process.stdout.write(JSON.stringify({{before, failed, calls, same:first === second, first}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"before": [],
"failed": True,
"calls": ["labels:17", "milestones:17", "labels:17", "milestones:17"],
"same": True,
"first": {"labels": [{"id": 1, "name": "P0"}], "milestones": [{"id": 9, "title": "RC"}]},
}
@pytest.mark.anyio
async def test_opening_issue_defers_planning_requests_until_disclosure_expands():
html = await dashboard()
open_handler = html[html.index('async function openIssueSheet'):html.index('function closeIssueSheet')]
assert 'loadIssueLabelEditor(' not in open_handler
assert 'loadIssueMilestoneEditor(' not in open_handler
assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler
assert "qs('#issue-planning').addEventListener('toggle'" in html
assert 'planningLoader.open(selectedIssue)' in html
assert 'id="retry-issue-planning"' in html
def test_my_work_reviews_filter_and_summary_are_actionable():
items = [
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},