Work through My Work sequentially on mobile #194

Merged
rockachopa merged 1 commits from timmy/193-mobile-work-session into main 2026-08-07 11:55:26 +00:00
3 changed files with 272 additions and 4 deletions

View File

@ -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; }
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
</div>
<div class="my-work-actions">
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
<button class="find-work-action" id="find-work" type="button">Find work</button>
<button class="new-issue" id="new-issue" type="button">New issue</button>
</div>
@ -403,6 +409,11 @@ textarea { resize: vertical; min-height: 120px; }
<button id="close-issue" type="button">Close issue</button>
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
</div>
@ -482,6 +493,11 @@ textarea { resize: vertical; min-height: 120px; }
<button id="mark-update-read-next" type="button">Mark read &amp; next</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
</div>
@ -509,6 +525,11 @@ textarea { resize: vertical; min-height: 120px; }
<button id="merge-pull" type="button" disabled>Merge</button>
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
</div>
@ -564,6 +585,11 @@ textarea { resize: vertical; min-height: 120px; }
</section>
<h2>Review history</h2>
<div id="review-history" class="muted"></div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
</div>
@ -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)
);

View File

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

View File

@ -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('<span class="small" aria-live="polite" data-work-session-progress>') == 4
assert html.count('<button type="button" data-work-session-previous>') == 4
assert html.count('<button type="button" data-work-session-next>') == 4
assert '.work-session-nav button { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
assert 'aria-live="polite" data-work-session-progress' in html
@pytest.mark.anyio
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
html = await dashboard()
assert 'const workSession = createWorkSession({' in html
assert 'function openWorkSessionItem(item)' in html
assert "qs('#start-work-session').addEventListener('click'" in html
assert "document.querySelectorAll('[data-work-session-previous]')" in html
assert "document.querySelectorAll('[data-work-session-next]')" in html
assert 'workSession.complete();' in html
assert 'workSession.reconcile();' in html
for opener in ('openIssueSheet(item', 'openPullSheet(item', 'openReviewSheet(item', 'notificationReader.open(item'):
assert opener in html
def test_my_work_filter_counts_distinguish_prs_from_review_requests():
items = [
{"kind": "issue", "is_review": False},