Reconcile long mobile interruptions before resuming Today time #592
|
|
@ -138,6 +138,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.plan-preview-actions button { min-height:44px; }
|
.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 { 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%; }
|
.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 { 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-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; }
|
.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 { 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%; }
|
.today-recap-actions button { min-height:44px; width:100%; }
|
||||||
@media (max-width:420px) {
|
@media (max-width:420px) {
|
||||||
|
.today-interruption-actions { grid-template-columns:1fr; }
|
||||||
.today-recap-actions { grid-template-columns:1fr; }
|
.today-recap-actions { grid-template-columns:1fr; }
|
||||||
.today-recap-row { grid-template-columns:1fr; }
|
.today-recap-row { grid-template-columns:1fr; }
|
||||||
.today-recap-row input { width:min(100%,8rem); }
|
.today-recap-row input { width:min(100%,8rem); }
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,8 @@
|
||||||
});
|
});
|
||||||
laterSync.startLifecycle({ window, document });
|
laterSync.startLifecycle({ window, document });
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) interruptionPrompt.background();
|
||||||
|
else interruptionPrompt.foreground();
|
||||||
if (!document.hidden) refreshMyWorkView();
|
if (!document.hidden) refreshMyWorkView();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -935,6 +937,23 @@
|
||||||
queryAll: s => document.querySelectorAll(s),
|
queryAll: s => document.querySelectorAll(s),
|
||||||
formatEstimate: formatPlanMinutes,
|
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(
|
const todayRecapView = setupTodayRecap(
|
||||||
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
|
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
|
||||||
() => planningOwnerLogin,
|
() => planningOwnerLogin,
|
||||||
|
|
@ -3481,6 +3500,7 @@
|
||||||
if (activeFlushLogin) {
|
if (activeFlushLogin) {
|
||||||
confirmedOwnerLogin = activeFlushLogin;
|
confirmedOwnerLogin = activeFlushLogin;
|
||||||
updateDeliveryReceiptControls();
|
updateDeliveryReceiptControls();
|
||||||
|
interruptionPrompt.restore();
|
||||||
}
|
}
|
||||||
snapshot.context.notifications = lastNotifications;
|
snapshot.context.notifications = lastNotifications;
|
||||||
renderContextSnapshot(snapshot.context);
|
renderContextSnapshot(snapshot.context);
|
||||||
|
|
@ -5097,6 +5117,7 @@
|
||||||
const outage = mode === 'outage';
|
const outage = mode === 'outage';
|
||||||
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
||||||
planningOwnerLogin = confirmedOwnerLogin;
|
planningOwnerLogin = confirmedOwnerLogin;
|
||||||
|
interruptionPrompt.restore();
|
||||||
updatePlanningAvailability();
|
updatePlanningAvailability();
|
||||||
saved.notifications = notificationReadOutbox.suppress(saved.notifications || []);
|
saved.notifications = notificationReadOutbox.suppress(saved.notifications || []);
|
||||||
lastNotifications = saved.notifications;
|
lastNotifications = saved.notifications;
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,19 @@
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
<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">
|
<section class="today-recap-panel">
|
||||||
<div class="today-recap-header">
|
<div class="today-recap-header">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||||
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
|
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 read = () => {
|
||||||
const ownerKey = key();
|
const ownerKey = key();
|
||||||
if (!ownerKey || !storage) return empty();
|
if (!ownerKey || !storage) return empty();
|
||||||
|
|
@ -26,6 +26,12 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||||
return false;
|
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 settle = (state, at = now()) => {
|
||||||
const entry = state.entries[state.active_identity];
|
const entry = state.entries[state.active_identity];
|
||||||
if (!entry?.running) return state;
|
if (!entry?.running) return state;
|
||||||
|
|
@ -55,11 +61,14 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||||
entry.started_at = now();
|
entry.started_at = now();
|
||||||
entry.running = true;
|
entry.running = true;
|
||||||
state.entries[identity] = entry;
|
state.entries[identity] = entry;
|
||||||
|
state.away_at = null;
|
||||||
|
state.pending_interruption = null;
|
||||||
return write(state);
|
return write(state);
|
||||||
},
|
},
|
||||||
pause() {
|
pause() {
|
||||||
const state = read();
|
const state = read();
|
||||||
settle(state);
|
settle(state);
|
||||||
|
state.away_at = null;
|
||||||
return write(state);
|
return write(state);
|
||||||
},
|
},
|
||||||
resume() {
|
resume() {
|
||||||
|
|
@ -75,6 +84,55 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||||
stop() {
|
stop() {
|
||||||
const state = read();
|
const state = read();
|
||||||
settle(state);
|
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);
|
return write(state);
|
||||||
},
|
},
|
||||||
recapEntries() {
|
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) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
createTodayTimer.createView = createTodayTimerView;
|
createTodayTimer.createView = createTodayTimerView;
|
||||||
|
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
|
||||||
module.exports = createTodayTimer;
|
module.exports = createTodayTimer;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1976,6 +1976,159 @@ process.stdout.write(JSON.stringify({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_timer_reconciles_long_away_time_once_and_keeps_short_switches_seamless():
|
||||||
|
script = f"""
|
||||||
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{
|
||||||
|
getItem:key => values.has(key) ? values.get(key) : null,
|
||||||
|
setItem:(key,value) => values.set(key,value),
|
||||||
|
removeItem:key => values.delete(key),
|
||||||
|
}};
|
||||||
|
let login = 'timmy';
|
||||||
|
let now = 0;
|
||||||
|
const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}});
|
||||||
|
timer.activate('issue:repo:591:');
|
||||||
|
now = 60000;
|
||||||
|
timer.markAway();
|
||||||
|
now = 4 * 60000;
|
||||||
|
const short = timer.reconcileInterruption();
|
||||||
|
now = 5 * 60000;
|
||||||
|
timer.markAway();
|
||||||
|
now = 11 * 60000;
|
||||||
|
const pending = timer.reconcileInterruption();
|
||||||
|
now = 12 * 60000;
|
||||||
|
const frozen = timer.snapshot();
|
||||||
|
const restored = createTodayTimer({{storage,getLogin:() => login,now:() => now}}).pendingInterruption();
|
||||||
|
const counted = timer.resolveInterruption('count');
|
||||||
|
const afterCount = timer.snapshot();
|
||||||
|
const duplicate = timer.resolveInterruption('count');
|
||||||
|
now = 13 * 60000;
|
||||||
|
timer.markAway();
|
||||||
|
now = 19 * 60000;
|
||||||
|
timer.reconcileInterruption();
|
||||||
|
const excluded = timer.resolveInterruption('exclude');
|
||||||
|
const afterExclude = timer.snapshot();
|
||||||
|
login = 'alexander';
|
||||||
|
const isolated = timer.pendingInterruption();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
short,pending,frozen,restored,counted,afterCount,duplicate,excluded,afterExclude,isolated,
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"short": None,
|
||||||
|
"pending": {
|
||||||
|
"identity": "issue:repo:591:",
|
||||||
|
"away_ms": 360000,
|
||||||
|
},
|
||||||
|
"frozen": {
|
||||||
|
"identity": "issue:repo:591:",
|
||||||
|
"elapsed_ms": 660000,
|
||||||
|
"running": False,
|
||||||
|
},
|
||||||
|
"restored": {
|
||||||
|
"identity": "issue:repo:591:",
|
||||||
|
"away_ms": 360000,
|
||||||
|
},
|
||||||
|
"counted": True,
|
||||||
|
"afterCount": {
|
||||||
|
"identity": "issue:repo:591:",
|
||||||
|
"elapsed_ms": 660000,
|
||||||
|
"running": True,
|
||||||
|
},
|
||||||
|
"duplicate": False,
|
||||||
|
"excluded": True,
|
||||||
|
"afterExclude": {
|
||||||
|
"identity": "issue:repo:591:",
|
||||||
|
"elapsed_ms": 720000,
|
||||||
|
"running": True,
|
||||||
|
},
|
||||||
|
"isolated": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_interruption_prompt_restores_and_resolves_the_pending_mobile_decision():
|
||||||
|
script = f"""
|
||||||
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
|
let now = 0;
|
||||||
|
const timer = createTodayTimer({{storage,getLogin:()=>'timmy',now:()=>now}});
|
||||||
|
const sheet = {{hidden:true}};
|
||||||
|
const description = {{textContent:''}};
|
||||||
|
let resolved = 0;
|
||||||
|
const prompt = createTodayTimer.createInterruptionPrompt({{
|
||||||
|
timer, sheet, description,
|
||||||
|
getItemLabel: identity => identity === 'issue:r:7:' ? 'Fix mobile timer' : '',
|
||||||
|
onResolved: () => resolved++,
|
||||||
|
}});
|
||||||
|
timer.activate('issue:r:7:');
|
||||||
|
now = 60000;
|
||||||
|
prompt.background();
|
||||||
|
now = 7 * 60000;
|
||||||
|
const opened = prompt.foreground();
|
||||||
|
const shown = {{hidden:sheet.hidden, text:description.textContent}};
|
||||||
|
const restored = prompt.restore();
|
||||||
|
const excluded = prompt.resolve('exclude');
|
||||||
|
const closed = sheet.hidden;
|
||||||
|
process.stdout.write(JSON.stringify({{opened,shown,restored,excluded,closed,resolved}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"opened": True,
|
||||||
|
"shown": {
|
||||||
|
"hidden": False,
|
||||||
|
"text": "Fix mobile timer · away for 6 minutes",
|
||||||
|
},
|
||||||
|
"restored": True,
|
||||||
|
"excluded": True,
|
||||||
|
"closed": True,
|
||||||
|
"resolved": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_interruption_detection_is_idempotent_and_fails_closed_for_invalid_state():
|
||||||
|
script = f"""
|
||||||
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
|
let now = 0;
|
||||||
|
const timer = createTodayTimer({{storage,getLogin:()=>'timmy',now:()=>now}});
|
||||||
|
timer.activate('issue:r:1:');
|
||||||
|
now = 60000;
|
||||||
|
timer.markAway();
|
||||||
|
now = 120000;
|
||||||
|
timer.markAway();
|
||||||
|
now = 6 * 60000;
|
||||||
|
const originalCheckpoint = timer.reconcileInterruption();
|
||||||
|
timer.resolveInterruption('exclude');
|
||||||
|
timer.pause();
|
||||||
|
now = 20 * 60000;
|
||||||
|
const paused = timer.markAway();
|
||||||
|
const key = 'stackchain.today-timer.v1.timmy';
|
||||||
|
const invalid = JSON.parse(values.get(key));
|
||||||
|
invalid.pending_interruption = {{identity:7,away_ms:'bad'}};
|
||||||
|
invalid.away_at = now + 1000;
|
||||||
|
values.set(key, JSON.stringify(invalid));
|
||||||
|
const malformed = timer.reconcileInterruption();
|
||||||
|
process.stdout.write(JSON.stringify({{originalCheckpoint,paused,malformed,pending:timer.pendingInterruption()}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"originalCheckpoint": {"identity": "issue:r:1:", "away_ms": 300000},
|
||||||
|
"paused": False,
|
||||||
|
"malformed": None,
|
||||||
|
"pending": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_today_timer_switches_items_retains_elapsed_time_and_is_account_bound():
|
def test_today_timer_switches_items_retains_elapsed_time_and_is_account_bound():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||||
|
|
@ -2057,6 +2210,25 @@ async def test_today_timer_is_wired_into_every_mobile_session_control():
|
||||||
assert '.work-session-nav [data-work-session-timer-toggle] { min-height:44px;' in html
|
assert '.work-session-nav [data-work-session-timer-toggle] { min-height:44px;' in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dashboard_reconciles_long_today_interruptions_in_a_touch_safe_sheet():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="today-interruption-sheet"' in html
|
||||||
|
assert 'id="today-interruption-description"' in html
|
||||||
|
assert 'data-today-interruption="count"' in html
|
||||||
|
assert 'data-today-interruption="exclude"' in html
|
||||||
|
assert "const interruptionPrompt = createTodayInterruptionPrompt({" in html
|
||||||
|
assert "if (document.hidden) interruptionPrompt.background();" in html
|
||||||
|
assert "else interruptionPrompt.foreground();" in html
|
||||||
|
assert "window.addEventListener('pagehide', () => interruptionPrompt.background());" in html
|
||||||
|
assert "interruptionPrompt.restore();" in html
|
||||||
|
assert "interruptionPrompt.resolve(button.dataset.todayInterruption)" in html
|
||||||
|
assert ".today-interruption-panel { box-sizing:border-box; width:min(620px,100%);" in html
|
||||||
|
assert "padding-bottom:calc(18px + env(safe-area-inset-bottom))" in html
|
||||||
|
assert ".today-interruption-actions button { min-height:44px; width:100%; }" in html
|
||||||
|
|
||||||
|
|
||||||
def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
|
def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
|
||||||
script = f"""
|
script = f"""
|
||||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user