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(
- "