feat: add synced Today recaps (Closes #579)
All checks were successful
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 2026-08-11 17:21:22 +00:00
parent 8cd2459468
commit 80ea81ad73
13 changed files with 522 additions and 22 deletions

View File

@ -137,6 +137,20 @@ textarea { resize: vertical; min-height: 120px; }
.plan-preview-actions button { min-height:44px; }
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.plan-today-actions button { min-height:44px; width:100%; }
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-recap-sheet[hidden] { display:none; }
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-recap-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-recap-header h2 { margin:.2rem 0; }
.today-recap-header button { min-height:44px; min-width:44px; }
.today-recap-items, .today-recap-history { display:grid; gap:8px; }
.today-recap-row, .today-recap-history-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:10px; }
.today-recap-row strong { overflow-wrap:anywhere; }
.today-recap-row input { width:6rem; min-height:44px; }
.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%; }
@media (max-width:420px) { .today-recap-actions { 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; }
.today-readiness-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }

View File

@ -931,10 +931,13 @@
});
const timerView = createTodayTimerView({
timer,
isActive: workSession.checkpointed,
isActive: () => workSession.checkpointed(),
queryAll: s => document.querySelectorAll(s),
formatEstimate: formatPlanMinutes,
});
const todayRecapView = setupTodayRecap(
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions
);
function updateDetailDeferLabels(active) {
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
button.textContent = active ? 'Later today & next' : 'Later today';
@ -977,17 +980,8 @@
button.hidden = !workSession.checkpointed();
});
},
onFinish: () => {
timerView.finish();
closeOpenWorkSheets();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
qs('#my-work-action-status').textContent = 'Work session complete.';
updateWorkSessionActions();
qs('#start-work-session').focus();
},
onFinish: () => todayRecapView.finish(selectedWorkFilter),
});
setInterval(timerView.render, 1000);
function selectTodayWork() {
qs('[data-work-filter="today"]').click();
}
@ -5222,11 +5216,8 @@
else workSession.start();
});
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());
qs('#end-today-session').addEventListener('click', () => {
workSession.end();
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
qs('#start-work-session').focus();
});
qs('#end-today-session').addEventListener('click', () => endTodaySession(workSession, qs));
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
button.addEventListener('click', () => workSession.previous())
);

View File

@ -96,6 +96,7 @@
</div>
<div class="my-work-actions">
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
<button class="today-recap-history-action" id="open-today-recaps" type="button">Recaps</button>
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
@ -265,6 +266,27 @@
</section>
</div>
<div class="today-recap-sheet" id="today-recap-sheet" role="dialog" aria-modal="true" aria-labelledby="today-recap-title" hidden>
<section class="today-recap-panel">
<div class="today-recap-header">
<div><div class="small">Estimate feedback</div><h2 id="today-recap-title">Today recap</h2></div>
<button id="close-today-recap" type="button">Close</button>
</div>
<p class="small muted">Review actual time before saving. Recaps sync to your account without changing Gitea time entries.</p>
<div id="today-recap-status" class="small" role="status" aria-live="polite"></div>
<div id="today-recap-items" class="today-recap-items"></div>
<div id="today-recap-totals" class="today-recap-totals"></div>
<section aria-labelledby="today-recap-history-title">
<h3 id="today-recap-history-title">Recent recaps</h3>
<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</button>
<button id="discard-today-recap" type="button">Keep timer &amp; close</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">
@ -801,6 +823,7 @@
<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-recap.js"></script>
<script src="static/today-completion.js"></script>
<script src="static/today-readiness.js"></script>
<script src="static/comment-next.js"></script>

View File

