370 lines
17 KiB
JavaScript
370 lines
17 KiB
JavaScript
function todayRecapFeedbackRows(draft, describe = () => null) {
|
|
return (draft?.items || []).map(item => {
|
|
const work = describe(item.identity) || {};
|
|
const estimate = Number.isInteger(item.estimate_minutes) ? item.estimate_minutes : null;
|
|
return {
|
|
identity:item.identity,
|
|
label:String(work.title || item.identity).slice(0, 180),
|
|
context:String(work.key || work.repository || 'Work details unavailable').slice(0, 180),
|
|
estimate_minutes:estimate,
|
|
actual_minutes:item.actual_minutes,
|
|
variance_minutes:estimate === null ? null : item.actual_minutes - estimate,
|
|
};
|
|
});
|
|
}
|
|
|
|
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);
|
|
return { estimated_minutes:estimated, actual_minutes:actual, variance_minutes:actual - estimated };
|
|
};
|
|
const storageKey = () => {
|
|
const login = String(getLogin() || '').trim().toLowerCase();
|
|
return login ? 'stackchain.today-recap-draft.v1.' + login : '';
|
|
};
|
|
const handoffKey = () => {
|
|
const login = String(getLogin() || '').trim().toLowerCase();
|
|
return login ? 'stackchain.today-recap-handoff.v1.' + login : '';
|
|
};
|
|
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;
|
|
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();
|
|
if (!storage || !key) return null;
|
|
try {
|
|
const saved = JSON.parse(storage.getItem(key) || 'null');
|
|
if (!validStoredDraft(saved)) {
|
|
if (saved !== null) storage.removeItem(key);
|
|
return null;
|
|
}
|
|
return { ...saved, ...totals(saved.items) };
|
|
}
|
|
catch (_error) {
|
|
try { storage.removeItem(key); } catch (_ignored) {}
|
|
return null;
|
|
}
|
|
};
|
|
const validHandoff = saved => {
|
|
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
|
|
!saved.actual_minutes || typeof saved.actual_minutes !== 'object' || Array.isArray(saved.actual_minutes)) return false;
|
|
const entries = Object.entries(saved.actual_minutes);
|
|
return entries.length > 0 && entries.length <= 20 && entries.every(([identity, minutes]) =>
|
|
identity.length > 0 && identity.length <= 500 && Number.isInteger(minutes) && minutes >= 0 && minutes <= 1440
|
|
);
|
|
};
|
|
const pendingReplan = () => {
|
|
const key = handoffKey();
|
|
if (!storage || !key) return null;
|
|
try {
|
|
const saved = JSON.parse(storage.getItem(key) || 'null');
|
|
if (!validHandoff(saved)) {
|
|
if (saved !== null) storage.removeItem(key);
|
|
return null;
|
|
}
|
|
return { session_id:saved.session_id, actual_minutes:{...saved.actual_minutes} };
|
|
} catch (_error) {
|
|
try { storage.removeItem(key); } catch (_ignored) {}
|
|
return null;
|
|
}
|
|
};
|
|
const completeReplan = () => {
|
|
const key = handoffKey();
|
|
if (storage && key) storage.removeItem(key);
|
|
};
|
|
let draftKey = storageKey();
|
|
let draft = load();
|
|
const persist = () => {
|
|
const key = storageKey();
|
|
if (!storage || !key || !draft) return;
|
|
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();
|
|
if (key !== draftKey) {
|
|
draft = null;
|
|
draftKey = key;
|
|
}
|
|
if (!draft) draft = load();
|
|
return Boolean(draft);
|
|
};
|
|
const snapshot = () => {
|
|
restore();
|
|
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();
|
|
if (!draft?.items.length) throw new Error('No timed work to save.');
|
|
const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) };
|
|
const actualMinutes = includeActuals ? Object.fromEntries(
|
|
payload.items.map(item => [item.identity, item.actual_minutes])
|
|
) : null;
|
|
const result = await save(payload);
|
|
if (includeActuals && storage && handoffKey()) storage.setItem(handoffKey(), JSON.stringify({
|
|
session_id:payload.session_id, actual_minutes:actualMinutes,
|
|
}));
|
|
clear();
|
|
if (storage && draftKey) storage.removeItem(draftKey);
|
|
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 = {}) {
|
|
const items = (entries || []).filter(entry => entry?.identity).map(entry => ({
|
|
identity:String(entry.identity),
|
|
estimate_minutes:Number.isInteger(estimates[entry.identity]) ? estimates[entry.identity] : null,
|
|
actual_minutes:Math.min(1440, Math.max(0, Math.round(Number(entry.elapsed_ms || 0) / 60000))),
|
|
}));
|
|
draftKey = storageKey();
|
|
draft = { session_id:makeId(), items, ...totals(items) };
|
|
persist();
|
|
return snapshot();
|
|
},
|
|
correct(identity, minutes) {
|
|
restore();
|
|
if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false;
|
|
const item = draft.items.find(candidate => candidate.identity === identity);
|
|
if (!item) return false;
|
|
item.actual_minutes = minutes;
|
|
Object.assign(draft, totals(draft.items));
|
|
persist();
|
|
return true;
|
|
},
|
|
snapshot,
|
|
save:() => saveConfirmed(false),
|
|
saveForReplan:() => saveConfirmed(true),
|
|
saveAndLog:saveWithTime,
|
|
pendingReplan,
|
|
completeReplan,
|
|
discardReplan:completeReplan,
|
|
};
|
|
}
|
|
|
|
async function saveTodayRecap(payload) {
|
|
const response = await fetch('api/v1/today/recaps', {
|
|
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 could not be saved.');
|
|
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';
|
|
const render = () => {
|
|
const draft = recap.snapshot();
|
|
const container = qs('#today-recap-items');
|
|
const rows = todayRecapFeedbackRows(draft, describeWork);
|
|
container.innerHTML = rows.map(item => {
|
|
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'));
|
|
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>' + 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)) +
|
|
(draft.variance_minutes >= 0 ? ' over' : ' under') : '';
|
|
container.querySelectorAll('[data-recap-identity]').forEach(input => input.addEventListener('change', () => {
|
|
if (!recap.correct(input.dataset.recapIdentity, Number(input.value))) {
|
|
qs('#today-recap-status').textContent = 'Actual time must be a whole number from 0 to 1,440 minutes.';
|
|
}
|
|
render();
|
|
}));
|
|
};
|
|
const loadHistory = async () => {
|
|
qs('#today-recap-history').innerHTML = '<div class="small">Loading recent recaps…</div>';
|
|
try {
|
|
const result = await api('api/v1/today/recaps');
|
|
qs('#today-recap-history').innerHTML = (result.recaps || []).map(saved =>
|
|
'<div class="today-recap-history-row"><span>' + new Date(saved.created_at * 1000).toLocaleString() +
|
|
'</span><strong>' + minutes(saved.actual_minutes) + ' actual · ' + minutes(Math.abs(saved.variance_minutes)) +
|
|
(saved.variance_minutes >= 0 ? ' over' : ' under') + '</strong></div>'
|
|
).join('') || '<div class="small">No saved recaps yet.</div>';
|
|
} catch (_error) {
|
|
qs('#today-recap-history').innerHTML = '<div class="small">Recent recaps are unavailable. Try again.</div>';
|
|
}
|
|
};
|
|
const open = ({ begin = false } = {}) => {
|
|
if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates);
|
|
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();
|
|
requestAnimationFrame(() => (qs('#today-recap-items input') || qs('#close-today-recap')).focus());
|
|
};
|
|
const close = () => {
|
|
qs('#today-recap-sheet').hidden = true;
|
|
document.body.classList.remove('task-overlay-open');
|
|
qs('#open-today-recaps').focus();
|
|
};
|
|
const saveDraft = async button => {
|
|
button.disabled = true;
|
|
qs('#today-recap-status').textContent = 'Saving recap…';
|
|
try {
|
|
const handoff = await recap.saveForReplan();
|
|
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
|
await loadHistory(); render(); button.hidden = true;
|
|
close();
|
|
adjustPlan(handoff.actual_minutes);
|
|
} catch (error) {
|
|
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, saveTimeDraft, loadHistory, render, bind,
|
|
pendingReplan:recap.pendingReplan,
|
|
completeReplan:recap.completeReplan,
|
|
discardReplan:recap.discardReplan,
|
|
};
|
|
}
|
|
|
|
function endTodaySession(workSession, qs) {
|
|
workSession.end();
|
|
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
|
|
}
|
|
|
|
function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
|
|
timerView.finish();
|
|
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
|
qs('#my-work-action-status').textContent = 'Work session complete.';
|
|
if (workFilter === 'today' && timer.recapEntries().length) view.open({ begin:true });
|
|
else qs('#start-work-session').focus();
|
|
}
|
|
|
|
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
|
|
describeWork, adjustPlan) {
|
|
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
|
|
const recap = createTodayRecap({
|
|
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
|
|
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
|
|
});
|
|
const view = createTodayRecapView({ ...options, recap });
|
|
view.bind();
|
|
setInterval(timerView.render, 1000);
|
|
view.finish = workFilter => {
|
|
closeSheets();
|
|
updateActions();
|
|
openTodayRecapAfterSession(view, timer, timerView, workFilter, qs);
|
|
};
|
|
return view;
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
createTodayRecap.createView = createTodayRecapView;
|
|
createTodayRecap.feedbackRows = todayRecapFeedbackRows;
|
|
module.exports = createTodayRecap;
|
|
}
|