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

-

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 @@
+
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 '