feat: review mobile update triage outcomes (Closes #765)
This commit is contained in:
parent
e21cc58cca
commit
fd2f801f3e
|
|
@ -708,6 +708,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||||
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
||||||
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||||
|
.mobile-update-outcome { margin-top:12px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
|
||||||
|
.mobile-update-outcome p { margin:0 0 10px; }
|
||||||
|
.mobile-update-outcome button { width:100%; min-height:44px; }
|
||||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
||||||
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
||||||
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
|
|
||||||
|
|
@ -82,15 +82,28 @@
|
||||||
|
|
||||||
findWork: () => qs('#find-work').click(),
|
findWork: () => qs('#find-work').click(),
|
||||||
});
|
});
|
||||||
function showMobileQueueCompletion(completedName) {
|
function showMobileQueueCompletion(completedName, cleared = true) {
|
||||||
const next = mobileQueueLauncher.recommend();
|
const next = mobileQueueLauncher.recommend();
|
||||||
qs('#mobile-queue-heading').textContent = completedName + ' cleared';
|
qs('#mobile-queue-heading').textContent = completedName + (cleared ? ' cleared' : '');
|
||||||
document.querySelectorAll('[data-mobile-queue]').forEach(row => row.removeAttribute('data-recommended'));
|
document.querySelectorAll('[data-mobile-queue]').forEach(row => row.removeAttribute('data-recommended'));
|
||||||
const row = qs('[data-mobile-queue="' + next.name + '"]');
|
const row = qs('[data-mobile-queue="' + next.name + '"]');
|
||||||
row.setAttribute('data-recommended', 'true');
|
row.setAttribute('data-recommended', 'true');
|
||||||
qs('#mobile-queue-sheet').showModal();
|
qs('#mobile-queue-sheet').showModal();
|
||||||
row.focus();
|
row.focus();
|
||||||
}
|
}
|
||||||
|
let keptUpdateIdentities = [];
|
||||||
|
function showUpdateTriageOutcome(outcome) {
|
||||||
|
const box = qs('#mobile-update-outcome');
|
||||||
|
const review = qs('#review-kept-updates');
|
||||||
|
keptUpdateIdentities = outcome.keptIdentities;
|
||||||
|
box.hidden = false;
|
||||||
|
qs('#mobile-update-outcome-summary').textContent = outcome.reviewed + ' reviewed · ' + outcome.resolved +
|
||||||
|
' marked read · ' + outcome.kept + ' kept unread';
|
||||||
|
review.hidden = !outcome.kept;
|
||||||
|
review.textContent = 'Review ' + outcome.kept + ' kept unread';
|
||||||
|
showMobileQueueCompletion(outcome.kept ? 'Updates reviewed' : 'Updates', !outcome.kept);
|
||||||
|
if (outcome.kept) review.focus();
|
||||||
|
}
|
||||||
const mobileTaskDock = createMobileTaskDock({
|
const mobileTaskDock = createMobileTaskDock({
|
||||||
nav: qs('#mobile-task-dock'),
|
nav: qs('#mobile-task-dock'),
|
||||||
sessionHud: qs('[data-mobile-today-hud]'),
|
sessionHud: qs('[data-mobile-today-hud]'),
|
||||||
|
|
@ -1029,12 +1042,16 @@
|
||||||
progress.hidden = false;
|
progress.hidden = false;
|
||||||
progress.textContent = 'Update ' + state.index + ' of ' + state.total;
|
progress.textContent = 'Update ' + state.index + ' of ' + state.total;
|
||||||
},
|
},
|
||||||
onFinish: () => {
|
onFinish: outcome => {
|
||||||
notificationReader.prefetch();
|
notificationReader.prefetch();
|
||||||
qs('#update-triage-progress').hidden = true;
|
qs('#update-triage-progress').hidden = true;
|
||||||
showMobileQueueCompletion('Updates');
|
showUpdateTriageOutcome(outcome);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
qs('#review-kept-updates').addEventListener('click', () => {
|
||||||
|
qs('#mobile-queue-sheet').close();
|
||||||
|
updateTriage.reviewKept(keptUpdateIdentities);
|
||||||
|
});
|
||||||
|
|
||||||
const updateTriageLauncher = createUpdateTriageLauncher({
|
const updateTriageLauncher = createUpdateTriageLauncher({
|
||||||
selectUpdates: () => selectMobileQueue('update'),
|
selectUpdates: () => selectMobileQueue('update'),
|
||||||
|
|
|
||||||
|
|
@ -993,6 +993,10 @@
|
||||||
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
|
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
|
||||||
<section class="mobile-queue-panel">
|
<section class="mobile-queue-panel">
|
||||||
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
|
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
|
||||||
|
<div id="mobile-update-outcome" class="mobile-update-outcome" role="status" aria-live="polite" hidden>
|
||||||
|
<p id="mobile-update-outcome-summary"></p>
|
||||||
|
<button id="review-kept-updates" type="button" hidden>Review kept unread</button>
|
||||||
|
</div>
|
||||||
<p class="small muted">Choose what to work through next.</p>
|
<p class="small muted">Choose what to work through next.</p>
|
||||||
<div class="mobile-queue-list">
|
<div class="mobile-queue-list">
|
||||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,20 @@
|
||||||
const login = () => String(options.getLogin() || '').trim();
|
const login = () => String(options.getLogin() || '').trim();
|
||||||
let state = null;
|
let state = null;
|
||||||
let running = false;
|
let running = false;
|
||||||
|
let lastOutcome = null;
|
||||||
|
|
||||||
function read() {
|
function read() {
|
||||||
const owner = login();
|
const owner = login();
|
||||||
if (!owner) return null;
|
if (!owner) return null;
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(options.storage.getItem(key) || 'null');
|
const value = JSON.parse(options.storage.getItem(key) || 'null');
|
||||||
if (value?.version !== 1 || value.login !== owner || !Array.isArray(value.identities) ||
|
if (![1, 2].includes(value?.version) || value.login !== owner || !Array.isArray(value.identities) ||
|
||||||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
|
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
|
||||||
const identities = value.identities.filter(value => typeof value === 'string' && value);
|
const identities = value.identities.filter(value => typeof value === 'string' && value);
|
||||||
const completed = value.completed.filter(value => identities.includes(value));
|
const completed = value.completed.filter(value => identities.includes(value));
|
||||||
|
const kept = Array.isArray(value.kept) ? value.kept.filter(value => identities.includes(value)) : [];
|
||||||
if (!identities.length || !identities.includes(value.current)) return null;
|
if (!identities.length || !identities.includes(value.current)) return null;
|
||||||
return { version:1, login:owner, identities, current:value.current, completed };
|
return { version:2, login:owner, identities, current:value.current, completed, kept };
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -42,10 +44,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function finish() {
|
function finish() {
|
||||||
|
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
|
||||||
|
const keptIdentities = state ? state.kept.filter(id => unread.has(id)) : [];
|
||||||
|
const reviewed = state ? state.completed.length : 0;
|
||||||
|
const outcome = { reviewed, resolved:reviewed - keptIdentities.length, kept:keptIdentities.length, keptIdentities };
|
||||||
|
lastOutcome = outcome;
|
||||||
running = false;
|
running = false;
|
||||||
state = null;
|
state = null;
|
||||||
options.storage.removeItem(key);
|
options.storage.removeItem(key);
|
||||||
options.onFinish();
|
options.onFinish(outcome);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +89,7 @@
|
||||||
start() {
|
start() {
|
||||||
const identities = (options.getItems() || []).map(identity).filter(Boolean);
|
const identities = (options.getItems() || []).map(identity).filter(Boolean);
|
||||||
if (!identities.length) return finish();
|
if (!identities.length) return finish();
|
||||||
state = { version:1, login:login(), identities, current:identities[0], completed:[] };
|
state = { version:2, login:login(), identities, current:identities[0], completed:[], kept:[] };
|
||||||
if (!state.login) return false;
|
if (!state.login) return false;
|
||||||
running = true;
|
running = true;
|
||||||
return openCurrent();
|
return openCurrent();
|
||||||
|
|
@ -99,7 +106,20 @@
|
||||||
},
|
},
|
||||||
completeAndNext: advance,
|
completeAndNext: advance,
|
||||||
acceptCompleted: () => advance(false),
|
acceptCompleted: () => advance(false),
|
||||||
keepUnreadAndNext: advance,
|
keepUnreadAndNext() {
|
||||||
|
if (!running || !state) return false;
|
||||||
|
if (!state.kept.includes(state.current)) state.kept.push(state.current);
|
||||||
|
return advance();
|
||||||
|
},
|
||||||
|
reviewKept(identities = lastOutcome?.keptIdentities || []) {
|
||||||
|
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
|
||||||
|
const kept = (identities || []).filter(id => unread.has(id));
|
||||||
|
if (!kept.length) return false;
|
||||||
|
state = { version:2, login:login(), identities:kept, current:kept[0], completed:[], kept:[] };
|
||||||
|
if (!state.login) return false;
|
||||||
|
running = true;
|
||||||
|
return openCurrent();
|
||||||
|
},
|
||||||
next: nextAvailable,
|
next: nextAvailable,
|
||||||
items: () => state ? available().slice() : [],
|
items: () => state ? available().slice() : [],
|
||||||
end: finish,
|
end: finish,
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,12 @@ process.stdout.write(JSON.stringify({opened, progress, saved, resumable, isolate
|
||||||
{"index": 2, "total": 3},
|
{"index": 2, "total": 3},
|
||||||
]
|
]
|
||||||
assert result["saved"] == {
|
assert result["saved"] == {
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"login": "timmy",
|
"login": "timmy",
|
||||||
"identities": ["1", "2", "3"],
|
"identities": ["1", "2", "3"],
|
||||||
"current": "2",
|
"current": "2",
|
||||||
"completed": ["1"],
|
"completed": ["1"],
|
||||||
|
"kept": ["1"],
|
||||||
}
|
}
|
||||||
assert result["resumable"] is True
|
assert result["resumable"] is True
|
||||||
assert result["isolated"] is False
|
assert result["isolated"] is False
|
||||||
|
|
@ -102,6 +103,52 @@ process.stdout.write(JSON.stringify({first, afterRemoval, afterAdvance:afterAdva
|
||||||
assert result == {"first": 2, "afterRemoval": 3, "afterAdvance": None}
|
assert result == {"first": 2, "afterRemoval": 3, "afterAdvance": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_triage_reports_truthful_outcomes_and_reopens_only_surviving_kept_items():
|
||||||
|
result = run_session("""
|
||||||
|
const values = new Map();
|
||||||
|
let items = [1,2,3].map(notification_id => ({notification_id}));
|
||||||
|
const opened = [], outcomes = [];
|
||||||
|
const session = createSession({
|
||||||
|
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||||
|
getLogin:()=> 'timmy', getItems:()=>items,
|
||||||
|
onOpen:item=>opened.push(item.notification_id), onProgress:()=>{}, onFinish:value=>outcomes.push(value),
|
||||||
|
});
|
||||||
|
session.start();
|
||||||
|
session.keepUnreadAndNext();
|
||||||
|
session.completeAndNext();
|
||||||
|
items = [1,3].map(notification_id => ({notification_id}));
|
||||||
|
session.keepUnreadAndNext();
|
||||||
|
const reviewed = session.reviewKept();
|
||||||
|
process.stdout.write(JSON.stringify({opened, outcomes, reviewed}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"opened": [1, 2, 3, 1],
|
||||||
|
"outcomes": [{"reviewed": 3, "resolved": 1, "kept": 2, "keptIdentities": ["1", "3"]}],
|
||||||
|
"reviewed": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_triage_reconciles_kept_outcomes_before_reporting_cleared():
|
||||||
|
result = run_session("""
|
||||||
|
const values = new Map();
|
||||||
|
let items = [1,2].map(notification_id => ({notification_id}));
|
||||||
|
const outcomes = [];
|
||||||
|
const session = createSession({
|
||||||
|
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||||
|
getLogin:()=> 'timmy', getItems:()=>items,
|
||||||
|
onOpen:()=>{}, onProgress:()=>{}, onFinish:value=>outcomes.push(value),
|
||||||
|
});
|
||||||
|
session.start();
|
||||||
|
session.keepUnreadAndNext();
|
||||||
|
items = [2].map(notification_id => ({notification_id}));
|
||||||
|
session.completeAndNext();
|
||||||
|
process.stdout.write(JSON.stringify(outcomes[0]));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {"reviewed": 2, "resolved": 2, "kept": 0, "keptIdentities": []}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_wires_resumable_updates_triage_mobile_flow():
|
async def test_dashboard_wires_resumable_updates_triage_mobile_flow():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
@ -113,4 +160,8 @@ async def test_dashboard_wires_resumable_updates_triage_mobile_flow():
|
||||||
assert "updateTriage.acceptCompleted()" in html
|
assert "updateTriage.acceptCompleted()" in html
|
||||||
assert "updateTriage.keepUnreadAndNext()" in html
|
assert "updateTriage.keepUnreadAndNext()" in html
|
||||||
assert "updateTriage.reconcile()" in html
|
assert "updateTriage.reconcile()" in html
|
||||||
|
assert 'id="mobile-update-outcome"' in html
|
||||||
|
assert 'id="review-kept-updates"' in html
|
||||||
|
assert "updateTriage.reviewKept(keptUpdateIdentities)" in html
|
||||||
|
assert "showUpdateTriageOutcome(outcome)" in html
|
||||||
assert ".update-triage-progress" in html
|
assert ".update-triage-progress" in html
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user