Start work from an empty mobile queue #692
|
|
@ -77,6 +77,14 @@ main { position: relative; z-index: 1; display: grid; grid-template-columns: 300
|
||||||
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
||||||
a { color: #60a5fa; text-decoration: none; }
|
a { color: #60a5fa; text-decoration: none; }
|
||||||
.muted { color: #94a3b8; }
|
.muted { color: #94a3b8; }
|
||||||
|
.empty-work-start { display:grid; gap:12px; margin:12px 0; padding:16px; border:1px solid #31577f; border-radius:14px; background:#0b1b2f; }
|
||||||
|
.empty-work-start[hidden] { display:none; }
|
||||||
|
.empty-work-start p { margin:6px 0 0; }
|
||||||
|
.empty-work-start-actions { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); gap:8px; }
|
||||||
|
.empty-work-start-actions button { min-width:0; min-height:44px; white-space:normal; }
|
||||||
|
@media (max-width:420px) {
|
||||||
|
.empty-work-start-actions { grid-template-columns:1fr; }
|
||||||
|
}
|
||||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
||||||
.kv .label { color: #94a3b8; }
|
.kv .label { color: #94a3b8; }
|
||||||
.kv .value { color: #e5e7eb; }
|
.kv .value { color: #e5e7eb; }
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,7 @@
|
||||||
attentionInterruption.hidden = !pending;
|
attentionInterruption.hidden = !pending;
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
function openMobileWorkFallback() {
|
|
||||||
qs('[data-work-filter="all"]').click();
|
|
||||||
qs('#my-work').scrollIntoView({block:'start'});
|
|
||||||
qs('#my-work').focus();
|
|
||||||
}
|
|
||||||
function selectMobileQueue(name) {
|
function selectMobileQueue(name) {
|
||||||
if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();
|
if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();
|
||||||
renderAttentionInterruption();
|
renderAttentionInterruption();
|
||||||
|
|
@ -71,7 +67,7 @@
|
||||||
resumeToday: resumeTodaySession,
|
resumeToday: resumeTodaySession,
|
||||||
startToday: startTodaySession,
|
startToday: startTodaySession,
|
||||||
planToday: () => openPlanToday(mobileTaskButtons.work),
|
planToday: () => openPlanToday(mobileTaskButtons.work),
|
||||||
openFallback: openMobileWorkFallback,
|
findWork: () => qs('#find-work').click(),
|
||||||
});
|
});
|
||||||
let mobileQueueCounts = {};
|
let mobileQueueCounts = {};
|
||||||
const mobileQueueLauncher = createMobileQueueLauncher({
|
const mobileQueueLauncher = createMobileQueueLauncher({
|
||||||
|
|
@ -122,6 +118,8 @@
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mobileTaskDock.start();
|
mobileTaskDock.start();
|
||||||
|
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
|
||||||
|
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
|
||||||
let liveMode = true;
|
let liveMode = true;
|
||||||
let offlineWorkMode = false;
|
let offlineWorkMode = false;
|
||||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||||
|
|
@ -2433,6 +2431,10 @@
|
||||||
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||||
const visible = findQueueItems(queueItems, queueFindQuery);
|
const visible = findQueueItems(queueItems, queueFindQuery);
|
||||||
|
const emptyWorkStart = qs('#empty-work-start');
|
||||||
|
const showEmptyStart = selectedWorkFilter === 'all' && !queueFindQuery &&
|
||||||
|
!queueItems.length && !incomplete && hasContextSnapshot;
|
||||||
|
emptyWorkStart.hidden = !showEmptyStart;
|
||||||
updateQueueFinder(visible.length, queueItems.length, incomplete);
|
updateQueueFinder(visible.length, queueItems.length, incomplete);
|
||||||
const selection = notificationSelection.snapshot();
|
const selection = notificationSelection.snapshot();
|
||||||
const selectedIds = new Set(selection.ids);
|
const selectedIds = new Set(selection.ids);
|
||||||
|
|
@ -2486,9 +2488,9 @@
|
||||||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||||
}
|
}
|
||||||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
|
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
|
||||||
}).join('') : '<div class="muted">' + (incomplete ?
|
}).join('') : (showEmptyStart ? '' : '<div class="muted">' + (incomplete ?
|
||||||
'More work is available. Load the next page.' :
|
'More work is available. Load the next page.' :
|
||||||
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
|
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>');
|
||||||
cardPlanning.wire();
|
cardPlanning.wire();
|
||||||
document.querySelectorAll('[data-select-notification-id]').forEach(input => {
|
document.querySelectorAll('[data-select-notification-id]').forEach(input => {
|
||||||
input.addEventListener('change', () => {
|
input.addEventListener('change', () => {
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,16 @@
|
||||||
<div class="small" id="queue-find-status" role="status" aria-live="polite"></div>
|
<div class="small" id="queue-find-status" role="status" aria-live="polite"></div>
|
||||||
<button id="search-older-work" type="button" hidden>Search older work</button>
|
<button id="search-older-work" type="button" hidden>Search older work</button>
|
||||||
</form>
|
</form>
|
||||||
|
<section class="empty-work-start" id="empty-work-start" aria-labelledby="empty-work-start-heading" hidden>
|
||||||
|
<div>
|
||||||
|
<strong id="empty-work-start-heading">Ready for something new?</strong>
|
||||||
|
<p class="small muted">Find an available issue to own, or create the work that needs doing.</p>
|
||||||
|
</div>
|
||||||
|
<div class="empty-work-start-actions">
|
||||||
|
<button id="empty-work-find" type="button">Find and start work</button>
|
||||||
|
<button id="empty-work-create" type="button">Create an issue</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div class="my-work-list" id="my-work-list"></div>
|
<div class="my-work-list" id="my-work-list"></div>
|
||||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@
|
||||||
resume: ['Resume', 'Resume Today'],
|
resume: ['Resume', 'Resume Today'],
|
||||||
start: ['Start', 'Start Today'],
|
start: ['Start', 'Start Today'],
|
||||||
plan: ['Plan', 'Plan Today'],
|
plan: ['Plan', 'Plan Today'],
|
||||||
|
find: ['Find', 'Find work'],
|
||||||
work: ['Work', 'Work'],
|
work: ['Work', 'Work'],
|
||||||
};
|
};
|
||||||
const workMode = labels[mode] ? mode : 'work';
|
const workMode = labels[mode] ? mode : 'work';
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||||
if (options.getTodayCount() > 0) return 'start';
|
if (options.getTodayCount() > 0) return 'start';
|
||||||
if (options.getEligibleCount() > 0) return 'plan';
|
if (options.getEligibleCount() > 0) return 'plan';
|
||||||
return 'work';
|
return 'find';
|
||||||
}
|
}
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
else if (current === 'resume') options.resumeToday();
|
else if (current === 'resume') options.resumeToday();
|
||||||
else if (current === 'start') options.startToday();
|
else if (current === 'start') options.startToday();
|
||||||
else if (current === 'plan') options.planToday();
|
else if (current === 'plan') options.planToday();
|
||||||
else options.openFallback();
|
else options.findWork();
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queu
|
||||||
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
|
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_work_entry_prioritizes_continue_resume_start_plan_then_fallback():
|
def test_mobile_work_entry_prioritizes_continue_resume_start_plan_then_find():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createEntry = require({json.dumps(str(ENTRY))});
|
const createEntry = require({json.dumps(str(ENTRY))});
|
||||||
const state = {{active:true, resumable:true, today:2, eligible:3}};
|
const state = {{active:true, resumable:true, today:2, eligible:3}};
|
||||||
|
|
@ -27,7 +27,7 @@ const entry = createEntry({{
|
||||||
resumeToday: () => calls.push('resume'),
|
resumeToday: () => calls.push('resume'),
|
||||||
startToday: () => calls.push('start'),
|
startToday: () => calls.push('start'),
|
||||||
planToday: () => calls.push('plan'),
|
planToday: () => calls.push('plan'),
|
||||||
openFallback: () => calls.push('fallback'),
|
findWork: () => calls.push('find'),
|
||||||
}});
|
}});
|
||||||
const modes = [];
|
const modes = [];
|
||||||
modes.push(entry.open());
|
modes.push(entry.open());
|
||||||
|
|
@ -43,8 +43,8 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||||
|
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
assert json.loads(result.stdout) == {
|
assert json.loads(result.stdout) == {
|
||||||
"modes": ["continue", "resume", "start", "plan", "work"],
|
"modes": ["continue", "resume", "start", "plan", "find"],
|
||||||
"calls": ["continue", "resume", "start", "plan", "fallback"],
|
"calls": ["continue", "resume", "start", "plan", "find"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -165,10 +165,10 @@ dock.updateWork('start', 0);
|
||||||
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||||
dock.updateWork('plan', 0);
|
dock.updateWork('plan', 0);
|
||||||
const planning = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
const planning = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||||
dock.updateWork('work', 0);
|
dock.updateWork('find', 0);
|
||||||
process.stdout.write(JSON.stringify({{
|
process.stdout.write(JSON.stringify({{
|
||||||
continuing, resuming, starting, planning,
|
continuing, resuming, starting, planning,
|
||||||
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
|
finding:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
|
||||||
}}));
|
}}));
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
|
|
@ -184,7 +184,7 @@ process.stdout.write(JSON.stringify({{
|
||||||
"resuming": {"text": "Resume", "label": "Resume Today"},
|
"resuming": {"text": "Resume", "label": "Resume Today"},
|
||||||
"starting": {"text": "Start", "label": "Start Today"},
|
"starting": {"text": "Start", "label": "Start Today"},
|
||||||
"planning": {"text": "Plan", "label": "Plan Today"},
|
"planning": {"text": "Plan", "label": "Plan Today"},
|
||||||
"fallback": {"text": "Work", "label": "Work"},
|
"finding": {"text": "Find", "label": "Find work"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,22 @@ import pytest
|
||||||
from tests.dashboard_bundle import dashboard
|
from tests.dashboard_bundle import dashboard
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_empty_all_work_queue_offers_find_and_create_actions():
|
||||||
|
markup = (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
|
||||||
|
source = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="empty-work-start"' in markup
|
||||||
|
assert 'Ready for something new?' in markup
|
||||||
|
assert 'id="empty-work-find"' in markup
|
||||||
|
assert 'id="empty-work-create"' in markup
|
||||||
|
assert "qs('#empty-work-find').addEventListener('click'" in source
|
||||||
|
assert "qs('#find-work').click()" in source
|
||||||
|
assert "qs('#empty-work-create').addEventListener('click'" in source
|
||||||
|
assert "qs('#new-issue').click()" in source
|
||||||
|
assert "emptyWorkStart.hidden = !showEmptyStart" in source
|
||||||
|
|
||||||
|
|
||||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||||
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
|
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
|
||||||
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user