Add synced Today recap with estimate-versus-actual history #580
|
|
@ -137,6 +137,20 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.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 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[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; }
|
||||
|
|
|
|||
|
|
@ -931,10 +931,13 @@
|
|||
});
|
||||
const timerView = createTodayTimerView({
|
||||
timer,
|
||||
isActive: workSession.checkpointed,
|
||||
isActive: () => workSession.checkpointed(),
|
||||
queryAll: s => document.querySelectorAll(s),
|
||||
formatEstimate: formatPlanMinutes,
|
||||
});
|
||||
const todayRecapView = setupTodayRecap(
|
||||
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions
|
||||
);
|
||||
function updateDetailDeferLabels(active) {
|
||||
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
||||
button.textContent = active ? 'Later today & next' : 'Later today';
|
||||
|
|
@ -977,17 +980,8 @@
|
|||
button.hidden = !workSession.checkpointed();
|
||||
});
|
||||
},
|
||||
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();
|
||||
},
|
||||
onFinish: () => todayRecapView.finish(selectedWorkFilter),
|
||||
});
|
||||
setInterval(timerView.render, 1000);
|
||||
|
||||
function selectTodayWork() {
|
||||
qs('[data-work-filter="today"]').click();
|
||||
}
|
||||
|
|
@ -5222,11 +5216,8 @@
|
|||
else workSession.start();
|
||||
});
|
||||
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());
|
||||
qs('#end-today-session').addEventListener('click', () => {
|
||||
workSession.end();
|
||||
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
|
||||
qs('#start-work-session').focus();
|
||||
});
|
||||
|
||||
qs('#end-today-session').addEventListener('click', () => endTodaySession(workSession, qs));
|
||||
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
|
||||
button.addEventListener('click', () => workSession.previous())
|
||||
);
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@
|
|||
</div>
|
||||
<div class="my-work-actions">
|
||||
<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="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>
|
||||
|
|
@ -265,6 +266,27 @@
|
|||
</section>
|
||||
</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>
|
||||
<section class="today-readiness-panel">
|
||||
<div class="today-readiness-header">
|
||||
|
|
@ -801,6 +823,7 @@
|
|||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/today-work.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-readiness.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const SHELL = [
|
|||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
|
|
|
|||
148
frontend/today-recap.js
Normal file
148
frontend/today-recap.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
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,6 +77,18 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
|||
settle(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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ import rjsmin
|
|||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
COMMONJS_EXPORT_LINE = re.compile(
|
||||
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$", re.MULTILINE
|
||||
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$(?!\n\s*else)", 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"
|
||||
FEATURE_SOURCES = {
|
||||
|
|
@ -22,7 +26,7 @@ FEATURE_SOURCES = {
|
|||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"today-timer": ("static/today-timer.js",),
|
||||
"today-timer": ("static/today-timer.js", "static/today-recap.js"),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
|
|
@ -58,6 +62,7 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
|||
source = b"".join(chunks)
|
||||
# Node-only export shims support source-level unit tests but are unreachable
|
||||
# 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)
|
||||
revision = hashlib.sha256(source).hexdigest()
|
||||
minified = rjsmin.jsmin(source.decode()).encode()
|
||||
|
|
|
|||
48
src/main.py
48
src/main.py
|
|
@ -415,6 +415,17 @@ class TodayOperation(BaseModel):
|
|||
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):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
action: Literal["defer", "restore"]
|
||||
|
|
@ -1868,6 +1879,43 @@ 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")
|
||||
async def get_later_plan():
|
||||
login = await _confirmed_login()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class TodayStore:
|
|||
timeout: float = 1.0,
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
recap_limit: int = 100,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
|
|
@ -26,6 +27,7 @@ class TodayStore:
|
|||
self.timeout = timeout
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.recap_limit = recap_limit
|
||||
self.clock = clock
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
|
|
@ -65,6 +67,21 @@ class TodayStore:
|
|||
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(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()
|
||||
return connection
|
||||
|
||||
|
|
@ -113,6 +130,90 @@ class TodayStore:
|
|||
).fetchone()
|
||||
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(
|
||||
self,
|
||||
login: str,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import gzip
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
|
@ -62,7 +63,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
|||
assert b"function createReviewController" not in first.runtime_bytes
|
||||
assert b"function createPullSheet" in pull_workflow.runtime_bytes
|
||||
assert b"function createReviewController" in pull_workflow.runtime_bytes
|
||||
assert len(first.runtime_gzip_bytes) <= 95 * 1024
|
||||
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
||||
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-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||
|
|
@ -84,6 +86,17 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
|||
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
|
||||
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
|
||||
build = build_frontend(FRONTEND)
|
||||
|
|
|
|||
|
|
@ -2042,13 +2042,14 @@ process.stdout.write(JSON.stringify({{activated,snapshot:timer.snapshot()}}));
|
|||
async def test_today_timer_is_wired_into_every_mobile_session_control():
|
||||
html = await dashboard()
|
||||
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 'createTodayTimer({' in html
|
||||
assert 'createTodayTimerView({' in html
|
||||
assert 'timerView.open(workIdentity(item), workSession.checkpointed(item))' in html
|
||||
assert 'active ? timer.activate(identity) : timer.stop()' in timer_source
|
||||
assert "setInterval(timerView.render, 1000)" in html
|
||||
assert "setInterval(timerView.render, 1000)" in recap_source
|
||||
assert 'timer.pause()' in timer_source
|
||||
assert 'timer.resume()' in timer_source
|
||||
assert "elapsed(snapshot.elapsed_ms)" in timer_source
|
||||
|
|
@ -2267,6 +2268,7 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
|
|||
@pytest.mark.anyio
|
||||
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
||||
html = await dashboard()
|
||||
recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
|
||||
|
||||
assert 'id="resume-today-session"' in html
|
||||
assert 'id="end-today-session"' in html
|
||||
|
|
@ -2278,7 +2280,7 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
|||
assert "qs('#end-today-session').addEventListener('click'" in html
|
||||
assert "workSession.resume(item)" in html
|
||||
assert "runTodayTransition('resume')" in html
|
||||
assert "workSession.end()" in html
|
||||
assert "workSession.end()" in recap_source
|
||||
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
|
||||
|
||||
|
||||
|
|
@ -2377,6 +2379,7 @@ async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_n
|
|||
@pytest.mark.anyio
|
||||
async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_announcement():
|
||||
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(
|
||||
"qs('#keep-update-unread').addEventListener", 1
|
||||
)[0]
|
||||
|
|
@ -2385,7 +2388,7 @@ async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_an
|
|||
assert "merging.key + ' merged.'" in merge_handler
|
||||
assert "else if (continuingSession" not in merge_handler
|
||||
assert "onFinish: () =>" in html
|
||||
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in html
|
||||
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in recap_source
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
|
|||
|
|
@ -679,6 +679,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/card-planning.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-timer.js",
|
||||
"/dashboard/static/today-recap.js",
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/today-readiness.js",
|
||||
"/dashboard/static/comment-next.js",
|
||||
|
|
|
|||
140
tests/test_today_recap.py
Normal file
140
tests/test_today_recap.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
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