feat: add synced Today recaps (Closes #579)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 17:21:22 +00:00
1 parent 8cd2459468
commit 80ea81ad73
13 files changed
+522 -22

No files matched your search

+14 -1
View File
@@ -1,6 +1,7 @@
import gzip
import re
import shutil
import subprocess
from pathlib import Path
import httpx
@@ -62,7 +63,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createReviewController" not in first.runtime_bytes
assert b"function createPullSheet" in pull_workflow.runtime_bytes
assert b"function createReviewController" in pull_workflow.runtime_bytes
assert len(first.runtime_gzip_bytes) <= 95 * 1024
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
assert len(first.runtime_gzip_bytes) <= 96 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
@@ -84,6 +86,17 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
build = build_frontend(FRONTEND)
bundles = {"core.js": build.runtime_bytes}
bundles.update({f"{name}.js": bundle.runtime_bytes for name, bundle in build.feature_bundles.items()})
for name, source in bundles.items():
path = tmp_path / name
path.write_bytes(source)
result = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
@pytest.mark.anyio
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
build = build_frontend(FRONTEND)
+6 -3
View File
@@ -2042,13 +2042,14 @@ process.stdout.write(JSON.stringify({{activated,snapshot:timer.snapshot()}}));
async def test_today_timer_is_wired_into_every_mobile_session_control():
html = await dashboard()
timer_source = TODAY_TIMER.read_text()
recap_source = TODAY_TIMER.with_name("today-recap.js").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 "setInterval(timerView.render, 1000)" in recap_source
assert 'timer.pause()' in timer_source
assert 'timer.resume()' in timer_source
assert "elapsed(snapshot.elapsed_ms)" in timer_source
@@ -2267,6 +2268,7 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
@pytest.mark.anyio
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
html = await dashboard()
recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
assert 'id="resume-today-session"' in html
assert 'id="end-today-session"' in html
@@ -2278,7 +2280,7 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
assert "qs('#end-today-session').addEventListener('click'" in html
assert "workSession.resume(item)" in html
assert "runTodayTransition('resume')" in html
assert "workSession.end()" in html
assert "workSession.end()" in recap_source
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
@@ -2377,6 +2379,7 @@ async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_n
@pytest.mark.anyio
async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_announcement():
html = await dashboard()
recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
"qs('#keep-update-unread').addEventListener", 1
)[0]
@@ -2385,7 +2388,7 @@ async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_an
assert "merging.key + ' merged.'" in merge_handler
assert "else if (continuingSession" not in merge_handler
assert "onFinish: () =>" in html
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in html
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in recap_source
@pytest.mark.anyio
+1
View File
@@ -679,6 +679,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/card-planning.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-timer.js",
"/dashboard/static/today-recap.js",
"/dashboard/static/today-completion.js",
"/dashboard/static/today-readiness.js",
"/dashboard/static/comment-next.js",
+140
View File
@@ -0,0 +1,140 @@
import json
import subprocess
from pathlib import Path
import httpx
import pytest
from src import main
from src.today_store import TodayStore
ROOT = Path(__file__).parents[1]
TODAY_RECAP = ROOT / "frontend" / "today-recap.js"
def test_recap_store_is_idempotent_account_scoped_bounded_and_newest_first(tmp_path):
now = [1_000.0]
store = TodayStore(tmp_path / "today.sqlite3", recap_limit=2, clock=lambda: now[0])
first = {
"session_id": "session-1",
"items": [
{"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42},
{"identity": "pull:stackchain/api:8:", "estimate_minutes": None, "actual_minutes": 12},
],
}
saved = store.save_recap("Timmy", first["session_id"], first["items"])
replay = store.save_recap("timmy", first["session_id"], first["items"])
now[0] += 1
store.save_recap("timmy", "session-2", [{"identity": "issue:r:2:", "estimate_minutes": 10, "actual_minutes": 8}])
now[0] += 1
store.save_recap("timmy", "session-3", [{"identity": "issue:r:3:", "estimate_minutes": 10, "actual_minutes": 15}])
store.save_recap("alexander", "other", [{"identity": "issue:r:9:", "estimate_minutes": 5, "actual_minutes": 5}])
assert saved == replay
assert saved["session_id"] == "session-1"
assert saved["estimated_minutes"] == 30
assert saved["actual_minutes"] == 54
assert [row["session_id"] for row in store.list_recaps("timmy")] == ["session-3", "session-2"]
assert [row["session_id"] for row in store.list_recaps("alexander")] == ["other"]
@pytest.mark.parametrize(
"items,message",
[
([], "at least one"),
([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": -1}], "actual"),
([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": 1441}], "actual"),
([{"identity": "", "estimate_minutes": 5, "actual_minutes": 1}], "identity"),
],
)
def test_recap_store_rejects_invalid_or_unbounded_rows(tmp_path, items, message):
store = TodayStore(tmp_path / "today.sqlite3")
with pytest.raises(ValueError, match=message):
store.save_recap("timmy", "session", items)
@pytest.mark.anyio
async def test_authenticated_recap_api_is_no_store_idempotent_and_account_scoped(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
async def user():
return {"id": 1, "login": "Timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
payload = {"session_id": "mobile-session-1", "items": [
{"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42}
]}
created = await client.post("/api/v1/today/recaps", json=payload, headers=headers)
replay = await client.post("/api/v1/today/recaps", json=payload, headers=headers)
history = await client.get("/api/v1/today/recaps")
assert created.status_code == replay.status_code == 200
assert created.json() == replay.json()
assert history.json()["recaps"] == [created.json()]
assert created.headers["cache-control"] == history.headers["cache-control"] == "no-store"
def test_recap_controller_calculates_variance_validates_corrections_and_clears_after_save():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const calls=[]; let cleared=0;
const recap=createRecap({{
save:payload=>{{calls.push(payload); return Promise.resolve(payload);}},
clear:()=>{{cleared += 1;}},
makeId:()=> 'session-fixed',
}});
const draft=recap.begin([
{{identity:'issue:r:1:',elapsed_ms:42*60000}},
{{identity:'pull:r:2:',elapsed_ms:12*60000}},
], {{'issue:r:1:':30}});
const invalidLow=recap.correct('issue:r:1:',-1);
const invalidHigh=recap.correct('issue:r:1:',1441);
const corrected=recap.correct('issue:r:1:',45);
recap.save().then(saved=>process.stdout.write(JSON.stringify({{
draft,invalidLow,invalidHigh,corrected,saved,calls,cleared
}}))).catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output["draft"]["estimated_minutes"] == 30
assert output["draft"]["actual_minutes"] == 54
assert output["draft"]["variance_minutes"] == 24
assert output["invalidLow"] is False
assert output["invalidHigh"] is False
assert output["corrected"] is True
assert output["calls"][0]["session_id"] == "session-fixed"
assert output["calls"][0]["items"][0]["actual_minutes"] == 45
assert output["cleared"] == 1
@pytest.mark.anyio
async def test_dashboard_renders_mobile_today_recap_flow():
html = main.FRONTEND_BUILD.dashboard_html
css = (ROOT / "frontend" / "dashboard.css").read_text()
timer = (ROOT / "frontend" / "today-timer.js").read_text()
recap_source = TODAY_RECAP.read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
assert 'id="today-recap-sheet" role="dialog"' in html
assert 'id="today-recap-history"' in html
assert 'static/today-recap.js' in main.FRONTEND_BUILD.page_sources
assert "timer.recapEntries()" in recap_source
assert "isActive: () => workSession.checkpointed()" in dashboard
assert "openTodayRecapAfterSession(view" in recap_source
assert "api/v1/today/recaps" in recap_source
assert "recapEntries()" in timer and "clearRecap()" in timer
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