stackchain-dashboard/frontend/today-timer.js
timmy aaa9aef78b
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: reconcile long Today interruptions (Closes #591)
2026-08-11 20:56:40 +00:00

219 lines
8.2 KiB
JavaScript

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:{}, away_at:null, pending_interruption:null });
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 validPending = state => {
const pending = state.pending_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
Number.isFinite(pending.away_ms) && pending.away_ms >= 0 ?
{ identity:pending.identity, away_ms:pending.away_ms } : null;
};
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;
state.away_at = null;
state.pending_interruption = null;
return write(state);
},
pause() {
const state = read();
settle(state);
state.away_at = null;
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);
state.away_at = null;
state.pending_interruption = null;
return write(state);
},
markAway() {
const state = read();
const entry = state.entries[state.active_identity];
if (!entry?.running || validPending(state)) return false;
if (Number.isFinite(state.away_at) && state.away_at >= 0) return true;
state.away_at = now();
return write(state);
},
reconcileInterruption(thresholdMs = 5 * 60 * 1000) {
const state = read();
const existing = validPending(state);
if (existing) return existing;
state.pending_interruption = null;
const entry = state.entries[state.active_identity];
const awayAt = Number(state.away_at);
const detectedAt = now();
state.away_at = null;
if (!entry?.running || !Number.isFinite(awayAt) || awayAt < 0 || detectedAt < awayAt ||
detectedAt - awayAt < thresholdMs) {
write(state);
return null;
}
const awayMs = detectedAt - awayAt;
settle(state, detectedAt);
state.pending_interruption = { identity:state.active_identity, away_ms:awayMs };
write(state);
return { ...state.pending_interruption };
},
pendingInterruption() {
return validPending(read());
},
resolveInterruption(decision) {
if (decision !== 'count' && decision !== 'exclude') return false;
const state = read();
const pending = state.pending_interruption;
const entry = pending && state.entries[pending.identity];
if (!entry || !Number.isFinite(pending.away_ms) || pending.away_ms < 0) return false;
if (decision === 'exclude') {
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) - pending.away_ms);
}
state.active_identity = pending.identity;
entry.started_at = now();
entry.running = true;
state.pending_interruption = null;
state.away_at = null;
return write(state);
},
recapEntries() {
const state = read();
settle(state);
write(state);
return Object.entries(state.entries).map(([identity, entry]) => ({
identity,
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0),
})).filter(entry => entry.elapsed_ms > 0);
},
clearRecap() {
return write(empty());
},
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,
};
}
function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel, onResolved }) {
const render = pending => {
if (!pending) {
sheet.hidden = true;
return false;
}
const label = String(getItemLabel?.(pending.identity) || 'Current Today item');
const minutes = Math.max(1, Math.round(pending.away_ms / 60000));
description.textContent = label + ' · away for ' + minutes + ' minute' + (minutes === 1 ? '' : 's');
sheet.hidden = false;
return true;
};
return {
background() { return timer.markAway(); },
foreground() { return render(timer.reconcileInterruption()); },
restore() { return render(timer.pendingInterruption()); },
resolve(decision) {
if (!timer.resolveInterruption(decision)) return false;
sheet.hidden = true;
onResolved?.();
return true;
},
};
}
if (typeof module !== 'undefined' && module.exports) {
createTodayTimer.createView = createTodayTimerView;
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
module.exports = createTodayTimer;
}