Merge pull request 'Guide first mobile task through a completed Today outcome' (#1147) from timmy/1146-first-task-completed-outcome into main
Merge pull request 'Guide first mobile task through a completed Today outcome' (#1147)
This commit is contained in:
commit
e2be14d7c7
|
|
@ -1206,6 +1206,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.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:repeat(6,minmax(0,1fr)); gap:4px; 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-first-task-coach { grid-column:1 / -1; display:grid; gap:2px; padding:8px 10px; border-left:3px solid #60a5fa; border-radius:6px; background:#172f4d; }
|
||||
.mobile-first-task-coach[hidden] { display:none; }
|
||||
.mobile-first-task-receipt { position:fixed; left:12px; right:12px; bottom:calc(var(--mobile-today-clearance, 166px) + 8px + env(safe-area-inset-bottom)); z-index:46; padding:12px; border:1px solid #34d399; border-radius:10px; background:#0d3b35; font-weight:700; }
|
||||
.mobile-first-task-receipt[hidden] { display:none; }
|
||||
.mobile-today-summary { grid-column:1 / 4; 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-column:4 / 7; align-self:center; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mobile-today-hud [data-mobile-today-complete] { grid-column:1 / 3; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
(async function(){
|
||||
const workspaceLifecycle = await loadWorkspace({ document, window });
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const announceWork = message => qs('#my-work-action-status').textContent = message;
|
||||
const fmt = (d) => new Date(d).toLocaleString();
|
||||
const cardPlanning = createCardPlanning(document);
|
||||
const mobileComposerViewport = createMobileComposerViewport({
|
||||
|
|
@ -128,7 +129,7 @@
|
|||
openFiled: openFiledFollowUp,
|
||||
selectFilter: selectMobileQueue,
|
||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
announce: announceWork,
|
||||
getCounts: () => mobileQueueCounts,
|
||||
openFindWork: () => qs('#find-work').click(),
|
||||
});
|
||||
|
|
@ -1700,7 +1701,7 @@
|
|||
hasCheckpoint: () => updateTriage.resumable(),
|
||||
resume: () => updateTriage.resume(),
|
||||
start: () => updateTriage.start(),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
announce: announceWork,
|
||||
});
|
||||
|
||||
function openUpdateTriage() {
|
||||
|
|
@ -2051,7 +2052,7 @@
|
|||
hasCheckpoint: () => Boolean(agendaSessionCheckpoint.read()),
|
||||
resume: () => workSession.resume(),
|
||||
start: () => workSession.start(),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
announce: announceWork,
|
||||
});
|
||||
function openAgendaSession() {
|
||||
return agendaSessionLauncher.open();
|
||||
|
|
@ -2193,7 +2194,7 @@
|
|||
qs('[data-work-filter="today"]').click();
|
||||
workSession.start(item);
|
||||
},
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
announce: announceWork,
|
||||
});
|
||||
function renderCreateStartCapacity() {
|
||||
const result = createAndStart.capacity(qs('#create-issue-estimate').value);
|
||||
|
|
@ -2219,7 +2220,7 @@
|
|||
qs('#my-work-action-status').textContent = 'Checking Today readiness…';
|
||||
return todayReadiness.run('start', workSession.items(), item);
|
||||
},
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
announce: announceWork,
|
||||
});
|
||||
|
||||
const selectedSessionItem = kind => ({
|
||||
|
|
@ -2234,8 +2235,8 @@
|
|||
workSession,
|
||||
refresh: () => refreshMyWorkView({ reconcileSession:false }),
|
||||
warm: warmTodayOffline,
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
advance: () => runTodayTransition('complete'),
|
||||
announce: announceWork,
|
||||
advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete')),
|
||||
});
|
||||
todayLockScreen = createTodayLockScreen({
|
||||
storage:localStorage,
|
||||
|
|
@ -2971,7 +2972,7 @@
|
|||
close: () => workRoute.close(),
|
||||
refresh: refreshMyWorkView,
|
||||
focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
announce: announceWork,
|
||||
formatTime: fmt,
|
||||
});
|
||||
const laterPickerElement = qs('#later-picker');
|
||||
|
|
|
|||
|
|
@ -1655,12 +1655,19 @@
|
|||
</section>
|
||||
|
||||
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
|
||||
<div class="mobile-first-task-coach small" data-mobile-first-task-coach hidden>
|
||||
<strong>Complete your first task</strong>
|
||||
<span>Open it, then use Done & next after a real outcome.</span>
|
||||
</div>
|
||||
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
|
||||
<div class="small" data-work-session-progress aria-live="polite"></div>
|
||||
<button data-mobile-today-complete type="button" hidden>Done & next</button>
|
||||
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
|
||||
<button data-mobile-today-more type="button" aria-controls="mobile-today-actions" aria-haspopup="dialog">More</button>
|
||||
</section>
|
||||
<div class="mobile-first-task-receipt" id="mobile-first-task-receipt" role="status" aria-live="polite" hidden>
|
||||
First task completed · continue Today
|
||||
</div>
|
||||
<dialog class="mobile-today-actions" id="mobile-today-actions" aria-modal="true" aria-labelledby="mobile-today-actions-title">
|
||||
<section class="mobile-today-actions-panel">
|
||||
<header><h2 id="mobile-today-actions-title">Today session</h2><button data-mobile-today-actions-close type="button">Close</button></header>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
isOnline: () => typeof navigator === 'undefined' || navigator.onLine,
|
||||
mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false},
|
||||
eventTarget: win,
|
||||
setTimer: (...args) => globalThis.setTimeout(...args),
|
||||
sheet: doc?.querySelector('#mobile-first-task'),
|
||||
title: doc?.querySelector('#mobile-first-task-title'),
|
||||
findButton: doc?.querySelector('#mobile-first-task-find'),
|
||||
|
|
@ -16,6 +17,8 @@
|
|||
setupButton: doc?.querySelector('#mobile-first-task-setup'),
|
||||
closeButton: doc?.querySelector('#close-mobile-first-task'),
|
||||
status: doc?.querySelector('#mobile-first-task-status'),
|
||||
coach: doc?.querySelector('[data-mobile-first-task-coach]'),
|
||||
receipt: doc?.querySelector('#mobile-first-task-receipt'),
|
||||
isTodayActive: () => false,
|
||||
onFind: () => doc?.querySelector('#find-work')?.click(),
|
||||
onCreate: () => doc?.querySelector('#new-issue')?.click(),
|
||||
|
|
@ -32,22 +35,27 @@
|
|||
return login ? prefix + login : '';
|
||||
}
|
||||
|
||||
function completed() {
|
||||
function state() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return false;
|
||||
try { return options.storage.getItem(currentKey) === 'complete'; }
|
||||
catch (_error) { return false; }
|
||||
if (!currentKey) return '';
|
||||
try { return options.storage.getItem(currentKey) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
}
|
||||
|
||||
function store(value) {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return;
|
||||
try { options.storage.setItem(currentKey, value); }
|
||||
catch (_error) {}
|
||||
}
|
||||
|
||||
function required() {
|
||||
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !completed());
|
||||
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !state());
|
||||
}
|
||||
|
||||
function markComplete() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return;
|
||||
try { options.storage.setItem(currentKey, 'complete'); }
|
||||
catch (_error) {}
|
||||
function renderCoach() {
|
||||
if (!options.coach) return;
|
||||
options.coach.hidden = !(options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive());
|
||||
}
|
||||
|
||||
function render() {
|
||||
|
|
@ -72,16 +80,29 @@
|
|||
}
|
||||
|
||||
function refresh() {
|
||||
if (account() && options.isTodayActive() && !completed()) {
|
||||
markComplete();
|
||||
if (account() && options.isTodayActive() && state() !== 'complete') {
|
||||
store('coaching');
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
return 'completed';
|
||||
renderCoach();
|
||||
return 'coaching';
|
||||
}
|
||||
renderCoach();
|
||||
if (options.sheet.open) render();
|
||||
if (required() && options.hasWork()) return 'awaiting-start';
|
||||
return required() ? 'required' : 'inactive';
|
||||
}
|
||||
|
||||
function completeOutcome() {
|
||||
if (state() !== 'coaching') return false;
|
||||
store('complete');
|
||||
renderCoach();
|
||||
if (options.receipt) {
|
||||
options.receipt.hidden = false;
|
||||
options.setTimer?.(() => { options.receipt.hidden = true; }, 6000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handoff(callback, requiresOnline = false) {
|
||||
if (requiresOnline && !options.isOnline()) return;
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
|
|
@ -99,5 +120,5 @@
|
|||
options.eventTarget?.addEventListener('offline', render);
|
||||
}
|
||||
|
||||
return {required, open, refresh, render, start};
|
||||
return {required, open, refresh, render, start, completeOutcome};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -64,6 +64,27 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
|||
expect(page.locator("#mobile-first-task-status")).to_contain_text("Create a task now")
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_enabled()
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_focused()
|
||||
|
||||
page.locator("#close-mobile-first-task").click()
|
||||
context.set_offline(False)
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
document.querySelector('[data-mobile-today-hud]').hidden = false;
|
||||
localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching');
|
||||
window.firstTaskOutcomeProbe = createMobileFirstTask({
|
||||
getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true,
|
||||
});
|
||||
return window.firstTaskOutcomeProbe.refresh();
|
||||
}"""
|
||||
)
|
||||
coach = page.locator("[data-mobile-first-task-coach]")
|
||||
expect(coach).to_be_visible()
|
||||
expect(coach).to_contain_text("Complete your first task")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True
|
||||
expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
|
||||
expect(coach).to_be_hidden()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
|
|
|
|||
|
|
@ -28,31 +28,39 @@ class Element {{
|
|||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_first_task_activation_is_account_bound_and_completes_only_when_today_is_active():
|
||||
def test_first_task_activation_is_account_bound_and_completes_only_after_a_today_outcome():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key => values.get(key) || null, setItem:(key,value) => values.set(key,value)};
|
||||
const sheet = new Element();
|
||||
const coach = new Element(); coach.hidden = true;
|
||||
const receipt = new Element(); receipt.hidden = true;
|
||||
let login = 'Timmy';
|
||||
let hasWork = false;
|
||||
let todayActive = false;
|
||||
let hideReceipt;
|
||||
const controller = createFirstTask({
|
||||
storage, getLogin:() => login, hasWork:() => hasWork, isTodayActive:() => todayActive,
|
||||
isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(),
|
||||
isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(), coach, receipt,
|
||||
findButton:new Element(), createButton:new Element(), setupButton:new Element(),
|
||||
closeButton:new Element(), status:new Element(), onFind() {}, onCreate() {}, onSetup() {},
|
||||
setTimer:callback => { hideReceipt = callback; return {unref() {}}; },
|
||||
});
|
||||
const before = [controller.required(), controller.open(), sheet.open];
|
||||
hasWork = true;
|
||||
const awaitingStart = controller.refresh();
|
||||
const stillRequired = controller.required();
|
||||
todayActive = true;
|
||||
const completed = controller.refresh();
|
||||
const timmyRequired = controller.required();
|
||||
const coaching = controller.refresh();
|
||||
const afterStart = {required:controller.required(), sheetOpen:sheet.open, coachHidden:coach.hidden, stored:values.get('stackchain.first-task.v1:timmy')};
|
||||
const completed = controller.completeOutcome();
|
||||
const afterOutcome = {required:controller.required(), coachHidden:coach.hidden, receiptHidden:receipt.hidden, stored:values.get('stackchain.first-task.v1:timmy')};
|
||||
hideReceipt();
|
||||
const receiptHiddenAfterTimeout = receipt.hidden;
|
||||
login = 'alexander'; hasWork = false; todayActive = false;
|
||||
const alexanderRequired = controller.required();
|
||||
process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, completed, sheetOpen:sheet.open, timmyRequired, alexanderRequired, values:[...values]}));
|
||||
process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, coaching, afterStart, completed, afterOutcome, receiptHiddenAfterTimeout, alexanderRequired}));
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
@ -60,11 +68,22 @@ process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, compl
|
|||
"before": [True, True, True],
|
||||
"awaitingStart": "awaiting-start",
|
||||
"stillRequired": True,
|
||||
"completed": "completed",
|
||||
"sheetOpen": False,
|
||||
"timmyRequired": False,
|
||||
"coaching": "coaching",
|
||||
"afterStart": {
|
||||
"required": False,
|
||||
"sheetOpen": False,
|
||||
"coachHidden": False,
|
||||
"stored": "coaching",
|
||||
},
|
||||
"completed": True,
|
||||
"afterOutcome": {
|
||||
"required": False,
|
||||
"coachHidden": True,
|
||||
"receiptHidden": False,
|
||||
"stored": "complete",
|
||||
},
|
||||
"receiptHiddenAfterTimeout": True,
|
||||
"alexanderRequired": True,
|
||||
"values": [["stackchain.first-task.v1:timmy", "complete"]],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -158,6 +177,10 @@ async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
|||
assert "shouldActivate: () => mobileFirstTask.required()" in html
|
||||
assert "openActivation: () => mobileFirstTask.open()" in html
|
||||
assert "mobileFirstTask.refresh()" in html
|
||||
assert 'data-mobile-first-task-coach hidden' in html
|
||||
assert 'Complete your first task' in html
|
||||
assert 'id="mobile-first-task-receipt" role="status" aria-live="polite" hidden' in html
|
||||
assert "advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete'))" in html
|
||||
assert '.mobile-first-task { box-sizing:border-box; width:100%;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-first-task-actions button { min-height:48px;' in html
|
||||
|
|
|
|||
|
|
@ -3829,7 +3829,7 @@ async def test_checkpointed_detail_defer_completes_today_and_advertises_next_ite
|
|||
|
||||
assert "continueSession: item => completeTodayItem(item, {" in html
|
||||
assert "successMessage: 'Deferred to Later. Next Today item opened.'" in html
|
||||
assert "advance: () => runTodayTransition('complete')" in html
|
||||
assert "advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete'))" in html
|
||||
assert "button.textContent = active ? 'Later today & next' : 'Later today'" in html
|
||||
assert "button.textContent = active ? 'Tomorrow & next' : 'Tomorrow'" in html
|
||||
assert "button.textContent = active ? 'Choose date & time & next' : 'Choose date & time'" in html
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user