Log corrected Today recap time to Gitea #588

Merged
rockachopa merged 1 commits from timmy/587-log-recap-time into main 2026-08-11 19:43:02 +00:00
9 changed files with 518 additions and 10 deletions

View File

@ -67,7 +67,7 @@ and an active Today session shows the current estimate plus estimated remaining
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
as unknown rather than unblocked. Starting a Today work session also
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. 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. Actual time appears there 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.
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. 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 a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
**Comment & next** on that current issue or pull request posts the handoff online or admits it

View File

@ -150,6 +150,10 @@ textarea { resize: vertical; min-height: 120px; }
.today-recap-row input { width:6rem; min-height:44px; }
.today-recap-context { display:block; overflow-wrap:anywhere; }
.today-recap-variance { grid-column:1 / -1; color:#bfdbfe; font-weight:700; }
.today-recap-log { grid-column:1 / -1; display:flex; align-items:center; gap:8px; min-height:44px; }
.today-recap-log input { width:24px; min-height:24px; }
.today-recap-log-success { color:#86efac; font-weight:700; }
.today-recap-log-verify { color:#fde68a; font-weight:700; }
.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%; }

View File

@ -273,7 +273,7 @@
<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>
<p class="small muted">Review actual time before saving. Gitea time logging is explicit and unchecked by default.</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>
@ -283,6 +283,7 @@
</section>
<div class="today-recap-actions">
<button id="save-today-recap" type="button">Save recap &amp; adjust plan</button>
<button id="save-log-today-recap" type="button">Log selected time to Gitea</button>
<button id="discard-today-recap" type="button">Keep timer &amp; close</button>
</div>
</section>

View File

@ -13,7 +13,7 @@ function todayRecapFeedbackRows(draft, describe = () => null) {
});
}
function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
function createTodayRecap({ save, saveAndLog = null, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
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);
@ -30,10 +30,18 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto
const validStoredDraft = saved => {
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
!Array.isArray(saved.items) || !saved.items.length || saved.items.length > 20) return false;
return saved.items.every(item => item && typeof item.identity === 'string' && item.identity.length > 0 &&
const itemsValid = saved.items.every(item => item && typeof item.identity === 'string' && item.identity.length > 0 &&
item.identity.length <= 500 && (item.estimate_minutes === null ||
(Number.isInteger(item.estimate_minutes) && item.estimate_minutes >= 5 && item.estimate_minutes <= 1440)) &&
Number.isInteger(item.actual_minutes) && item.actual_minutes >= 0 && item.actual_minutes <= 1440);
if (!itemsValid) return false;
const identities = new Set(saved.items.map(item => item.identity));
if (saved.log_identities !== undefined && (!Array.isArray(saved.log_identities) ||
saved.log_identities.length > 20 || saved.log_identities.some(identity => !identities.has(identity)))) return false;
if (saved.time_logs !== undefined && (!saved.time_logs || typeof saved.time_logs !== 'object' ||
Array.isArray(saved.time_logs) || Object.entries(saved.time_logs).some(([identity, status]) =>
!identities.has(identity) || !['logged', 'retry', 'verify'].includes(status)))) return false;
return true;
};
const load = () => {
const key = storageKey();
@ -83,7 +91,10 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto
const persist = () => {
const key = storageKey();
if (!storage || !key || !draft) return;
storage.setItem(key, JSON.stringify({ session_id:draft.session_id, items:draft.items }));
const saved = { session_id:draft.session_id, items:draft.items };
if (draft.log_identities) saved.log_identities = draft.log_identities;
if (draft.time_logs) saved.time_logs = draft.time_logs;
storage.setItem(key, JSON.stringify(saved));
};
const restore = () => {
const key = storageKey();
@ -96,7 +107,11 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto
};
const snapshot = () => {
restore();
return draft ? { ...draft, items:draft.items.map(item => ({...item})) } : null;
return draft ? {
...draft, items:draft.items.map(item => ({...item})),
...(draft.log_identities ? {log_identities:[...draft.log_identities]} : {}),
...(draft.time_logs ? {time_logs:{...draft.time_logs}} : {}),
} : null;
};
const saveConfirmed = async includeActuals => {
restore();
@ -114,6 +129,40 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto
draft = null;
return includeActuals ? { result, actual_minutes:actualMinutes } : result;
};
const saveWithTime = async identities => {
restore();
if (!draft?.items.length) throw new Error('No timed work to save.');
if (typeof saveAndLog !== 'function') throw new Error('Gitea time logging is unavailable.');
const requested = new Set(Array.isArray(identities) ? identities : []);
const eligible = new Set(draft.items.filter(item => item.actual_minutes > 0).map(item => item.identity));
const intended = [...new Set([...(draft.log_identities || []), ...requested])]
.filter(identity => eligible.has(identity));
if (!intended.length) throw new Error('Select at least one non-zero time entry.');
draft.log_identities = intended;
draft.time_logs = draft.time_logs || {};
persist();
const logIdentities = intended.filter(identity => !['logged', 'verify'].includes(draft.time_logs[identity]));
const payload = {
session_id:draft.session_id,
items:draft.items.map(item => ({...item})),
log_identities:logIdentities,
};
const result = await saveAndLog(payload);
Object.assign(draft.time_logs, Object.fromEntries((result.time_logs || []).map(item => [item.identity, item.status])));
persist();
const logged = intended.filter(identity => draft.time_logs[identity] === 'logged').length;
const verify = intended.filter(identity => draft.time_logs[identity] === 'verify').length;
const retry = intended.length - logged - verify;
if (retry || verify) {
if (verify) throw new Error(verify + ' time ' + (verify === 1 ? 'entry needs' : 'entries need') +
' verification in Gitea; it will not be posted again automatically.');
throw new Error(logged + ' time ' + (logged === 1 ? 'entry' : 'entries') + ' logged; ' + retry + ' needs retry.');
}
clear();
if (storage && draftKey) storage.removeItem(draftKey);
draft = null;
return result;
};
return {
restore,
begin(entries, estimates = {}) {
@ -140,6 +189,7 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto
snapshot,
save:() => saveConfirmed(false),
saveForReplan:() => saveConfirmed(true),
saveAndLog:saveWithTime,
pendingReplan,
completeReplan,
discardReplan:completeReplan,
@ -156,6 +206,16 @@ async function saveTodayRecap(payload) {
return result;
}
async function saveTodayRecapTime(payload) {
const response = await fetch('api/v1/today/recaps/log-time', {
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 time could not be logged.');
return result;
}
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
describeWork = () => null, adjustPlan = () => {} }) {
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
@ -167,11 +227,21 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
const variance = item.variance_minutes === null ? 'No estimate comparison' :
(item.variance_minutes === 0 ? 'On estimate' : minutes(Math.abs(item.variance_minutes)) +
(item.variance_minutes > 0 ? ' over estimate' : ' under estimate'));
return '<label class="today-recap-row"><span><strong>' + escapeHtml(item.label) + '</strong><span class="small today-recap-context">' +
const canonical = /^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9][0-9]*:$/.test(item.identity);
const logStatus = draft?.time_logs?.[item.identity];
const logControl = logStatus === 'logged' ?
'<span class="today-recap-log today-recap-log-success">Logged to Gitea</span>' :
logStatus === 'verify' ? '<span class="today-recap-log today-recap-log-verify">Verify this entry in Gitea</span>' :
item.actual_minutes > 0 && canonical ?
'<label class="today-recap-log"><input type="checkbox" data-recap-log-identity="' +
escapeHtml(item.identity) + '"> ' + (logStatus === 'retry' ? 'Retry ' : 'Log ') +
minutes(item.actual_minutes) + ' to Gitea</label>' :
'<span class="small muted today-recap-log">No non-zero Gitea time to log</span>';
return '<div class="today-recap-row"><span><strong>' + escapeHtml(item.label) + '</strong><span class="small today-recap-context">' +
escapeHtml(item.context) + ' · ' + (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.label) + '"> min</span><span class="today-recap-variance">' + variance + '</span></label>';
escapeHtml(item.label) + '"> min</span><span class="today-recap-variance">' + variance + '</span>' + logControl + '</div>';
}).join('') || '';
qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' +
minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) +
@ -201,6 +271,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
else recap.restore();
render();
qs('#save-today-recap').hidden = !recap.snapshot()?.items.length;
qs('#save-log-today-recap').hidden = !recap.snapshot()?.items.length;
qs('#today-recap-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
loadHistory();
@ -224,14 +295,36 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
} finally { button.disabled = false; }
};
const saveTimeDraft = async button => {
const selected = Array.from(qs('#today-recap-items').querySelectorAll('[data-recap-log-identity]:checked'))
.map(input => input.dataset.recapLogIdentity);
if (!selected.length) {
qs('#today-recap-status').textContent = 'Select at least one non-zero time entry.';
return;
}
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
button.disabled = true;
qs('#today-recap-status').textContent = 'Logging selected time…';
try {
await recap.saveAndLog(selected);
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
await loadHistory(); render(); button.hidden = true;
close();
adjustPlan(actualMinutes);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
render();
} 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));
qs('#save-log-today-recap').addEventListener('click', event => saveTimeDraft(event.currentTarget));
};
return {
open, close, saveDraft, loadHistory, render, bind,
open, close, saveDraft, saveTimeDraft, loadHistory, render, bind,
pendingReplan:recap.pendingReplan,
completeReplan:recap.completeReplan,
discardReplan:recap.discardReplan,
@ -255,7 +348,8 @@ function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, close
describeWork, adjustPlan) {
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
const recap = createTodayRecap({
save:saveTodayRecap, clear:() => timer.clearRecap(), storage:localStorage, getLogin,
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
});
const view = createTodayRecapView({ ...options, recap });
view.bind();

View File

@ -214,6 +214,35 @@ async def fetch(path: str) -> Any:
return r.json()
def issue_time_target(identity: str) -> tuple[str, str]:
"""Return the canonical repository and issue number encoded by an identity."""
match = re.fullmatch(
r"(?:issue|pull):([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+):([1-9][0-9]*):",
identity,
)
if match is None:
raise ValueError("time log target is invalid")
return match.groups()
async def log_issue_time(identity: str, seconds: int) -> None:
"""Log time to the canonical issue endpoint encoded by a dashboard identity."""
repository, number = issue_time_target(identity)
if not isinstance(seconds, int) or isinstance(seconds, bool) or not 1 <= seconds <= 86400:
raise ValueError("time log duration is invalid")
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues/{number}/times",
headers={**_auth(), "Content-Type": "application/json"},
json={"time": seconds},
)
response.raise_for_status()
def time_log_failure_is_retryable(error: Exception) -> bool:
"""Return true only when failure proves no successful response was lost."""
return isinstance(error, (ValueError, GiteaOverloadedError, httpx.ConnectError, httpx.HTTPStatusError))
async def fetch_text(path: str, max_bytes: int) -> tuple[str, bool]:
chunks: list[bytes] = []
size = 0

