Merge pull request 'Track interruption-safe actual time through Today' (#578) from timmy/577-today-actual-time into main
This commit is contained in:
commit
8cd2459468
|
|
@ -67,7 +67,8 @@ and an active Today session shows the current estimate plus estimated remaining
|
|||
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
|
||||
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
|
||||
as unknown rather than unblocked. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device. After a reload or installed-app
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data.
|
||||
After a reload or installed-app
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
||||
**Comment & next** on that current issue or pull request posts the handoff online or admits it
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
|
|
|
|||
|
|
@ -371,9 +371,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.resume-today-session, .end-today-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav[hidden] { display:none; }
|
||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||
.work-session-nav [data-work-session-timer-toggle] { min-height:44px; }
|
||||
.work-session-nav button { min-height:44px; width:100%; }
|
||||
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
|
|
|
|||
|
|
@ -913,6 +913,7 @@
|
|||
}
|
||||
|
||||
function openWorkSessionItem(item) {
|
||||
timerView.open(workIdentity(item), workSession.checkpointed(item));
|
||||
openRoutedWork(item, null, { replace:true });
|
||||
}
|
||||
|
||||
|
|
@ -924,6 +925,16 @@
|
|||
'Session recovery could not be saved on this device. You can keep working.';
|
||||
},
|
||||
});
|
||||
const timer = createTodayTimer({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
const timerView = createTodayTimerView({
|
||||
timer,
|
||||
isActive: workSession.checkpointed,
|
||||
queryAll: s => document.querySelectorAll(s),
|
||||
formatEstimate: formatPlanMinutes,
|
||||
});
|
||||
function updateDetailDeferLabels(active) {
|
||||
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
||||
button.textContent = active ? 'Later today & next' : 'Later today';
|
||||
|
|
@ -955,10 +966,7 @@
|
|||
updateWorkSessionActions();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
|
||||
const runway = selectedWorkFilter === 'today' ? todayWork.runway(todayMyWork, state.index - 1) : null;
|
||||
document.querySelectorAll('[data-work-session-progress]').forEach(element => {
|
||||
element.textContent = 'Item ' + state.index + ' of ' + state.total + (runway?.current_minutes ?
|
||||
' · ' + formatPlanMinutes(runway.current_minutes) + ' · ' + formatPlanMinutes(runway.remaining_minutes) + ' remaining' : '');
|
||||
});
|
||||
timerView.update(state, runway);
|
||||
document.querySelectorAll('[data-work-session-previous]').forEach(button => {
|
||||
button.disabled = !state.can_previous;
|
||||
});
|
||||
|
|
@ -970,6 +978,7 @@
|
|||
});
|
||||
},
|
||||
onFinish: () => {
|
||||
timerView.finish();
|
||||
closeOpenWorkSheets();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
||||
qs('#my-work-action-status').textContent = 'Work session complete.';
|
||||
|
|
@ -977,6 +986,7 @@
|
|||
qs('#start-work-session').focus();
|
||||
},
|
||||
});
|
||||
setInterval(timerView.render, 1000);
|
||||
|
||||
function selectTodayWork() {
|
||||
qs('[data-work-filter="today"]').click();
|
||||
|
|
@ -5223,6 +5233,9 @@
|
|||
document.querySelectorAll('[data-work-session-next]').forEach(button =>
|
||||
button.addEventListener('click', () => workSession.checkpointed() ? runTodayTransition('next') : workSession.next())
|
||||
);
|
||||
document.querySelectorAll('[data-work-session-timer-toggle]').forEach(button =>
|
||||
button.addEventListener('click', () => timerView.toggle())
|
||||
);
|
||||
document.querySelectorAll('[data-work-session-complete]').forEach(button =>
|
||||
button.addEventListener('click', () =>
|
||||
completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))
|
||||
|
|
|
|||
|
|
@ -450,6 +450,7 @@
|
|||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="issue" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
|
|
@ -603,6 +604,7 @@
|
|||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="update" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
|
|
@ -674,6 +676,7 @@
|
|||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="pull" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
|
|
@ -748,6 +751,7 @@
|
|||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="review" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
|
|
@ -796,6 +800,7 @@
|
|||
<script src="static/my-work.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const SHELL = [
|
|||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
|
|
|
|||
122
frontend/today-timer.js
Normal file
122
frontend/today-timer.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
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);
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ FEATURE_SOURCES = {
|
|||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"today-timer": ("static/today-timer.js",),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
|
|
@ -95,7 +96,9 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
|||
)
|
||||
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>", f'<script src="{core.runtime_name}"></script>\n</body>'
|
||||
"</body>",
|
||||
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{core.runtime_name}"></script>\n</body>',
|
||||
)
|
||||
|
||||
worker = (frontend_dir / "service-worker.js").read_text()
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
|||
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
|
||||
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
|
||||
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert first.dashboard_html.count("<script src=") == 2
|
||||
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' in first.dashboard_html
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
assert first.runtime_name.startswith("runtime-")
|
||||
assert first.runtime_name.endswith(".js")
|
||||
|
|
@ -46,8 +47,13 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
|||
first = build_frontend(FRONTEND)
|
||||
|
||||
assert set(first.feature_bundles) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup"
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
||||
"today-timer",
|
||||
}
|
||||
assert (
|
||||
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{first.runtime_name}"></script>'
|
||||
) in first.dashboard_html
|
||||
capture = first.feature_bundles["issue-capture"]
|
||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
|
|||
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
||||
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
|
||||
|
|
@ -1931,6 +1932,130 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
|
|||
assert json.loads(result.stdout) == {"finished": 1, "active": False}
|
||||
|
||||
|
||||
def test_today_timer_restores_wall_clock_time_once_and_excludes_pauses():
|
||||
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 now = 1000;
|
||||
const create = () => createTodayTimer({{
|
||||
storage, getLogin:() => 'Timmy', now:() => now,
|
||||
}});
|
||||
const first = create();
|
||||
first.activate('issue:stackchain/dashboard:577:');
|
||||
now += 10 * 60 * 1000;
|
||||
const beforeReload = first.snapshot();
|
||||
const restored = create();
|
||||
const afterReload = restored.snapshot();
|
||||
restored.pause();
|
||||
now += 5 * 60 * 1000;
|
||||
const whilePaused = restored.snapshot();
|
||||
restored.resume();
|
||||
now += 2 * 60 * 1000;
|
||||
const afterResume = restored.snapshot();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
api:typeof createTodayTimer,
|
||||
beforeReload, afterReload, whilePaused, afterResume,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"api": "function",
|
||||
"beforeReload": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": True},
|
||||
"afterReload": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": True},
|
||||
"whilePaused": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": False},
|
||||
"afterResume": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 720000, "running": True},
|
||||
}
|
||||
|
||||
|
||||
def test_today_timer_switches_items_retains_elapsed_time_and_is_account_bound():
|
||||
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:1:');
|
||||
now = 90000;
|
||||
timer.activate('pull:repo:2:');
|
||||
now = 120000;
|
||||
const first = timer.snapshot('issue:repo:1:');
|
||||
const second = timer.snapshot();
|
||||
timer.stop();
|
||||
now = 300000;
|
||||
const stopped = timer.snapshot();
|
||||
login = 'alexander';
|
||||
const other = createTodayTimer({{storage,getLogin:() => login,now:() => now}}).snapshot();
|
||||
process.stdout.write(JSON.stringify({{first,second,stopped,other}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"first": {"identity": "issue:repo:1:", "elapsed_ms": 90000, "running": False},
|
||||
"second": {"identity": "pull:repo:2:", "elapsed_ms": 30000, "running": True},
|
||||
"stopped": {"identity": "pull:repo:2:", "elapsed_ms": 30000, "running": False},
|
||||
"other": {"identity": "", "elapsed_ms": 0, "running": False},
|
||||
}
|
||||
|
||||
|
||||
def test_today_timer_can_be_initialized_before_operator_identity_is_restored():
|
||||
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 = '';
|
||||
let now = 100;
|
||||
const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}});
|
||||
login = 'timmy';
|
||||
const activated = timer.activate('issue:repo:1:');
|
||||
now = 1100;
|
||||
process.stdout.write(JSON.stringify({{activated,snapshot:timer.snapshot()}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"activated": True,
|
||||
"snapshot": {"identity": "issue:repo:1:", "elapsed_ms": 1000, "running": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_today_timer_is_wired_into_every_mobile_session_control():
|
||||
html = await dashboard()
|
||||
timer_source = TODAY_TIMER.read_text()
|
||||
|
||||
assert html.count('<button type="button" data-work-session-timer-toggle>') == 4
|
||||
assert 'createTodayTimer({' in html
|
||||
assert 'createTodayTimerView({' in html
|
||||
assert 'timerView.open(workIdentity(item), workSession.checkpointed(item))' in html
|
||||
assert 'active ? timer.activate(identity) : timer.stop()' in timer_source
|
||||
assert "setInterval(timerView.render, 1000)" in html
|
||||
assert 'timer.pause()' in timer_source
|
||||
assert 'timer.resume()' in timer_source
|
||||
assert "elapsed(snapshot.elapsed_ms)" in timer_source
|
||||
assert "' / ' + formatEstimate(runway.current_minutes)" in timer_source
|
||||
assert '.work-session-nav [data-work-session-timer-toggle] { min-height:44px;' in html
|
||||
|
||||
|
||||
def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
|
|
|
|||
|
|
@ -678,6 +678,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/card-planning.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-timer.js",
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/today-readiness.js",
|
||||
"/dashboard/static/comment-next.js",
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_acti
|
|||
assert "document.querySelectorAll('[data-work-session-complete]')" in html
|
||||
assert "completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))" in html
|
||||
assert ".work-session-nav button { min-height:44px; width:100%; }" in html
|
||||
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in html
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user