feat: Plan the next seven days from mobile #1179

Merged
rockachopa merged 5 commits from timmy/1177-plan-the-next-seven-days-from-mobile into main 2026-08-20 13:01:22 +00:00
18 changed files with 731 additions and 44 deletions

View File

@ -57,7 +57,7 @@ jobs:
pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium
- name: Exercise packaged mobile work journeys
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
release-candidate:
runs-on: ubuntu-latest

View File

@ -241,6 +241,10 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.plan-today-header h2, .plan-today-header p { margin-top:0; }
.plan-today-header button { min-width:44px; min-height:44px; }
.week-plan-dates { display:flex; gap:8px; margin:8px 0 12px; padding:2px 0 8px; overflow-x:auto; overscroll-behavior-inline:contain; }
.week-plan-dates[hidden] { display:none; }
.week-plan-dates button { min-height:44px; min-width:86px; flex:0 0 auto; padding:6px 10px; }
.week-plan-dates button[aria-current="date"] { color:#bfdbfe; background:#17365a; border-color:#60a5fa; }
.tomorrow-conflict-review { margin-top:14px; }
.tomorrow-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
.tomorrow-conflict-plans > section { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }

View File

@ -229,6 +229,7 @@
deadlineBadge: qs('#mobile-deadline-count'),
onSelectQueue:(name, row) => name === 'recaps' ? qs('#open-today-recaps').click() :
name === 'find' ? qs('#find-work').click() :
name === 'week' ? openWeekPlanner(row) :
name === 'tomorrow' ? openTomorrowPlanner(row) : mobileQueueLauncher.open(name),
detour:() => timerView,
overlays: mobileTaskOverlays,
@ -380,6 +381,7 @@
const todayRollover = createTodayRollover();
let planningTomorrow = false;
let latestTodayPlan = null;
const tomorrowPlan = createTomorrowPlan({
fetchJson:fetchReviewJson,
localDate:todayRollover.localDate,
@ -387,6 +389,10 @@
storage:localStorage,
getLogin:() => planningOwnerLogin,
});
const weekPlan = createWeekPlan({
fetchJson:fetchReviewJson,localDate:todayRollover.localDate,timeZone:todayRollover.timeZone,
});
function openWeekPlanner(trigger) { planningTomorrow=false; return weekWorkflow.open(trigger); }
function renderTomorrowQueueSummary(value) {
qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary();
}
@ -445,27 +451,9 @@
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 weekWorkflow=createWeekPlanWorkflow({controller:weekPlan,qs,getLogin:()=>planningOwnerLogin,
openPlanner:openPlanToday,escapeHtml,escapeAttribute:escAttr,todayWork,
refresh:refreshMyWorkView,warm:warmTodayOffline});
const todaySync = createTodaySync({
storage: localStorage,
getLogin: () => planningOwnerLogin,
@ -480,7 +468,7 @@
if (!planningOwnerLogin) return;
latestTodayPlan = plan;
const startDayLaunch = window.location.hash === '#/my-work/start-day';
promoteTomorrowIfDue(plan).finally(() => {
weekWorkflow.promote(plan).finally(() => {
if (!startDayLaunch) return;
window.history.replaceState({}, '', '#/my-work/today');
openMobileStartDay();
@ -523,7 +511,7 @@
documentObject:document,
check:() => Promise.all([
syncPendingTomorrow(),
latestTodayPlan ? promoteTomorrowIfDue(latestTodayPlan) : false,
latestTodayPlan ? weekWorkflow.promote(latestTodayPlan) : false,
]),
});
const todayHandoff = createTodayHandoff({
@ -2709,6 +2697,7 @@
}
planToday.cancel();
planningTomorrow = false;
weekWorkflow.clear();
qs('#tomorrow-conflict-review').hidden = true;
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
qs('#plan-today-sheet').hidden = true;
@ -2765,7 +2754,8 @@
const planToday = createPlanToday({
identity: item => todayWork.identity(item),
limit: todayWork.limit,
save: plan => planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan),
save: plan => weekWorkflow.active() ? weekWorkflow.save(plan) :
(planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)),
start: () => {
qs('[data-work-filter="today"]').click();
startTodaySession();
@ -3070,13 +3060,14 @@
}
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
if (!planningOwnerLogin) {
if (!planningOwnerLogin && !weekWorkflow.active()) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
if (trigger) planTodayTrigger = trigger;
qs('#plan-today-title').textContent = planningTomorrow ? 'Plan Tomorrow' :
(pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today'));
const weekCopy=weekWorkflow.copy();
qs('#plan-today-title').textContent = weekCopy ? weekCopy.title : (planningTomorrow ? 'Plan Tomorrow' :
(pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today')));
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
if (navigate) {
taskOverlayHistory.open('plan-today');
@ -3093,22 +3084,24 @@
pendingPlanActualMinutes = null;
const protectProposal = pendingProtectToday;
const tomorrow = planningTomorrow ? tomorrowPlan.state() : null;
const weekDay = weekWorkflow.day();
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))
const selected = (planningTomorrow || weekCopy) ? availablePlanningItems.filter(item =>
(weekDay || 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;
planToday.open(selected, activeMyWork, weekDay || (planningTomorrow ? tomorrow : todayWork.planning()), recommendations);
qs('#today-plan-heading').textContent = weekCopy ? weekCopy.heading : (planningTomorrow ? 'Tomorrow, in order' : 'Today, in order');
qs('.plan-today-available').firstChild.textContent = weekCopy ? weekCopy.available : (planningTomorrow ? 'Available this day' : 'Available today');
qs('#build-today-plan').textContent = weekCopy ? weekCopy.build : (planningTomorrow ? 'Build my Tomorrow' : 'Build my Today');
qs('#save-and-start-today').hidden = planningTomorrow || Boolean(weekCopy);
pendingProtectToday = null;
qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan();
qs('#plan-today-error').textContent = '';
qs('#plan-today-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
weekWorkflow.renderDates();
renderPlanToday();
if (protectProposal) qs('#plan-today-build-status').textContent = protectProposal.summary +
(protectProposal.displaced.length ? '. Displaced work remains unchanged until you save.' : '. Review estimates and capacity before saving.');

View File

@ -460,6 +460,7 @@
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
<button id="cancel-plan-today" type="button">Cancel</button>
</div>
<nav class="week-plan-dates" id="week-plan-dates" aria-label="Week Ahead dates" hidden></nav>
<section class="tomorrow-conflict-review" id="tomorrow-conflict-review" aria-labelledby="tomorrow-conflict-title" hidden>
<div class="small">Cross-device change</div>
<h3 id="tomorrow-conflict-title">Choose which Tomorrow plan to keep</h3>
@ -1842,6 +1843,7 @@
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="week" type="button"><span><strong>Week Ahead</strong><small id="mobile-week-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
@ -1952,6 +1954,7 @@
<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/week-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

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

View File

@ -4,7 +4,13 @@ function createTodayRollover(options = {}) {
Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
function timeZone() {
return options.timeZone?.() || resolvedTimeZone();
const candidate = options.timeZone?.() || resolvedTimeZone();
try {
new Intl.DateTimeFormat('en', { timeZone: candidate }).format(now());
return candidate;
} catch (_error) {
return 'UTC';
}
}
function localDate() {

106
frontend/week-plan.js Normal file
View File

@ -0,0 +1,106 @@
function createWeekPlan({fetchJson,localDate,timeZone}={}) {
let week={revision:0,timezone:null,days:[]};
let lastConflict=null;
const cloneDay=day=>({
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
estimates:{...(day.estimates||{})},
});
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay)});
function adopt(value) {
if(!Number.isInteger(value?.revision)||!Array.isArray(value?.days)) return false;
week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay)
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
lastConflict=null;
return state();
}
function addDays(value, amount) {
const [year,month,day]=value.split('-').map(Number);
return new Date(Date.UTC(year,month-1,day+amount)).toISOString().slice(0,10);
}
function dates() {
return Array.from({length:7},(_,index)=>{
const date=addDays(localDate(),index+1);
const parsed=new Date(date+'T12:00:00Z');
return {date,label:new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(parsed)};
});
}
function day(planDate) {
const found=week.days.find(item=>item.plan_date===planDate);
return found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}};
}
async function load() { return adopt(await fetchJson('api/v1/week')); }
async function saveDay(planDate, value) {
const local={revision:week.revision,timezone:timeZone(),days:week.days
.filter(item=>item.plan_date!==planDate).concat([{
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
estimates:{...(value.estimates||{})},
}]).sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
const body={base_revision:local.revision,timezone:local.timezone,days:local.days};
try {
return adopt(await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
} catch(error) {
if(error?.status===409) {
const remote=await fetchJson('api/v1/week');
lastConflict={local,remote:{revision:remote.revision,timezone:remote.timezone,days:remote.days.map(cloneDay)}};
}
throw error;
}
}
function conflict() {
return lastConflict?{
local:{...lastConflict.local,days:lastConflict.local.days.map(cloneDay)},
remote:{...lastConflict.remote,days:lastConflict.remote.days.map(cloneDay)},
}:null;
}
async function promote(todayRevision) {
const due=week.days.find(item=>item.plan_date<=localDate()&&item.ids.length);
if(!due) return false;
return fetchJson('api/v1/week/promote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
promotion_id:`week-${due.plan_date}-r${week.revision}`,week_revision:week.revision,
plan_date:due.plan_date,today_revision:todayRevision,
})});
}
function summary() {
const planned=week.days.filter(item=>item.ids.length);
const items=planned.reduce((total,item)=>total+item.ids.length,0);
return planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
}
return {adopt,state,dates,day,load,saveDay,conflict,promote,summary};
}
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute,
todayWork,refresh,warm}={}) {
let selectedDate=null;
function renderDates() {
const root=qs('#week-plan-dates');
root.hidden=!selectedDate;
if(!selectedDate)return;
root.innerHTML=controller.dates().map(item=>'<button type="button" data-week-plan-date="'+
escapeAttribute(item.date)+'"'+(item.date===selectedDate?' aria-current="date"':'')+'><strong>'+escapeHtml(item.label)+
'</strong><small>'+escapeHtml(controller.day(item.date).ids.length+' planned')+'</small></button>').join('');
root.querySelectorAll('[data-week-plan-date]').forEach(button=>button.addEventListener('click',()=>{
selectedDate=button.dataset.weekPlanDate;renderDates();openPlanner(null,false);
}));
}
async function open(trigger) {
trigger.disabled=true;selectedDate=controller.dates()[0].date;renderDates();openPlanner(trigger);
qs('#mobile-week-summary').textContent='Loading Week Ahead…';
try{await controller.load();qs('#mobile-week-summary').textContent=controller.summary();openPlanner(null,false);return true;}
catch(error){qs('#mobile-week-summary').textContent='Unavailable · tap to retry';qs('#my-work-action-status').textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;}
finally{trigger.disabled=false;}
}
function save(plan){
const date=selectedDate,normalized=Array.isArray(plan)?{ids:plan,capacity_minutes:null,estimates:{}}:plan;
controller.saveDay(date,normalized).then(()=>{qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent=(normalized.ids.length?'Week Ahead saved for ':'Week Ahead cleared for ')+date+'.';})
.catch(error=>{qs('#mobile-week-summary').textContent=controller.conflict()?'Conflict · review required':'Unavailable · tap to retry';qs('#my-work-action-status').textContent=controller.conflict()?'Another device changed Week Ahead. Both versions are preserved; reopen Week Ahead to review.':(error.message||'Week Ahead could not be saved.')+' Retry when connected.';});
return true;
}
async function promote(plan){
try{await controller.load();const promoted=await controller.promote(plan.revision);if(!promoted)return false;todayWork.replace(promoted.ids);todayWork.replacePlanning({capacity_minutes:promoted.capacity_minutes??null,estimates:promoted.estimates||{}});refresh();warm();qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent='Your saved Week Ahead plan is now Today.';return true;}
catch(error){qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;}
}
return {open,save,promote,renderDates,active:()=>Boolean(selectedDate),selectedDate:()=>selectedDate,
day:()=>selectedDate?controller.day(selectedDate):null,
copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null,
clear(){selectedDate=null;renderDates();}};
}
if(typeof module!=='undefined'&&module.exports)module.exports=createWeekPlan;

View File

@ -35,8 +35,8 @@ FEATURE_SOURCES = {
"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",
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
"static/tomorrow-plan.js", "static/week-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-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",

View File

@ -75,7 +75,7 @@ from src.suggestion_engine import compute
from src.later_store import LaterStore
from src.today_store import (
TodayPlanFull, TodayPromotionConflict, TodaySessionConflict, TodayStore,
TomorrowPlanConflict, TomorrowPlanNotDue,
TomorrowPlanConflict, TomorrowPlanNotDue, WeekPlanConflict,
)
from src.state_encryption import PrivateStateEncryptionError
from src.views import FRONTEND_BUILD, router as frontend_router
@ -727,6 +727,26 @@ class TomorrowPromotion(BaseModel):
today_revision: int = Field(ge=0)
class WeekPlanDay(BaseModel):
plan_date: str = Field(min_length=10, max_length=10)
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)
class WeekPlanUpdate(BaseModel):
base_revision: int = Field(ge=0)
days: list[WeekPlanDay] = Field(max_length=7)
timezone: str = Field(min_length=1, max_length=100)
class WeekPromotion(BaseModel):
promotion_id: str = Field(min_length=1, max_length=100)
week_revision: int = Field(ge=0)
plan_date: str = Field(min_length=10, max_length=10)
today_revision: int = Field(ge=0)
class LaterOperationBatch(BaseModel):
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
@ -1431,7 +1451,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/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 (
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/week", "/api/v1/week/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 (
@ -2752,6 +2772,69 @@ async def promote_tomorrow_plan(payload: TomorrowPromotion):
)
@app.get("/api/v1/week")
async def get_week_plan():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_today_store().get_week, login)
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Week Ahead synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.put("/api/v1/week")
async def replace_week_plan(payload: WeekPlanUpdate):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().replace_week, login,
base_revision=payload.base_revision,
days=[day.model_dump() for day in payload.days],
timezone=payload.timezone,
)
except WeekPlanConflict as error:
raise HTTPException(
status_code=409, detail={"code": "week_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="Week Ahead synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.post("/api/v1/week/promote")
async def promote_week_plan(payload: WeekPromotion):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().promote_week, login, **payload.model_dump()
)
except WeekPlanConflict as error:
raise HTTPException(
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
)
except TodayPromotionConflict as error:
raise HTTPException(
status_code=409, detail={"code": "today_changed", "today": error.today}
)
except TomorrowPlanNotDue as error:
raise HTTPException(
status_code=409, detail={"code": "week_not_due", "plan_date": error.plan_date}
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Week Ahead promotion is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/today/session")
async def get_today_session():
login = await _confirmed_login()

View File

@ -39,6 +39,14 @@ class TomorrowPlanNotDue(ValueError):
self.plan_date = plan_date
class WeekPlanConflict(ValueError):
"""Raised when Week Ahead was edited from an obsolete revision."""
def __init__(self, snapshot: dict):
super().__init__("Week Ahead changed on another device")
self.snapshot = snapshot
class TodayPromotionConflict(ValueError):
"""Raised when rollover would overwrite a changed Today plan."""
@ -75,7 +83,7 @@ class TodayStore:
def _initialize(self) -> None:
connection = connect_private_sqlite(self.path, timeout=self.timeout)
if connection.execute("PRAGMA user_version").fetchone()[0] >= 4:
if connection.execute("PRAGMA user_version").fetchone()[0] >= 5:
connection.close()
return
connection.execute("PRAGMA journal_mode=WAL")
@ -171,7 +179,16 @@ class TodayStore:
"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.execute(
"CREATE TABLE IF NOT EXISTS week_plans ("
"login TEXT PRIMARY KEY, revision INTEGER NOT NULL, payload TEXT NOT NULL)"
)
connection.execute(
"CREATE TABLE IF NOT EXISTS week_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 = 5")
connection.commit()
connection.close()
@ -420,6 +437,168 @@ class TodayStore:
)
return result
@staticmethod
def _empty_week(revision: int = 0) -> dict:
return {"revision": revision, "timezone": None, "days": []}
def _week_snapshot(self, row, login: str) -> dict:
if row is None:
return self._empty_week()
payload, _legacy = self._cipher.open(row[1], binding=f"week:{login}")
if not isinstance(payload, dict) or not isinstance(payload.get("days"), list):
raise PrivateStateEncryptionError("private state could not be decrypted")
return {"revision": int(row[0]), **payload}
def get_week(self, login: str) -> dict:
login = self._normalize_login(login)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
).fetchone()
if row is not None:
return self._week_snapshot(row, login)
tomorrow_row = connection.execute(
"SELECT revision, payload FROM tomorrow_plans WHERE login = ?", (login,)
).fetchone()
tomorrow = self._tomorrow_snapshot(tomorrow_row, login)
if not tomorrow.get("plan_date"):
return self._empty_week()
day = {key: tomorrow[key] for key in (
"plan_date", "ids", "capacity_minutes", "estimates"
)}
payload = {"timezone": tomorrow["timezone"], "days": [day]}
connection.execute(
"INSERT INTO week_plans(login, revision, payload) VALUES (?, 1, ?)",
(login, self._cipher.seal(payload, binding=f"week:{login}")),
)
empty = self._empty_tomorrow(tomorrow["revision"] + 1)
connection.execute(
"UPDATE tomorrow_plans SET revision = ?, payload = ? WHERE login = ?",
(empty["revision"], self._cipher.seal(
{key: empty[key] for key in ("ids", "capacity_minutes", "estimates")},
binding=f"tomorrow:{login}",
), login),
)
return {"revision": 1, **payload}
def _normalize_week(self, *, days: list[dict], timezone: str) -> dict:
if not isinstance(days, list) or len(days) > 7:
raise ValueError("Week Ahead is limited to seven dates")
if not isinstance(timezone, str) or not timezone.strip() or len(timezone) > 100:
raise ValueError("timezone is required and bounded")
timezone = timezone.strip()
try:
ZoneInfo(timezone)
except (ZoneInfoNotFoundError, ValueError):
raise ValueError("timezone must be a valid IANA timezone") from None
normalized = []
seen_dates = set()
for day in days:
if not isinstance(day, dict):
raise ValueError("each Week Ahead date must be an object")
plan_date = day.get("plan_date")
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 plan_date in seen_dates:
raise ValueError("Week Ahead dates must be unique")
seen_dates.add(plan_date)
plan = self._normalize_tomorrow(
ids=day.get("ids", []), capacity_minutes=day.get("capacity_minutes"),
estimates=day.get("estimates", {}), plan_date=plan_date, timezone=timezone,
)
plan.pop("timezone")
normalized.append(plan)
normalized.sort(key=lambda item: item["plan_date"])
return {"timezone": timezone, "days": normalized}
def replace_week(self, login: str, *, base_revision: int, days: list[dict], timezone: str) -> 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_week(days=days, timezone=timezone)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
).fetchone()
current = self._week_snapshot(row, login)
if current["revision"] != base_revision:
raise WeekPlanConflict(current)
snapshot = {"revision": base_revision + 1, **normalized}
connection.execute(
"INSERT INTO week_plans(login, revision, payload) VALUES (?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, payload=excluded.payload",
(login, snapshot["revision"], self._cipher.seal(normalized, binding=f"week:{login}")),
)
return snapshot
def promote_week(
self, login: str, *, promotion_id: str, week_revision: int,
plan_date: str, 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 week_promotions WHERE login = ? AND promotion_id = ?",
(login, promotion_id),
).fetchone()
if receipt:
result, _legacy = self._cipher.open(
receipt[0], binding=f"week-promotion:{login}:{promotion_id}"
)
if not isinstance(result, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
return result
row = connection.execute(
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
).fetchone()
week = self._week_snapshot(row, login)
if week["revision"] != week_revision:
raise WeekPlanConflict(week)
day = next((item for item in week["days"] if item["plan_date"] == plan_date), None)
if day is None:
raise WeekPlanConflict(week)
local_date = datetime.fromtimestamp(self.clock(), ZoneInfo(week["timezone"])).date().isoformat()
if local_date < plan_date:
raise TomorrowPlanNotDue(plan_date)
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, **day, "timezone": week["timezone"]}
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"], self._sealed_plan(login, result)),
)
remaining = {"timezone": week["timezone"], "days": [
item for item in week["days"] if item["plan_date"] != plan_date
]}
connection.execute(
"UPDATE week_plans SET revision = ?, payload = ? WHERE login = ?",
(week_revision + 1, self._cipher.seal(remaining, binding=f"week:{login}"), login),
)
connection.execute(
"INSERT INTO week_promotions(login, promotion_id, result, created_at) VALUES (?, ?, ?, ?)",
(login, promotion_id, self._cipher.seal(
result, binding=f"week-promotion:{login}:{promotion_id}"
), self.clock()),
)
return result
@staticmethod
def _empty_session() -> dict:
return {

View File

@ -0,0 +1,79 @@
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Week Ahead journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_plans_seven_touch_safe_mobile_dates(
tmp_path: Path, width: int, height: int
):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
saved: list[dict] = []
try:
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": width, "height": height})
page_errors: list[str] = []
page.on("pageerror", lambda error: page_errors.append(str(error)))
def week_route(route):
if route.request.method == "PUT":
body = json.loads(route.request.post_data or "{}")
saved.append(body)
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": body["base_revision"] + 1,
"timezone": body["timezone"], "days": body["days"],
}))
return
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": 0, "timezone": None, "days": [],
}))
page.route("**/api/v1/week", week_route)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Week Ahead release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="week"]').click()
page.wait_for_timeout(100)
assert not page_errors, f"Week Ahead launch raised: {page_errors}"
expect(page.locator("#plan-today-title")).to_have_text("Plan Week Ahead")
dates = page.locator("#week-plan-dates button")
expect(dates).to_have_count(7)
expect(dates.first).to_have_attribute("aria-current", "date")
for index in range(7):
bounds = dates.nth(index).bounding_box()
assert bounds and bounds["height"] >= 44
dates.nth(2).click()
expect(dates.nth(2)).to_have_attribute("aria-current", "date")
page.locator("#save-today-plan").click()
expect(page.locator("#plan-today-sheet")).to_be_hidden()
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
assert saved and len(saved[-1]["days"]) == 1
assert saved[-1]["days"][0]["plan_date"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)