View File

@ -426,6 +426,10 @@ class TodayRecap(BaseModel):
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
class TodayRecapTimeLog(TodayRecap):
log_identities: list[str] = Field(min_length=1, max_length=20)
class LaterOperation(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
action: Literal["defer", "restore"]
@ -1916,6 +1920,73 @@ async def save_today_recap(payload: TodayRecap, response: Response):
return recap
@app.post("/api/v1/today/recaps/log-time")
async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Response):
login = await _confirmed_login()
if len(set(payload.log_identities)) != len(payload.log_identities):
raise HTTPException(status_code=422, detail="time log targets must be unique")
requested_items = {item.identity: item for item in payload.items}
selected = []
try:
for identity in payload.log_identities:
gitea_proxy.issue_time_target(identity)
requested = requested_items.get(identity)
if requested is None:
raise ValueError("time log target must match the recap")
if requested.actual_minutes <= 0:
raise ValueError("only non-zero recap time can be logged")
selected.append(requested)
recap = await asyncio.to_thread(
_today_store().save_recap,
login,
payload.session_id,
[item.model_dump() for item in payload.items],
)
saved_items = {item["identity"]: item for item in recap["items"]}
for requested in selected:
saved = saved_items.get(requested.identity)
if saved is None or saved["actual_minutes"] != requested.actual_minutes:
raise ValueError("time log target must match the saved recap")
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"})
results = []
for item in selected:
try:
state = await asyncio.to_thread(
_today_store().begin_time_log, login, payload.session_id, item.identity, item.actual_minutes
)
if state == "claimed":
try:
await gitea_proxy.log_issue_time(item.identity, item.actual_minutes * 60)
except Exception as error:
if gitea_proxy.time_log_failure_is_retryable(error):
await asyncio.to_thread(
_today_store().finish_time_log,
login, payload.session_id, item.identity, succeeded=False,
)
status = "retry"
else:
status = "verify"
results.append({"identity": item.identity, "status": status})
continue
await asyncio.to_thread(
_today_store().finish_time_log,
login, payload.session_id, item.identity, succeeded=True,
)
state = "logged"
results.append({
"identity": item.identity,
"status": "logged" if state == "logged" else ("verify" if state == "pending" else "retry"),
})
except (ValueError, OSError, sqlite3.Error):
results.append({"identity": item.identity, "status": "retry"})
response.headers["Cache-Control"] = "no-store"
return {**recap, "time_logs": results}
@app.get("/api/v1/later")
async def get_later_plan():
login = await _confirmed_login()

