From 2072de337e13d359782184ead084a9dedc893969 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 7 Aug 2026 11:54:31 +0000 Subject: [PATCH] feat: add mobile My Work sessions (#193) --- frontend/index.html | 102 ++++++++++++++++++++++++++++++++++++++++-- frontend/my-work.js | 80 +++++++++++++++++++++++++++++++++ tests/test_my_work.py | 94 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 272 insertions(+), 4 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 8375937..71da5d5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -157,6 +157,11 @@ textarea { resize: vertical; min-height: 120px; } .new-issue { min-height:44px; } .find-work-action { min-height:44px; } .my-work-actions { display:flex; flex-wrap:wrap; gap:8px; } +.start-work-session { min-height:44px; } +.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } +.work-session-nav[hidden] { display:none; } +.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; } +.work-session-nav button { min-height:44px; width:100%; } .find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .find-work-sheet.open { display:flex; } .find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; } @@ -241,6 +246,7 @@ textarea { resize: vertical; min-height: 120px; }
Loading assigned work…
+
@@ -403,6 +409,11 @@ textarea { resize: vertical; min-height: 120px; } Open in Gitea + @@ -482,6 +493,11 @@ textarea { resize: vertical; min-height: 120px; } Open in Gitea + @@ -509,6 +525,11 @@ textarea { resize: vertical; min-height: 120px; } Open in Gitea + @@ -564,6 +585,11 @@ textarea { resize: vertical; min-height: 120px; }

Review history

+ @@ -837,6 +863,59 @@ textarea { resize: vertical; min-height: 120px; } onClose: () => closeUpdateSheet(false), }); + function closeOpenWorkSheets() { + ['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector => + qs(selector).classList.remove('open') + ); + selectedIssue = null; + selectedIssueDetail = null; + selectedPull = null; + selectedPullDetail = null; + selectedReview = null; + selectedUpdate = null; + } + + function openWorkSessionItem(item) { + closeOpenWorkSheets(); + if (selectedWorkFilter === 'update' && item.has_update && Number.isInteger(item.notification_id)) { + updateTrigger = null; + notificationReader.open(item, lastMyWork); + } else if (item.is_review) { + openReviewSheet(item, null); + } else if (item.kind === 'issue') { + openIssueSheet(item, null); + } else if (item.kind === 'pull') { + openPullSheet(item, null); + } else if (item.has_update && Number.isInteger(item.notification_id)) { + updateTrigger = null; + notificationReader.open(item, lastMyWork); + } + } + + const workSession = createWorkSession({ + getItems: () => lastMyWork, + getFilter: () => selectedWorkFilter, + onOpen: openWorkSessionItem, + onProgress: state => { + document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; }); + document.querySelectorAll('[data-work-session-progress]').forEach(element => { + element.textContent = 'Item ' + state.index + ' of ' + state.total; + }); + document.querySelectorAll('[data-work-session-previous]').forEach(button => { + button.disabled = !state.can_previous; + }); + document.querySelectorAll('[data-work-session-next]').forEach(button => { + button.textContent = state.can_next ? 'Next work item' : 'Finish session'; + }); + }, + onFinish: () => { + closeOpenWorkSheets(); + document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; }); + qs('#my-work-action-status').textContent = 'Work session complete.'; + qs('#start-work-session').focus(); + }, + }); + function renderContextSnapshot(data) { liveMode = true; hasContextSnapshot = true; @@ -914,6 +993,7 @@ textarea { resize: vertical; min-height: 120px; } summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.'; updateWorkPaginationControls(); renderMyWork(); + if (workSession.active()) workSession.reconcile(); } function activeWorkStreams() { @@ -1955,8 +2035,8 @@ textarea { resize: vertical; min-height: 120px; } lastContextSnapshot = buildMyWork.removeIssue( lastContextSnapshot, releasing.repository, releasing.number ); - paintMyWork(lastContextSnapshot); closeIssueSheet(); + paintMyWork(lastContextSnapshot); qs('#my-work-action-status').textContent = releasing.key + ' released.' + (confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.'); } catch (error) { @@ -1973,10 +2053,10 @@ textarea { resize: vertical; min-height: 120px; } qs('#issue-sheet-status').textContent = 'Closing issue…'; try { await issueController.close(selectedIssue); + closeIssueSheet(); lastMyWork = lastMyWork.filter(item => !(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number) ); - closeIssueSheet(); refreshMyWorkView(); qs('#my-work-action-status').textContent = closing.key + ' closed.'; } catch (error) { @@ -2025,10 +2105,10 @@ textarea { resize: vertical; min-height: 120px; } qs('#pull-sheet-status').textContent = 'Merging pull request…'; try { await pullController.merge(selectedPull, selectedPullDetail.head_sha); + closePullSheet(); lastMyWork = lastMyWork.filter(item => !(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number) ); - closePullSheet(); refreshMyWorkView(); qs('#my-work-action-status').textContent = merging.key + ' merged.'; } catch (error) { @@ -2130,7 +2210,8 @@ textarea { resize: vertical; min-height: 120px; } if (progress) showReviewProgress(progress.snapshot()); qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.'; await load(); - button.focus(); + if (workSession.active()) workSession.complete(); + else button.focus(); } catch (error) { qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.'; button.disabled = false; @@ -2190,6 +2271,19 @@ textarea { resize: vertical; min-height: 120px; } function load() { return contextPoller.refresh(); } qs('#refresh').addEventListener('click', load); + qs('#start-work-session').addEventListener('click', () => { + if (!filterMyWork(lastMyWork, selectedWorkFilter).length) { + qs('#my-work-action-status').textContent = 'No visible work to start.'; + return; + } + workSession.start(); + }); + document.querySelectorAll('[data-work-session-previous]').forEach(button => + button.addEventListener('click', () => workSession.previous()) + ); + document.querySelectorAll('[data-work-session-next]').forEach(button => + button.addEventListener('click', () => workSession.next()) + ); qs('#load-more-notifications').addEventListener('click', () => notificationPager.loadMore(lastNotifications) ); diff --git a/frontend/my-work.js b/frontend/my-work.js index 92c7342..4a55366 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -360,6 +360,84 @@ function filterMyWork(items, selectedFilter) { return items.filter((item) => item.kind === selectedFilter); } +function workIdentity(item) { + if (!item) return ''; + const kind = item.is_review ? 'review' : (item.kind || 'work'); + const repository = item.repository || ''; + const number = Number.isInteger(item.number) ? item.number : ''; + const notification = Number.isInteger(item.notification_id) ? item.notification_id : ''; + return [kind, repository, number, notification].join(':'); +} + +function createWorkSession({ getItems, getFilter, onOpen, onProgress, onFinish }) { + let currentIdentity = ''; + let currentIndex = -1; + let running = false; + + const queue = () => filterMyWork(getItems() || [], getFilter()); + const report = (items, index) => onProgress({ + index: index + 1, + total: items.length, + can_previous: index > 0, + can_next: index < items.length - 1, + }); + const finish = () => { + running = false; + currentIdentity = ''; + currentIndex = -1; + onFinish(); + return false; + }; + const openAt = (items, index) => { + if (!items.length || index < 0 || index >= items.length) return finish(); + currentIndex = index; + currentIdentity = workIdentity(items[index]); + report(items, index); + onOpen(items[index]); + return true; + }; + + return { + active: () => running, + start() { + const items = queue(); + if (!items.length) return finish(); + running = true; + return openAt(items, 0); + }, + reconcile() { + if (!running) return false; + const items = queue(); + const index = items.findIndex(item => workIdentity(item) === currentIdentity); + if (index < 0) return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish(); + currentIndex = index; + report(items, index); + return true; + }, + previous() { + if (!running) return false; + const items = queue(); + const index = items.findIndex(item => workIdentity(item) === currentIdentity); + return index > 0 ? openAt(items, index - 1) : false; + }, + next() { + if (!running) return false; + const items = queue(); + const index = items.findIndex(item => workIdentity(item) === currentIdentity); + return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish(); + }, + complete() { + if (!running) return false; + const items = queue(); + const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity); + if (stillPresent >= 0) { + return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish(); + } + return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish(); + }, + }; +} + function replaceIssueLabels(data, repository, number, labels) { return { ...data, @@ -418,6 +496,8 @@ function countMyWork(items) { if (typeof module !== 'undefined' && module.exports) { buildMyWork.filterMyWork = filterMyWork; + buildMyWork.createWorkSession = createWorkSession; + buildMyWork.workIdentity = workIdentity; buildMyWork.replaceIssueLabels = replaceIssueLabels; buildMyWork.replaceIssueContent = replaceIssueContent; buildMyWork.replaceIssueDueDate = replaceIssueDueDate; diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 8bb9a06..eac5b53 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -345,6 +345,100 @@ process.stdout.write(JSON.stringify({{ assert output["summary"] == "1 review · 2 assigned" +def test_mobile_work_session_follows_filter_and_reconciles_by_identity(): + items = [ + {"kind": "issue", "repository": "stackchain/api", "number": 1, "title": "First"}, + {"kind": "pull", "repository": "stackchain/web", "number": 2, "title": "Second"}, + {"kind": "issue", "repository": "stackchain/api", "number": 3, "title": "Third"}, + ] + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +let items = {json.dumps(items)}; +let filter = 'issue'; +const opened = []; +const progress = []; +let finished = 0; +const session = buildMyWork.createWorkSession({{ + getItems: () => items, + getFilter: () => filter, + onOpen: item => opened.push(item.title), + onProgress: state => progress.push(state), + onFinish: () => {{ finished += 1; }}, +}}); +session.start(); +items = [items[2], items[1], items[0]]; +session.reconcile(); +session.previous(); +items = items.filter(item => item.number !== 3); +session.complete(); +items = []; +session.complete(); +process.stdout.write(JSON.stringify({{opened, progress, finished, active:session.active()}})); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert output["opened"] == ["First", "Third", "First"] + assert output["progress"] == [ + {"index": 1, "total": 2, "can_previous": False, "can_next": True}, + {"index": 2, "total": 2, "can_previous": True, "can_next": False}, + {"index": 1, "total": 2, "can_previous": False, "can_next": True}, + {"index": 1, "total": 1, "can_previous": False, "can_next": False}, + ] + assert output["finished"] == 1 + assert output["active"] is False + + +def test_mobile_work_session_completion_finishes_when_current_item_is_last(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const items = [{{kind:'pull',repository:'stackchain/api',number:9,is_review:true}}]; +let finished = 0; +const session = buildMyWork.createWorkSession({{ + getItems: () => items, getFilter: () => 'review', onOpen: () => {{}}, + onProgress: () => {{}}, onFinish: () => {{ finished += 1; }}, +}}); +session.start(); +session.complete(); +process.stdout.write(JSON.stringify({{finished,active:session.active()}})); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + assert json.loads(result.stdout) == {"finished": 1, "active": False} + + +@pytest.mark.anyio +async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet(): + html = await dashboard() + + assert 'id="start-work-session"' in html + assert html.count('class="work-session-nav"') == 4 + assert html.count('') == 4 + assert html.count('