feat: schedule unfinished work during Today wrap-up (Closes #990)
This commit is contained in:
parent
edf5d3fda4
commit
110fd860b3
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_today_handoff_release.py -q
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -258,12 +258,26 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
|
||||
.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-wrap-up-sheet { position:fixed; inset:0; z-index:89; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-wrap-up-sheet[hidden] { display:none; }
|
||||
.today-wrap-up-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 #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-wrap-up-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-wrap-up-header h2 { margin:.2rem 0; }
|
||||
.today-wrap-up-header button { min-height:44px; min-width:44px; }
|
||||
.today-wrap-up-items { display:grid; gap:8px; margin:14px 0; }
|
||||
.today-wrap-up-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; min-width:0; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233d; overflow-wrap:anywhere; }
|
||||
.today-wrap-up-item span { display:block; }
|
||||
.today-wrap-up-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; }
|
||||
.today-wrap-up-item input { width:24px; min-height:24px; }
|
||||
.today-wrap-up-actions { position:sticky; bottom:0; 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-wrap-up-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); }
|
||||
.today-recap-variance { grid-column:1; }
|
||||
.today-wrap-up-item { grid-template-columns:1fr; }
|
||||
}
|
||||
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-readiness-sheet[hidden] { display:none; }
|
||||
|
|
|
|||
|
|
@ -1767,11 +1767,18 @@
|
|||
interruptionPrompt.resolve(button.dataset.todayInterruption)
|
||||
);
|
||||
});
|
||||
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
|
||||
onComplete:() => {
|
||||
todayRecapView['completeReplan']();
|
||||
refreshMyWorkView();
|
||||
warmTodayOffline();
|
||||
},
|
||||
});
|
||||
const todayRecapView = setupTodayRecap(
|
||||
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
|
||||
() => planningOwnerLogin,
|
||||
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
|
||||
actualMinutes => openPlanToday(qs('#plan-today'), true, actualMinutes)
|
||||
actualMinutes => todayWrapUpView.open(todayMyWork, actualMinutes)
|
||||
);
|
||||
function updateDetailDeferLabels(active) {
|
||||
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
||||
|
|
|
|||
|
|
@ -406,13 +406,28 @@
|
|||
<div id="today-recap-history" class="today-recap-history"></div>
|
||||
</section>
|
||||
<div class="today-recap-actions">
|
||||
<button id="save-today-recap" type="button">Save recap & adjust plan</button>
|
||||
<button id="save-today-recap" type="button">Save recap & wrap up</button>
|
||||
<button id="save-log-today-recap" type="button">Log selected time to Gitea</button>
|
||||
<button id="discard-today-recap" type="button">Keep timer & close</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-wrap-up-sheet" id="today-wrap-up-sheet" role="dialog" aria-modal="true" aria-labelledby="today-wrap-up-title" hidden>
|
||||
<section class="today-wrap-up-panel">
|
||||
<div class="today-wrap-up-header">
|
||||
<div><div class="small">End your day intentionally</div><h2 id="today-wrap-up-title">Wrap up Today</h2></div>
|
||||
<button id="close-today-wrap-up" type="button">Not now</button>
|
||||
</div>
|
||||
<p class="small muted">Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.</p>
|
||||
<div id="today-wrap-up-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-wrap-up-items" class="today-wrap-up-items"></div>
|
||||
<div class="today-wrap-up-actions">
|
||||
<button id="finish-today-wrap-up" type="button">Finish wrap-up</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
|
||||
<section class="today-readiness-panel">
|
||||
<div class="today-readiness-header">
|
||||
|
|
@ -1547,6 +1562,7 @@
|
|||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
<script src="static/today-wrap-up.js"></script>
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const SHELL = [
|
|||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
BASE + 'static/today-wrap-up.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ async function saveTodayRecapTime(payload) {
|
|||
}
|
||||
|
||||
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
|
||||
describeWork = () => null, adjustPlan = () => {} }) {
|
||||
describeWork = () => null, openWrapUp = () => {} }) {
|
||||
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
|
||||
const render = () => {
|
||||
const draft = recap.snapshot();
|
||||
|
|
@ -290,7 +290,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
|||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(handoff.actual_minutes);
|
||||
openWrapUp(handoff.actual_minutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
||||
} finally { button.disabled = false; }
|
||||
|
|
@ -310,7 +310,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
|||
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(actualMinutes);
|
||||
openWrapUp(actualMinutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
|
||||
render();
|
||||
|
|
@ -345,8 +345,8 @@ function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
|
|||
}
|
||||
|
||||
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
|
||||
describeWork, adjustPlan) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
|
||||
describeWork, openWrapUp) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, openWrapUp };
|
||||
const recap = createTodayRecap({
|
||||
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
|
||||
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
|
||||
|
|
|
|||
122
frontend/today-wrap-up.js
Normal file
122
frontend/today-wrap-up.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
let items = [];
|
||||
const selected = new Set();
|
||||
|
||||
function open(candidates) {
|
||||
const current = new Set(todayWork.read());
|
||||
const byIdentity = new Map((candidates || []).map(item => [todayWork.identity(item), item]));
|
||||
items = todayWork.read().flatMap(identity =>
|
||||
current.has(identity) && byIdentity.has(identity) ? [byIdentity.get(identity)] : []
|
||||
);
|
||||
selected.clear();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function choose(identity, schedule) {
|
||||
if (!items.some(item => todayWork.identity(item) === identity)) return false;
|
||||
if (schedule) selected.add(identity);
|
||||
else selected.delete(identity);
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return items.map(item => ({
|
||||
item,
|
||||
identity:todayWork.identity(item),
|
||||
schedule:selected.has(todayWork.identity(item)),
|
||||
}));
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
const wake = laterWork.presetUntil('tomorrow');
|
||||
let scheduled = 0;
|
||||
for (const item of items) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (!selected.has(identity) || !todayWork.contains(item)) continue;
|
||||
const deferred = laterWork.defer(item, wake);
|
||||
if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.');
|
||||
if (!todaySync.enqueue('remove', identity)) {
|
||||
laterWork.restore?.(item);
|
||||
throw new Error('Today sync could not be queued. Your Today plan is unchanged.');
|
||||
}
|
||||
if (todayWork.contains(item) && !todayWork.remove(item)) {
|
||||
throw new Error('Today could not be updated. Retry wrap-up.');
|
||||
}
|
||||
scheduled += 1;
|
||||
}
|
||||
await todaySync.flush();
|
||||
return { scheduled, left:items.length - scheduled, wake_at:wake.toISOString() };
|
||||
}
|
||||
|
||||
return { open, choose, snapshot, finish };
|
||||
}
|
||||
|
||||
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
|
||||
let actualMinutes = {};
|
||||
|
||||
function close() {
|
||||
qs('#today-wrap-up-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
onClose();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = controller.snapshot();
|
||||
qs('#today-wrap-up-items').innerHTML = rows.map(row => {
|
||||
const title = String(row.item.title || row.identity).slice(0, 180);
|
||||
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
|
||||
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
|
||||
'> Tomorrow 09:00</label></div>';
|
||||
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
|
||||
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
|
||||
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
|
||||
});
|
||||
}
|
||||
|
||||
function open(items, recapActualMinutes = {}) {
|
||||
actualMinutes = { ...recapActualMinutes };
|
||||
controller.open(items);
|
||||
qs('#today-wrap-up-status').textContent = '';
|
||||
render();
|
||||
qs('#today-wrap-up-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
requestAnimationFrame(() => (qs('#today-wrap-up-items input') || qs('#finish-today-wrap-up')).focus());
|
||||
}
|
||||
|
||||
async function finish(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-wrap-up-status').textContent = 'Saving tomorrow’s plan…';
|
||||
try {
|
||||
const result = await controller.finish();
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
|
||||
close();
|
||||
onComplete(result, actualMinutes);
|
||||
} catch (error) {
|
||||
qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
qs('#close-today-wrap-up').addEventListener('click', close);
|
||||
qs('#finish-today-wrap-up').addEventListener('click', event => finish(event.currentTarget));
|
||||
}
|
||||
|
||||
return { open, close, finish, render, bind };
|
||||
}
|
||||
|
||||
function setupTodayWrapUp(options) {
|
||||
const controller = createTodayWrapUp(options);
|
||||
const view = createTodayWrapUpView({ ...options, controller });
|
||||
view.bind();
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayWrapUp.createView = createTodayWrapUpView;
|
||||
createTodayWrapUp.setup = setupTodayWrapUp;
|
||||
module.exports = createTodayWrapUp;
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ FEATURE_SOURCES = {
|
|||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js",
|
||||
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
|
|
|
|||
99
tests/e2e/test_mobile_today_wrap_up_release.py
Normal file
99
tests/e2e/test_mobile_today_wrap_up_release.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Today wrap-up journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on(
|
||||
"console",
|
||||
lambda message: browser_errors.append(message.text)
|
||||
if message.type == "error"
|
||||
else None,
|
||||
)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Today wrap-up release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const items=[
|
||||
{kind:'issue',repository:'acme/mobile',number:41,title:'Ship mobile capture',key:'acme/mobile#41'},
|
||||
{kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'},
|
||||
];
|
||||
const identity=item => `${item.kind}:${item.repository}:${item.number}:`;
|
||||
const today=items.map(identity); const later={}; const operations=[];
|
||||
const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{
|
||||
const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true;
|
||||
}};
|
||||
const laterWork={presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:(item,wake)=>{
|
||||
later[identity(item)]=wake.toISOString(); return 'deferred';
|
||||
}};
|
||||
const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)};
|
||||
const controller=createTodayWrapUp({todayWork,laterWork,todaySync});
|
||||
const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
||||
window.__wrapTest={view,today,later,operations};
|
||||
view.open(items,{});
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
sheet = page.locator("#today-wrap-up-sheet")
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator(".today-wrap-up-item")).to_have_count(2)
|
||||
for control in sheet.locator("button, label").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator('[data-wrap-up-identity="issue:acme/mobile:41:"]').check()
|
||||
page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))")
|
||||
expect(sheet).to_be_hidden()
|
||||
result = page.evaluate(
|
||||
"({today:window.__wrapTest.today,later:window.__wrapTest.later,operations:window.__wrapTest.operations})"
|
||||
)
|
||||
assert result == {
|
||||
"today": ["issue:acme/mobile:42:"],
|
||||
"later": {"issue:acme/mobile:41:": "2026-08-17T09:00:00.000Z"},
|
||||
"operations": [["remove", "issue:acme/mobile:41:"]],
|
||||
}
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
|
@ -59,6 +59,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
|||
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
|
||||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||
"tests/e2e/test_mobile_find_work_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py -q"
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py -q"
|
||||
) in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
|
|
|
|||
|
|
@ -988,6 +988,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-timer.js",
|
||||
"/dashboard/static/today-recap.js",
|
||||
"/dashboard/static/today-wrap-up.js",
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/today-readiness.js",
|
||||
"/dashboard/static/comment-next.js",
|
||||
|
|
|
|||
|
|
@ -876,13 +876,13 @@ async def test_dashboard_renders_mobile_today_recap_flow():
|
|||
assert ".today-recap-header button { min-height:44px;" in css
|
||||
assert ".today-recap-actions button { min-height:44px;" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
assert "Save recap & adjust plan" in html
|
||||
assert "Save recap & wrap up" in html
|
||||
assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source
|
||||
assert 'class="today-recap-variance"' in recap_source
|
||||
assert "await recap.saveForReplan()" in recap_source
|
||||
assert "adjustPlan(handoff.actual_minutes)" in recap_source
|
||||
assert "openWrapUp(handoff.actual_minutes)" in recap_source
|
||||
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
|
||||
assert "openPlanToday(qs('#plan-today'), true, actualMinutes)" in dashboard
|
||||
assert "todayWrapUpView.open(todayMyWork, actualMinutes)" in dashboard
|
||||
assert ".today-recap-row { grid-template-columns:1fr; }" in css
|
||||
|
||||
|
||||
|
|
|
|||
93
tests/test_today_wrap_up.py
Normal file
93
tests/test_today_wrap_up.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
TODAY_WRAP_UP = ROOT / "frontend" / "today-wrap-up.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_wrap_up_schedules_selected_work_for_tomorrow_and_leaves_other_work_in_order():
|
||||
script = f"""
|
||||
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||
const items = [
|
||||
{{kind:'issue', repository:'stackchain/dashboard', number:1, title:'First'}},
|
||||
{{kind:'issue', repository:'stackchain/dashboard', number:2, title:'Second'}},
|
||||
{{kind:'pull', repository:'stackchain/dashboard', number:3, title:'Third'}},
|
||||
];
|
||||
const ids = items.map(item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':');
|
||||
const today = [...ids];
|
||||
const later = {{}};
|
||||
const todayOps = [];
|
||||
const laterOps = [];
|
||||
const todayWork = {{
|
||||
identity:item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':',
|
||||
read:() => [...today],
|
||||
contains:item => today.includes((item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'),
|
||||
remove:item => {{
|
||||
const id=(item.kind || 'work') + ':' + item.repository + ':' + item.number + ':';
|
||||
const index=today.indexOf(id); if(index < 0) return false; today.splice(index,1); return true;
|
||||
}},
|
||||
}};
|
||||
const laterWork = {{
|
||||
identity:todayWork.identity,
|
||||
presetUntil:preset => new Date('2026-08-17T09:00:00-04:00'),
|
||||
defer:(item, until) => {{ later[todayWork.identity(item)] = until.toISOString(); laterOps.push(todayWork.identity(item)); return 'deferred'; }},
|
||||
}};
|
||||
const todaySync = {{enqueue:(action,id) => {{todayOps.push([action,id]); return true;}}, flush:()=>Promise.resolve(true)}};
|
||||
const wrap = createWrapUp({{todayWork,laterWork,todaySync}});
|
||||
wrap.open(items);
|
||||
wrap.choose(ids[1], true);
|
||||
const result = await wrap.finish();
|
||||
process.stdout.write(JSON.stringify({{result,today,later,todayOps,laterOps}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output["result"] == {"scheduled": 1, "left": 2, "wake_at": "2026-08-17T13:00:00.000Z"}
|
||||
assert output["today"] == ["issue:stackchain/dashboard:1:", "pull:stackchain/dashboard:3:"]
|
||||
assert output["later"] == {"issue:stackchain/dashboard:2:": "2026-08-17T13:00:00.000Z"}
|
||||
assert output["todayOps"] == [["remove", "issue:stackchain/dashboard:2:"]]
|
||||
assert output["laterOps"] == ["issue:stackchain/dashboard:2:"]
|
||||
|
||||
|
||||
def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_save():
|
||||
from src import main
|
||||
|
||||
html = main.FRONTEND_BUILD.dashboard_html
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
recap = (ROOT / "frontend" / "today-recap.js").read_text()
|
||||
|
||||
assert 'id="today-wrap-up-sheet" role="dialog"' in html
|
||||
assert 'id="today-wrap-up-items"' in html
|
||||
assert 'id="finish-today-wrap-up"' in html
|
||||
assert 'static/today-wrap-up.js' in main.FRONTEND_BUILD.page_sources
|
||||
assert 'setupTodayWrapUp({' in dashboard
|
||||
assert 'todayWrapUpView.open' in dashboard
|
||||
assert 'openWrapUp(handoff.actual_minutes)' in recap
|
||||
assert '.today-wrap-up-actions button { min-height:44px;' in css
|
||||
assert '.today-wrap-up-panel' in css and 'overflow-x:hidden' in css
|
||||
assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
|
||||
|
||||
|
||||
def test_wrap_up_repeat_confirmation_does_not_repeat_later_or_today_operations():
|
||||
script = f"""
|
||||
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Ship'}};
|
||||
const id='issue:acme/mobile:41:';
|
||||
let present=true; let deferred=0; let removed=0;
|
||||
const todayWork={{identity:()=>id,read:()=>present?[id]:[],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
||||
const laterWork={{presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:()=>{{deferred+=1;return 'deferred';}}}};
|
||||
const todaySync={{enqueue:()=>{{removed+=1;return true;}},flush:()=>Promise.resolve(true)}};
|
||||
const wrap=createWrapUp({{todayWork,laterWork,todaySync}});
|
||||
wrap.open([item]); wrap.choose(id,true);
|
||||
await wrap.finish(); await wrap.finish();
|
||||
process.stdout.write(JSON.stringify({{deferred,removed,present}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output == {"deferred": 1, "removed": 1, "present": False}
|
||||
Loading…
Reference in New Issue
Block a user