Plan tomorrow without disrupting active Today work #1153
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
29
frontend/tomorrow-plan.js
Normal 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;
|
||||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
92
src/main.py
92
src/main.py
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
112
tests/test_tomorrow_plan.py
Normal 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}
|
||||
100
tests/test_tomorrow_plan_frontend.py
Normal file
100
tests/test_tomorrow_plan_frontend.py
Normal 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
|
||||
|
|
@ -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": "",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user