149 lines
6.9 KiB
JavaScript
149 lines
6.9 KiB
JavaScript
function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID() }) {
|
|
let draft = null;
|
|
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 snapshot = () => draft ? { ...draft, items:draft.items.map(item => ({...item})) } : null;
|
|
return {
|
|
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))),
|
|
}));
|
|
draft = { session_id:makeId(), items, ...totals(items) };
|
|
return snapshot();
|
|
},
|
|
correct(identity, minutes) {
|
|
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));
|
|
return true;
|
|
},
|
|
snapshot,
|
|
async save() {
|
|
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 result = await save(payload);
|
|
clear();
|
|
draft = null;
|
|
return result;
|
|
},
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml }) {
|
|
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
|
|
const render = () => {
|
|
const draft = recap.snapshot();
|
|
const container = qs('#today-recap-items');
|
|
container.innerHTML = draft?.items.map(item =>
|
|
'<label class="today-recap-row"><span><strong>' + escapeHtml(item.identity) + '</strong><span class="small">' +
|
|
(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.identity) + '"> min</span></label>'
|
|
).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);
|
|
render();
|
|
qs('#save-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 {
|
|
await recap.save();
|
|
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
|
await loadHistory(); render(); button.hidden = true;
|
|
} catch (error) {
|
|
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
|
} 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));
|
|
};
|
|
return { open, close, saveDraft, loadHistory, render, bind };
|
|
}
|
|
|
|
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) {
|
|
const options = { timer, timerView, todayWork, api, qs, escapeHtml };
|
|
const recap = createTodayRecap({ save:saveTodayRecap, clear:() => timer.clearRecap() });
|
|
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;
|
|
module.exports = createTodayRecap;
|
|
}
|