@ -32,6 +32,7 @@ const SHELL = [
BASE + 'static/card-planning.js',
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',
BASE + 'static/today-recap.js',
BASE + 'static/today-completion.js',
BASE + 'static/today-readiness.js',
BASE + 'static/comment-next.js',

148
frontend/today-recap.js Normal file
View File

@ -0,0 +1,148 @@
function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID() }) {
let draft = null;
const totals = items => {
const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0);
const actual = items.reduce((sum, item) => sum + item.actual_minutes, 0);
return { estimated_minutes:estimated, actual_minutes:actual, variance_minutes:actual - estimated };
};
const snapshot = () => draft ? { ...draft, items:draft.items.map(item => ({...item})) } : null;
return {
begin(entries, estimates = {}) {
const items = (entries || []).filter(entry => entry?.identity).map(entry => ({
identity:String(entry.identity),
estimate_minutes:Number.isInteger(estimates[entry.identity]) ? estimates[entry.identity] : null,
actual_minutes:Math.min(1440, Math.max(0, Math.round(Number(entry.elapsed_ms || 0) / 60000))),
}));
draft = { session_id:makeId(), items, ...totals(items) };
return snapshot();
},
correct(identity, minutes) {
if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false;
const item = draft.items.find(candidate => candidate.identity === identity);
if (!item) return false;
item.actual_minutes = minutes;
Object.assign(draft, totals(draft.items));
return true;
},
snapshot,
async save() {
if (!draft?.items.length) throw new Error('No timed work to save.');
const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) };
const result = await save(payload);
clear();
draft = null;
return result;
},
};
}
async function saveTodayRecap(payload) {
const response = await fetch('api/v1/today/recaps', {
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify(payload),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.detail || 'Recap could not be saved.');
return result;
}
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml }) {
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
const render = () => {
const draft = recap.snapshot();
const container = qs('#today-recap-items');
container.innerHTML = draft?.items.map(item =>
'<label class="today-recap-row"><span><strong>' + escapeHtml(item.identity) + '</strong><span class="small">' +
(item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') +
'</span></span><span><input type="number" inputmode="numeric" min="0" max="1440" step="1" value="' +
item.actual_minutes + '" data-recap-identity="' + escapeHtml(item.identity) + '" aria-label="Actual minutes for ' +
escapeHtml(item.identity) + '"> min</span></label>'
).join('') || '';
qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' +
minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) +
(draft.variance_minutes >= 0 ? ' over' : ' under') : '';
container.querySelectorAll('[data-recap-identity]').forEach(input => input.addEventListener('change', () => {
if (!recap.correct(input.dataset.recapIdentity, Number(input.value))) {
qs('#today-recap-status').textContent = 'Actual time must be a whole number from 0 to 1,440 minutes.';
}
render();
}));
};
const loadHistory = async () => {
qs('#today-recap-history').innerHTML = '<div class="small">Loading recent recaps…</div>';
try {
const result = await api('api/v1/today/recaps');
qs('#today-recap-history').innerHTML = (result.recaps || []).map(saved =>
'<div class="today-recap-history-row"><span>' + new Date(saved.created_at * 1000).toLocaleString() +
'</span><strong>' + minutes(saved.actual_minutes) + ' actual · ' + minutes(Math.abs(saved.variance_minutes)) +
(saved.variance_minutes >= 0 ? ' over' : ' under') + '</strong></div>'
).join('') || '<div class="small">No saved recaps yet.</div>';
} catch (_error) {
qs('#today-recap-history').innerHTML = '<div class="small">Recent recaps are unavailable. Try again.</div>';
}
};
const open = ({ begin = false } = {}) => {
if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates);
render();
qs('#save-today-recap').hidden = !recap.snapshot()?.items.length;
qs('#today-recap-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
loadHistory();
requestAnimationFrame(() => (qs('#today-recap-items input') || qs('#close-today-recap')).focus());
};
const close = () => {
qs('#today-recap-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
qs('#open-today-recaps').focus();
};
const saveDraft = async button => {
button.disabled = true;
qs('#today-recap-status').textContent = 'Saving recap…';
try {
await recap.save();
qs('#today-recap-status').textContent = 'Recap saved to your account.';
await loadHistory(); render(); button.hidden = true;
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
} finally { button.disabled = false; }
};
const bind = () => {
qs('#open-today-recaps').addEventListener('click', () => open());
qs('#close-today-recap').addEventListener('click', close);
qs('#discard-today-recap').addEventListener('click', close);
qs('#save-today-recap').addEventListener('click', event => saveDraft(event.currentTarget));
};
return { open, close, saveDraft, loadHistory, render, bind };
}
function endTodaySession(workSession, qs) {
workSession.end();
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
}
function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
timerView.finish();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
qs('#my-work-action-status').textContent = 'Work session complete.';
if (workFilter === 'today' && timer.recapEntries().length) view.open({ begin:true });
else qs('#start-work-session').focus();
}
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions) {
const options = { timer, timerView, todayWork, api, qs, escapeHtml };
const recap = createTodayRecap({ save:saveTodayRecap, clear:() => timer.clearRecap() });
const view = createTodayRecapView({ ...options, recap });
view.bind();
setInterval(timerView.render, 1000);
view.finish = workFilter => {
closeSheets();
updateActions();
openTodayRecapAfterSession(view, timer, timerView, workFilter, qs);
};
return view;
}
if (typeof module !== 'undefined' && module.exports) {
createTodayRecap.createView = createTodayRecapView;
module.exports = createTodayRecap;
}

