219 lines
8.2 KiB
JavaScript
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;
|
|
}
|