diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4632a16..b9b5012 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_wrap_up_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_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q release-candidate: runs-on: ubuntu-latest diff --git a/README.md b/README.md index eb0b7db..6638521 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,8 @@ as unknown rather than unblocked. Completing any Today item now exposes a 10-sec plan changes, and leaves the already-advanced work session on its current item; expiry, capacity, or a concurrently changed plan is reported without overwriting newer work. Starting a Today work session also 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. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. 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. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap. +After wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it. + A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. When work is blocked, **Post blocker & move on** requires a future return time, admits the comment before any planning change, defers the item to Later, and continues the existing Today session. An admitted planning transition remains checkpointed for retry, so a storage or Today-removal failure cannot post the blocker twice. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable. After a reload or installed-app restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open diff --git a/frontend/dashboard.css b/frontend/dashboard.css index b5bc5f6..5435ea8 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -342,6 +342,21 @@ textarea { resize: vertical; min-height: 120px; } .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%; } +.today-summary-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-summary-sheet[hidden] { display:none; } +.today-summary-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-summary-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } +.today-summary-header h2 { margin:.2rem 0; } +.today-summary-header button { min-height:44px; min-width:44px; } +.today-summary-items { display:grid; gap:8px; } +.today-summary-item, .today-summary-option { display:flex; align-items:center; gap:10px; min-height:44px; padding:10px; border:1px solid #2a496e; border-radius:10px; background:#10233d; overflow-wrap:anywhere; } +.today-summary-item input, .today-summary-option input { width:24px; min-height:24px; flex:0 0 auto; } +.today-summary-item span { display:block; min-width:0; } +.today-summary-note { display:grid; gap:6px; margin:14px 0; font-weight:700; } +.today-summary-note textarea { box-sizing:border-box; width:100%; min-height:88px; resize:vertical; } +.today-summary-preview { min-height:72px; white-space:pre-wrap; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#07101e; color:#e8f1ff; } +.today-summary-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-summary-actions button { min-height:44px; width:100%; } .today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; } .today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); } .today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 6da5ff8..22619ac 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1902,18 +1902,21 @@ interruptionPrompt.resolve(button.dataset.todayInterruption) ); }); + const todaySummaryView = setupTodaySummary({ qs, escapeHtml, getLogin:() => planningOwnerLogin }); + todaySummaryView.resume(); const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml, - onComplete:() => { + onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => { todayRecapView['completeReplan'](); refreshMyWorkView(); warmTodayOffline(); + todaySummaryView.open(workedItems, tomorrowItems); }, }); const todayRecapView = setupTodayRecap( timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions, () => planningOwnerLogin, identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null, - actualMinutes => todayWrapUpView.open(todayMyWork, actualMinutes) + (actualMinutes, workedItems) => todayWrapUpView.open(todayMyWork, actualMinutes, workedItems) ); function updateDetailDeferLabels(active) { document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => { diff --git a/frontend/index.html b/frontend/index.html index 564037a..fa3c9ae 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -507,6 +507,29 @@ +
Review exactly what will leave this device. Time stays private unless you include it.
+ +No worked items selected.
'; + qs('#today-summary-tomorrow').innerHTML = draft.tomorrow.map(row => rowMarkup('tomorrow', row)).join('') || + 'Nothing scheduled for tomorrow.
'; + qs('#today-summary-include-actuals').checked = draft.include_actuals; + qs('#today-summary-note').value = draft.note; + qs('#today-summary-preview').textContent = summary.text(); + qs('#today-summary-sheet').querySelectorAll('[data-summary-identity]').forEach(input => { + input.addEventListener('change', () => { + summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked); + render(); + }); + }); + } + + function show() { + qs('#today-summary-status').textContent = ''; + render(); + qs('#today-summary-sheet').hidden = false; + document.body.classList.add('task-overlay-open'); + requestAnimationFrame(() => (qs('#today-summary-worked input') || qs('#share-today-summary')).focus()); + } + + function open(worked, tomorrow) { + summary.begin(worked, tomorrow); + show(); + } + + async function shareDraft(button) { + button.disabled = true; + qs('#today-summary-status').textContent = 'Opening share options…'; + try { + const result = await summary.shareSummary(); + if (result.status === 'canceled') { + qs('#today-summary-status').textContent = 'Share canceled. Your private draft is still here.'; + return; + } + qs('#my-work-action-status').textContent = result.status === 'shared' ? + 'Today summary shared.' : 'Today summary copied to your clipboard.'; + close(); + } catch (error) { + qs('#today-summary-status').textContent = error.message || 'Summary could not be shared. Your draft is unchanged.'; + } finally { + button.disabled = false; + } + } + + function bind() { + qs('#close-today-summary').addEventListener('click', close); + qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); }); + qs('#today-summary-include-actuals').addEventListener('change', event => { + summary.includeActuals(event.currentTarget.checked); render(); + }); + qs('#today-summary-note').addEventListener('input', event => { + summary.setNote(event.currentTarget.value); + qs('#today-summary-preview').textContent = summary.text(); + }); + qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget)); + } + + return { + open, close, render, bind, shareDraft, + resume() { if (summary.restore()) show(); }, + }; +} + +function setupTodaySummary({ qs, escapeHtml, getLogin }) { + const summary = createTodaySummary({ + storage:localStorage, + getLogin, + share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null, + copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null, + }); + const view = createTodaySummaryView({ summary, qs, escapeHtml }); + view.bind(); + return view; +} + +if (typeof module !== 'undefined' && module.exports) { + createTodaySummary.createView = createTodaySummaryView; + createTodaySummary.setup = setupTodaySummary; + module.exports = createTodaySummary; +} diff --git a/frontend/today-wrap-up.js b/frontend/today-wrap-up.js index 38d350f..7768d44 100644 --- a/frontend/today-wrap-up.js +++ b/frontend/today-wrap-up.js @@ -53,6 +53,7 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) { function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) { let actualMinutes = {}; + let workedItems = []; function close() { qs('#today-wrap-up-sheet').hidden = true; @@ -75,8 +76,9 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => }); } - function open(items, recapActualMinutes = {}) { + function open(items, recapActualMinutes = {}, recapWorkedItems = []) { actualMinutes = { ...recapActualMinutes }; + workedItems = recapWorkedItems.map(item => ({...item})); controller.open(items); qs('#today-wrap-up-status').textContent = ''; render(); @@ -89,10 +91,15 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => button.disabled = true; qs('#today-wrap-up-status').textContent = 'Saving tomorrow’s plan…'; try { + const tomorrowItems = controller.snapshot().filter(row => row.schedule).map(row => ({ + identity:row.identity, + title:String(row.item.title || row.identity).slice(0, 180), + context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180), + })); 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); + onComplete(result, actualMinutes, workedItems, tomorrowItems); } catch (error) { qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.'; } finally { diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index b5b4a12..bb2d004 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -34,7 +34,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-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.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/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.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-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.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/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.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-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js", "static/today-rollover.js", "static/later-work.js", "static/detail-defer.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", diff --git a/tests/e2e/test_mobile_today_summary_release.py b/tests/e2e/test_mobile_today_summary_release.py new file mode 100644 index 0000000..cbc9556 --- /dev/null +++ b/tests/e2e/test_mobile_today_summary_release.py @@ -0,0 +1,111 @@ +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 summary 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_reviews_and_shares_a_private_mobile_today_summary( + 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.add_init_script( + "Object.defineProperty(navigator, 'share', {configurable:true, value:async payload => { window.__sharedSummary = payload; }});" + ) + 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 summary 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 summary=createTodaySummary({ + storage:localStorage,getLogin:()=> 'timmy', + share:payload=>navigator.share(payload), + copy:text=>navigator.clipboard.writeText(text), + }); + const view=createTodaySummaryView({summary,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)}); + window.__summaryTest={summary,view}; + view.open( + [{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}], + [{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}], + ); + } + """ + ) + + sheet = page.locator("#today-summary-sheet") + expect(sheet).to_be_visible() + expect(page.locator("#today-summary-preview")).to_have_text( + "Today\n- Ship mobile capture\n\nTomorrow\n- Review release" + ) + assert "42m" not in page.locator("#today-summary-preview").inner_text() + for control in page.locator(".today-summary-actions button").all(): + bounds = control.bounding_box() + assert bounds and bounds["height"] >= 44 + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + + page.evaluate( + """ + () => { + window.__summaryTest.summary.includeActuals(true); + window.__summaryTest.summary.setNote('Waiting on review'); + window.__summaryTest.view.render(); + } + """ + ) + expect(page.locator("#today-summary-preview")).to_contain_text( + "Ship mobile capture (42m)" + ) + page.evaluate( + "window.__summaryTest.view.shareDraft(document.querySelector('#share-today-summary'))" + ) + expect(sheet).to_be_hidden() + assert page.evaluate("window.__sharedSummary") == { + "title": "Today summary", + "text": "Today\n- Ship mobile capture (42m)\n\nTomorrow\n- Review release\n\nNote\nWaiting on review", + } + assert page.evaluate( + "localStorage.getItem('stackchain.today-summary-draft.v1.timmy')" + ) is None + assert browser_errors == [] + browser.close() + finally: + fake.shutdown() + fake.server_close() + fake_thread.join(timeout=5) diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 432bebe..ddbf1cc 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -65,6 +65,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey(): "tests/e2e/test_mobile_home_bootstrap_release.py " "tests/e2e/test_mobile_today_handoff_release.py " "tests/e2e/test_mobile_today_wrap_up_release.py " + "tests/e2e/test_mobile_today_summary_release.py " "tests/e2e/test_mobile_wrap_up_handoff_release.py -q" ) in browser assert "needs: [lint, build-release, browser-journey]" in release diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index d8206d4..e7362c4 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1159,6 +1159,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/today-session-sync.js", "/dashboard/static/today-recap.js", "/dashboard/static/today-wrap-up.js", + "/dashboard/static/today-summary.js", "/dashboard/static/today-handoff.js", "/dashboard/static/today-completion.js", "/dashboard/static/today-readiness.js", diff --git a/tests/test_today_recap.py b/tests/test_today_recap.py index 55ce835..1885470 100644 --- a/tests/test_today_recap.py +++ b/tests/test_today_recap.py @@ -880,9 +880,9 @@ async def test_dashboard_renders_mobile_today_recap_flow(): assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source assert 'class="today-recap-variance"' in recap_source assert "await recap.saveForReplan()" in recap_source - assert "openWrapUp(handoff.actual_minutes)" in recap_source + assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap_source assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard - assert "todayWrapUpView.open(todayMyWork, actualMinutes)" in dashboard + assert "todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)" in dashboard assert ".today-recap-row { grid-template-columns:1fr; }" in css diff --git a/tests/test_today_summary.py b/tests/test_today_summary.py new file mode 100644 index 0000000..ece8c34 --- /dev/null +++ b/tests/test_today_summary.py @@ -0,0 +1,155 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +TODAY_SUMMARY = ROOT / "frontend" / "today-summary.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_summary_begins_with_exact_worked_and_tomorrow_items_but_keeps_time_private(): + script = f""" +const createSummary = require({json.dumps(str(TODAY_SUMMARY))}); +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), +}}; +const summary = createSummary({{storage,getLogin:()=> ' Timmy '}}); +const snapshot = summary.begin( + [{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}}], + [{{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}}], +); +process.stdout.write(JSON.stringify({{ + snapshot, + text:summary.text(), + keys:[...values.keys()], +}})); +""" + output = run_node(script) + assert output["snapshot"]["worked"] == [{ + "identity": "issue:acme/mobile:41:", + "title": "Ship mobile capture", + "context": "acme/mobile#41", + "actual_minutes": 42, + "include": True, + }] + assert output["snapshot"]["tomorrow"] == [{ + "identity": "issue:acme/mobile:42:", + "title": "Review release", + "context": "acme/mobile#42", + "include": True, + }] + assert output["snapshot"]["include_actuals"] is False + assert output["text"] == "Today\n- Ship mobile capture\n\nTomorrow\n- Review release" + assert output["keys"] == ["stackchain.today-summary-draft.v1.timmy"] + + +def test_summary_privacy_choices_and_note_survive_reload_for_the_same_account(): + script = f""" +const createSummary = require({json.dumps(str(TODAY_SUMMARY))}); +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), +}}; +const options = {{storage,getLogin:()=> 'timmy'}}; +const summary = createSummary(options); +summary.begin( + [{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}}], + [{{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}}], +); +summary.includeActuals(true); +summary.setNote(' Waiting on review '); +summary.choose('tomorrow', 'issue:acme/mobile:42:', false); +const restored = createSummary(options); +process.stdout.write(JSON.stringify({{ + restored:restored.restore(), + snapshot:restored.snapshot(), + text:restored.text(), +}})); +""" + output = run_node(script) + assert output["restored"] is True + assert output["snapshot"]["include_actuals"] is True + assert output["snapshot"]["note"] == "Waiting on review" + assert output["snapshot"]["tomorrow"][0]["include"] is False + assert output["text"] == "Today\n- Ship mobile capture (42m)\n\nNote\nWaiting on review" + + +def test_summary_shares_natively_or_once_to_clipboard_and_keeps_canceled_drafts(): + script = f""" +const createSummary = require({json.dumps(str(TODAY_SUMMARY))}); +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), +}}; +const row = [{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}}]; +const canceled = createSummary({{storage,getLogin:()=> 'timmy',share:async()=>{{const error=new Error('cancel');error.name='AbortError';throw error;}}}}); +canceled.begin(row, []); +const canceledResult = await canceled.shareSummary(); +const keptAfterCancel = values.has('stackchain.today-summary-draft.v1.timmy'); +const copies = []; +const fallback = createSummary({{storage,getLogin:()=> 'timmy',copy:async text=>copies.push(text)}}); +fallback.restore(); +const copiedResult = await fallback.shareSummary(); +process.stdout.write(JSON.stringify({{ + canceledResult, keptAfterCancel, copiedResult, copies, + keptAfterCopy:values.has('stackchain.today-summary-draft.v1.timmy'), +}})); +""" + output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})") + assert output["canceledResult"] == {"status": "canceled"} + assert output["keptAfterCancel"] is True + assert output["copiedResult"] == {"status": "copied"} + assert output["copies"] == ["Today\n- Ship mobile capture"] + assert output["keptAfterCopy"] is False + + +def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet(): + 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() + wrap_up = (ROOT / "frontend" / "today-wrap-up.js").read_text() + + assert 'id="today-summary-sheet" role="dialog"' in html + assert 'id="today-summary-worked"' in html + assert 'id="today-summary-tomorrow"' in html + assert 'id="today-summary-include-actuals"' in html + assert 'id="share-today-summary"' in html + assert "static/today-summary.js" in main.FRONTEND_BUILD.page_sources + assert "setupTodaySummary({" in dashboard + assert "todaySummaryView.open(workedItems, tomorrowItems)" in dashboard + assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap + assert "onComplete(result, actualMinutes, workedItems, tomorrowItems)" in wrap_up + assert ".today-summary-actions button { min-height:44px;" in css + assert ".today-summary-panel" in css and "overflow-x:hidden" in css + assert "tests/e2e/test_mobile_today_summary_release.py" in ( + ROOT / ".gitea" / "workflows" / "ci.yml" + ).read_text() + + +def test_summary_uses_the_human_label_from_recap_feedback_rows(): + script = f""" +const createSummary = require({json.dumps(str(TODAY_SUMMARY))}); +const summary = createSummary(); +summary.begin([{{ + identity:'issue:acme/mobile:41:', label:'Ship mobile capture', + context:'acme/mobile#41', actual_minutes:42, +}}], []); +process.stdout.write(JSON.stringify({{text:summary.text()}})); +""" + assert run_node(script)["text"] == "Today\n- Ship mobile capture" diff --git a/tests/test_today_wrap_up.py b/tests/test_today_wrap_up.py index b63f198..0c6cf09 100644 --- a/tests/test_today_wrap_up.py +++ b/tests/test_today_wrap_up.py @@ -69,7 +69,7 @@ def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_sav 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 'openWrapUp(handoff.actual_minutes, workedItems)' 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()