Merge pull request 'Plan tomorrow without disrupting active Today work' (#1153) from timmy/1152-tomorrow-plan into main
All checks were successful
CI / lint (push) Successful in 3m14s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m6s
CI / release-candidate (push) Successful in 8s

This commit is contained in:
rockachopa 2026-08-19 22:56:33 +00:00
commit 559fd2dc89
15 changed files with 632 additions and 25 deletions

View File

@ -768,6 +768,7 @@ textarea { resize: vertical; min-height: 120px; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.plan-tomorrow { min-height:44px; }
.start-work-session { min-height:44px; }
.resume-today-session, .end-today-session { min-height:44px; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
@ -1095,6 +1096,7 @@ textarea { resize: vertical; min-height: 120px; }
.attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); }
.my-work-header { align-items:flex-start; }
.my-work-actions { display:none; }
.my-work-actions { display:flex; }
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
.work-settings:not([open]) > .work-settings-panel { display:none; }
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }

View File

@ -376,10 +376,37 @@
let rolloverReviewPlan = null;
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
const todayRollover = createTodayRollover();
let planningTomorrow = false;
const tomorrowPlan = createTomorrowPlan({
fetchJson:fetchReviewJson,
localDate:todayRollover.localDate,
timeZone:todayRollover.timeZone,
});
const todayWork = createTodayWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
});
async function promoteTomorrowIfDue(plan) {
try {
await tomorrowPlan.load();
const promoted = await tomorrowPlan.promote(plan.revision);
if (!promoted) return false;
todayWork.replace(promoted.ids);
todayWork.replacePlanning({
capacity_minutes:promoted.capacity_minutes ?? null,
estimates:promoted.estimates || {},
});
refreshMyWorkView();
warmTodayOffline();
qs('#my-work-action-status').textContent =
'Your saved Tomorrow plan is now Today.';
return true;
} catch (error) {
qs('#my-work-action-status').textContent =
`${error.message || 'Tomorrow needs review before promotion.'} Open Plan Tomorrow to review.`;
return false;
}
}
const todaySync = createTodaySync({
storage: localStorage,
getLogin: () => planningOwnerLogin,
@ -392,6 +419,7 @@
},
onRemotePlan: plan => {
if (!planningOwnerLogin) return;
promoteTomorrowIfDue(plan);
todayWork.replacePlanning({
capacity_minutes: plan.capacity_minutes ?? null,
estimates: plan.estimates || {},
@ -2605,6 +2633,7 @@
return;
}
planToday.cancel();
planningTomorrow = false;
qs('#plan-today-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
planTodayTrigger?.focus();
@ -2638,10 +2667,24 @@
return true;
}
function saveTomorrowPlan(plan) {
const normalized = Array.isArray(plan) ?
{ids:plan, capacity_minutes:null, estimates:{}} : plan;
tomorrowPlan.save(normalized).then(saved => {
qs('#my-work-action-status').textContent = saved.ids.length ?
`Tomorrow saved for ${saved.plan_date} without changing Today.` :
`Tomorrow cleared for ${saved.plan_date}.`;
}).catch(error => {
qs('#my-work-action-status').textContent =
`${error.message || 'Tomorrow could not be saved.'} Reopen Plan Tomorrow to retry.`;
});
return true;
}
const planToday = createPlanToday({
identity: item => todayWork.identity(item),
limit: todayWork.limit,
save: saveTodayPlan,
save: plan => planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan),
start: () => {
qs('[data-work-filter="today"]').click();
startTodaySession();
@ -2919,7 +2962,8 @@
return;
}
if (trigger) planTodayTrigger = trigger;
qs('#plan-today-title').textContent = pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today');
qs('#plan-today-title').textContent = planningTomorrow ? 'Plan Tomorrow' :
(pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today'));
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
if (navigate) {
taskOverlayHistory.open('plan-today');
@ -2928,8 +2972,18 @@
const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes;
pendingPlanActualMinutes = null;
const protectProposal = pendingProtectToday;
const selected = protectProposal?.selected || todayMyWork;
planToday.open(selected, activeMyWork, todayWork.planning(), recommendations);
const tomorrow = planningTomorrow ? tomorrowPlan.state() : null;
const availablePlanningItems = [...todayMyWork, ...activeMyWork].filter((item, index, items) =>
items.findIndex(candidate => todayWork.identity(candidate) === todayWork.identity(item)) === index
);
const selected = planningTomorrow ? availablePlanningItems.filter(item =>
tomorrow.ids.includes(todayWork.identity(item))
) : (protectProposal?.selected || todayMyWork);
planToday.open(selected, activeMyWork, planningTomorrow ? tomorrow : todayWork.planning(), recommendations);
qs('#today-plan-heading').textContent = planningTomorrow ? 'Tomorrow, in order' : 'Today, in order';
qs('.plan-today-available').firstChild.textContent = planningTomorrow ? 'Available tomorrow' : 'Available today';
qs('#build-today-plan').textContent = planningTomorrow ? 'Build my Tomorrow' : 'Build my Today';
qs('#save-and-start-today').hidden = planningTomorrow;
pendingProtectToday = null;
qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan();
qs('#plan-today-error').textContent = '';
@ -7633,6 +7687,25 @@
qs('#refresh').addEventListener('click', load);
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
qs('#plan-tomorrow').addEventListener('click', async event => {
const button = event.currentTarget;
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
button.disabled = true;
qs('#my-work-action-status').textContent = 'Loading Tomorrow…';
try {
await tomorrowPlan.load();
planningTomorrow = true;
openPlanToday(button);
qs('#my-work-action-status').textContent = '';
} catch (error) {
qs('#my-work-action-status').textContent = `${error.message || 'Tomorrow is unavailable.'} Retry when connected.`;
} finally {
button.disabled = false;
}
});
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
qs('#plan-today-sheet').addEventListener('click', event => {
if (event.target === qs('#plan-today-sheet')) closePlanToday();

View File

@ -192,6 +192,7 @@
<button class="work-filter" data-work-filter="later" aria-pressed="false">Later <span data-work-count="later">0</span></button>
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
</div>
<button class="plan-tomorrow" id="plan-tomorrow" type="button">Plan Tomorrow</button>
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
<select class="work-milestone-filter" id="work-milestone-filter">
<option value="all">All milestones</option>
@ -1918,6 +1919,7 @@
<script src="static/plan-today.js"></script>
<script src="static/plan-today-readiness.js"></script>
<script src="static/plan-today-preview.js"></script>
<script src="static/tomorrow-plan.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/today-rollover.js"></script>
<script src="static/update-ownership.js"></script>

View File

@ -68,6 +68,7 @@ const SHELL = [
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-readiness.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/tomorrow-plan.js',
BASE + 'static/today-sync.js',
BASE + 'static/today-rollover.js',
BASE + 'static/update-ownership.js',

29
frontend/tomorrow-plan.js Normal file
View File

@ -0,0 +1,29 @@
function createTomorrowPlan({fetchJson,localDate,timeZone,createId}={}) {
let plan={revision:0,ids:[],capacity_minutes:null,estimates:{}};
const state=()=>({...plan,ids:[...plan.ids],estimates:{...plan.estimates}});
function adopt(value) {
if (!Number.isInteger(value?.revision)||!Array.isArray(value?.ids)) return false;
plan={revision:value.revision,ids:[...value.ids],capacity_minutes:value.capacity_minutes??null,
estimates:{...(value.estimates||{})},...(value.plan_date?{plan_date:value.plan_date,timezone:value.timezone||null}:{})};
return state();
}
function nextLocalDate() {
const [y,m,d]=localDate().split('-').map(Number);
return new Date(Date.UTC(y,m-1,d+1)).toISOString().slice(0,10);
}
async function load(){return adopt(await fetchJson('api/v1/tomorrow'));}
async function save(value) {
const body={base_revision:plan.revision,ids:[...(value.ids||[])],
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
plan_date:nextLocalDate(),timezone:timeZone()};
return adopt(await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
}
async function promote(today_revision) {
if (!plan.plan_date||plan.plan_date!==localDate()||!plan.ids.length) return false;
const promotion_id=createId?.()||globalThis.crypto?.randomUUID?.()||`${plan.plan_date}-${plan.revision}`;
return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({promotion_id,tomorrow_revision:plan.revision,today_revision})});
}
return {adopt,load,save,promote,state,nextLocalDate};
}
if(typeof module!=='undefined'&&module.exports)module.exports=createTomorrowPlan;

View File

@ -11,16 +11,18 @@ async function loadWorkspace({
window?.addEventListener('online', captureOnline);
const status = document.querySelector('#my-work-action-status');
const retryButton = document.querySelector('#retry-workspace');
const url = document.querySelector(
'meta[name="stackchain-feature-today-timer"]'
)?.content || '';
const urls = {'today-timer':url};
const urls = Object.fromEntries(['today-timer', 'planning'].map(name => [name,
document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || ''
]));
const originalUrls = {...urls};
const loader = createLoader({document, urls});
let attempts = 0;
const load = () => {
if (attempts++) urls['today-timer'] = url + '?retry=' + attempts;
return loader.load('today-timer');
if (attempts++) Object.keys(urls).forEach(name => {
urls[name] = originalUrls[name] + '?retry=' + attempts;
});
return Promise.all(Object.keys(urls).map(name => loader.load(name)));
};
const waitForRecovery = () => new Promise(resolve => {
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';

View File

@ -33,10 +33,15 @@ FEATURE_SOURCES = {
"static/device-storage.js", "static/mobile-device-setup.js",
),
"security-center": ("static/security-center.js",),
"planning": (
"static/plan-today.js", "static/plan-today-readiness.js",
"static/plan-today-preview.js", "static/today-rollover.js",
"static/tomorrow-plan.js", "static/mobile-plan-today-nav.js",
),
"today-timer": (
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",

View File

@ -72,7 +72,10 @@ from src.unfiled_draft_store import (
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
from src.suggestion_engine import compute
from src.later_store import LaterStore
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore
from src.today_store import (
TodayPlanFull, TodayPromotionConflict, TodaySessionConflict, TodayStore,
TomorrowPlanConflict,
)
from src.state_encryption import PrivateStateEncryptionError
from src.views import FRONTEND_BUILD, router as frontend_router
@ -658,6 +661,32 @@ class TodayOperationBatch(BaseModel):
operations: list[TodayOperation] = Field(min_length=1, max_length=50)
class TomorrowPlanUpdate(BaseModel):
base_revision: int = Field(ge=0)
ids: list[str] = Field(max_length=5)
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
plan_date: str = Field(min_length=10, max_length=10)
timezone: str = Field(min_length=1, max_length=100)
@model_validator(mode="after")
def validate_plan(self):
if len(set(self.ids)) != len(self.ids):
raise ValueError("Tomorrow IDs must be unique")
if any(not item_id or len(item_id) > 500 for item_id in self.ids):
raise ValueError("Tomorrow IDs must be bounded")
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
class TomorrowPromotion(BaseModel):
promotion_id: str = Field(min_length=1, max_length=100)
tomorrow_revision: int = Field(ge=0)
today_revision: int = Field(ge=0)
class LaterOperationBatch(BaseModel):
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
@ -1362,7 +1391,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
path = dashboard_auth.application_path(request)
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@ -2590,6 +2619,65 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
)
@app.get("/api/v1/tomorrow")
async def get_tomorrow_plan():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_today_store().get_tomorrow, login)
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Tomorrow synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.put("/api/v1/tomorrow")
async def replace_tomorrow_plan(payload: TomorrowPlanUpdate):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().replace_tomorrow, login, **payload.model_dump()
)
except TomorrowPlanConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "tomorrow_changed", "snapshot": error.snapshot},
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Tomorrow synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.post("/api/v1/tomorrow/promote")
async def promote_tomorrow_plan(payload: TomorrowPromotion):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().promote_tomorrow, login, **payload.model_dump()
)
except TomorrowPlanConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "tomorrow_changed", "snapshot": error.snapshot},
)
except TodayPromotionConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "today_changed", "today": error.today},
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Tomorrow promotion is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/today/session")
async def get_today_session():
login = await _confirmed_login()

View File

@ -22,6 +22,22 @@ class TodaySessionConflict(ValueError):
self.session = session
class TomorrowPlanConflict(ValueError):
"""Raised when Tomorrow was edited from an obsolete revision."""
def __init__(self, snapshot: dict):
super().__init__("Tomorrow plan changed on another device")
self.snapshot = snapshot
class TodayPromotionConflict(ValueError):
"""Raised when rollover would overwrite a changed Today plan."""
def __init__(self, today: dict):
super().__init__("Today changed before Tomorrow could be promoted")
self.today = today
class TodayStore:
def __init__(
self,
@ -50,7 +66,7 @@ class TodayStore:
def _initialize(self) -> None:
connection = connect_private_sqlite(self.path, timeout=self.timeout)
if connection.execute("PRAGMA user_version").fetchone()[0] >= 3:
if connection.execute("PRAGMA user_version").fetchone()[0] >= 4:
connection.close()
return
connection.execute("PRAGMA journal_mode=WAL")
@ -137,7 +153,16 @@ class TodayStore:
session_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_sessions)")}
if "break_deadline_at" not in session_columns:
connection.execute("ALTER TABLE today_sessions ADD COLUMN break_deadline_at INTEGER")
connection.execute("PRAGMA user_version = 3")
connection.execute(
"CREATE TABLE IF NOT EXISTS tomorrow_plans ("
"login TEXT PRIMARY KEY, revision INTEGER NOT NULL, payload TEXT NOT NULL)"
)
connection.execute(
"CREATE TABLE IF NOT EXISTS tomorrow_promotions ("
"login TEXT NOT NULL, promotion_id TEXT NOT NULL, result TEXT NOT NULL, "
"created_at REAL NOT NULL, PRIMARY KEY (login, promotion_id))"
)
connection.execute("PRAGMA user_version = 4")
connection.commit()
connection.close()
@ -231,6 +256,151 @@ class TodayStore:
)
return snapshot
@staticmethod
def _empty_tomorrow(revision: int = 0) -> dict:
return {"revision": revision, "ids": [], "capacity_minutes": None, "estimates": {}}
def _tomorrow_snapshot(self, row, login: str) -> dict:
if row is None:
return self._empty_tomorrow()
payload, _legacy = self._cipher.open(row[1], binding=f"tomorrow:{login}")
if not isinstance(payload, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
snapshot = {"revision": int(row[0]), **payload}
if not isinstance(snapshot.get("ids"), list) or not isinstance(snapshot.get("estimates"), dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
return snapshot
def get_tomorrow(self, login: str) -> dict:
login = self._normalize_login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT revision, payload FROM tomorrow_plans WHERE login = ?", (login,)
).fetchone()
return self._tomorrow_snapshot(row, login)
def _normalize_tomorrow(
self, *, ids: list[str], capacity_minutes: int | None,
estimates: dict[str, int], plan_date: str, timezone: str,
) -> dict:
try:
if date.fromisoformat(plan_date or "").isoformat() != plan_date:
raise ValueError
except (TypeError, ValueError):
raise ValueError("plan_date must be an ISO calendar date") from None
if not isinstance(timezone, str) or not timezone.strip() or len(timezone) > 100:
raise ValueError("timezone is required and bounded")
if not isinstance(ids, list) or len(ids) > self.limit or any(
not isinstance(item_id, str) or not item_id or len(item_id) > 500 for item_id in ids
):
raise ValueError("Tomorrow IDs are invalid or exceed the plan limit")
if len(set(ids)) != len(ids):
raise ValueError("Tomorrow IDs must be unique")
if capacity_minutes is not None and (
not isinstance(capacity_minutes, int) or isinstance(capacity_minutes, bool)
or capacity_minutes < 15 or capacity_minutes > 1440
):
raise ValueError("capacity_minutes must be between 15 and 1440")
if not isinstance(estimates, dict):
raise ValueError("estimates must be an object")
normalized_estimates = {}
for item_id, minutes in estimates.items():
if item_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[item_id] = minutes
return {
"ids": list(ids), "capacity_minutes": capacity_minutes,
"estimates": normalized_estimates, "plan_date": plan_date,
"timezone": timezone.strip(),
}
def replace_tomorrow(self, login: str, *, base_revision: int, **plan) -> dict:
login = self._normalize_login(login)
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
raise ValueError("base_revision must be a non-negative integer")
normalized = self._normalize_tomorrow(**plan)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, payload FROM tomorrow_plans WHERE login = ?", (login,)
).fetchone()
current = self._tomorrow_snapshot(row, login)
if current["revision"] != base_revision:
raise TomorrowPlanConflict(current)
snapshot = {"revision": base_revision + 1, **normalized}
sealed = self._cipher.seal(normalized, binding=f"tomorrow:{login}")
connection.execute(
"INSERT INTO tomorrow_plans(login, revision, payload) VALUES (?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, payload=excluded.payload",
(login, snapshot["revision"], sealed),
)
return snapshot
def promote_tomorrow(
self, login: str, *, promotion_id: str, tomorrow_revision: int, today_revision: int,
) -> dict:
login = self._normalize_login(login)
if not isinstance(promotion_id, str) or not promotion_id.strip() or len(promotion_id) > 100:
raise ValueError("promotion_id is required and bounded")
promotion_id = promotion_id.strip()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
receipt = connection.execute(
"SELECT result FROM tomorrow_promotions WHERE login = ? AND promotion_id = ?",
(login, promotion_id),
).fetchone()
if receipt:
result, _legacy = self._cipher.open(
receipt[0], binding=f"tomorrow-promotion:{login}:{promotion_id}"
)
if not isinstance(result, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
return result
tomorrow_row = connection.execute(
"SELECT revision, payload FROM tomorrow_plans WHERE login = ?", (login,)
).fetchone()
tomorrow = self._tomorrow_snapshot(tomorrow_row, login)
if tomorrow["revision"] != tomorrow_revision or not tomorrow.get("plan_date"):
raise TomorrowPlanConflict(tomorrow)
today_row = connection.execute(
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
"FROM today_plans WHERE login = ?", (login,)
).fetchone()
today, _legacy = self._snapshot(today_row, login)
if today["revision"] != today_revision:
raise TodayPromotionConflict(today)
result = {"revision": today_revision + 1, **{
key: tomorrow[key] for key in
("ids", "capacity_minutes", "estimates", "plan_date", "timezone")
}}
sealed_today = self._sealed_plan(login, result)
connection.execute(
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
"VALUES (?, ?, ?, NULL, '{}', NULL, NULL) ON CONFLICT(login) DO UPDATE SET "
"revision=excluded.revision, ids=excluded.ids, capacity_minutes=NULL, estimates='{}', "
"plan_date=NULL, timezone=NULL",
(login, result["revision"], sealed_today),
)
empty = self._empty_tomorrow(tomorrow_revision + 1)
sealed_empty = self._cipher.seal(
{key: empty[key] for key in ("ids", "capacity_minutes", "estimates")},
binding=f"tomorrow:{login}",
)
connection.execute(
"UPDATE tomorrow_plans SET revision = ?, payload = ? WHERE login = ?",
(empty["revision"], sealed_empty, login),
)
sealed_result = self._cipher.seal(
result, binding=f"tomorrow-promotion:{login}:{promotion_id}"
)
connection.execute(
"INSERT INTO tomorrow_promotions(login, promotion_id, result, created_at) VALUES (?, ?, ?, ?)",
(login, promotion_id, sealed_result, self.clock()),
)
return result
@staticmethod
def _empty_session() -> dict:
return {

View File

@ -219,6 +219,18 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#work-settings-toggle").click()
tomorrow = page.locator("#plan-tomorrow")
expect(tomorrow).to_be_visible()
tomorrow_bounds = tomorrow.bounding_box()
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
tomorrow.click()
expect(page.locator("#plan-today-sheet")).to_be_visible()
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
expect(page.locator("#save-and-start-today")).to_be_hidden()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#cancel-plan-today").click()
page.locator('[data-mobile-task="queues"]').click()
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
expect(delivery_queue).to_be_visible()

View File

@ -50,7 +50,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert set(first.feature_bundles) == {
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
"today-timer", "security-center",
"today-timer", "security-center", "planning",
}
assert first.dashboard_html.count("<script src=") == 1
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
@ -71,6 +71,9 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createQueueToday" not in first.runtime_bytes
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createPlanToday" in first.feature_bundles["planning"].runtime_bytes
assert b"function createTomorrowPlan" in first.feature_bundles["planning"].runtime_bytes
assert b"function createPlanToday" not in first.runtime_bytes
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes

View File

@ -1212,6 +1212,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/plan-today.js",
"/dashboard/static/plan-today-readiness.js",
"/dashboard/static/plan-today-preview.js",
"/dashboard/static/tomorrow-plan.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/today-rollover.js",
"/dashboard/static/update-ownership.js",

112
tests/test_tomorrow_plan.py Normal file
View File

@ -0,0 +1,112 @@
import sqlite3
import pytest
from src import main
from src.today_store import TodayStore, TomorrowPlanConflict, TodayPromotionConflict
def test_tomorrow_plan_is_encrypted_scoped_and_promotes_atomically_exactly_once(tmp_path):
path = tmp_path / "today.sqlite3"
key = b"n" * 32
store = TodayStore(path, encryption_key=key)
store.apply("timmy", "today-1", "add", "issue:r:1:")
tomorrow = store.replace_tomorrow(
"Timmy", base_revision=0,
ids=["issue:secret/repo:3:", "issue:secret/repo:2:"],
capacity_minutes=150,
estimates={"issue:secret/repo:3:": 60, "issue:secret/repo:2:": 45},
plan_date="2026-08-20", timezone="America/Los_Angeles",
)
assert tomorrow == {
"revision": 1,
"ids": ["issue:secret/repo:3:", "issue:secret/repo:2:"],
"capacity_minutes": 150,
"estimates": {"issue:secret/repo:3:": 60, "issue:secret/repo:2:": 45},
"plan_date": "2026-08-20",
"timezone": "America/Los_Angeles",
}
assert store.get("timmy")["ids"] == ["issue:r:1:"]
assert store.get_tomorrow("alexander")["ids"] == []
retained = path.read_bytes()
assert b"issue:secret/repo" not in retained
assert b"America/Los_Angeles" not in retained
promoted = store.promote_tomorrow(
"timmy", promotion_id="rollover-2026-08-20",
tomorrow_revision=1, today_revision=1,
)
replay = store.promote_tomorrow(
"timmy", promotion_id="rollover-2026-08-20",
tomorrow_revision=1, today_revision=1,
)
assert replay == promoted
assert promoted["ids"] == tomorrow["ids"]
assert promoted["capacity_minutes"] == 150
assert promoted["plan_date"] == "2026-08-20"
assert store.get_tomorrow("timmy") == {
"revision": 2, "ids": [], "capacity_minutes": None, "estimates": {}
}
def test_tomorrow_edit_and_promotion_reject_stale_revisions_without_touching_today(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"s" * 32)
original_today = store.apply("timmy", "today", "add", "issue:r:1:")
first = store.replace_tomorrow(
"timmy", base_revision=0, ids=["issue:r:2:"], capacity_minutes=60,
estimates={"issue:r:2:": 30}, plan_date="2026-08-20", timezone="UTC",
)
with pytest.raises(TomorrowPlanConflict) as stale_edit:
store.replace_tomorrow(
"timmy", base_revision=0, ids=["issue:r:9:"], capacity_minutes=None,
estimates={}, plan_date="2026-08-20", timezone="UTC",
)
assert stale_edit.value.snapshot == first
store.apply("timmy", "today-changed", "add", "issue:r:3:")
with pytest.raises(TodayPromotionConflict) as stale_today:
store.promote_tomorrow(
"timmy", promotion_id="rollover", tomorrow_revision=1,
today_revision=original_today["revision"],
)
assert stale_today.value.today["ids"] == ["issue:r:1:", "issue:r:3:"]
assert store.get_tomorrow("timmy") == first
def test_tomorrow_validation_is_bounded_and_date_specific(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3")
with pytest.raises(ValueError, match="ISO calendar date"):
store.replace_tomorrow(
"timmy", base_revision=0, ids=[], capacity_minutes=None, estimates={},
plan_date="tomorrow", timezone="UTC",
)
with pytest.raises(ValueError, match="unique"):
store.replace_tomorrow(
"timmy", base_revision=0, ids=["same", "same"], capacity_minutes=None,
estimates={}, plan_date="2026-08-20", timezone="UTC",
)
@pytest.mark.anyio
async def test_tomorrow_api_round_trip_and_conflict_contract(monkeypatch, tmp_path):
async def user():
return {"login": "timmy"}
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"a" * 32)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_today_store", lambda: store)
payload = main.TomorrowPlanUpdate(
base_revision=0, ids=["issue:r:2:"], capacity_minutes=90,
estimates={"issue:r:2:": 45}, plan_date="2026-08-20", timezone="UTC",
)
saved = await main.replace_tomorrow_plan(payload)
assert await main.get_tomorrow_plan() == saved
with pytest.raises(main.HTTPException) as raised:
await main.replace_tomorrow_plan(payload)
assert raised.value.status_code == 409
assert raised.value.detail == {"code": "tomorrow_changed", "snapshot": saved}

View File

@ -0,0 +1,100 @@
import json
import subprocess
from pathlib import Path
FRONTEND = Path(__file__).parents[1] / "frontend"
CONTROLLER = FRONTEND / "tomorrow-plan.js"
INDEX = FRONTEND / "index.html"
CSS = FRONTEND / "dashboard.css"
def run_controller(scenario: str) -> dict:
harness = f"""
const createTomorrowPlan = require({json.dumps(str(CONTROLLER))});
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], check=True, capture_output=True, text=True
)
return json.loads(completed.stdout)
def test_tomorrow_planner_loads_and_saves_independently_from_today():
result = run_controller("""
const requests=[];
const fetchJson=async (url, options={})=>{
requests.push({url,method:options.method||'GET',body:options.body ? JSON.parse(options.body) : null});
if ((options.method||'GET') === 'GET') return {
revision:2, ids:['issue:r:2:'], capacity_minutes:90,
estimates:{'issue:r:2:':45}, plan_date:'2026-08-20', timezone:'UTC'
};
return {revision:3,...JSON.parse(options.body)};
};
const planner=createTomorrowPlan({fetchJson, localDate:()=> '2026-08-19', timeZone:()=> 'UTC'});
const loaded=await planner.load();
const saved=await planner.save({ids:['issue:r:3:'],capacity_minutes:120,estimates:{'issue:r:3:':60}});
console.log(JSON.stringify({loaded,saved,requests}));
""")
assert result["loaded"]["ids"] == ["issue:r:2:"]
assert result["saved"]["plan_date"] == "2026-08-20"
assert result["requests"] == [
{"url": "api/v1/tomorrow", "method": "GET", "body": None},
{
"url": "api/v1/tomorrow",
"method": "PUT",
"body": {
"base_revision": 2,
"ids": ["issue:r:3:"],
"capacity_minutes": 120,
"estimates": {"issue:r:3:": 60},
"plan_date": "2026-08-20",
"timezone": "UTC",
},
},
]
assert all(request["url"] != "api/v1/today" for request in result["requests"])
def test_tomorrow_planner_promotes_only_on_matching_local_date():
result = run_controller("""
const requests=[];
const fetchJson=async (url, options={})=>{
requests.push({url,body:options.body ? JSON.parse(options.body) : null});
return {ids:['issue:r:2:'],revision:8};
};
const before=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-19',timeZone:()=> 'UTC',createId:()=> 'rollover-id'});
before.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
const early=await before.promote(7);
const due=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC',createId:()=> 'rollover-id'});
due.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
const promoted=await due.promote(7);
console.log(JSON.stringify({early,promoted,requests}));
""")
assert result["early"] is False
assert result["promoted"]["revision"] == 8
assert result["requests"] == [{
"url": "api/v1/tomorrow/promote",
"body": {
"promotion_id": "rollover-id",
"tomorrow_revision": 3,
"today_revision": 7,
},
}]
def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_planner():
index = INDEX.read_text()
css = CSS.read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
assert '<button class="plan-tomorrow" id="plan-tomorrow" type="button">Plan Tomorrow</button>' in index
assert ".plan-tomorrow { min-height:44px;" in css
assert '<script src="static/tomorrow-plan.js"></script>' in index
assert "const tomorrowPlan = createTomorrowPlan" in dashboard
assert "qs('#plan-tomorrow').addEventListener('click'" in dashboard
assert "planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)" in dashboard
assert "tomorrowPlan.load()" in dashboard
assert "tomorrowPlan.promote(plan.revision)" in dashboard

View File

@ -25,16 +25,23 @@ const status={textContent:''};
const document={
querySelector(selector) {
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
if (selector === 'meta[name="stackchain-feature-planning"]') return {content:'feature-planning-def.js'};
if (selector === '#my-work-action-status') return status;
return null;
}
};
let requested='';
const createLoader=options=>({load:async name=>{requested=name + ':' + options.urls[name];}});
const requested=[];
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
await loadWorkspace({document,createLoader});
console.log(JSON.stringify({requested,status:status.textContent}));
""")
assert result == {"requested": "today-timer:feature-workspace-abc.js", "status": ""}
assert result == {
"requested": [
"today-timer:feature-workspace-abc.js",
"planning:feature-planning-def.js",
],
"status": "",
}
def test_workspace_bootstrap_recovers_one_transient_failure_in_place():
@ -51,7 +58,7 @@ const schedule=callback=>{callback();};
await loadWorkspace({document,createLoader,schedule});
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
""")
assert result == {"attempts": 2, "status": "", "retryHidden": True}
assert result == {"attempts": 4, "status": "", "retryHidden": True}
def test_workspace_bootstrap_offers_single_flight_manual_retry_without_reloading():
@ -65,7 +72,7 @@ const document={querySelector(selector) {
return null;
}};
const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}};
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}});
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
await new Promise(resolve=>setImmediate(resolve));
const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent};
@ -74,7 +81,7 @@ await Promise.all([first,second,loading]);
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
""")
assert result == {
"attempts": 3,
"attempts": 6,
"reloads": 0,
"offered": {
"hidden": False,
@ -109,7 +116,7 @@ const window={
addEventListener(name,callback) { listeners[name]=callback; },
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
};
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}});
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
await new Promise(resolve=>setImmediate(resolve));
const waiting=Boolean(listeners.online);
@ -118,7 +125,7 @@ await loading;
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
""")
assert result == {
"attempts": 3,
"attempts": 6,
"waiting": True,
"reloads": 0,
"status": "",