feat: track actual time through Today (Closes #577)
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 committed 2026-08-11 16:14:17 +00:00
1 parent a8da5ce949
commit 7ee10619a4
11 files changed
+288 -10

No files matched your search

+2 -1
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; }
+17 -4
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))
+5
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>
+1
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
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;
}