Compare commits
No commits in common. "cab230e3a2c17876805f045331f69af8a138fc20" and "8cd2459468a718e6969ecc94f84f9357f5bc2444" have entirely different histories.
cab230e3a2
...
8cd2459468
|
|
@ -137,20 +137,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.plan-preview-actions button { min-height:44px; }
|
.plan-preview-actions button { min-height:44px; }
|
||||||
.plan-today-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; }
|
.plan-today-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; }
|
||||||
.plan-today-actions button { min-height:44px; width:100%; }
|
.plan-today-actions button { min-height:44px; width:100%; }
|
||||||
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
|
||||||
.today-recap-sheet[hidden] { display:none; }
|
|
||||||
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
|
||||||
.today-recap-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
|
||||||
.today-recap-header h2 { margin:.2rem 0; }
|
|
||||||
.today-recap-header button { min-height:44px; min-width:44px; }
|
|
||||||
.today-recap-items, .today-recap-history { display:grid; gap:8px; }
|
|
||||||
.today-recap-row, .today-recap-history-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:10px; }
|
|
||||||
.today-recap-row strong { overflow-wrap:anywhere; }
|
|
||||||
.today-recap-row input { width:6rem; min-height:44px; }
|
|
||||||
.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%; }
|
|
||||||
@media (max-width:420px) { .today-recap-actions { grid-template-columns:1fr; } }
|
|
||||||
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||||
.today-readiness-sheet[hidden] { display:none; }
|
.today-readiness-sheet[hidden] { display:none; }
|
||||||
.today-readiness-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
.today-readiness-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||||
|
|
|
||||||
|
|
@ -931,13 +931,10 @@
|
||||||
});
|
});
|
||||||
const timerView = createTodayTimerView({
|
const timerView = createTodayTimerView({
|
||||||
timer,
|
timer,
|
||||||
isActive: () => workSession.checkpointed(),
|
isActive: workSession.checkpointed,
|
||||||
queryAll: s => document.querySelectorAll(s),
|
queryAll: s => document.querySelectorAll(s),
|
||||||
formatEstimate: formatPlanMinutes,
|
formatEstimate: formatPlanMinutes,
|
||||||
});
|
});
|
||||||
const todayRecapView = setupTodayRecap(
|
|
||||||
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions
|
|
||||||
);
|
|
||||||
function updateDetailDeferLabels(active) {
|
function updateDetailDeferLabels(active) {
|
||||||
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
||||||
button.textContent = active ? 'Later today & next' : 'Later today';
|
button.textContent = active ? 'Later today & next' : 'Later today';
|
||||||
|
|
@ -980,8 +977,17 @@
|
||||||
button.hidden = !workSession.checkpointed();
|
button.hidden = !workSession.checkpointed();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onFinish: () => todayRecapView.finish(selectedWorkFilter),
|
onFinish: () => {
|
||||||
|
timerView.finish();
|
||||||
|
closeOpenWorkSheets();
|
||||||
|
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
||||||
|
qs('#my-work-action-status').textContent = 'Work session complete.';
|
||||||
|
updateWorkSessionActions();
|
||||||
|
qs('#start-work-session').focus();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
setInterval(timerView.render, 1000);
|
||||||
|
|
||||||
function selectTodayWork() {
|
function selectTodayWork() {
|
||||||
qs('[data-work-filter="today"]').click();
|
qs('[data-work-filter="today"]').click();
|
||||||
}
|
}
|
||||||
|
|
@ -5216,8 +5222,11 @@
|
||||||
else workSession.start();
|
else workSession.start();
|
||||||
});
|
});
|
||||||
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());
|
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());
|
||||||
|
qs('#end-today-session').addEventListener('click', () => {
|
||||||
qs('#end-today-session').addEventListener('click', () => endTodaySession(workSession, qs));
|
workSession.end();
|
||||||
|
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
|
||||||
|
qs('#start-work-session').focus();
|
||||||
|
});
|
||||||
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
|
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
|
||||||
button.addEventListener('click', () => workSession.previous())
|
button.addEventListener('click', () => workSession.previous())
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,6 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="my-work-actions">
|
<div class="my-work-actions">
|
||||||
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
|
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
|
||||||
<button class="today-recap-history-action" id="open-today-recaps" type="button">Recaps</button>
|
|
||||||
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
|
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
|
||||||
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
|
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
|
||||||
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
|
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
|
||||||
|
|
@ -266,27 +265,6 @@
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="today-recap-sheet" id="today-recap-sheet" role="dialog" aria-modal="true" aria-labelledby="today-recap-title" hidden>
|
|
||||||
<section class="today-recap-panel">
|
|
||||||
<div class="today-recap-header">
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
<section aria-labelledby="today-recap-history-title">
|
|
||||||
<h3 id="today-recap-history-title">Recent recaps</h3>
|
|
||||||
<div id="today-recap-history" class="today-recap-history"></div>
|
|
||||||
</section>
|
|
||||||
<div class="today-recap-actions">
|
|
||||||
<button id="save-today-recap" type="button">Save recap</button>
|
|
||||||
<button id="discard-today-recap" type="button">Keep timer & close</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
|
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
|
||||||
<section class="today-readiness-panel">
|
<section class="today-readiness-panel">
|
||||||
<div class="today-readiness-header">
|
<div class="today-readiness-header">
|
||||||
|
|
@ -823,7 +801,6 @@
|
||||||
<script src="static/card-planning.js"></script>
|
<script src="static/card-planning.js"></script>
|
||||||
<script src="static/today-work.js"></script>
|
<script src="static/today-work.js"></script>
|
||||||
<script src="static/today-timer.js"></script>
|
<script src="static/today-timer.js"></script>
|
||||||
<script src="static/today-recap.js"></script>
|
|
||||||
<script src="static/today-completion.js"></script>
|
<script src="static/today-completion.js"></script>
|
||||||
<script src="static/today-readiness.js"></script>
|
<script src="static/today-readiness.js"></script>
|
||||||
<script src="static/comment-next.js"></script>
|
<script src="static/comment-next.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ const SHELL = [
|
||||||
BASE + 'static/card-planning.js',
|
BASE + 'static/card-planning.js',
|
||||||
BASE + 'static/today-work.js',
|
BASE + 'static/today-work.js',
|
||||||
BASE + 'static/today-timer.js',
|
BASE + 'static/today-timer.js',
|
||||||
BASE + 'static/today-recap.js',
|
|
||||||
BASE + 'static/today-completion.js',
|
BASE + 'static/today-completion.js',
|
||||||
BASE + 'static/today-readiness.js',
|
BASE + 'static/today-readiness.js',
|
||||||
BASE + 'static/comment-next.js',
|
BASE + 'static/comment-next.js',
|
||||||
|
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -77,18 +77,6 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||||
settle(state);
|
settle(state);
|
||||||
return write(state);
|
return write(state);
|
||||||
},
|
},
|
||||||
recapEntries() {
|
|
||||||
const state = read();
|
|
||||||
settle(state);
|
|
||||||
write(state);
|
|
||||||
return Object.entries(state.entries).map(([identity, entry]) => ({
|
|
||||||
identity,
|
|
||||||
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0),
|
|
||||||
})).filter(entry => entry.elapsed_ms > 0);
|
|
||||||
},
|
|
||||||
clearRecap() {
|
|
||||||
return write(empty());
|
|
||||||
},
|
|
||||||
snapshot,
|
snapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,7 @@ import rjsmin
|
||||||
|
|
||||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||||
COMMONJS_EXPORT_LINE = re.compile(
|
COMMONJS_EXPORT_LINE = re.compile(
|
||||||
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$(?!\n\s*else)", re.MULTILINE
|
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$", re.MULTILINE
|
||||||
)
|
|
||||||
COMMONJS_BROWSER_BRANCH = re.compile(
|
|
||||||
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*\n\s*else\s*\{",
|
|
||||||
re.MULTILINE,
|
|
||||||
)
|
)
|
||||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||||
FEATURE_SOURCES = {
|
FEATURE_SOURCES = {
|
||||||
|
|
@ -26,7 +22,7 @@ FEATURE_SOURCES = {
|
||||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||||
"push-notifications": ("static/push-notifications.js",),
|
"push-notifications": ("static/push-notifications.js",),
|
||||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||||
"today-timer": ("static/today-timer.js", "static/today-recap.js"),
|
"today-timer": ("static/today-timer.js",),
|
||||||
}
|
}
|
||||||
CACHE_DECLARATION = re.compile(
|
CACHE_DECLARATION = re.compile(
|
||||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||||
|
|
@ -62,7 +58,6 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
||||||
source = b"".join(chunks)
|
source = b"".join(chunks)
|
||||||
# Node-only export shims support source-level unit tests but are unreachable
|
# Node-only export shims support source-level unit tests but are unreachable
|
||||||
# in the browser. Strip the simple one-line form from shipped bundles.
|
# in the browser. Strip the simple one-line form from shipped bundles.
|
||||||
source = COMMONJS_BROWSER_BRANCH.sub(b"{", source)
|
|
||||||
source = COMMONJS_EXPORT_LINE.sub(b"", source)
|
source = COMMONJS_EXPORT_LINE.sub(b"", source)
|
||||||
revision = hashlib.sha256(source).hexdigest()
|
revision = hashlib.sha256(source).hexdigest()
|
||||||
minified = rjsmin.jsmin(source.decode()).encode()
|
minified = rjsmin.jsmin(source.decode()).encode()
|
||||||
|
|
|
||||||
48
src/main.py
48
src/main.py
|
|
@ -415,17 +415,6 @@ class TodayOperation(BaseModel):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class TodayRecapItem(BaseModel):
|
|
||||||
identity: str = Field(min_length=1, max_length=500)
|
|
||||||
estimate_minutes: int | None = Field(default=None, ge=5, le=1440)
|
|
||||||
actual_minutes: int = Field(ge=0, le=1440)
|
|
||||||
|
|
||||||
|
|
||||||
class TodayRecap(BaseModel):
|
|
||||||
session_id: str = Field(min_length=1, max_length=100)
|
|
||||||
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
|
|
||||||
|
|
||||||
|
|
||||||
class LaterOperation(BaseModel):
|
class LaterOperation(BaseModel):
|
||||||
operation_id: str = Field(min_length=1, max_length=100)
|
operation_id: str = Field(min_length=1, max_length=100)
|
||||||
action: Literal["defer", "restore"]
|
action: Literal["defer", "restore"]
|
||||||
|
|
@ -1879,43 +1868,6 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/today/recaps")
|
|
||||||
async def get_today_recaps(response: Response):
|
|
||||||
login = await _confirmed_login()
|
|
||||||
try:
|
|
||||||
recaps = await asyncio.to_thread(_today_store().list_recaps, login)
|
|
||||||
except (OSError, sqlite3.Error):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Today recap history is unavailable",
|
|
||||||
headers={"Retry-After": "1"},
|
|
||||||
)
|
|
||||||
response.headers["Cache-Control"] = "no-store"
|
|
||||||
return {"recaps": recaps}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/today/recaps")
|
|
||||||
async def save_today_recap(payload: TodayRecap, response: Response):
|
|
||||||
login = await _confirmed_login()
|
|
||||||
try:
|
|
||||||
recap = await asyncio.to_thread(
|
|
||||||
_today_store().save_recap,
|
|
||||||
login,
|
|
||||||
payload.session_id,
|
|
||||||
[item.model_dump() for item in payload.items],
|
|
||||||
)
|
|
||||||
except ValueError as error:
|
|
||||||
raise HTTPException(status_code=422, detail=str(error))
|
|
||||||
except (OSError, sqlite3.Error):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Today recap could not be saved",
|
|
||||||
headers={"Retry-After": "1"},
|
|
||||||
)
|
|
||||||
response.headers["Cache-Control"] = "no-store"
|
|
||||||
return recap
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/later")
|
@app.get("/api/v1/later")
|
||||||
async def get_later_plan():
|
async def get_later_plan():
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ class TodayStore:
|
||||||
timeout: float = 1.0,
|
timeout: float = 1.0,
|
||||||
operation_limit: int = 4096,
|
operation_limit: int = 4096,
|
||||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||||
recap_limit: int = 100,
|
|
||||||
clock=time.time,
|
clock=time.time,
|
||||||
):
|
):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
|
|
@ -27,7 +26,6 @@ class TodayStore:
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.operation_limit = operation_limit
|
self.operation_limit = operation_limit
|
||||||
self.operation_retention_seconds = operation_retention_seconds
|
self.operation_retention_seconds = operation_retention_seconds
|
||||||
self.recap_limit = recap_limit
|
|
||||||
self.clock = clock
|
self.clock = clock
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
|
@ -67,21 +65,6 @@ class TodayStore:
|
||||||
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
|
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
|
||||||
(self.clock(),),
|
(self.clock(),),
|
||||||
)
|
)
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS today_recaps (
|
|
||||||
login TEXT NOT NULL,
|
|
||||||
session_id TEXT NOT NULL,
|
|
||||||
created_at REAL NOT NULL,
|
|
||||||
items TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (login, session_id)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS today_recaps_recent "
|
|
||||||
"ON today_recaps(login, created_at DESC)"
|
|
||||||
)
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
return connection
|
return connection
|
||||||
|
|
||||||
|
|
@ -130,90 +113,6 @@ class TodayStore:
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return self._snapshot(row)
|
return self._snapshot(row)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_recap_items(items: list[dict]) -> list[dict]:
|
|
||||||
if not isinstance(items, list) or not items:
|
|
||||||
raise ValueError("recap requires at least one item")
|
|
||||||
if len(items) > 20:
|
|
||||||
raise ValueError("recap is limited to 20 items")
|
|
||||||
normalized = []
|
|
||||||
seen = set()
|
|
||||||
for item in items:
|
|
||||||
identity = item.get("identity", "") if isinstance(item, dict) else ""
|
|
||||||
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
|
|
||||||
raise ValueError("recap item identity is required and bounded")
|
|
||||||
identity = identity.strip()
|
|
||||||
if identity in seen:
|
|
||||||
raise ValueError("recap item identities must be unique")
|
|
||||||
actual = item.get("actual_minutes")
|
|
||||||
estimate = item.get("estimate_minutes")
|
|
||||||
if not isinstance(actual, int) or isinstance(actual, bool) or actual < 0 or actual > 1440:
|
|
||||||
raise ValueError("actual minutes must be between 0 and 1440")
|
|
||||||
if estimate is not None and (
|
|
||||||
not isinstance(estimate, int) or isinstance(estimate, bool)
|
|
||||||
or estimate < 5 or estimate > 1440
|
|
||||||
):
|
|
||||||
raise ValueError("estimate minutes must be between 5 and 1440")
|
|
||||||
seen.add(identity)
|
|
||||||
normalized.append({
|
|
||||||
"identity": identity,
|
|
||||||
"estimate_minutes": estimate,
|
|
||||||
"actual_minutes": actual,
|
|
||||||
})
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _recap_snapshot(session_id: str, created_at: float, serialized: str) -> dict:
|
|
||||||
items = json.loads(serialized)
|
|
||||||
estimated = sum(item["estimate_minutes"] for item in items if item["estimate_minutes"] is not None)
|
|
||||||
actual = sum(item["actual_minutes"] for item in items)
|
|
||||||
return {
|
|
||||||
"session_id": session_id,
|
|
||||||
"created_at": created_at,
|
|
||||||
"items": items,
|
|
||||||
"estimated_minutes": estimated,
|
|
||||||
"actual_minutes": actual,
|
|
||||||
"variance_minutes": actual - estimated,
|
|
||||||
}
|
|
||||||
|
|
||||||
def save_recap(self, login: str, session_id: str, items: list[dict]) -> dict:
|
|
||||||
login = self._normalize_login(login)
|
|
||||||
if not isinstance(session_id, str) or not session_id.strip() or len(session_id) > 100:
|
|
||||||
raise ValueError("session_id is required and bounded")
|
|
||||||
session_id = session_id.strip()
|
|
||||||
normalized = self._normalize_recap_items(items)
|
|
||||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
|
||||||
with self._connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
existing = connection.execute(
|
|
||||||
"SELECT created_at, items FROM today_recaps WHERE login = ? AND session_id = ?",
|
|
||||||
(login, session_id),
|
|
||||||
).fetchone()
|
|
||||||
if existing is not None:
|
|
||||||
return self._recap_snapshot(session_id, existing[0], existing[1])
|
|
||||||
created_at = self.clock()
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO today_recaps(login, session_id, created_at, items) VALUES (?, ?, ?, ?)",
|
|
||||||
(login, session_id, created_at, serialized),
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"DELETE FROM today_recaps WHERE login = ? AND rowid NOT IN "
|
|
||||||
"(SELECT rowid FROM today_recaps WHERE login = ? "
|
|
||||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
|
||||||
(login, login, self.recap_limit),
|
|
||||||
)
|
|
||||||
return self._recap_snapshot(session_id, created_at, serialized)
|
|
||||||
|
|
||||||
def list_recaps(self, login: str, *, limit: int = 30) -> list[dict]:
|
|
||||||
bounded_limit = max(1, min(int(limit), self.recap_limit, 100))
|
|
||||||
with self._connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ? "
|
|
||||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
|
||||||
(self._normalize_login(login), bounded_limit),
|
|
||||||
).fetchall()
|
|
||||||
return [self._recap_snapshot(*row) for row in rows]
|
|
||||||
|
|
||||||
def apply(
|
def apply(
|
||||||
self,
|
self,
|
||||||
login: str,
|
login: str,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import gzip
|
import gzip
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -63,8 +62,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
assert b"function createReviewController" not in first.runtime_bytes
|
assert b"function createReviewController" not in first.runtime_bytes
|
||||||
assert b"function createPullSheet" in pull_workflow.runtime_bytes
|
assert b"function createPullSheet" in pull_workflow.runtime_bytes
|
||||||
assert b"function createReviewController" in pull_workflow.runtime_bytes
|
assert b"function createReviewController" in pull_workflow.runtime_bytes
|
||||||
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
assert len(first.runtime_gzip_bytes) <= 95 * 1024
|
||||||
assert len(first.runtime_gzip_bytes) <= 96 * 1024
|
|
||||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||||
|
|
@ -86,17 +84,6 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
|
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
|
||||||
|
|
||||||
|
|
||||||
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
|
|
||||||
build = build_frontend(FRONTEND)
|
|
||||||
bundles = {"core.js": build.runtime_bytes}
|
|
||||||
bundles.update({f"{name}.js": bundle.runtime_bytes for name, bundle in build.feature_bundles.items()})
|
|
||||||
for name, source in bundles.items():
|
|
||||||
path = tmp_path / name
|
|
||||||
path.write_bytes(source)
|
|
||||||
result = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
|
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
|
||||||
build = build_frontend(FRONTEND)
|
build = build_frontend(FRONTEND)
|
||||||
|
|
|
||||||
|
|
@ -2042,14 +2042,13 @@ process.stdout.write(JSON.stringify({{activated,snapshot:timer.snapshot()}}));
|
||||||
async def test_today_timer_is_wired_into_every_mobile_session_control():
|
async def test_today_timer_is_wired_into_every_mobile_session_control():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
timer_source = TODAY_TIMER.read_text()
|
timer_source = TODAY_TIMER.read_text()
|
||||||
recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
|
|
||||||
|
|
||||||
assert html.count('<button type="button" data-work-session-timer-toggle>') == 4
|
assert html.count('<button type="button" data-work-session-timer-toggle>') == 4
|
||||||
assert 'createTodayTimer({' in html
|
assert 'createTodayTimer({' in html
|
||||||
assert 'createTodayTimerView({' in html
|
assert 'createTodayTimerView({' in html
|
||||||
assert 'timerView.open(workIdentity(item), workSession.checkpointed(item))' in html
|
assert 'timerView.open(workIdentity(item), workSession.checkpointed(item))' in html
|
||||||
assert 'active ? timer.activate(identity) : timer.stop()' in timer_source
|
assert 'active ? timer.activate(identity) : timer.stop()' in timer_source
|
||||||
assert "setInterval(timerView.render, 1000)" in recap_source
|
assert "setInterval(timerView.render, 1000)" in html
|
||||||
assert 'timer.pause()' in timer_source
|
assert 'timer.pause()' in timer_source
|
||||||
assert 'timer.resume()' in timer_source
|
assert 'timer.resume()' in timer_source
|
||||||
assert "elapsed(snapshot.elapsed_ms)" in timer_source
|
assert "elapsed(snapshot.elapsed_ms)" in timer_source
|
||||||
|
|
@ -2268,7 +2267,6 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
|
|
||||||
|
|
||||||
assert 'id="resume-today-session"' in html
|
assert 'id="resume-today-session"' in html
|
||||||
assert 'id="end-today-session"' in html
|
assert 'id="end-today-session"' in html
|
||||||
|
|
@ -2280,7 +2278,7 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
||||||
assert "qs('#end-today-session').addEventListener('click'" in html
|
assert "qs('#end-today-session').addEventListener('click'" in html
|
||||||
assert "workSession.resume(item)" in html
|
assert "workSession.resume(item)" in html
|
||||||
assert "runTodayTransition('resume')" in html
|
assert "runTodayTransition('resume')" in html
|
||||||
assert "workSession.end()" in recap_source
|
assert "workSession.end()" in html
|
||||||
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
|
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2379,7 +2377,6 @@ async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_n
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_announcement():
|
async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_announcement():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
|
|
||||||
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
||||||
"qs('#keep-update-unread').addEventListener", 1
|
"qs('#keep-update-unread').addEventListener", 1
|
||||||
)[0]
|
)[0]
|
||||||
|
|
@ -2388,7 +2385,7 @@ async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_an
|
||||||
assert "merging.key + ' merged.'" in merge_handler
|
assert "merging.key + ' merged.'" in merge_handler
|
||||||
assert "else if (continuingSession" not in merge_handler
|
assert "else if (continuingSession" not in merge_handler
|
||||||
assert "onFinish: () =>" in html
|
assert "onFinish: () =>" in html
|
||||||
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in recap_source
|
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -679,7 +679,6 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/card-planning.js",
|
"/dashboard/static/card-planning.js",
|
||||||
"/dashboard/static/today-work.js",
|
"/dashboard/static/today-work.js",
|
||||||
"/dashboard/static/today-timer.js",
|
"/dashboard/static/today-timer.js",
|
||||||
"/dashboard/static/today-recap.js",
|
|
||||||
"/dashboard/static/today-completion.js",
|
"/dashboard/static/today-completion.js",
|
||||||
"/dashboard/static/today-readiness.js",
|
"/dashboard/static/today-readiness.js",
|
||||||
"/dashboard/static/comment-next.js",
|
"/dashboard/static/comment-next.js",
|
||||||
|
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src import main
|
|
||||||
from src.today_store import TodayStore
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).parents[1]
|
|
||||||
TODAY_RECAP = ROOT / "frontend" / "today-recap.js"
|
|
||||||
|
|
||||||
|
|
||||||
def test_recap_store_is_idempotent_account_scoped_bounded_and_newest_first(tmp_path):
|
|
||||||
now = [1_000.0]
|
|
||||||
store = TodayStore(tmp_path / "today.sqlite3", recap_limit=2, clock=lambda: now[0])
|
|
||||||
first = {
|
|
||||||
"session_id": "session-1",
|
|
||||||
"items": [
|
|
||||||
{"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42},
|
|
||||||
{"identity": "pull:stackchain/api:8:", "estimate_minutes": None, "actual_minutes": 12},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
saved = store.save_recap("Timmy", first["session_id"], first["items"])
|
|
||||||
replay = store.save_recap("timmy", first["session_id"], first["items"])
|
|
||||||
now[0] += 1
|
|
||||||
store.save_recap("timmy", "session-2", [{"identity": "issue:r:2:", "estimate_minutes": 10, "actual_minutes": 8}])
|
|
||||||
now[0] += 1
|
|
||||||
store.save_recap("timmy", "session-3", [{"identity": "issue:r:3:", "estimate_minutes": 10, "actual_minutes": 15}])
|
|
||||||
store.save_recap("alexander", "other", [{"identity": "issue:r:9:", "estimate_minutes": 5, "actual_minutes": 5}])
|
|
||||||
|
|
||||||
assert saved == replay
|
|
||||||
assert saved["session_id"] == "session-1"
|
|
||||||
assert saved["estimated_minutes"] == 30
|
|
||||||
assert saved["actual_minutes"] == 54
|
|
||||||
assert [row["session_id"] for row in store.list_recaps("timmy")] == ["session-3", "session-2"]
|
|
||||||
assert [row["session_id"] for row in store.list_recaps("alexander")] == ["other"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"items,message",
|
|
||||||
[
|
|
||||||
([], "at least one"),
|
|
||||||
([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": -1}], "actual"),
|
|
||||||
([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": 1441}], "actual"),
|
|
||||||
([{"identity": "", "estimate_minutes": 5, "actual_minutes": 1}], "identity"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_recap_store_rejects_invalid_or_unbounded_rows(tmp_path, items, message):
|
|
||||||
store = TodayStore(tmp_path / "today.sqlite3")
|
|
||||||
with pytest.raises(ValueError, match=message):
|
|
||||||
store.save_recap("timmy", "session", items)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_authenticated_recap_api_is_no_store_idempotent_and_account_scoped(monkeypatch, tmp_path):
|
|
||||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
|
||||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
|
||||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
|
||||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
|
||||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
|
||||||
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
|
|
||||||
|
|
||||||
async def user():
|
|
||||||
return {"id": 1, "login": "Timmy"}
|
|
||||||
|
|
||||||
monkeypatch.setattr(main, "current_user", user)
|
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
|
||||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
|
||||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
|
||||||
payload = {"session_id": "mobile-session-1", "items": [
|
|
||||||
{"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42}
|
|
||||||
]}
|
|
||||||
created = await client.post("/api/v1/today/recaps", json=payload, headers=headers)
|
|
||||||
replay = await client.post("/api/v1/today/recaps", json=payload, headers=headers)
|
|
||||||
history = await client.get("/api/v1/today/recaps")
|
|
||||||
|
|
||||||
assert created.status_code == replay.status_code == 200
|
|
||||||
assert created.json() == replay.json()
|
|
||||||
assert history.json()["recaps"] == [created.json()]
|
|
||||||
assert created.headers["cache-control"] == history.headers["cache-control"] == "no-store"
|
|
||||||
|
|
||||||
|
|
||||||
def test_recap_controller_calculates_variance_validates_corrections_and_clears_after_save():
|
|
||||||
script = f"""
|
|
||||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
|
||||||
const calls=[]; let cleared=0;
|
|
||||||
const recap=createRecap({{
|
|
||||||
save:payload=>{{calls.push(payload); return Promise.resolve(payload);}},
|
|
||||||
clear:()=>{{cleared += 1;}},
|
|
||||||
makeId:()=> 'session-fixed',
|
|
||||||
}});
|
|
||||||
const draft=recap.begin([
|
|
||||||
{{identity:'issue:r:1:',elapsed_ms:42*60000}},
|
|
||||||
{{identity:'pull:r:2:',elapsed_ms:12*60000}},
|
|
||||||
], {{'issue:r:1:':30}});
|
|
||||||
const invalidLow=recap.correct('issue:r:1:',-1);
|
|
||||||
const invalidHigh=recap.correct('issue:r:1:',1441);
|
|
||||||
const corrected=recap.correct('issue:r:1:',45);
|
|
||||||
recap.save().then(saved=>process.stdout.write(JSON.stringify({{
|
|
||||||
draft,invalidLow,invalidHigh,corrected,saved,calls,cleared
|
|
||||||
}}))).catch(error=>{{console.error(error);process.exit(1);}});
|
|
||||||
"""
|
|
||||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
output = json.loads(result.stdout)
|
|
||||||
assert output["draft"]["estimated_minutes"] == 30
|
|
||||||
assert output["draft"]["actual_minutes"] == 54
|
|
||||||
assert output["draft"]["variance_minutes"] == 24
|
|
||||||
assert output["invalidLow"] is False
|
|
||||||
assert output["invalidHigh"] is False
|
|
||||||
assert output["corrected"] is True
|
|
||||||
assert output["calls"][0]["session_id"] == "session-fixed"
|
|
||||||
assert output["calls"][0]["items"][0]["actual_minutes"] == 45
|
|
||||||
assert output["cleared"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_dashboard_renders_mobile_today_recap_flow():
|
|
||||||
html = main.FRONTEND_BUILD.dashboard_html
|
|
||||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
|
||||||
timer = (ROOT / "frontend" / "today-timer.js").read_text()
|
|
||||||
recap_source = TODAY_RECAP.read_text()
|
|
||||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
|
||||||
|
|
||||||
assert 'id="today-recap-sheet" role="dialog"' in html
|
|
||||||
assert 'id="today-recap-history"' in html
|
|
||||||
assert 'static/today-recap.js' in main.FRONTEND_BUILD.page_sources
|
|
||||||
assert "timer.recapEntries()" in recap_source
|
|
||||||
assert "isActive: () => workSession.checkpointed()" in dashboard
|
|
||||||
assert "openTodayRecapAfterSession(view" in recap_source
|
|
||||||
assert "api/v1/today/recaps" in recap_source
|
|
||||||
assert "recapEntries()" in timer and "clearRecap()" in timer
|
|
||||||
assert ".today-recap-header button { min-height:44px;" in css
|
|
||||||
assert ".today-recap-actions button { min-height:44px;" in css
|
|
||||||
assert "overflow-x:hidden" in css
|
|
||||||
Loading…
Reference in New Issue
Block a user