View File

@ -77,6 +77,18 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
settle(state);
return write(state);
},
recapEntries() {
const state = read();
settle(state);
write(state);
return Object.entries(state.entries).map(([identity, entry]) => ({
identity,
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0),
})).filter(entry => entry.elapsed_ms > 0);
},
clearRecap() {
return write(empty());
},
snapshot,
};
}

View File

@ -13,7 +13,11 @@ import rjsmin
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
COMMONJS_EXPORT_LINE = re.compile(
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$", re.MULTILINE
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$(?!\n\s*else)", re.MULTILINE
)
COMMONJS_BROWSER_BRANCH = re.compile(
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*\n\s*else\s*\{",
re.MULTILINE,
)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
@ -22,7 +26,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",),
"today-timer": ("static/today-timer.js", "static/today-recap.js"),
}
CACHE_DECLARATION = re.compile(
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
@ -58,6 +62,7 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
source = b"".join(chunks)
# Node-only export shims support source-level unit tests but are unreachable
# in the browser. Strip the simple one-line form from shipped bundles.
source = COMMONJS_BROWSER_BRANCH.sub(b"{", source)
source = COMMONJS_EXPORT_LINE.sub(b"", source)
revision = hashlib.sha256(source).hexdigest()
minified = rjsmin.jsmin(source.decode()).encode()

View File

@ -415,6 +415,17 @@ class TodayOperation(BaseModel):
return self
class TodayRecapItem(BaseModel):
identity: str = Field(min_length=1, max_length=500)
estimate_minutes: int | None = Field(default=None, ge=5, le=1440)
actual_minutes: int = Field(ge=0, le=1440)
class TodayRecap(BaseModel):
session_id: str = Field(min_length=1, max_length=100)
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
class LaterOperation(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
action: Literal["defer", "restore"]
@ -1868,6 +1879,43 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
)
@app.get("/api/v1/today/recaps")
async def get_today_recaps(response: Response):
login = await _confirmed_login()
try:
recaps = await asyncio.to_thread(_today_store().list_recaps, login)
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Today recap history is unavailable",
headers={"Retry-After": "1"},
)
response.headers["Cache-Control"] = "no-store"
return {"recaps": recaps}
@app.post("/api/v1/today/recaps")
async def save_today_recap(payload: TodayRecap, response: Response):
login = await _confirmed_login()
try:
recap = await asyncio.to_thread(
_today_store().save_recap,
login,
payload.session_id,
[item.model_dump() for item in payload.items],
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Today recap could not be saved",
headers={"Retry-After": "1"},
)
response.headers["Cache-Control"] = "no-store"
return recap
@app.get("/api/v1/later")
async def get_later_plan():
login = await _confirmed_login()

View File

