feat: replan Today from live budget risk (Closes #597)
CI / lint (pull_request) Successful in 1m24s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 23:04:49 +00:00
1 parent 798790ce1a
commit ebe19f2281
15 files changed
+254 -32

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v91';
const CACHE = 'stackchain-dashboard-shell-v92';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
+84 -4
View File
@@ -188,13 +188,42 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
clearRecap() {
return write(empty());
},
totalElapsed() {
const state = read();
return Object.entries(state.entries).reduce((total, [identity, entry]) => {
const live = identity === state.active_identity && entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0;
return total + Math.max(0, Number(entry.elapsed_ms) || 0) + live;
}, 0);
},
snapshot,
};
}
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) {
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway }) {
let progress = null;
let runway = null;
if (typeof document !== 'undefined') queryAll('.work-session-nav').forEach(nav => {
const button = document.createElement('button');
button.type = 'button';
button.hidden = true;
button.dataset.workSessionAdjustPlan = '';
button.textContent = 'Adjust remaining plan';
nav.insertBefore(button, nav.querySelector('[data-work-session-complete]'));
});
if (typeof MutationObserver !== 'undefined') {
const plan = queryAll('#plan-today')[0];
const sheet = queryAll('#plan-today-sheet')[0];
if (plan && sheet) {
const replan = createTodayBudgetReplan({ timer, openPlan:() => plan.click() });
queryAll('[data-work-session-adjust-plan]').forEach(button =>
button.addEventListener('click', () => replan.open())
);
new MutationObserver(() => {
if (sheet.hidden && replan.restore()) render();
}).observe(sheet, { attributes:true, attributeFilter:['hidden'] });
}
}
const elapsed = milliseconds => {
const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000));
const hours = Math.floor(seconds / 3600);
@@ -205,11 +234,37 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) {
const render = () => {
if (!progress) return;
const snapshot = timer.snapshot();
const sourceRunway = getRunway?.(snapshot) || runway;
const liveRunway = sourceRunway?.future_minutes !== undefined ? (() => {
const currentElapsed = Math.max(0, Math.ceil(snapshot.elapsed_ms / 60000));
const currentRemaining = sourceRunway.current_minutes === null ? null :
Math.max(0, sourceRunway.current_minutes - currentElapsed);
const remaining = currentRemaining === null || sourceRunway.future_minutes === null ? null :
currentRemaining + sourceRunway.future_minutes;
const projected = remaining === null ? null : Math.ceil(timer.totalElapsed() / 60000) + remaining;
const capacityRemaining = sourceRunway.capacity_minutes === null || projected === null ? null :
sourceRunway.capacity_minutes - projected;
return {
...sourceRunway,
remaining_minutes:remaining,
over_estimate_minutes:sourceRunway.current_minutes === null ? 0 :
Math.max(0, currentElapsed - sourceRunway.current_minutes),
over_capacity_minutes:capacityRemaining === null ? 0 : Math.max(0, -capacityRemaining),
};
})() : sourceRunway;
const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) +
(runway?.current_minutes ? ' / ' + formatEstimate(runway.current_minutes) : '') : '';
(liveRunway?.current_minutes ? ' / ' + formatEstimate(liveRunway.current_minutes) : '') : '';
const estimateRisk = liveRunway?.over_estimate_minutes ?
' · ' + formatEstimate(liveRunway.over_estimate_minutes) + ' over estimate' : '';
const capacityRisk = liveRunway?.over_capacity_minutes ?
' · Today projected ' + formatEstimate(liveRunway.over_capacity_minutes) + ' over capacity' : '';
queryAll('[data-work-session-progress]').forEach(element => {
element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing +
(runway?.remaining_minutes ? ' · ' + formatEstimate(runway.remaining_minutes) + ' remaining' : '');
element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + estimateRisk + capacityRisk +
(!estimateRisk && !capacityRisk && liveRunway?.remaining_minutes ?
' · ' + formatEstimate(liveRunway.remaining_minutes) + ' remaining' : '');
});
queryAll('[data-work-session-adjust-plan]').forEach(button => {
button.hidden = !(liveRunway?.over_estimate_minutes || liveRunway?.over_capacity_minutes);
});
queryAll('[data-work-session-timer-toggle]').forEach(button => {
button.hidden = !isActive();
@@ -227,6 +282,30 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) {
};
}
function createTodayBudgetReplan({ timer, openPlan }) {
let active = false;
let resume = false;
return {
open() {
if (active) return false;
const state = timer.snapshot();
if (!state.identity) return false;
resume = Boolean(state.running);
if (resume && timer.pause() === false) return false;
active = true;
openPlan?.(state);
return true;
},
restore() {
if (!active) return false;
active = false;
const shouldResume = resume;
resume = false;
return !shouldResume || timer.resume() !== false;
},
};
}
function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel, onResolved }) {
const render = pending => {
if (!pending) {
@@ -255,5 +334,6 @@ function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel
if (typeof module !== 'undefined' && module.exports) {
createTodayTimer.createView = createTodayTimerView;
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
module.exports = createTodayTimer;
}
+7 -4
View File
@@ -150,12 +150,15 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
}
function runway(items, currentIndex = 0) {
const estimates = planning().estimates;
const remaining = (items || []).slice(Math.max(0, currentIndex));
const minutes = remaining.map(item => estimates[identity(item)] || null);
const plan = planning();
const minutes = (items || []).slice(Math.max(0, currentIndex)).map(item => plan.estimates[identity(item)] || null);
const total = values => values.some(value => value === null) ? null :
values.reduce((sum, value) => sum + value, 0);
return {
current_minutes: minutes[0] || null,
remaining_minutes: minutes.some(value => value === null) ? null : minutes.reduce((total, value) => total + value, 0),
remaining_minutes: total(minutes),
future_minutes: total(minutes.slice(1)),
capacity_minutes: plan.capacity_minutes,
};
}