135 lines
5.0 KiB
JavaScript
135 lines
5.0 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:{} });
|
|
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);
|
|
},
|
|
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,
|
|
};
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
createTodayTimer.createView = createTodayTimerView;
|
|
module.exports = createTodayTimer;
|
|
}
|