@ -19,6 +19,7 @@ class TodayStore:
timeout: float = 1.0,
operation_limit: int = 4096,
operation_retention_seconds: float = 30 * 24 * 60 * 60,
recap_limit: int = 100,
clock=time.time,
):
self.path = Path(path)
@ -26,6 +27,7 @@ class TodayStore:
self.timeout = timeout
self.operation_limit = operation_limit
self.operation_retention_seconds = operation_retention_seconds
self.recap_limit = recap_limit
self.clock = clock
def _connect(self) -> sqlite3.Connection:
@ -65,6 +67,21 @@ class TodayStore:
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
(self.clock(),),
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_recaps (
login TEXT NOT NULL,
session_id TEXT NOT NULL,
created_at REAL NOT NULL,
items TEXT NOT NULL,
PRIMARY KEY (login, session_id)
)
"""
)
connection.execute(
"CREATE INDEX IF NOT EXISTS today_recaps_recent "
"ON today_recaps(login, created_at DESC)"
)
connection.commit()
return connection
@ -113,6 +130,90 @@ class TodayStore:
).fetchone()
return self._snapshot(row)
@staticmethod
def _normalize_recap_items(items: list[dict]) -> list[dict]:
if not isinstance(items, list) or not items:
raise ValueError("recap requires at least one item")
if len(items) > 20:
raise ValueError("recap is limited to 20 items")
normalized = []
seen = set()
for item in items:
identity = item.get("identity", "") if isinstance(item, dict) else ""
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
raise ValueError("recap item identity is required and bounded")
identity = identity.strip()
if identity in seen:
raise ValueError("recap item identities must be unique")
actual = item.get("actual_minutes")
estimate = item.get("estimate_minutes")
if not isinstance(actual, int) or isinstance(actual, bool) or actual < 0 or actual > 1440:
raise ValueError("actual minutes must be between 0 and 1440")
if estimate is not None and (
not isinstance(estimate, int) or isinstance(estimate, bool)
or estimate < 5 or estimate > 1440
):
raise ValueError("estimate minutes must be between 5 and 1440")
seen.add(identity)
normalized.append({
"identity": identity,
"estimate_minutes": estimate,
"actual_minutes": actual,
})
return normalized
@staticmethod
def _recap_snapshot(session_id: str, created_at: float, serialized: str) -> dict:
items = json.loads(serialized)
estimated = sum(item["estimate_minutes"] for item in items if item["estimate_minutes"] is not None)
actual = sum(item["actual_minutes"] for item in items)
return {
"session_id": session_id,
"created_at": created_at,
"items": items,
"estimated_minutes": estimated,
"actual_minutes": actual,
"variance_minutes": actual - estimated,
}
def save_recap(self, login: str, session_id: str, items: list[dict]) -> dict:
login = self._normalize_login(login)
if not isinstance(session_id, str) or not session_id.strip() or len(session_id) > 100:
raise ValueError("session_id is required and bounded")
session_id = session_id.strip()
normalized = self._normalize_recap_items(items)
serialized = json.dumps(normalized, separators=(",", ":"))
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT created_at, items FROM today_recaps WHERE login = ? AND session_id = ?",
(login, session_id),
).fetchone()
if existing is not None:
return self._recap_snapshot(session_id, existing[0], existing[1])
created_at = self.clock()
connection.execute(
"INSERT INTO today_recaps(login, session_id, created_at, items) VALUES (?, ?, ?, ?)",
(login, session_id, created_at, serialized),
)
connection.execute(
"DELETE FROM today_recaps WHERE login = ? AND rowid NOT IN "
"(SELECT rowid FROM today_recaps WHERE login = ? "
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
(login, login, self.recap_limit),
)
return self._recap_snapshot(session_id, created_at, serialized)
def list_recaps(self, login: str, *, limit: int = 30) -> list[dict]:
bounded_limit = max(1, min(int(limit), self.recap_limit, 100))
with self._connect() as connection:
rows = connection.execute(
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ? "
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
(self._normalize_login(login), bounded_limit),
).fetchall()
return [self._recap_snapshot(*row) for row in rows]
def apply(
self,
login: str,

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)

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

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
tests/test_today_recap.py Normal file
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