Compare commits

...
Author SHA1 Message Date
timmy ebb7a78cab feat: finish onboarding with active Today (Closes #1144)
CI / lint (pull_request) Successful in 2m48s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 4m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 18:51:15 +00:00
rockachopa b4a5401b22 Merge pull request 'Preserve timed break across reload' (#1143)
CI / lint (push) Successful in 3m3s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m2s
CI / release-candidate (push) Successful in 6s
Closes #1142
2026-08-19 18:32:30 +00:00
timmy 870edda123 Preserve timed break when active work reopens
CI / lint (pull_request) Successful in 2m55s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 18:08:35 +00:00
rockachopa 156d4b0ffc Merge pull request 'Guide new mobile operators to their first task' (#1141) from timmy/1140-first-task-activation into main
CI / lint (push) Successful in 2m54s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 3m36s
CI / release-candidate (push) Has been skipped
2026-08-19 17:41:31 +00:00
6 changed files with 92 additions and 16 deletions

No files matched your search

+1
View File
@@ -143,6 +143,7 @@
const mobileFirstTask = createMobileFirstTask({
getLogin: () => confirmedOwnerLogin,
hasWork: () => todayMyWork.length > 0 || activeMyWork.length > 0,
isTodayActive: () => workSession.checkpointed(),
});
mobileFirstTask.start();
const mobileWorkEntry = createMobileWorkEntry({
+2 -2
View File
@@ -1838,8 +1838,8 @@
<p class="small">Choose work, start it, then return to Work whenever you want to continue.</p>
<p id="mobile-first-task-status" class="small" role="status" aria-live="polite"></p>
<div class="mobile-first-task-actions">
<button id="mobile-first-task-find" type="button">Find a task</button>
<button id="mobile-first-task-create" type="button">Create a task</button>
<button id="mobile-first-task-find" type="button">Find &amp; start</button>
<button id="mobile-first-task-create" type="button">Create &amp; start</button>
<button id="mobile-first-task-setup" type="button">Make this phone work-ready</button>
</div>
</section>
+12 -4
View File
@@ -10,11 +10,13 @@
mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false},
eventTarget: win,
sheet: doc?.querySelector('#mobile-first-task'),
title: doc?.querySelector('#mobile-first-task-title'),
findButton: doc?.querySelector('#mobile-first-task-find'),
createButton: doc?.querySelector('#mobile-first-task-create'),
setupButton: doc?.querySelector('#mobile-first-task-setup'),
closeButton: doc?.querySelector('#close-mobile-first-task'),
status: doc?.querySelector('#mobile-first-task-status'),
isTodayActive: () => false,
onFind: () => doc?.querySelector('#find-work')?.click(),
onCreate: () => doc?.querySelector('#new-issue')?.click(),
onSetup: () => doc?.querySelector('#open-device-setup')?.click(),
@@ -38,7 +40,7 @@
}
function required() {
return Boolean(options.mediaQuery.matches && account() && !options.hasWork() && !completed());
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !completed());
}
function markComplete() {
@@ -50,9 +52,14 @@
function render() {
const online = options.isOnline();
const awaitingStart = options.hasWork();
options.findButton.disabled = !online;
options.status.textContent = online ?
'Choose a task to claim or create one of your own.' :
if (options.title) options.title.textContent = awaitingStart ? 'Finish starting your first task' : 'Start your first task';
options.findButton.textContent = 'Find & start';
options.createButton.textContent = 'Create & start';
options.status.textContent = awaitingStart ?
'Your task is ready. Start it from Find or create and start another task.' : online ?
'Choose a task to claim and start, or create and start one of your own.' :
'You are offline. Create a task now and it will stay in Drafts until you reconnect.';
}
@@ -65,12 +72,13 @@
}
function refresh() {
if (account() && options.hasWork() && !completed()) {
if (account() && options.isTodayActive() && !completed()) {
markComplete();
if (options.sheet.open) options.sheet.close();
return 'completed';
}
if (options.sheet.open) render();
if (required() && options.hasWork()) return 'awaiting-start';
return required() ? 'required' : 'inactive';
}
+2 -1
View File
@@ -114,7 +114,8 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
activate(identity) {
if (!key() || typeof identity !== 'string' || !identity) return false;
const state = read();
if (state.active_identity === identity && state.entries[identity]?.running) return true;
if (state.active_identity === identity &&
(state.entries[identity]?.running || validBreak(state)?.identity === identity)) return true;
settle(state);
state.active_identity = identity;
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
+47 -9
View File
@@ -28,7 +28,7 @@ class Element {{
return json.loads(result.stdout)
def test_first_task_activation_is_account_bound_and_completes_when_work_appears():
def test_first_task_activation_is_account_bound_and_completes_only_when_today_is_active():
result = run_node(
"""
const values = new Map();
@@ -36,24 +36,30 @@ const storage = {getItem:key => values.get(key) || null, setItem:(key,value) =>
const sheet = new Element();
let login = 'Timmy';
let hasWork = false;
let todayActive = false;
const controller = createFirstTask({
storage, getLogin:() => login, hasWork:() => hasWork, isOnline:() => true,
mediaQuery:{matches:true}, sheet, findButton:new Element(), createButton:new Element(),
setupButton:new Element(), closeButton:new Element(), status:new Element(),
onFind() {}, onCreate() {}, onSetup() {},
storage, getLogin:() => login, hasWork:() => hasWork, isTodayActive:() => todayActive,
isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(),
findButton:new Element(), createButton:new Element(), setupButton:new Element(),
closeButton:new Element(), status:new Element(), onFind() {}, onCreate() {}, onSetup() {},
});
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();
login = 'alexander'; hasWork = false;
login = 'alexander'; hasWork = false; todayActive = false;
const alexanderRequired = controller.required();
process.stdout.write(JSON.stringify({before, completed, sheetOpen:sheet.open, timmyRequired, alexanderRequired, values:[...values]}));
process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, completed, sheetOpen:sheet.open, timmyRequired, alexanderRequired, values:[...values]}));
"""
)
assert result == {
"before": [True, True, True],
"awaitingStart": "awaiting-start",
"stillRequired": True,
"completed": "completed",
"sheetOpen": False,
"timmyRequired": False,
@@ -62,6 +68,37 @@ process.stdout.write(JSON.stringify({before, completed, sheetOpen:sheet.open, ti
}
def test_first_task_activation_resumes_with_finish_starting_guidance():
result = run_node(
"""
const sheet = new Element();
const title = new Element();
const findButton = new Element();
const createButton = new Element();
const status = new Element();
const controller = createFirstTask({
storage:{getItem:() => null, setItem() {}}, getLogin:() => 'timmy', hasWork:() => true,
isTodayActive:() => false, isOnline:() => true, mediaQuery:{matches:true}, sheet, title,
findButton, createButton, setupButton:new Element(), closeButton:new Element(), status,
onFind() {}, onCreate() {}, onSetup() {},
});
controller.open();
process.stdout.write(JSON.stringify({
required:controller.required(), title:title.textContent, status:status.textContent,
find:findButton.textContent, create:createButton.textContent,
}));
"""
)
assert result == {
"required": True,
"title": "Finish starting your first task",
"status": "Your task is ready. Start it from Find or create and start another task.",
"find": "Find & start",
"create": "Create & start",
}
def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline():
result = run_node(
"""
@@ -112,11 +149,12 @@ async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
assert '<dialog class="mobile-first-task" id="mobile-first-task" aria-labelledby="mobile-first-task-title">' in html
assert '<h2 id="mobile-first-task-title">Start your first task</h2>' in html
assert 'id="mobile-first-task-find" type="button">Find a task</button>' in html
assert 'id="mobile-first-task-create" type="button">Create a task</button>' in html
assert 'id="mobile-first-task-find" type="button">Find &amp; start</button>' in html
assert 'id="mobile-first-task-create" type="button">Create &amp; start</button>' in html
assert 'id="mobile-first-task-setup" type="button">Make this phone work-ready</button>' in html
assert '<script src="static/mobile-first-task.js"></script>' in html
assert "const mobileFirstTask = createMobileFirstTask({" in html
assert "isTodayActive: () => workSession.checkpointed()" in html
assert "shouldActivate: () => mobileFirstTask.required()" in html
assert "openActivation: () => mobileFirstTask.open()" in html
assert "mobileFirstTask.refresh()" in html
+28
View File
@@ -65,6 +65,34 @@ process.stdout.write(JSON.stringify(timer.snapshot()));
}
def test_reactivating_the_active_item_during_reload_preserves_its_timed_break():
script = TIMER.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now = 100000;
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
timer.activate('issue:r:42:');
timer.startBreak(5);
const reopened = timer.activate('issue:r:42:');
process.stdout.write(JSON.stringify({reopened,snapshot:timer.snapshot(),pending:timer.breakSnapshot()}));
"""
assert run_node(script) == {
"reopened": True,
"snapshot": {
"identity": "issue:r:42:",
"elapsed_ms": 0,
"running": False,
"break_deadline_at": 400_000,
},
"pending": {
"identity": "issue:r:42:",
"deadline_at": 400_000,
"expired": False,
},
}
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
script = TIMER.read_text() + r"""
const values = new Map();