diff --git a/README.md b/README.md
index b9f967f..97f7cfe 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 044f5ee..5ede2b1 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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%; }
diff --git a/frontend/index.html b/frontend/index.html
index 33289f4..1373aca 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -273,7 +273,7 @@
Estimate feedback
Today recap
Close
- Review actual time before saving. Recaps sync to your account without changing Gitea time entries.
+ Review actual time before saving. Gitea time logging is explicit and unchecked by default.
@@ -283,6 +283,7 @@
Save recap & adjust plan
+ Log selected time to Gitea
Keep timer & close
diff --git a/frontend/today-recap.js b/frontend/today-recap.js
index 97a5ba0..076d8ec 100644
--- a/frontend/today-recap.js
+++ b/frontend/today-recap.js
@@ -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 '' + escapeHtml(item.label) + ' ' +
+ 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' ?
+ 'Logged to Gitea ' :
+ logStatus === 'verify' ? 'Verify this entry in Gitea ' :
+ item.actual_minutes > 0 && canonical ?
+ ' ' + (logStatus === 'retry' ? 'Retry ' : 'Log ') +
+ minutes(item.actual_minutes) + ' to Gitea ' :
+ 'No non-zero Gitea time to log ';
+ return '' + escapeHtml(item.label) + ' ' +
escapeHtml(item.context) + ' · ' + (item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') +
' min' + variance + ' ';
+ escapeHtml(item.label) + '"> min' + variance + ' ' + logControl + '
';
}).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();
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index 179bddb..4f0fd48 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -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
diff --git a/src/main.py b/src/main.py
index f08e28f..0ce3aa6 100644
--- a/src/main.py
+++ b/src/main.py
@@ -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()
diff --git a/src/today_store.py b/src/today_store.py
index b12c8b9..d76f097 100644
--- a/src/today_store.py
+++ b/src/today_store.py
@@ -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,
diff --git a/tests/test_gitea_transport.py b/tests/test_gitea_transport.py
index 660d5be..36f79d5 100644
--- a/tests/test_gitea_transport.py
+++ b/tests/test_gitea_transport.py
@@ -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 = []
diff --git a/tests/test_today_recap.py b/tests/test_today_recap.py
index 9df866e..40702b2 100644
--- a/tests/test_today_recap.py
+++ b/tests/test_today_recap.py
@@ -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))});