diff --git a/README.md b/README.md
index ab9f55b..07d4c35 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,10 @@ the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. The ordered,
-five-item Today plan syncs across the operator's devices. Adding an issue through **Plan Today**
+five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
+and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
+time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
+and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
as unknown rather than unblocked. Starting a Today work session also
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 9e03f44..e10dfef 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -89,6 +89,10 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-header h2, .plan-today-header p { margin-top:0; }
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
+.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
+.plan-today-available span { display:flex; align-items:center; gap:6px; }
+.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
+.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
.plan-today-error { min-height:1.4em; color:#fca5a5; }
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index bf78336..9e155e1 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -163,6 +163,13 @@
refreshMyWorkView();
warmTodayOffline();
},
+ onRemotePlan: plan => {
+ if (!planningOwnerLogin) return;
+ todayWork.replacePlanning({
+ capacity_minutes: plan.capacity_minutes ?? null,
+ estimates: plan.estimates || {},
+ });
+ },
onStatus: (state, detail = {}) => {
const status = qs('#today-sync-status');
status.textContent = state === 'saved' ? 'Today saved to account.' :
@@ -744,8 +751,10 @@
onProgress: state => {
updateWorkSessionActions();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
+ const runway = selectedWorkFilter === 'today' ? todayWork.runway(todayMyWork, state.index - 1) : null;
document.querySelectorAll('[data-work-session-progress]').forEach(element => {
- element.textContent = 'Item ' + state.index + ' of ' + state.total;
+ element.textContent = 'Item ' + state.index + ' of ' + state.total + (runway?.current_minutes ?
+ ' · ' + formatPlanMinutes(runway.current_minutes) + ' · ' + formatPlanMinutes(runway.remaining_minutes) + ' remaining' : '');
});
document.querySelectorAll('[data-work-session-previous]').forEach(button => {
button.disabled = !state.can_previous;
@@ -1059,21 +1068,48 @@
});
let planTodayTrigger = null;
+ function formatPlanMinutes(minutes) {
+ if (!Number.isInteger(minutes)) return 'Not set';
+ const absolute = Math.abs(minutes);
+ const hours = Math.floor(absolute / 60);
+ const remainder = absolute % 60;
+ return [hours ? hours + 'h' : '', remainder ? remainder + 'm' : ''].filter(Boolean).join(' ') || '0m';
+ }
+
function planTodayItemMarkup(item, selected, index = -1) {
const id = todayWork.identity(item);
const key = escapeHtml(item.key || (item.repository + '#' + (item.number || '')));
const title = escapeHtml(item.title || 'Untitled work');
+ const state = planToday.snapshot();
+ const estimate = state.estimates?.[id] || '';
+ const estimateControl = selected ? '' : '';
const controls = selected ?
- '
' :
+ '' :
(item.kind === 'issue' ?
'' :
'');
- return '' + key + '' + title + '
' + controls + '';
+ return '' + key + '' + title + '' + estimateControl + '
' + controls + '';
+ }
+
+ function resetPlanTodayConfirmation() {
+ const save = qs('#save-today-plan');
+ const start = qs('#save-and-start-today');
+ save.dataset.confirmOverCapacity = '';
+ start.dataset.confirmOverCapacity = '';
+ save.textContent = 'Save plan';
+ start.textContent = 'Save & start';
}
function renderPlanToday() {
+ resetPlanTodayConfirmation();
const state = planToday.snapshot();
- qs('#plan-today-capacity').textContent = state.count + ' of ' + state.limit + ' selected';
+ const capacityText = state.capacity_minutes === null ? 'Set available time to check fit' :
+ (state.over_capacity ? formatPlanMinutes(-state.remaining_minutes) + ' over capacity' :
+ formatPlanMinutes(state.remaining_minutes) + ' free');
+ qs('#plan-today-capacity').textContent = state.count + ' of ' + state.limit + ' selected · Planned ' +
+ formatPlanMinutes(state.planned_minutes) + ' · ' + capacityText +
+ (state.unestimated_count ? ' · ' + state.unestimated_count + ' unestimated' : '');
+ qs('#plan-today-available').value = state.capacity_minutes || '';
qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) =>
planTodayItemMarkup(planToday.item(id), true, index)
).join('') : 'No work selected yet.
';
@@ -1105,6 +1141,10 @@
renderPlanToday();
document.querySelector('[data-plan-id="' + CSS.escape(button.dataset.planId) + '"][data-plan-move="' + button.dataset.planMove + '"]')?.focus();
}));
+ document.querySelectorAll('[data-plan-estimate]').forEach(input => input.addEventListener('change', () => {
+ planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
+ renderPlanToday();
+ }));
}
function closePlanToday(navigate = true) {
@@ -1118,15 +1158,19 @@
planTodayTrigger?.focus();
}
- function saveTodayPlan(ids) {
+ function saveTodayPlan(plan) {
+ const capacityAware = !Array.isArray(plan);
+ const ids = capacityAware ? plan.ids : plan;
const previous = todayWork.read();
const operations = previous.map(id => ['remove', id]).concat(ids.map(id => ['add', id]));
if (!operations.every(([action, id]) => todaySync.enqueue(action, id))) return false;
+ if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false;
if (!todayWork.replace(ids)) return false;
+ if (capacityAware && !todayWork.replacePlanning(plan)) return false;
refreshMyWorkView();
todaySync.flush();
warmTodayOffline();
- qs('#my-work-action-status').textContent = ids.length ? 'Today plan saved in your chosen order.' : 'Today plan cleared.';
+ qs('#my-work-action-status').textContent = ids.length ? 'Today plan saved in your chosen order and available time.' : 'Today plan cleared.';
return true;
}
@@ -1252,7 +1296,7 @@
taskOverlayHistory.open('plan-today');
return;
}
- planToday.open(todayMyWork, activeMyWork);
+ planToday.open(todayMyWork, activeMyWork, todayWork.planning());
qs('#plan-today-error').textContent = '';
qs('#plan-today-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
@@ -4380,16 +4424,26 @@
closeTodayReadiness();
openPlanToday(qs('#plan-today'));
});
- qs('#save-today-plan').addEventListener('click', () => {
- const result = planToday.commit();
- if (result === 'saved') taskOverlayHistory.leave();
- else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
- });
- qs('#save-and-start-today').addEventListener('click', () => {
- const result = planToday.commit({ start:true });
- if (result === 'saved') taskOverlayHistory.leave();
- else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
+ qs('#plan-today-available').addEventListener('change', event => {
+ planToday.setCapacity(Number(event.currentTarget.value));
+ qs('#plan-today-error').textContent = '';
+ renderPlanToday();
});
+ function commitPlanToday(start, button) {
+ const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
+ if (result === 'saved') {
+ button.dataset.confirmOverCapacity = '';
+ taskOverlayHistory.leave();
+ } else if (result === 'confirm-over-capacity') {
+ button.dataset.confirmOverCapacity = 'true';
+ qs('#plan-today-error').textContent = 'This plan exceeds your available time. Press again to save over capacity.';
+ button.textContent = start ? 'Save over capacity & start' : 'Save over capacity';
+ } else {
+ qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
+ }
+ }
+ qs('#save-today-plan').addEventListener('click', event => commitPlanToday(false, event.currentTarget));
+ qs('#save-and-start-today').addEventListener('click', event => commitPlanToday(true, event.currentTarget));
qs('#start-work-session').addEventListener('click', () => {
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
if (!sessionItems.length) {
diff --git a/frontend/index.html b/frontend/index.html
index 9a049b5..4897fae 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -180,6 +180,9 @@
0 of 5 selected
+
Today, in order
diff --git a/frontend/plan-today.js b/frontend/plan-today.js
index 25c69a7..b0baabc 100644
--- a/frontend/plan-today.js
+++ b/frontend/plan-today.js
@@ -2,6 +2,9 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
let openState = false;
let draftIds = [];
let itemsById = new Map();
+ let capacityMinutes = null;
+ let estimates = {};
+ let capacityAware = false;
function cleanItems(items) {
const unique = new Map();
@@ -12,13 +15,20 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return unique;
}
- function open(selectedItems, candidates) {
+ function open(selectedItems, candidates, planning = null) {
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
draftIds = [];
for (const item of selectedItems || []) {
const id = identity?.(item);
if (id && itemsById.has(id) && !draftIds.includes(id) && draftIds.length < limit) draftIds.push(id);
}
+ capacityAware = Boolean(planning && ('capacity_minutes' in planning || 'estimates' in planning));
+ capacityMinutes = Number.isInteger(planning?.capacity_minutes) && planning.capacity_minutes > 0
+ ? planning.capacity_minutes : null;
+ estimates = {};
+ for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
+ if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
+ }
openState = true;
return snapshot();
}
@@ -50,6 +60,9 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
openState = false;
draftIds = [];
itemsById = new Map();
+ capacityMinutes = null;
+ estimates = {};
+ capacityAware = false;
}
function cancel() {
@@ -57,10 +70,31 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return true;
}
- function commit({ start: startAfterSave = false } = {}) {
+ function setCapacity(minutes) {
+ capacityAware = true;
+ capacityMinutes = Number.isInteger(minutes) && minutes > 0 ? minutes : null;
+ return snapshot();
+ }
+
+ function setEstimate(id, minutes) {
+ if (!openState || !draftIds.includes(id)) return false;
+ capacityAware = true;
+ if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
+ else delete estimates[id];
+ return true;
+ }
+
+ function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
if (!openState) return 'closed';
const ids = [...draftIds];
- if (save?.(ids) === false) return 'unavailable';
+ const state = snapshot();
+ if (state.over_capacity && !confirmOverCapacity) return 'confirm-over-capacity';
+ const payload = capacityAware ? {
+ ids,
+ capacity_minutes: capacityMinutes,
+ estimates: Object.fromEntries(ids.filter(id => estimates[id]).map(id => [id, estimates[id]])),
+ } : ids;
+ if (save?.(payload) === false) return 'unavailable';
const first = ids.length ? itemsById.get(ids[0]) : null;
close();
if (startAfterSave && first) start?.(first);
@@ -68,7 +102,20 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
}
function snapshot() {
- return { open: openState, ids: [...draftIds], count: draftIds.length, limit };
+ const basic = { open: openState, ids: [...draftIds], count: draftIds.length, limit };
+ if (!capacityAware) return basic;
+ const selectedEstimates = Object.fromEntries(draftIds.filter(id => estimates[id]).map(id => [id, estimates[id]]));
+ const plannedMinutes = Object.values(selectedEstimates).reduce((total, value) => total + value, 0);
+ const remainingMinutes = capacityMinutes === null ? null : capacityMinutes - plannedMinutes;
+ return {
+ ...basic,
+ capacity_minutes: capacityMinutes,
+ estimates: selectedEstimates,
+ planned_minutes: plannedMinutes,
+ remaining_minutes: remainingMinutes,
+ unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
+ over_capacity: remainingMinutes !== null && remainingMinutes < 0,
+ };
}
function item(id) {
@@ -79,7 +126,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
}
- return { open, toggle, move, cancel, commit, snapshot, item, candidates };
+ return { open, toggle, move, cancel, setCapacity, setEstimate, commit, snapshot, item, candidates };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index e54539a..8f548b2 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v78';
+const CACHE = 'stackchain-dashboard-shell-v79';
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;
diff --git a/frontend/today-sync.js b/frontend/today-sync.js
index 864ffcd..f5b1a7c 100644
--- a/frontend/today-sync.js
+++ b/frontend/today-sync.js
@@ -1,4 +1,4 @@
-function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
+function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePlan, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
const prefix = 'stackchain.today-sync.v1.';
@@ -56,12 +56,19 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
if (plan.revision < savedRevision()) return false;
try {
- storage?.setItem(snapshotKey(), JSON.stringify({ revision: plan.revision, ids: plan.ids }));
+ storage?.setItem(snapshotKey(), JSON.stringify({
+ revision: plan.revision, ids: plan.ids,
+ capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
+ }));
} catch (_error) {
// A storage quota failure must not prevent the current tab from using server truth.
}
onRemoteIds?.(plan.ids);
- if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids });
+ onRemotePlan?.(plan);
+ if (broadcast) channel?.postMessage({
+ revision: plan.revision, ids: plan.ids,
+ capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
+ });
return true;
}
@@ -122,7 +129,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
? record.operation.base_revision : Math.max(0, savedRevision()),
}))
.filter(operation => operation && typeof operation.operation_id === 'string' &&
- ['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
+ ['add', 'remove', 'move', 'configure'].includes(operation.action) && typeof operation.item_id === 'string');
} catch (_error) {
return [];
}
@@ -173,6 +180,27 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
return saved;
}
+ function enqueueConfiguration(capacityMinutes, estimates) {
+ const operation = {
+ operation_id: operationId(), action: 'configure', item_id: 'plan', direction: null,
+ capacity_minutes: capacityMinutes, estimates: estimates || {},
+ base_revision: Math.max(0, savedRevision()),
+ };
+ const storageKey = key();
+ if (!storageKey || !storage) return false;
+ const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
+ try {
+ storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
+ knownOperationKeys.add(recordKey);
+ coordinator?.notify('today');
+ onStatus?.('pending');
+ return true;
+ } catch (_error) {
+ onStatus?.('error');
+ return false;
+ }
+ }
+
function migrate(ids) {
const storageKey = key();
if (!storageKey || !storage) return false;
@@ -253,7 +281,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
if (change.queue === 'today' && pending().length) flush();
});
- return { enqueue, migrate, flush, pending, startLifecycle };
+ return { enqueue, enqueueConfiguration, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
diff --git a/frontend/today-work.js b/frontend/today-work.js
index 286ba68..8e87ed5 100644
--- a/frontend/today-work.js
+++ b/frontend/today-work.js
@@ -1,5 +1,6 @@
function createTodayWork({ storage, getLogin, limit = 5 }) {
const prefix = 'stackchain.today-work.v1.';
+ const planningPrefix = 'stackchain.today-planning.v1.';
function identity(item) {
if (!item) return '';
@@ -9,9 +10,17 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return [kind, item.repository || '', number, notification].join(':');
}
- function storageKey() {
+ function ownerKey(keyPrefix) {
const login = String(getLogin?.() || '').trim().toLowerCase();
- return login ? prefix + encodeURIComponent(login) : '';
+ return login ? keyPrefix + encodeURIComponent(login) : '';
+ }
+
+ function storageKey() {
+ return ownerKey(prefix);
+ }
+
+ function planningKey() {
+ return ownerKey(planningPrefix);
}
function read() {
@@ -37,6 +46,40 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
}
}
+ function planning() {
+ const key = planningKey();
+ if (!key || !storage) return { capacity_minutes: null, estimates: {} };
+ try {
+ const value = JSON.parse(storage.getItem(key) || 'null');
+ const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
+ ? value.capacity_minutes : null;
+ const ids = new Set(read());
+ const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
+ ids.has(id) && Number.isInteger(minutes) && minutes > 0
+ ));
+ return { capacity_minutes: capacity, estimates };
+ } catch (_error) {
+ return { capacity_minutes: null, estimates: {} };
+ }
+ }
+
+ function replacePlanning(value) {
+ const key = planningKey();
+ if (!key || !storage) return false;
+ const ids = new Set(read());
+ const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
+ ? value.capacity_minutes : null;
+ const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
+ ids.has(id) && Number.isInteger(minutes) && minutes > 0
+ ));
+ try {
+ storage.setItem(key, JSON.stringify({ capacity_minutes: capacity, estimates }));
+ return true;
+ } catch (_error) {
+ return false;
+ }
+ }
+
function add(item) {
if (!storageKey()) return 'unavailable';
const id = identity(item);
@@ -55,14 +98,18 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
unique.push(id);
}
}
- return write(unique);
+ const saved = write(unique);
+ if (saved && planningKey()) replacePlanning(planning());
+ return saved;
}
function remove(item) {
const id = identity(item);
const ids = read();
const next = ids.filter(candidate => candidate !== id);
- return next.length !== ids.length && write(next);
+ const saved = next.length !== ids.length && write(next);
+ if (saved) replacePlanning(planning());
+ return saved;
}
function move(item, direction) {
@@ -81,7 +128,10 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
const retired = ids.filter(id => !retained.includes(id));
if (retired.length) {
const reported = onPrune?.(retired);
- if (reported !== false) write(retained);
+ if (reported !== false) {
+ write(retained);
+ replacePlanning(planning());
+ }
}
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
}
@@ -99,7 +149,17 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
};
}
- return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
+ 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);
+ return {
+ current_minutes: minutes[0] || null,
+ remaining_minutes: minutes.some(value => value === null) ? null : minutes.reduce((total, value) => total + value, 0),
+ };
+ }
+
+ return { identity, read, replace, planning, replacePlanning, runway, add, remove, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
diff --git a/src/main.py b/src/main.py
index 70cbd1a..946d666 100644
--- a/src/main.py
+++ b/src/main.py
@@ -223,17 +223,24 @@ class NotificationReadBatch(BaseModel):
class TodayOperation(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
- action: Literal["add", "remove", "move"]
+ action: Literal["add", "remove", "move", "configure"]
item_id: str = Field(min_length=1, max_length=500)
direction: Literal["up", "down"] | None = None
base_revision: int | None = Field(default=None, ge=0)
+ capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
+ estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
@model_validator(mode="after")
- def require_move_direction(self):
+ def validate_action_fields(self):
if self.action == "move" and self.direction is None:
raise ValueError("move requires a direction")
if self.action != "move" and self.direction is not None:
raise ValueError("direction is only valid for move")
+ if self.action != "configure" and (self.capacity_minutes is not None or self.estimates):
+ raise ValueError("capacity and estimates are only valid for configure")
+ if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
+ for item_id, minutes in self.estimates.items()):
+ raise ValueError("estimates must use bounded item IDs and minutes")
return self
@@ -954,6 +961,12 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
login,
[operation.model_dump() for operation in payload.operations],
)
+ if payload.action == "configure":
+ return await asyncio.to_thread(
+ _today_store().apply_batch,
+ login,
+ [payload.model_dump()],
+ )
return await asyncio.to_thread(
_today_store().apply,
login,
diff --git a/src/today_store.py b/src/today_store.py
index ced7054..4f7cffb 100644
--- a/src/today_store.py
+++ b/src/today_store.py
@@ -37,10 +37,17 @@ class TodayStore:
CREATE TABLE IF NOT EXISTS today_plans (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
- ids TEXT NOT NULL
+ ids TEXT NOT NULL,
+ capacity_minutes INTEGER,
+ estimates TEXT NOT NULL DEFAULT '{}'
)
"""
)
+ plan_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_plans)")}
+ if "capacity_minutes" not in plan_columns:
+ connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER")
+ if "estimates" not in plan_columns:
+ connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_operations (
@@ -88,13 +95,20 @@ class TodayStore:
@staticmethod
def _snapshot(row) -> dict:
if row is None:
- return {"revision": 0, "ids": []}
- return {"revision": int(row[0]), "ids": json.loads(row[1])}
+ return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
+ ids = json.loads(row[1])
+ estimates = json.loads(row[3] or "{}")
+ return {
+ "revision": int(row[0]),
+ "ids": ids,
+ "capacity_minutes": row[2],
+ "estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
+ }
def get(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
- "SELECT revision, ids FROM today_plans WHERE login = ?",
+ "SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?",
(self._normalize_login(login),),
).fetchone()
return self._snapshot(row)
@@ -119,7 +133,7 @@ class TodayStore:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
- "SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
+ "SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
duplicate = connection.execute(
@@ -130,6 +144,7 @@ class TodayStore:
return snapshot
ids = list(snapshot["ids"])
+ estimates = dict(snapshot["estimates"])
changed = False
if action == "add":
if item_id not in ids:
@@ -140,6 +155,7 @@ class TodayStore:
elif action == "remove":
if item_id in ids:
ids.remove(item_id)
+ estimates.pop(item_id, None)
changed = True
else:
try:
@@ -152,18 +168,25 @@ class TodayStore:
changed = True
revision = snapshot["revision"] + (1 if changed else 0)
+ serialized_ids = json.dumps(ids, separators=(",", ":"))
+ serialized_estimates = json.dumps(estimates, separators=(",", ":"))
if row is None:
connection.execute(
- "INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
- (login, revision, json.dumps(ids, separators=(",", ":"))),
+ "INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
+ (login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates),
)
elif changed:
connection.execute(
- "UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
- (revision, json.dumps(ids, separators=(",", ":")), login),
+ "UPDATE today_plans SET revision = ?, ids = ?, estimates = ? WHERE login = ?",
+ (revision, serialized_ids, serialized_estimates, login),
)
self._record_operation(connection, login, operation_id)
- return {"revision": revision, "ids": ids}
+ return {
+ "revision": revision,
+ "ids": ids,
+ "capacity_minutes": snapshot["capacity_minutes"],
+ "estimates": estimates,
+ }
def apply_batch(self, login: str, operations: list[dict]) -> dict:
"""Apply an ordered batch with one lock and receipt per operation."""
@@ -171,10 +194,12 @@ class TodayStore:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
- "SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
+ "SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
ids = list(snapshot["ids"])
+ capacity_minutes = snapshot["capacity_minutes"]
+ estimates = dict(snapshot["estimates"])
revision = snapshot["revision"]
accepted: list[str] = []
duplicates: list[str] = []
@@ -187,7 +212,7 @@ class TodayStore:
direction = operation.get("direction")
if not operation_id or not item_id:
raise ValueError("operation_id and item_id are required")
- if action not in {"add", "remove", "move"}:
+ if action not in {"add", "remove", "move", "configure"}:
raise ValueError("unsupported Today action")
if action == "move" and direction not in {"up", "down"}:
raise ValueError("move direction must be up or down")
@@ -220,8 +245,9 @@ class TodayStore:
elif action == "remove":
if item_id in ids:
ids.remove(item_id)
+ estimates.pop(item_id, None)
changed = True
- else:
+ elif action == "move":
try:
index = ids.index(item_id)
except ValueError:
@@ -230,24 +256,48 @@ class TodayStore:
if index >= 0 and 0 <= target < len(ids):
ids[index], ids[target] = ids[target], ids[index]
changed = True
+ else:
+ proposed_capacity = operation.get("capacity_minutes")
+ if proposed_capacity is not None and (
+ not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
+ or proposed_capacity < 15 or proposed_capacity > 1440
+ ):
+ raise ValueError("capacity_minutes must be between 15 and 1440")
+ proposed_estimates = operation.get("estimates", {})
+ if not isinstance(proposed_estimates, dict):
+ raise ValueError("estimates must be an object")
+ normalized_estimates = {}
+ for estimate_id, minutes in proposed_estimates.items():
+ if estimate_id not in ids:
+ continue
+ if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
+ raise ValueError("estimate minutes must be between 5 and 1440")
+ normalized_estimates[estimate_id] = minutes
+ changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
+ capacity_minutes = proposed_capacity
+ estimates = normalized_estimates
+
revision += 1 if changed else 0
self._record_operation(connection, login, operation_id)
accepted.append(operation_id)
serialized = json.dumps(ids, separators=(",", ":"))
+ serialized_estimates = json.dumps(estimates, separators=(",", ":"))
if row is None:
connection.execute(
- "INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
- (login, revision, serialized),
+ "INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
+ (login, revision, serialized, capacity_minutes, serialized_estimates),
)
elif accepted:
connection.execute(
- "UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
- (revision, serialized, login),
+ "UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ? WHERE login = ?",
+ (revision, serialized, capacity_minutes, serialized_estimates, login),
)
return {
"revision": revision,
"ids": ids,
+ "capacity_minutes": capacity_minutes,
+ "estimates": estimates,
"accepted_operation_ids": accepted,
"duplicate_operation_ids": duplicates,
"rejected_operations": rejected,
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index b037a37..e52bb0a 100644
--- a/tests/test_comment_next.py
+++ b/tests/test_comment_next.py
@@ -281,4 +281,4 @@ async def test_current_today_update_offers_reply_and_next_without_marking_read()
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v78" in worker
+ assert "stackchain-dashboard-shell-v79" in worker
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 27c9f22..b55f06f 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/later-sync.js'" in source
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index e3c7176..5fc211e 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
- assert "stackchain-dashboard-shell-v78" in worker
+ assert "stackchain-dashboard-shell-v79" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 0d69156..ad789dd 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -35,7 +35,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
- assert "stackchain-dashboard-shell-v78" in worker
+ assert "stackchain-dashboard-shell-v79" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index 54c4fa8..0de95a3 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -79,6 +79,54 @@ process.stdout.write(JSON.stringify({{result, calls, snapshot:planner.snapshot()
}
+def test_plan_today_calculates_capacity_and_confirms_overcommitment_before_saving():
+ script = f"""
+const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
+const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
+const saved = [];
+const planner = createPlanToday({{
+ identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
+ save: plan => saved.push(plan),
+}});
+planner.open([item(1), item(2)], [item(1), item(2)]);
+planner.setCapacity(90);
+planner.setEstimate('issue:stackchain/dashboard:1:', 60);
+planner.setEstimate('issue:stackchain/dashboard:2:', 45);
+const over = planner.snapshot();
+const confirmation = planner.commit();
+const stillOpen = planner.snapshot().open;
+const result = planner.commit({{confirmOverCapacity:true}});
+process.stdout.write(JSON.stringify({{over, confirmation, stillOpen, result, saved}}));
+"""
+ result = run_node(script)
+ assert result["over"] == {
+ "open": True,
+ "ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
+ "count": 2,
+ "limit": 5,
+ "capacity_minutes": 90,
+ "estimates": {
+ "issue:stackchain/dashboard:1:": 60,
+ "issue:stackchain/dashboard:2:": 45,
+ },
+ "planned_minutes": 105,
+ "remaining_minutes": -15,
+ "unestimated_count": 0,
+ "over_capacity": True,
+ }
+ assert result["confirmation"] == "confirm-over-capacity"
+ assert result["stillOpen"] is True
+ assert result["result"] == "saved"
+ assert result["saved"] == [{
+ "ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
+ "capacity_minutes": 90,
+ "estimates": {
+ "issue:stackchain/dashboard:1:": 60,
+ "issue:stackchain/dashboard:2:": 45,
+ },
+ }]
+
+
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
@@ -192,6 +240,17 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
assert 'id="plan-today"' in html
assert 'id="plan-today-sheet" role="dialog"' in html
assert 'id="plan-today-capacity"' in html
+ assert 'id="plan-today-available"' in html
+ assert 'inputmode="numeric" min="15" max="1440"' in html
+ assert 'data-plan-estimate="' in html
+ assert 'aria-label="Estimate for ' in html
+ assert "formatPlanMinutes(state.planned_minutes)" in html
+ assert "planToday.setCapacity" in html
+ assert "planToday.setEstimate" in html
+ assert "todaySync.enqueueConfiguration" in html
+ assert "todayWork.runway(todayMyWork, state.index - 1)" in html
+ assert "resetPlanTodayConfirmation()" in html
+ assert "result === 'confirm-over-capacity'" in html
assert 'id="save-and-start-today"' in html
assert "const planToday = createPlanToday({" in html
assert "todaySync.enqueue('remove'" in html
@@ -226,12 +285,13 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
assert "kind === 'plan-today' && previous !== 'plan-today'" in html
assert "openPlanToday(planTodayTrigger, false)" in html
assert "closePlanToday(false)" in html
- assert "if (result === 'saved') taskOverlayHistory.leave();" in html
+ assert "if (result === 'saved')" in html
+ assert "taskOverlayHistory.leave();" in html
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 559c4fb..a8a576b 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -146,7 +146,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -170,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -365,7 +365,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/queue-today.js'" in source
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
index 1e4d612..ebf8d7b 100644
--- a/tests/test_today_readiness.py
+++ b/tests/test_today_readiness.py
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
- assert "const CACHE = 'stackchain-dashboard-shell-v78';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v79';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
diff --git a/tests/test_today_store.py b/tests/test_today_store.py
index fcd735d..ff92735 100644
--- a/tests/test_today_store.py
+++ b/tests/test_today_store.py
@@ -36,6 +36,8 @@ def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
assert store.apply("timmy", "op-1", "add", "issue:stackchain/dashboard:1:") == {
"revision": 1,
"ids": ["issue:stackchain/dashboard:1:"],
+ "capacity_minutes": None,
+ "estimates": {},
}
store.apply("timmy", "op-2", "add", "issue:stackchain/dashboard:2:")
store.apply("timmy", "op-3", "add", "issue:stackchain/dashboard:3:")
@@ -53,9 +55,49 @@ def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
"issue:stackchain/dashboard:3:",
"issue:stackchain/dashboard:2:",
],
+ "capacity_minutes": None,
+ "estimates": {},
}
assert TodayStore(path, limit=3).get("timmy") == moved
- assert store.get("alexander") == {"revision": 0, "ids": []}
+ assert store.get("alexander") == {
+ "revision": 0,
+ "ids": [],
+ "capacity_minutes": None,
+ "estimates": {},
+ }
+
+
+def test_capacity_and_estimates_are_durable_account_scoped_and_follow_item_identity(tmp_path):
+ path = tmp_path / "today.sqlite3"
+ store = TodayStore(path, limit=3)
+ store.apply("timmy", "add-1", "add", "issue:r:1:")
+ store.apply("timmy", "add-2", "add", "issue:r:2:")
+
+ configured = store.apply_batch("timmy", [{
+ "operation_id": "capacity-1",
+ "action": "configure",
+ "item_id": "plan",
+ "capacity_minutes": 180,
+ "estimates": {"issue:r:1:": 60, "issue:r:2:": 45, "issue:r:99:": 30},
+ "base_revision": 2,
+ }])
+ store.apply("timmy", "move-2", "move", "issue:r:2:", direction="up")
+ store.apply("timmy", "remove-1", "remove", "issue:r:1:")
+
+ assert configured["capacity_minutes"] == 180
+ assert configured["estimates"] == {"issue:r:1:": 60, "issue:r:2:": 45}
+ assert TodayStore(path, limit=3).get("timmy") == {
+ "revision": 5,
+ "ids": ["issue:r:2:"],
+ "capacity_minutes": 180,
+ "estimates": {"issue:r:2:": 45},
+ }
+ assert store.get("alexander") == {
+ "revision": 0,
+ "ids": [],
+ "capacity_minutes": None,
+ "estimates": {},
+ }
def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
@@ -69,6 +111,8 @@ def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
assert store.get("timmy") == {
"revision": 2,
"ids": ["issue:r:1:", "issue:r:2:"],
+ "capacity_minutes": None,
+ "estimates": {},
}
store.apply("timmy", "remove", "remove", "issue:r:1:")
assert store.apply("timmy", "retry-three", "add", "issue:r:3:")["ids"] == [
@@ -94,6 +138,8 @@ def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflic
assert result == {
"revision": 4,
"ids": ["issue:r:2:", "issue:r:3:"],
+ "capacity_minutes": None,
+ "estimates": {},
"accepted_operation_ids": ["one", "two", "remove", "three"],
"duplicate_operation_ids": [],
"rejected_operations": [{"operation_id": "full", "reason": "today_full"}],
@@ -197,6 +243,48 @@ def test_today_api_model_preserves_client_base_revision():
assert operation.model_dump()["base_revision"] == 7
+def test_today_api_model_accepts_bounded_capacity_configuration():
+ operation = main.TodayOperation(
+ operation_id="capacity", action="configure", item_id="plan",
+ capacity_minutes=180,
+ estimates={"issue:r:1:": 60, "issue:r:2:": 45},
+ base_revision=2,
+ )
+
+ assert operation.model_dump() == {
+ "operation_id": "capacity",
+ "action": "configure",
+ "item_id": "plan",
+ "direction": None,
+ "base_revision": 2,
+ "capacity_minutes": 180,
+ "estimates": {"issue:r:1:": 60, "issue:r:2:": 45},
+ }
+
+
+@pytest.mark.anyio
+async def test_single_capacity_configuration_uses_atomic_batch_path(monkeypatch):
+ async def user():
+ return {"login": "timmy"}
+
+ calls = []
+ class Store:
+ def apply_batch(self, login, operations):
+ calls.append((login, operations))
+ return {"revision": 1, "ids": [], "capacity_minutes": 180, "estimates": {}}
+
+ monkeypatch.setattr(main, "current_user", user)
+ monkeypatch.setattr(main, "_today_store", lambda: Store())
+ payload = main.TodayOperation(
+ operation_id="capacity", action="configure", item_id="plan", capacity_minutes=180,
+ )
+
+ result = await main.update_today_plan(payload)
+
+ assert result["capacity_minutes"] == 180
+ assert calls == [("timmy", [payload.model_dump()])]
+
+
@pytest.mark.anyio
async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
@@ -246,9 +334,16 @@ async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypat
assert changed.json() == {
"revision": 2,
"ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
+ "capacity_minutes": None,
+ "estimates": {},
"accepted_operation_ids": ["mobile-1", "mobile-2"],
"duplicate_operation_ids": [],
"rejected_operations": [],
}
- assert fetched.json() == {"revision": 2, "ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"]}
+ assert fetched.json() == {
+ "revision": 2,
+ "ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
+ "capacity_minutes": None,
+ "estimates": {},
+ }
assert fetched.headers["cache-control"] == "no-store"
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index 5711152..2147c64 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -83,10 +83,51 @@ sync.enqueue('add', 'issue:r:1:');
}
+def test_today_capacity_configuration_syncs_offline_and_adopts_remote_plan():
+ script = f"""
+const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
+const values = new Map(); let delivered; let adopted; let broadcast;
+const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
+ getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
+const sync = createTodaySync({{
+ storage, getLogin:()=> 'timmy', createOperationId:()=> 'capacity-op',
+ createChannel:()=>({{addEventListener() {{}},postMessage:plan=>{{broadcast=plan}},close() {{}}}}),
+ fetchJson:async(_url, options={{}})=>{{
+ if (!options.method) return {{revision:2,ids:['issue:r:1:'],capacity_minutes:null,estimates:{{}}}};
+ delivered=JSON.parse(options.body).operations[0];
+ return {{revision:3,ids:['issue:r:1:'],capacity_minutes:120,estimates:{{'issue:r:1:':60}},accepted_operation_ids:['capacity-op'],duplicate_operation_ids:[],rejected_operations:[]}};
+ }},
+ onRemotePlan:plan=>{{adopted=plan}}, onRemoteIds:()=>{{}}, onStatus:()=>{{}},
+}});
+sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
+(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,adopted,broadcast,pending:sync.pending()}}));}})();
+"""
+ assert json.loads(subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout) == {
+ "delivered": {
+ "operation_id": "capacity-op", "action": "configure", "item_id": "plan",
+ "direction": None, "capacity_minutes": 120,
+ "estimates": {"issue:r:1:": 60}, "base_revision": 0,
+ },
+ "adopted": {
+ "revision": 3, "ids": ["issue:r:1:"], "capacity_minutes": 120,
+ "estimates": {"issue:r:1:": 60},
+ "accepted_operation_ids": ["capacity-op"],
+ "duplicate_operation_ids": [], "rejected_operations": [],
+ },
+ "broadcast": {
+ "revision": 3, "ids": ["issue:r:1:"], "capacity_minutes": 120,
+ "estimates": {"issue:r:1:": 60},
+ },
+ "pending": [],
+ }
+
+
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v78" in source
+ assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/today-sync.js'" in source
diff --git a/tests/test_today_work.py b/tests/test_today_work.py
index 2517d62..eab00f0 100644
--- a/tests/test_today_work.py
+++ b/tests/test_today_work.py
@@ -64,6 +64,45 @@ process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted,
}
+def test_today_capacity_metadata_is_account_scoped_and_prunes_removed_estimates():
+ script = f"""
+const createTodayWork = require({json.dumps(str(TODAY_WORK))});
+const values = new Map();
+const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
+let login = 'timmy';
+const queue = createTodayWork({{storage, getLogin: () => login}});
+queue.replace(['issue:r:1:', 'issue:r:2:']);
+const saved = queue.replacePlanning({{capacity_minutes:120,estimates:{{'issue:r:1:':60,'issue:r:2:':45}}}});
+queue.replace(['issue:r:2:']);
+const timmy = queue.planning();
+login = 'alexander';
+const alexander = queue.planning();
+process.stdout.write(JSON.stringify({{saved,timmy,alexander}}));
+"""
+ assert json.loads(run_node(script)) == {
+ "saved": True,
+ "timmy": {"capacity_minutes": 120, "estimates": {"issue:r:2:": 45}},
+ "alexander": {"capacity_minutes": None, "estimates": {}},
+ }
+
+
+def test_today_estimated_runway_follows_current_session_position():
+ script = f"""
+const createTodayWork = require({json.dumps(str(TODAY_WORK))});
+const values = new Map();
+const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
+const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}});
+const item = number => ({{kind:'issue',repository:'r',number}});
+queue.replace(['issue:r:1:','issue:r:2:','issue:r:3:']);
+queue.replacePlanning({{capacity_minutes:180,estimates:{{'issue:r:1:':30,'issue:r:2:':60,'issue:r:3:':45}}}});
+process.stdout.write(JSON.stringify({{first:queue.runway([item(1),item(2),item(3)],0),second:queue.runway([item(1),item(2),item(3)],1)}}));
+"""
+ assert json.loads(run_node(script)) == {
+ "first": {"current_minutes": 30, "remaining_minutes": 135},
+ "second": {"current_minutes": 60, "remaining_minutes": 105},
+ }
+
+
def test_today_queue_exposes_reorder_boundaries_for_touch_controls():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});