Add a one-tap mobile Attention queue #245
|
|
@ -307,6 +307,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
</div>
|
</div>
|
||||||
<div class="work-filters" aria-label="Filter My Work">
|
<div class="work-filters" aria-label="Filter My Work">
|
||||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
||||||
|
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||||
|
|
@ -725,7 +726,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page">Work</button>
|
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page">Work <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
||||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||||
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
||||||
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||||
|
|
@ -782,12 +783,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button])
|
Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button])
|
||||||
);
|
);
|
||||||
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
|
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
|
||||||
|
function openMobileWork() {
|
||||||
|
const counts = countMyWork(lastMyWork);
|
||||||
|
const filter = counts.attention ? 'attention' : 'all';
|
||||||
|
qs('[data-work-filter="' + filter + '"]').click();
|
||||||
|
qs('#my-work').scrollIntoView({block:'start'});
|
||||||
|
qs('#my-work').focus();
|
||||||
|
}
|
||||||
const mobileTaskDock = createMobileTaskDock({
|
const mobileTaskDock = createMobileTaskDock({
|
||||||
nav: qs('#mobile-task-dock'),
|
nav: qs('#mobile-task-dock'),
|
||||||
buttons: mobileTaskButtons,
|
buttons: mobileTaskButtons,
|
||||||
|
attentionBadge: qs('#mobile-attention-count'),
|
||||||
overlays: mobileTaskOverlays,
|
overlays: mobileTaskOverlays,
|
||||||
actions: {
|
actions: {
|
||||||
work: () => qs('#my-work').scrollIntoView({block:'start'}) || qs('#my-work').focus(),
|
work: openMobileWork,
|
||||||
find: () => qs('#find-work').click(),
|
find: () => qs('#find-work').click(),
|
||||||
new: () => qs('#new-issue').click(),
|
new: () => qs('#new-issue').click(),
|
||||||
search: () => qs('#open-palette').click(),
|
search: () => qs('#open-palette').click(),
|
||||||
|
|
@ -816,7 +825,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
let selectedWorkMilestone = 'all';
|
let selectedWorkMilestone = 'all';
|
||||||
try {
|
try {
|
||||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||||
if (['all', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
if (['all', 'attention', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||||
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
||||||
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -1285,6 +1294,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
const element = qs('[data-work-count="' + filter + '"]');
|
const element = qs('[data-work-count="' + filter + '"]');
|
||||||
if (element) element.textContent = count;
|
if (element) element.textContent = count;
|
||||||
});
|
});
|
||||||
|
mobileTaskDock.updateAttention(counts.attention);
|
||||||
const milestoneSelect = qs('#work-milestone-filter');
|
const milestoneSelect = qs('#work-milestone-filter');
|
||||||
const lanes = milestoneLanes(lastMyWork);
|
const lanes = milestoneLanes(lastMyWork);
|
||||||
milestoneSelect.innerHTML = '<option value="all">All milestones</option>' +
|
milestoneSelect.innerHTML = '<option value="all">All milestones</option>' +
|
||||||
|
|
@ -1308,6 +1318,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeWorkStreams() {
|
function activeWorkStreams() {
|
||||||
|
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
|
||||||
if (selectedWorkFilter === 'issue') return ['issue'];
|
if (selectedWorkFilter === 'issue') return ['issue'];
|
||||||
if (selectedWorkFilter === 'pull') return ['pull'];
|
if (selectedWorkFilter === 'pull') return ['pull'];
|
||||||
if (selectedWorkFilter === 'review') return ['review'];
|
if (selectedWorkFilter === 'review') return ['review'];
|
||||||
|
|
@ -1450,7 +1461,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + '</article>';
|
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + '</article>';
|
||||||
}).join('') : '<div class="muted">' + (incomplete ?
|
}).join('') : '<div class="muted">' + (incomplete ?
|
||||||
'More work is available. Load the next page.' :
|
'More work is available. Load the next page.' :
|
||||||
'No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.') + '</div>';
|
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')))) + '.') + '</div>';
|
||||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,5 +34,20 @@
|
||||||
refreshVisibility();
|
refreshVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {start, refreshVisibility};
|
function updateAttention(count) {
|
||||||
|
const total = Math.max(0, Number(count) || 0);
|
||||||
|
const badge = options.attentionBadge;
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = String(total);
|
||||||
|
badge.hidden = total === 0;
|
||||||
|
}
|
||||||
|
if (buttons.work) {
|
||||||
|
buttons.work.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
total ? 'Work, ' + total + ' items need attention' : 'Work'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {start, refreshVisibility, updateAttention};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -400,7 +400,8 @@ function createNotificationReplier({
|
||||||
|
|
||||||
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||||
let filtered = items;
|
let filtered = items;
|
||||||
if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
if (selectedFilter === 'attention') filtered = items.filter((item) => item.has_update || item.is_review);
|
||||||
|
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||||
if (selectedMilestone === 'all') return filtered;
|
if (selectedMilestone === 'all') return filtered;
|
||||||
|
|
@ -559,6 +560,7 @@ function summarizeMyWork(items) {
|
||||||
function countMyWork(items) {
|
function countMyWork(items) {
|
||||||
return {
|
return {
|
||||||
all: items.length,
|
all: items.length,
|
||||||
|
attention: items.filter((item) => item.has_update || item.is_review).length,
|
||||||
issue: items.filter((item) => item.kind === 'issue').length,
|
issue: items.filter((item) => item.kind === 'issue').length,
|
||||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||||
review: items.filter((item) => item.is_review).length,
|
review: items.filter((item) => item.is_review).length,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const CACHE = 'stackchain-dashboard-shell-v10';
|
const CACHE = 'stackchain-dashboard-shell-v11';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,31 @@ process.stdout.write(JSON.stringify({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
|
||||||
|
script = f"""
|
||||||
|
const createDock = require({json.dumps(str(DOCK))});
|
||||||
|
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||||
|
const badge = {{ textContent:'', hidden:true }};
|
||||||
|
const dock = createDock({{ nav:{{}}, buttons:{{work}}, attentionBadge:badge }});
|
||||||
|
dock.updateAttention(3);
|
||||||
|
const pending = {{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||||
|
dock.updateAttention(0);
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
pending,
|
||||||
|
empty:{{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }},
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"pending": {"count": "3", "hidden": False, "label": "Work, 3 items need attention"},
|
||||||
|
"empty": {"count": "0", "hidden": True, "label": "Work"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
@ -79,15 +104,20 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||||
for task in ("work", "find", "new", "search", "drafts"):
|
for task in ("work", "find", "new", "search", "drafts"):
|
||||||
assert f'data-mobile-task="{task}"' in html
|
assert f'data-mobile-task="{task}"' in html
|
||||||
assert 'id="mobile-draft-count"' in html
|
assert 'id="mobile-draft-count"' in html
|
||||||
|
assert 'data-work-filter="attention"' in html
|
||||||
|
assert 'id="mobile-attention-count"' in html
|
||||||
assert '.mobile-task-dock { display:none;' in html
|
assert '.mobile-task-dock { display:none;' in html
|
||||||
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
|
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
|
||||||
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
|
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
|
||||||
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
|
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
|
||||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||||
assert "createMobileTaskDock({" in html
|
assert "createMobileTaskDock({" in html
|
||||||
assert "work: () => qs('#my-work').scrollIntoView" in html
|
assert "work: openMobileWork" in html
|
||||||
|
assert "counts.attention ? 'attention' : 'all'" in html
|
||||||
|
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
|
||||||
assert "find: () => qs('#find-work').click()" in html
|
assert "find: () => qs('#find-work').click()" in html
|
||||||
assert "new: () => qs('#new-issue').click()" in html
|
assert "new: () => qs('#new-issue').click()" in html
|
||||||
assert "search: () => qs('#open-palette').click()" in html
|
assert "search: () => qs('#open-palette').click()" in html
|
||||||
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
|
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
|
||||||
assert "draftCount.textContent = sourceDraftCount.textContent" in html
|
assert "draftCount.textContent = sourceDraftCount.textContent" in html
|
||||||
|
assert "mobileTaskDock.updateAttention(counts.attention)" in html
|
||||||
|
|
|
||||||
|
|
@ -651,6 +651,32 @@ process.stdout.write(JSON.stringify({{
|
||||||
assert output["summary"] == "1 review · 2 assigned"
|
assert output["summary"] == "1 review · 2 assigned"
|
||||||
|
|
||||||
|
|
||||||
|
def test_attention_filter_unions_updates_and_reviews_without_double_counting():
|
||||||
|
items = [
|
||||||
|
{"title": "Unread issue", "kind": "issue", "has_update": True, "is_review": False},
|
||||||
|
{"title": "Requested review", "kind": "pull", "has_update": False, "is_review": True},
|
||||||
|
{"title": "Updated review", "kind": "pull", "has_update": True, "is_review": True},
|
||||||
|
{"title": "Ordinary assignment", "kind": "issue", "has_update": False, "is_review": False},
|
||||||
|
]
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
const items = {json.dumps(items)};
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
attention: buildMyWork.filterMyWork(items, 'attention').map(item => item.title),
|
||||||
|
count: buildMyWork.countMyWork(items).attention,
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"attention": ["Unread issue", "Requested review", "Updated review"],
|
||||||
|
"count": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
||||||
payload = {
|
payload = {
|
||||||
"issues": [
|
"issues": [
|
||||||
|
|
@ -842,7 +868,9 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
|
||||||
["node", "-e", script], check=True, capture_output=True, text=True
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
)
|
)
|
||||||
|
|
||||||
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0}
|
assert json.loads(result.stdout) == {
|
||||||
|
"all": 3, "attention": 1, "issue": 1, "pull": 1, "review": 1, "update": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_work_pager_is_single_flight_and_unions_pull_responsibilities():
|
def test_work_pager_is_single_flight_and_unions_pull_responsibilities():
|
||||||
|
|
@ -1458,7 +1486,9 @@ process.stdout.write(JSON.stringify({{
|
||||||
assert output["updates"][0]["kind"] == "update"
|
assert output["updates"][0]["kind"] == "update"
|
||||||
assert output["updates"][1]["kind"] == "issue"
|
assert output["updates"][1]["kind"] == "issue"
|
||||||
assert output["updates"][1]["url"].endswith("#issuecomment-9")
|
assert output["updates"][1]["url"].endswith("#issuecomment-9")
|
||||||
assert output["counts"] == {"all": 2, "issue": 1, "pull": 0, "review": 0, "update": 2}
|
assert output["counts"] == {
|
||||||
|
"all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2
|
||||||
|
}
|
||||||
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
|
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2641,11 +2671,12 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||||
|
|
||||||
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
|
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
|
||||||
assert 'data-work-count="all"' in html
|
assert 'data-work-count="all"' in html
|
||||||
|
assert 'data-work-count="attention"' in html
|
||||||
assert 'data-work-count="issue"' in html
|
assert 'data-work-count="issue"' in html
|
||||||
assert 'data-work-count="pull"' in html
|
assert 'data-work-count="pull"' in html
|
||||||
assert 'data-work-count="review"' in html
|
assert 'data-work-count="review"' in html
|
||||||
assert 'data-work-count="update"' in html
|
assert 'data-work-count="update"' in html
|
||||||
assert "['all', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
|
assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
|
||||||
assert 'data-work-count="draft"' in html
|
assert 'data-work-count="draft"' in html
|
||||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,10 @@ async function dispatch(name, request) {{
|
||||||
return json.loads(completed.stdout)
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
def test_cross_tab_outbox_coordination_ships_in_a_new_shell_cache():
|
def test_mobile_attention_queue_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v10" in source
|
assert "stackchain-dashboard-shell-v11" in source
|
||||||
|
|
||||||
|
|
||||||
def test_install_precaches_complete_subpath_scoped_app_shell():
|
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user