From 161977cb672fb03ee3ac7820fbb7294e7b97e36f Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 10 Aug 2026 06:53:25 +0000 Subject: [PATCH] feat: plan Today by available time (#459) --- README.md | 5 +- frontend/dashboard.css | 4 + frontend/dashboard.js | 86 ++++++++++++++++---- frontend/index.html | 3 + frontend/plan-today.js | 57 +++++++++++-- frontend/service-worker.js | 2 +- frontend/today-sync.js | 38 +++++++-- frontend/today-work.js | 72 +++++++++++++++-- src/main.py | 17 +++- src/today_store.py | 84 +++++++++++++++---- tests/test_comment_next.py | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_plan_today.py | 64 ++++++++++++++- tests/test_service_worker.py | 20 ++--- tests/test_today_readiness.py | 2 +- tests/test_today_store.py | 99 ++++++++++++++++++++++- tests/test_today_sync.py | 43 +++++++++- tests/test_today_work.py | 39 +++++++++ 20 files changed, 570 insertions(+), 73 deletions(-) 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))}); -- 2.43.0