feat: track actual time through Today (Closes #577)
This commit is contained in:
1 parent
a8da5ce949
commit
7ee10619a4
11 files changed
+288
-10
No files matched your search
@@ -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
@@ -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))
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user