Log corrected Today recap time to Gitea #588
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%; }
|
||||
|
|
|
|||
|
|
@ -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 & 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 & close</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
71
src/main.py
71
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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user