stackchain-dashboard/frontend/today-timer.js
timmy 8d12a0384b
All checks were successful
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m22s
CI / release-candidate (pull_request) Has been skipped
feat: hand off active Today sessions across devices (Closes #1022)
2026-08-17 12:44:01 +00:00

392 lines
16 KiB
JavaScript

function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange = () => {} }) {
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, attention_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));
onChange(snapshot());
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 validAttention = state => {
const pending = state.attention_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : 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 {
adopt(identity, elapsedMs, running) {
if (!key() || typeof identity !== 'string' || !identity ||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
const state = read();
settle(state);
state.active_identity = identity;
state.entries[identity] = {
elapsed_ms:Math.floor(Number(elapsedMs)),
started_at:running ? now() : null,
running:Boolean(running),
};
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
return write(state);
},
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;
state.attention_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;
state.attention_interruption = null;
return write(state);
},
beginAttention() {
const state = read();
const existing = validAttention(state);
if (existing) return existing;
const identity = state.active_identity;
const entry = state.entries[identity];
if (!identity || !entry) return null;
const resume = Boolean(entry.running);
if (resume) settle(state);
state.away_at = null;
state.attention_interruption = { identity, resume };
return write(state) ? { ...state.attention_interruption } : null;
},
attentionInterruption() {
return validAttention(read());
},
returnFromAttention() {
const state = read();
const pending = validAttention(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry) return null;
state.active_identity = pending.identity;
if (pending.resume && !entry.running) {
entry.started_at = now();
entry.running = true;
}
state.attention_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed:pending.resume } : null;
},
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());
},
totalElapsed() {
const state = read();
return Object.entries(state.entries).reduce((total, [identity, entry]) => {
const live = identity === state.active_identity && entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0;
return total + Math.max(0, Number(entry.elapsed_ms) || 0) + live;
}, 0);
},
snapshot,
};
}
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
let progress = null;
let runway = null;
queryAll('[data-mobile-today-open]').forEach(button =>
button.addEventListener('click', () => {
const identity = timer.snapshot().identity;
if (identity) onReopen?.(identity);
})
);
queryAll('[data-mobile-today-toggle]').forEach(button =>
button.addEventListener('click', () => view.toggle())
);
queryAll('[data-mobile-today-complete]').forEach(button =>
button.addEventListener('click', () => {
const identity = timer.snapshot().identity;
if (identity) onComplete?.(identity);
})
);
if (typeof document !== 'undefined') queryAll('.work-session-nav').forEach(nav => {
const button = document.createElement('button');
button.type = 'button';
button.hidden = true;
button.dataset.workSessionAdjustPlan = '';
button.textContent = 'Adjust remaining plan';
nav.insertBefore(button, nav.querySelector('[data-work-session-complete]'));
});
if (typeof MutationObserver !== 'undefined') {
const plan = queryAll('#plan-today')[0];
const sheet = queryAll('#plan-today-sheet')[0];
if (plan && sheet) {
const replan = createTodayBudgetReplan({ timer, openPlan:() => plan.click() });
queryAll('[data-work-session-adjust-plan]').forEach(button =>
button.addEventListener('click', () => replan.open())
);
new MutationObserver(() => {
if (sheet.hidden && replan.restore()) render();
}).observe(sheet, { attributes:true, attributeFilter:['hidden'] });
}
}
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 = () => {
const snapshot = timer.snapshot();
const active = Boolean(progress && isActive() && snapshot.identity);
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
queryAll('[data-mobile-today-open]').forEach(element => {
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
});
queryAll('[data-mobile-today-complete]').forEach(button => {
const itemTitle = String(getItem?.(snapshot.identity)?.title || 'current Today item');
const finalItem = Boolean(progress && progress.index >= progress.total);
button.hidden = !active;
button.textContent = finalItem ? 'Done & recap' : 'Done & next';
button.setAttribute('aria-label', finalItem ?
'Complete ' + itemTitle + ' and open Today recap' :
'Complete ' + itemTitle + ' and open next Today item');
});
if (typeof document !== 'undefined') document.body.classList.toggle('mobile-today-active', active);
if (!progress) return;
const sourceRunway = getRunway?.(snapshot) || runway;
const liveRunway = sourceRunway?.future_minutes !== undefined ? (() => {
const currentElapsed = Math.max(0, Math.ceil(snapshot.elapsed_ms / 60000));
const currentRemaining = sourceRunway.current_minutes === null ? null :
Math.max(0, sourceRunway.current_minutes - currentElapsed);
const remaining = currentRemaining === null || sourceRunway.future_minutes === null ? null :
currentRemaining + sourceRunway.future_minutes;
const projected = remaining === null ? null : Math.ceil(timer.totalElapsed() / 60000) + remaining;
const capacityRemaining = sourceRunway.capacity_minutes === null || projected === null ? null :
sourceRunway.capacity_minutes - projected;
return {
...sourceRunway,
remaining_minutes:remaining,
over_estimate_minutes:sourceRunway.current_minutes === null ? 0 :
Math.max(0, currentElapsed - sourceRunway.current_minutes),
over_capacity_minutes:capacityRemaining === null ? 0 : Math.max(0, -capacityRemaining),
};
})() : sourceRunway;
const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) +
(liveRunway?.current_minutes ? ' / ' + formatEstimate(liveRunway.current_minutes) : '') : '';
const estimateRisk = liveRunway?.over_estimate_minutes ?
' · ' + formatEstimate(liveRunway.over_estimate_minutes) + ' over estimate' : '';
const capacityRisk = liveRunway?.over_capacity_minutes ?
' · Today projected ' + formatEstimate(liveRunway.over_capacity_minutes) + ' over capacity' : '';
queryAll('[data-work-session-progress]').forEach(element => {
element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + estimateRisk + capacityRisk +
(!estimateRisk && !capacityRisk && liveRunway?.remaining_minutes ?
' · ' + formatEstimate(liveRunway.remaining_minutes) + ' remaining' : '');
});
queryAll('[data-work-session-adjust-plan]').forEach(button => {
button.hidden = !(liveRunway?.over_estimate_minutes || liveRunway?.over_capacity_minutes);
});
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) {
const current = timer.snapshot();
if (active && current.identity !== identity) timer.activate(identity);
else if (!active) timer.stop();
render();
},
finish() { timer.stop(); progress = null; runway = null; render(); },
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
reset() { progress = null; runway = null; render(); },
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); },
render,
};
}
function createTodayBudgetReplan({ timer, openPlan }) {
let active = false;
let resume = false;
return {
open() {
if (active) return false;
const state = timer.snapshot();
if (!state.identity) return false;
resume = Boolean(state.running);
if (resume && timer.pause() === false) return false;
active = true;
openPlan?.(state);
return true;
},
restore() {
if (!active) return false;
active = false;
const shouldResume = resume;
resume = false;
return !shouldResume || timer.resume() !== false;
},
};
}
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;
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
module.exports = createTodayTimer;
}