stackchain-dashboard/frontend/today-recap.js
timmy 6a3c6c7a9e
All checks were successful
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
feat: preserve offline Today recap drafts (Closes #581)
2026-08-11 17:58:50 +00:00

205 lines
8.9 KiB
JavaScript

function createTodayRecap({ save, 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 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 &&
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);
};
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;
}
};
let draftKey = storageKey();
let draft = load();
const persist = () => {
const key = storageKey();
if (!storage || !key || !draft) return;
storage.setItem(key, JSON.stringify({ session_id:draft.session_id, items:draft.items }));
};
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})) } : null;
};
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,
async save() {
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 result = await save(payload);
clear();
if (storage && draftKey) storage.removeItem(draftKey);
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);
else recap.restore();
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, getLogin) {
const options = { timer, timerView, todayWork, api, qs, escapeHtml };
const recap = createTodayRecap({
save:saveTodayRecap, 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;
module.exports = createTodayRecap;
}