feat: add mobile My Work sessions (#193)
This commit is contained in:
parent
ecd4b55a26
commit
2072de337e
|
|
@ -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 & 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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user