View File

@ -68,6 +68,7 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
"tests/e2e/test_mobile_today_wrap_up_release.py "
"tests/e2e/test_mobile_today_summary_release.py "
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
"tests/e2e/test_mobile_week_ahead_release.py "
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
) in browser
assert "needs: [lint, build-release, browser-journey]" in release

View File

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

View File

@ -309,6 +309,6 @@ def test_mobile_settings_and_launch_route_wire_start_day_into_existing_promotion
assert "startDayControl:qs('#push-start-day')" in dashboard
assert "startDayHour:qs('#push-start-day-hour')" in dashboard
assert "window.location.hash === '#/my-work/start-day'" in dashboard
assert "promoteTomorrowIfDue(plan).finally(() =>" in dashboard
assert "weekWorkflow.promote(plan).finally(() =>" in dashboard
assert "onRemotePlan: async plan =>" not in dashboard
assert "openMobileStartDay()" in dashboard

View File

@ -132,6 +132,18 @@ process.stdout.write(JSON.stringify({{date:rollover.localDate(),zone:rollover.ti
assert run_node(script) == {"date": "2026-11-01", "zone": "America/New_York"}
def test_invalid_runtime_timezone_falls_back_to_utc_without_breaking_planners():
script = f"""
const create = require({json.dumps(str(ROLLOVER))});
const rollover = create({{
now:()=>new Date('2026-08-20T23:30:00Z'),
resolvedTimeZone:()=> 'Etc/Unknown'
}});
process.stdout.write(JSON.stringify({{date:rollover.localDate(),zone:rollover.timeZone()}}));
"""
assert run_node(script) == {"date": "2026-08-20", "zone": "UTC"}
def test_rollover_is_one_durable_sync_operation_with_the_server_revision():
script = f"""
const createSync = require({json.dumps(str(SYNC))});

View File

@ -419,5 +419,5 @@ def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_plan
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
assert "weekWorkflow.promote(plan)" in dashboard
assert "tomorrowPlan.startLifecycle" in dashboard

112
tests/test_week_plan.py Normal file
View File

@ -0,0 +1,112 @@
import sqlite3
from datetime import datetime, timezone
import pytest
from src import main
from src.today_store import TodayStore, WeekPlanConflict
def sample_days():
return [
{
"plan_date": "2026-08-21",
"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-23",
"ids": ["issue:secret/repo:5:"],
"capacity_minutes": 90,
"estimates": {"issue:secret/repo:5:": 30},
},
]
def test_week_plan_is_encrypted_account_scoped_revisioned_and_preserves_other_dates(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, encryption_key=b"w" * 32)
saved = store.replace_week(
"Timmy", base_revision=0, days=sample_days(), timezone="America/Los_Angeles"
)
assert saved == {
"revision": 1,
"timezone": "America/Los_Angeles",
"days": sample_days(),
}
assert store.get_week("timmy") == saved
assert store.get_week("alexander") == {"revision": 0, "timezone": None, "days": []}
retained = path.read_bytes()
assert b"issue:secret/repo" not in retained
assert b"America/Los_Angeles" not in retained
changed = [dict(sample_days()[0], capacity_minutes=180), sample_days()[1]]
updated = store.replace_week("timmy", base_revision=1, days=changed, timezone="America/Los_Angeles")
assert updated["days"][1] == saved["days"][1]
with pytest.raises(WeekPlanConflict) as conflict:
store.replace_week("timmy", base_revision=1, days=[], timezone="UTC")
assert conflict.value.snapshot == updated
def test_week_promotion_moves_only_due_date_to_today_exactly_once(tmp_path):
store = TodayStore(
tmp_path / "today.sqlite3",
encryption_key=b"p" * 32,
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(),
)
today = store.apply("timmy", "today", "add", "issue:r:1:")
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
promoted = store.promote_week(
"timmy", promotion_id="week-2026-08-21-r1", week_revision=week["revision"],
plan_date="2026-08-21", today_revision=today["revision"],
)
replay = store.promote_week(
"timmy", promotion_id="week-2026-08-21-r1", week_revision=week["revision"],
plan_date="2026-08-21", today_revision=today["revision"],
)
assert replay == promoted
assert promoted["ids"] == sample_days()[0]["ids"]
remaining = store.get_week("timmy")
assert remaining["revision"] == 2
assert remaining["days"] == [sample_days()[1]]
def test_existing_tomorrow_plan_migrates_into_week_without_losing_planning_data(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"m" * 32)
tomorrow = store.replace_tomorrow(
"timmy", base_revision=0, ids=["issue:r:9:"], capacity_minutes=75,
estimates={"issue:r:9:": 45}, plan_date="2026-08-21", timezone="UTC",
)
week = store.get_week("timmy")
assert week == {
"revision": 1, "timezone": "UTC",
"days": [{key: tomorrow[key] for key in ("plan_date", "ids", "capacity_minutes", "estimates")}],
}
assert store.get_tomorrow("timmy") == {
"revision": 2, "ids": [], "capacity_minutes": None, "estimates": {}
}
@pytest.mark.anyio
async def test_week_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.WeekPlanUpdate(base_revision=0, days=sample_days(), timezone="UTC")
saved = await main.replace_week_plan(payload)
assert await main.get_week_plan() == saved
with pytest.raises(main.HTTPException) as raised:
await main.replace_week_plan(payload)
assert raised.value.status_code == 409
assert raised.value.detail == {"code": "week_changed", "snapshot": saved}

View File

@ -0,0 +1,107 @@
import json
import subprocess
from pathlib import Path
FRONTEND = Path(__file__).parents[1] / "frontend"
CONTROLLER = FRONTEND / "week-plan.js"
def run_controller(scenario: str) -> dict:
harness = f"""
const createWeekPlan = 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_week_controller_lists_seven_local_dates_and_saves_one_without_changing_other_days():
result = run_controller("""
const requests=[];
const initial={revision:4,timezone:'UTC',days:[
{plan_date:'2026-08-21',ids:['issue:r:1:'],capacity_minutes:60,estimates:{'issue:r:1:':30}},
{plan_date:'2026-08-23',ids:['issue:r:3:'],capacity_minutes:90,estimates:{'issue:r:3:':45}}
]};
const fetchJson=async(url,options={})=>{
requests.push({url,method:options.method||'GET',body:options.body?JSON.parse(options.body):null});
if(!options.method)return initial;
return {revision:5,timezone:'UTC',days:JSON.parse(options.body).days};
};
const week=createWeekPlan({fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
await week.load();
const dates=week.dates();
const saved=await week.saveDay('2026-08-22',{ids:['issue:r:2:'],capacity_minutes:120,estimates:{'issue:r:2:':50}});
console.log(JSON.stringify({dates,saved,requests}));
""")
assert [item["date"] for item in result["dates"]] == [
"2026-08-21", "2026-08-22", "2026-08-23", "2026-08-24",
"2026-08-25", "2026-08-26", "2026-08-27",
]
assert [day["plan_date"] for day in result["saved"]["days"]] == [
"2026-08-21", "2026-08-22", "2026-08-23"
]
assert result["requests"][1]["body"]["base_revision"] == 4
assert result["requests"][1]["body"]["days"][0]["ids"] == ["issue:r:1:"]
assert result["requests"][1]["body"]["days"][2]["ids"] == ["issue:r:3:"]
def test_week_controller_preserves_both_versions_when_another_device_changes_the_week():
result = run_controller("""
let calls=0;
const remote={revision:8,timezone:'UTC',days:[{plan_date:'2026-08-22',ids:['server'],capacity_minutes:60,estimates:{}}]};
const fetchJson=async(_url,options={})=>{
calls+=1;
if(options.method==='PUT'){const error=new Error('changed');error.status=409;throw error;}
return calls===1?{revision:7,timezone:'UTC',days:[]}:remote;
};
const week=createWeekPlan({fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
await week.load();
try { await week.saveDay('2026-08-21',{ids:['phone'],capacity_minutes:90,estimates:{}}); } catch(_error) {}
console.log(JSON.stringify({conflict:week.conflict(),state:week.state()}));
""")
assert result["conflict"]["local"]["days"][0]["ids"] == ["phone"]
assert result["conflict"]["remote"]["days"][0]["ids"] == ["server"]
assert result["state"]["revision"] == 7
def test_week_controller_promotes_only_the_earliest_due_plan_with_stable_identity():
result = run_controller("""
const requests=[];
const week=createWeekPlan({fetchJson:async(url,options={})=>{
requests.push({url,body:options.body?JSON.parse(options.body):null});
return {revision:9,ids:['oldest']};
},localDate:()=> '2026-08-23',timeZone:()=> 'UTC'});
week.adopt({revision:6,timezone:'UTC',days:[
{plan_date:'2026-08-21',ids:['oldest'],capacity_minutes:60,estimates:{}},
{plan_date:'2026-08-22',ids:['newer'],capacity_minutes:60,estimates:{}}
]});
const promoted=await week.promote(3);
console.log(JSON.stringify({promoted,requests}));
""")
assert result["requests"] == [{
"url": "api/v1/week/promote",
"body": {
"promotion_id": "week-2026-08-21-r6",
"week_revision": 6,
"plan_date": "2026-08-21",
"today_revision": 3,
},
}]
def test_mobile_week_ahead_entry_and_date_strip_are_touch_safe():
index = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
assert 'data-mobile-queue="week"' in index
assert 'id="week-plan-dates"' in index
assert '<script src="static/week-plan.js"></script>' in index
assert "name === 'week' ? openWeekPlanner" in dashboard
assert "weekWorkflow.save" in dashboard
assert ".week-plan-dates button { min-height:44px;" in css
assert "overflow-x:auto" in css