diff --git a/README.md b/README.md index 81167bc..37c80b0 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ and an active Today session shows the current estimate plus estimated remaining first previews its Gitea dependencies: unresolved blockers are listed with links and require the explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported as unknown rather than unblocked. Starting a Today work session also -stores an account-bound checkpoint on the current device. After a reload or installed-app +stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. +After a reload or installed-app restart, **Resume Today** reopens the saved item (or the next surviving item if work changed); **Comment & next** on that current issue or pull request posts the handoff online or admits it to durable account-bound delivery, then removes the item only from Today and opens the next diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 4fd36f3..e8a60aa 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -371,9 +371,10 @@ textarea { resize: vertical; min-height: 120px; } .my-work-actions { display:flex; flex-wrap:wrap; gap:8px; } .start-work-session { min-height:44px; } .resume-today-session, .end-today-session { min-height:44px; } -.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } +.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } .work-session-nav[hidden] { display:none; } .work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; } +.work-session-nav [data-work-session-timer-toggle] { min-height:44px; } .work-session-nav button { min-height:44px; width:100%; } .find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .find-work-sheet.open { display:flex; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 8f3ade9..58a336a 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -913,6 +913,7 @@ } function openWorkSessionItem(item) { + timerView.open(workIdentity(item), workSession.checkpointed(item)); openRoutedWork(item, null, { replace:true }); } @@ -924,6 +925,16 @@ 'Session recovery could not be saved on this device. You can keep working.'; }, }); + const timer = createTodayTimer({ + storage: localStorage, + getLogin: () => confirmedOwnerLogin, + }); + const timerView = createTodayTimerView({ + timer, + isActive: workSession.checkpointed, + queryAll: s => document.querySelectorAll(s), + formatEstimate: formatPlanMinutes, + }); function updateDetailDeferLabels(active) { document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => { button.textContent = active ? 'Later today & next' : 'Later today'; @@ -955,10 +966,7 @@ updateWorkSessionActions(); document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; }); const runway = selectedWorkFilter === 'today' ? todayWork.runway(todayMyWork, state.index - 1) : null; - document.querySelectorAll('[data-work-session-progress]').forEach(element => { - element.textContent = 'Item ' + state.index + ' of ' + state.total + (runway?.current_minutes ? - ' · ' + formatPlanMinutes(runway.current_minutes) + ' · ' + formatPlanMinutes(runway.remaining_minutes) + ' remaining' : ''); - }); + timerView.update(state, runway); document.querySelectorAll('[data-work-session-previous]').forEach(button => { button.disabled = !state.can_previous; }); @@ -970,6 +978,7 @@ }); }, onFinish: () => { + timerView.finish(); closeOpenWorkSheets(); document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; }); qs('#my-work-action-status').textContent = 'Work session complete.'; @@ -977,6 +986,7 @@ qs('#start-work-session').focus(); }, }); + setInterval(timerView.render, 1000); function selectTodayWork() { qs('[data-work-filter="today"]').click(); @@ -5223,6 +5233,9 @@ document.querySelectorAll('[data-work-session-next]').forEach(button => button.addEventListener('click', () => workSession.checkpointed() ? runTodayTransition('next') : workSession.next()) ); + document.querySelectorAll('[data-work-session-timer-toggle]').forEach(button => + button.addEventListener('click', () => timerView.toggle()) + ); document.querySelectorAll('[data-work-session-complete]').forEach(button => button.addEventListener('click', () => completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete)) diff --git a/frontend/index.html b/frontend/index.html index fd8af5d..2aa2c01 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -450,6 +450,7 @@ @@ -603,6 +604,7 @@ @@ -674,6 +676,7 @@ @@ -748,6 +751,7 @@ @@ -796,6 +800,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 7bd732e..a473dc9 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -31,6 +31,7 @@ const SHELL = [ BASE + 'static/my-work.js', BASE + 'static/card-planning.js', BASE + 'static/today-work.js', + BASE + 'static/today-timer.js', BASE + 'static/today-completion.js', BASE + 'static/today-readiness.js', BASE + 'static/comment-next.js', diff --git a/frontend/today-timer.js b/frontend/today-timer.js new file mode 100644 index 0000000..5865c83 --- /dev/null +++ b/frontend/today-timer.js @@ -0,0 +1,122 @@ +function createTodayTimer({ storage, getLogin, now = () => Date.now() }) { + const key = () => { + const login = String(getLogin?.() || '').trim().toLowerCase(); + return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : ''; + }; + const empty = () => ({ version:1, active_identity:'', entries:{} }); + const read = () => { + const ownerKey = key(); + if (!ownerKey || !storage) return empty(); + try { + const value = JSON.parse(storage.getItem(ownerKey) || 'null'); + if (value?.version !== 1 || typeof value.active_identity !== 'string' || + !value.entries || typeof value.entries !== 'object') return empty(); + return value; + } catch (_error) { + return empty(); + } + }; + const write = state => { + const ownerKey = key(); + if (!ownerKey || !storage) return false; + try { + storage.setItem(ownerKey, JSON.stringify(state)); + return true; + } catch (_error) { + return false; + } + }; + const settle = (state, at = now()) => { + const entry = state.entries[state.active_identity]; + if (!entry?.running) return state; + entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) || 0) + + Math.max(0, at - Number(entry.started_at ?? at)); + entry.started_at = null; + entry.running = false; + return state; + }; + const snapshot = (identity = '') => { + const state = read(); + const selected = identity || state.active_identity; + const entry = state.entries[selected]; + if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false }; + const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ? + Math.max(0, now() - Number(entry.started_at ?? now())) : 0); + return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) }; + }; + return { + activate(identity) { + if (!key() || typeof identity !== 'string' || !identity) return false; + const state = read(); + if (state.active_identity === identity && state.entries[identity]?.running) return true; + settle(state); + state.active_identity = identity; + const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false }; + entry.started_at = now(); + entry.running = true; + state.entries[identity] = entry; + return write(state); + }, + pause() { + const state = read(); + settle(state); + return write(state); + }, + resume() { + const state = read(); + const entry = state.entries[state.active_identity]; + if (!entry) return false; + if (!entry.running) { + entry.started_at = now(); + entry.running = true; + } + return write(state); + }, + stop() { + const state = read(); + settle(state); + return write(state); + }, + snapshot, + }; +} + +function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) { + let progress = null; + let runway = null; + const elapsed = milliseconds => { + const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainder = String(seconds % 60).padStart(2, '0'); + return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remainder : minutes + ':' + remainder; + }; + const render = () => { + if (!progress) return; + const snapshot = timer.snapshot(); + const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) + + (runway?.current_minutes ? ' / ' + formatEstimate(runway.current_minutes) : '') : ''; + queryAll('[data-work-session-progress]').forEach(element => { + element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + + (runway?.remaining_minutes ? ' · ' + formatEstimate(runway.remaining_minutes) + ' remaining' : ''); + }); + queryAll('[data-work-session-timer-toggle]').forEach(button => { + button.hidden = !isActive(); + button.textContent = snapshot.running ? 'Pause timer' : 'Resume timer'; + button.setAttribute('aria-pressed', String(!snapshot.running)); + }); + }; + return { + open(identity, active) { active ? timer.activate(identity) : timer.stop(); render(); }, + finish() { timer.stop(); progress = null; runway = null; }, + update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); }, + reset() { progress = null; runway = null; }, + toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); }, + render, + }; +} + +if (typeof module !== 'undefined' && module.exports) { + createTodayTimer.createView = createTodayTimerView; + module.exports = createTodayTimer; +} diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 14c0e5f..49d212d 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -22,6 +22,7 @@ FEATURE_SOURCES = { "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"), "push-notifications": ("static/push-notifications.js",), "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), + "today-timer": ("static/today-timer.js",), } CACHE_DECLARATION = re.compile( r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';" @@ -95,7 +96,9 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: ) dashboard_html = dashboard_html.replace("", feature_metadata + "\n") dashboard_html = dashboard_html.replace( - "", f'\n' + "", + f'\n' + f'\n', ) worker = (frontend_dir / "service-worker.js").read_text() diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index fba4944..29f212b 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -25,7 +25,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path): assert first.runtime_gzip_bytes == second.runtime_gzip_bytes assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes assert PAGE_SCRIPT.findall(first.dashboard_html) == [] - assert first.dashboard_html.count("' in first.dashboard_html assert f'' in first.dashboard_html assert first.runtime_name.startswith("runtime-") assert first.runtime_name.endswith(".js") @@ -46,8 +47,13 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): first = build_frontend(FRONTEND) assert set(first.feature_bundles) == { - "comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup" + "comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup", + "today-timer", } + assert ( + f'\n' + f'' + ) in first.dashboard_html capture = first.feature_bundles["issue-capture"] pull_workflow = first.feature_bundles["pull-workflow"] assert b"function createIssueCapture" not in first.runtime_bytes diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 0a5056e..0884721 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js" +TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js" LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js" DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js" LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js" @@ -1931,6 +1932,130 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}})); assert json.loads(result.stdout) == {"finished": 1, "active": False} +def test_today_timer_restores_wall_clock_time_once_and_excludes_pauses(): + script = f""" +const createTodayTimer = require({json.dumps(str(TODAY_TIMER))}); +const values = new Map(); +const storage = {{ + getItem:key => values.has(key) ? values.get(key) : null, + setItem:(key,value) => values.set(key,value), + removeItem:key => values.delete(key), +}}; +let now = 1000; +const create = () => createTodayTimer({{ + storage, getLogin:() => 'Timmy', now:() => now, +}}); +const first = create(); +first.activate('issue:stackchain/dashboard:577:'); +now += 10 * 60 * 1000; +const beforeReload = first.snapshot(); +const restored = create(); +const afterReload = restored.snapshot(); +restored.pause(); +now += 5 * 60 * 1000; +const whilePaused = restored.snapshot(); +restored.resume(); +now += 2 * 60 * 1000; +const afterResume = restored.snapshot(); +process.stdout.write(JSON.stringify({{ + api:typeof createTodayTimer, + beforeReload, afterReload, whilePaused, afterResume, +}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True + ) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "api": "function", + "beforeReload": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": True}, + "afterReload": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": True}, + "whilePaused": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": False}, + "afterResume": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 720000, "running": True}, + } + + +def test_today_timer_switches_items_retains_elapsed_time_and_is_account_bound(): + script = f""" +const createTodayTimer = require({json.dumps(str(TODAY_TIMER))}); +const values = new Map(); +const storage = {{ + getItem:key => values.has(key) ? values.get(key) : null, + setItem:(key,value) => values.set(key,value), + removeItem:key => values.delete(key), +}}; +let login = 'timmy'; +let now = 0; +const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}}); +timer.activate('issue:repo:1:'); +now = 90000; +timer.activate('pull:repo:2:'); +now = 120000; +const first = timer.snapshot('issue:repo:1:'); +const second = timer.snapshot(); +timer.stop(); +now = 300000; +const stopped = timer.snapshot(); +login = 'alexander'; +const other = createTodayTimer({{storage,getLogin:() => login,now:() => now}}).snapshot(); +process.stdout.write(JSON.stringify({{first,second,stopped,other}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "first": {"identity": "issue:repo:1:", "elapsed_ms": 90000, "running": False}, + "second": {"identity": "pull:repo:2:", "elapsed_ms": 30000, "running": True}, + "stopped": {"identity": "pull:repo:2:", "elapsed_ms": 30000, "running": False}, + "other": {"identity": "", "elapsed_ms": 0, "running": False}, + } + + +def test_today_timer_can_be_initialized_before_operator_identity_is_restored(): + script = f""" +const createTodayTimer = require({json.dumps(str(TODAY_TIMER))}); +const values = new Map(); +const storage = {{ + getItem:key => values.has(key) ? values.get(key) : null, + setItem:(key,value) => values.set(key,value), + removeItem:key => values.delete(key), +}}; +let login = ''; +let now = 100; +const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}}); +login = 'timmy'; +const activated = timer.activate('issue:repo:1:'); +now = 1100; +process.stdout.write(JSON.stringify({{activated,snapshot:timer.snapshot()}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "activated": True, + "snapshot": {"identity": "issue:repo:1:", "elapsed_ms": 1000, "running": True}, + } + + +@pytest.mark.anyio +async def test_today_timer_is_wired_into_every_mobile_session_control(): + html = await dashboard() + timer_source = TODAY_TIMER.read_text() + + assert html.count('