View File

@ -82,6 +82,18 @@ class TodayStore:
"CREATE INDEX IF NOT EXISTS today_recaps_recent "
"ON today_recaps(login, created_at DESC)"
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_time_logs (
login TEXT NOT NULL,
session_id TEXT NOT NULL,
identity TEXT NOT NULL,
actual_minutes INTEGER NOT NULL,
status TEXT NOT NULL,
PRIMARY KEY (login, session_id, identity)
)
"""
)
connection.commit()
return connection
@ -214,6 +226,42 @@ class TodayStore:
).fetchall()
return [self._recap_snapshot(*row) for row in rows]
def begin_time_log(self, login: str, session_id: str, identity: str, actual_minutes: int) -> str:
"""Claim one recap item for upstream logging, returning its current state."""
login = self._normalize_login(login)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT actual_minutes, status FROM today_time_logs "
"WHERE login = ? AND session_id = ? AND identity = ?",
(login, session_id, identity),
).fetchone()
if row is None:
connection.execute(
"INSERT INTO today_time_logs(login, session_id, identity, actual_minutes, status) "
"VALUES (?, ?, ?, ?, 'pending')",
(login, session_id, identity, actual_minutes),
)
return "claimed"
if row[0] != actual_minutes:
raise ValueError("logged recap time cannot be changed")
if row[1] == "failed":
connection.execute(
"UPDATE today_time_logs SET status = 'pending' "
"WHERE login = ? AND session_id = ? AND identity = ?",
(login, session_id, identity),
)
return "claimed"
return row[1]
def finish_time_log(self, login: str, session_id: str, identity: str, *, succeeded: bool) -> None:
with self._connect() as connection:
connection.execute(
"UPDATE today_time_logs SET status = ? "
"WHERE login = ? AND session_id = ? AND identity = ? AND status = 'pending'",
("logged" if succeeded else "failed", self._normalize_login(login), session_id, identity),
)
def apply(
self,
login: str,

View File

@ -7,6 +7,26 @@ from src import gitea_proxy
from src import main
@pytest.mark.anyio
async def test_log_issue_time_uses_canonical_shared_issue_endpoint_and_seconds():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(201, json={"id": 1})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
await gitea_proxy.log_issue_time("pull:stackchain/stackchain-dashboard:586:", 2520)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 1
assert requests[0].method == "POST"
assert requests[0].url.path == "/api/v1/repos/stackchain/stackchain-dashboard/issues/586/times"
assert requests[0].read() == b'{"time":2520}'
@pytest.mark.anyio
async def test_gitea_transport_is_reused_across_requests_and_closed():
client_ids = []

View File

@ -13,6 +13,20 @@ ROOT = Path(__file__).parents[1]
TODAY_RECAP = ROOT / "frontend" / "today-recap.js"
def test_mobile_recap_time_logging_controls_are_explicit_accessible_and_opt_in():
html = (ROOT / "frontend" / "index.html").read_text()
source = TODAY_RECAP.read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
assert 'id="save-log-today-recap"' in html
assert "Log selected time to Gitea" in html
assert 'type="checkbox"' in source
assert "data-recap-log-identity" in source
assert "item.actual_minutes > 0" in source
assert "querySelectorAll('[data-recap-log-identity]:checked')" in source
assert ".today-recap-log" in css and "min-height:44px" in css
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])
@ -85,6 +99,127 @@ async def test_authenticated_recap_api_is_no_store_idempotent_and_account_scoped
assert created.headers["cache-control"] == history.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_explicit_recap_time_logging_posts_corrected_seconds_once(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"))
calls = []
async def user():
return {"id": 1, "login": "Timmy"}
async def log_time(identity, seconds):
calls.append((identity, seconds))
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
payload = {
"session_id": "mobile-session-1",
"items": [
{"identity": "issue:stackchain/dashboard:587:", "estimate_minutes": 30, "actual_minutes": 42},
{"identity": "pull:stackchain/dashboard:586:", "estimate_minutes": 10, "actual_minutes": 0},
],
"log_identities": ["issue:stackchain/dashboard:587:"],
}
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"]}
created = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
replay = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
assert created.status_code == replay.status_code == 200
assert calls == [("issue:stackchain/dashboard:587:", 42 * 60)]
assert created.json()["time_logs"] == [{
"identity": "issue:stackchain/dashboard:587:", "status": "logged"
}]
assert replay.json()["time_logs"] == [{
"identity": "issue:stackchain/dashboard:587:", "status": "logged"
}]
assert created.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_ambiguous_time_log_failure_is_not_reposted_automatically(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"))
calls = 0
async def user():
return {"id": 1, "login": "Timmy"}
async def log_time(_identity, _seconds):
nonlocal calls
calls += 1
raise httpx.ReadTimeout("response was lost after sending")
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
payload = {
"session_id": "ambiguous-session",
"items": [{"identity": "issue:stackchain/dashboard:587:", "estimate_minutes": 30, "actual_minutes": 42}],
"log_identities": ["issue:stackchain/dashboard:587:"],
}
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"]}
first = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
replay = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
assert calls == 1
assert first.json()["time_logs"] == replay.json()["time_logs"] == [{
"identity": "issue:stackchain/dashboard:587:", "status": "verify"
}]
@pytest.mark.anyio
async def test_recap_time_logging_rejects_noncanonical_and_zero_targets_before_upstream(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"))
calls = []
async def user():
return {"id": 1, "login": "Timmy"}
async def log_time(identity, seconds):
calls.append((identity, seconds))
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
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"]}
malformed = await client.post("/api/v1/today/recaps/log-time", headers=headers, json={
"session_id": "bad-target",
"items": [{"identity": "issue:https://evil.test:1:", "estimate_minutes": 5, "actual_minutes": 5}],
"log_identities": ["issue:https://evil.test:1:"],
})
zero = await client.post("/api/v1/today/recaps/log-time", headers=headers, json={
"session_id": "zero-target",
"items": [{"identity": "issue:stackchain/dashboard:1:", "estimate_minutes": 5, "actual_minutes": 0}],
"log_identities": ["issue:stackchain/dashboard:1:"],
})
history = await client.get("/api/v1/today/recaps")
assert malformed.status_code == zero.status_code == 422
assert calls == []
assert history.json()["recaps"] == []
def test_recap_controller_calculates_variance_validates_corrections_and_clears_after_save():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
@ -119,6 +254,112 @@ recap.save().then(saved=>process.stdout.write(JSON.stringify({{
assert output["cleared"] == 1
def test_recap_controller_logs_only_selected_nonzero_items_and_retains_failed_draft():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const calls=[]; let fail=true; let cleared=0;
const recap=createRecap({{
save:payload=>Promise.resolve(payload),
saveAndLog:payload=>{{calls.push(payload); return fail ? Promise.reject(new Error('offline')) : Promise.resolve({{
time_logs:payload.log_identities.map(identity=>({{identity,status:'logged'}}))
}});}},
clear:()=>{{cleared += 1;}}, makeId:()=> 'log-session',
}});
recap.begin([
{{identity:'issue:stackchain/dashboard:587:',elapsed_ms:42*60000}},
{{identity:'pull:stackchain/dashboard:586:',elapsed_ms:0}},
]);
(async()=>{{
let error='';
try {{ await recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']); }}
catch (caught) {{ error=caught.message; }}
const retained=recap.snapshot(); fail=false;
const result=await recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']);
process.stdout.write(JSON.stringify({{error,retained,result,calls,cleared,after:recap.snapshot()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
output = json.loads(run.stdout)
assert output["error"] == "offline"
assert output["retained"]["session_id"] == "log-session"
assert output["calls"][0]["log_identities"] == ["issue:stackchain/dashboard:587:"]
assert output["result"]["time_logs"] == [{
"identity": "issue:stackchain/dashboard:587:", "status": "logged"
}]
assert output["cleared"] == 1
assert output["after"] is None
def test_recap_controller_exposes_per_item_partial_logging_results_for_retry():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const recap=createRecap({{
save:payload=>Promise.resolve(payload), clear:()=>{{}}, makeId:()=> 'partial-session',
saveAndLog:payload=>Promise.resolve({{time_logs:[
{{identity:payload.log_identities[0],status:'logged'}},
{{identity:payload.log_identities[1],status:'retry'}},
]}}),
}});
recap.begin([
{{identity:'issue:stackchain/dashboard:587:',elapsed_ms:20*60000}},
{{identity:'pull:stackchain/dashboard:586:',elapsed_ms:10*60000}},
]);
recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:'])
.then(()=>{{throw new Error('expected retry');}})
.catch(error=>process.stdout.write(JSON.stringify({{message:error.message,draft:recap.snapshot()}})));
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
output = json.loads(run.stdout)
assert output["message"] == "1 time entry logged; 1 needs retry."
assert output["draft"]["time_logs"] == {
"issue:stackchain/dashboard:587:": "logged",
"pull:stackchain/dashboard:586:": "retry",
}
def test_recap_partial_time_logging_intent_survives_reload_until_every_item_is_logged():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values=new Map(); const calls=[]; let attempt=0; let cleared=0;
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const saveAndLog=payload=>{{
calls.push(payload.log_identities); attempt += 1;
return Promise.resolve({{time_logs:payload.log_identities.map((identity,index)=>({{
identity,status:attempt === 1 && index === 1 ? 'retry' : 'logged'
}}))}});
}};
const options={{save:payload=>Promise.resolve(payload),saveAndLog,clear:()=>{{cleared += 1;}},storage,getLogin:()=> 'timmy'}};
const first=createRecap({{...options,makeId:()=> 'durable-log'}});
first.begin([
{{identity:'issue:stackchain/dashboard:587:',elapsed_ms:20*60000}},
{{identity:'pull:stackchain/dashboard:586:',elapsed_ms:10*60000}},
]);
(async()=>{{
try {{ await first.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']); }} catch (_error) {{}}
const restored=createRecap(options);
const pending=restored.snapshot();
await restored.saveAndLog(['pull:stackchain/dashboard:586:']);
process.stdout.write(JSON.stringify({{pending,calls,cleared,after:restored.snapshot(),stored:[...values.keys()]}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
output = json.loads(run.stdout)
assert output["pending"]["log_identities"] == [
"issue:stackchain/dashboard:587:", "pull:stackchain/dashboard:586:"
]
assert output["pending"]["time_logs"]["pull:stackchain/dashboard:586:"] == "retry"
assert output["calls"] == [
["issue:stackchain/dashboard:587:", "pull:stackchain/dashboard:586:"],
["pull:stackchain/dashboard:586:"],
]
assert output["cleared"] == 1
assert output["after"] is None
assert output["stored"] == []
def test_recap_feedback_uses_work_metadata_and_reports_per_item_variance():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});