feat: track actual time through Today (Closes #577)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-11 16:14:17 +00:00
parent a8da5ce949
commit 7ee10619a4
11 changed files with 288 additions and 10 deletions

View File

@ -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

View File

@ -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; }

View File

@ -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))

View File

@ -450,6 +450,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-timer-toggle>Pause timer</button>
<button type="button" data-work-session-complete="issue" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
@ -603,6 +604,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-timer-toggle>Pause timer</button>
<button type="button" data-work-session-complete="update" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
@ -674,6 +676,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-timer-toggle>Pause timer</button>
<button type="button" data-work-session-complete="pull" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
@ -748,6 +751,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-timer-toggle>Pause timer</button>
<button type="button" data-work-session-complete="review" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
@ -796,6 +800,7 @@
<script src="static/my-work.js"></script>
<script src="static/card-planning.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-timer.js"></script>
<script src="static/today-completion.js"></script>
<script src="static/today-readiness.js"></script>
<script src="static/comment-next.js"></script>

View File

@ -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',

122
frontend/today-timer.js Normal file
View File

@ -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;
}

View File

@ -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("</head>", feature_metadata + "\n</head>")
dashboard_html = dashboard_html.replace(
"</body>", f'<script src="{core.runtime_name}"></script>\n</body>'
"</body>",
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
f'<script src="{core.runtime_name}"></script>\n</body>',
)
worker = (frontend_dir / "service-worker.js").read_text()

View File

@ -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("<script src=") == 1
assert first.dashboard_html.count("<script src=") == 2
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' in first.dashboard_html
assert f'<script src="{first.runtime_name}"></script>' 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'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
f'<script src="{first.runtime_name}"></script>'
) 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

View File

@ -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('<button type="button" data-work-session-timer-toggle>') == 4
assert 'createTodayTimer({' in html
assert 'createTodayTimerView({' in html
assert 'timerView.open(workIdentity(item), workSession.checkpointed(item))' in html
assert 'active ? timer.activate(identity) : timer.stop()' in timer_source
assert "setInterval(timerView.render, 1000)" in html
assert 'timer.pause()' in timer_source
assert 'timer.resume()' in timer_source
assert "elapsed(snapshot.elapsed_ms)" in timer_source
assert "' / ' + formatEstimate(runway.current_minutes)" in timer_source
assert '.work-session-nav [data-work-session-timer-toggle] { min-height:44px;' in html
def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});

View File

@ -678,6 +678,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/my-work.js",
"/dashboard/static/card-planning.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-timer.js",
"/dashboard/static/today-completion.js",
"/dashboard/static/today-readiness.js",
"/dashboard/static/comment-next.js",

View File

@ -333,7 +333,7 @@ async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_acti
assert "document.querySelectorAll('[data-work-session-complete]')" in html
assert "completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))" in html
assert ".work-session-nav button { min-height:44px; width:100%; }" in html
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in html
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in html
@pytest.mark.anyio