feat: log corrected Today recap time (Closes #587)
This commit is contained in:
1 parent
c170240057
commit
697f72da09
9 files changed
+518
-10
No files matched your search
@@ -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%; }
|
||||
|
||||
+2
-1
@@ -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>
|
||||
|
||||
+102
-8
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user