feat: reconcile long Today interruptions (Closes #591)
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 20:56:40 +00:00
1 parent 10a8b2738b
commit aaa9aef78b
5 files changed
+298 -1

No files matched your search

+7
View File
@@ -138,6 +138,12 @@ textarea { resize: vertical; min-height: 120px; }
.plan-preview-actions button { min-height:44px; }
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.plan-today-actions button { min-height:44px; width:100%; }
.today-interruption-sheet { position:fixed; inset:0; z-index:92; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-interruption-sheet[hidden] { display:none; }
.today-interruption-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
.today-interruption-panel h2 { margin:.25rem 0; overflow-wrap:anywhere; }
.today-interruption-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.today-interruption-actions button { min-height:44px; width:100%; }
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-recap-sheet[hidden] { display:none; }
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
@@ -158,6 +164,7 @@ textarea { resize: vertical; min-height: 120px; }
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-recap-actions button { min-height:44px; width:100%; }
@media (max-width:420px) {
.today-interruption-actions { grid-template-columns:1fr; }
.today-recap-actions { grid-template-columns:1fr; }
.today-recap-row { grid-template-columns:1fr; }
.today-recap-row input { width:min(100%,8rem); }
+21
View File
@@ -219,6 +219,8 @@
});
laterSync.startLifecycle({ window, document });
document.addEventListener('visibilitychange', () => {
if (document.hidden) interruptionPrompt.background();
else interruptionPrompt.foreground();
if (!document.hidden) refreshMyWorkView();
});
@@ -935,6 +937,23 @@
queryAll: s => document.querySelectorAll(s),
formatEstimate: formatPlanMinutes,
});
const interruptionPrompt = createTodayInterruptionPrompt({
timer,
sheet: qs('#today-interruption-sheet'),
description: qs('#today-interruption-description'),
getItemLabel: identity => {
const item = [...todayMyWork, ...activeMyWork].find(entry => todayWork.identity(entry) === identity);
return item?.title || '';
},
onResolved: () => timerView.render(),
});
window.addEventListener('pagehide', () => interruptionPrompt.background());
interruptionPrompt.restore();
document.querySelectorAll('[data-today-interruption]').forEach(button => {
button.addEventListener('click', () =>
interruptionPrompt.resolve(button.dataset.todayInterruption)
);
});
const todayRecapView = setupTodayRecap(
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
() => planningOwnerLogin,
@@ -3481,6 +3500,7 @@
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
updateDeliveryReceiptControls();
interruptionPrompt.restore();
}
snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context);
@@ -5097,6 +5117,7 @@
const outage = mode === 'outage';
confirmedOwnerLogin = String(saved.user?.login || '').trim();
planningOwnerLogin = confirmedOwnerLogin;
interruptionPrompt.restore();
updatePlanningAvailability();
saved.notifications = notificationReadOutbox.suppress(saved.notifications || []);
lastNotifications = saved.notifications;
+13
View File
@@ -267,6 +267,19 @@
</section>
</div>
<div class="today-interruption-sheet" id="today-interruption-sheet" role="dialog" aria-modal="true" aria-labelledby="today-interruption-title" hidden>
<section class="today-interruption-panel">
<div class="small">Today timer</div>
<h2 id="today-interruption-title">Were you working while away?</h2>
<p id="today-interruption-description" class="small"></p>
<p class="small muted">Choose once before the timer continues. You can include the full interval or remove it from your recap.</p>
<div class="today-interruption-actions">
<button type="button" data-today-interruption="count">Count this time</button>
<button type="button" data-today-interruption="exclude">Exclude this time</button>
</div>
</section>
</div>
<div class="today-recap-sheet" id="today-recap-sheet" role="dialog" aria-modal="true" aria-labelledby="today-recap-title" hidden>
<section class="today-recap-panel">
<div class="today-recap-header">
+85 -1
View File
@@ -3,7 +3,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
};
const empty = () => ({ version:1, active_identity:'', entries:{} });
const empty = () => ({ version:1, active_identity:'', entries:{}, away_at:null, pending_interruption:null });
const read = () => {
const ownerKey = key();
if (!ownerKey || !storage) return empty();
@@ -26,6 +26,12 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
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;
@@ -55,11 +61,14 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
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() {
@@ -75,6 +84,55 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
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() {
@@ -128,7 +186,33 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) {
};
}
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;
}