diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 7189de5..ff73238 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -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
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 9e1800d..01c78c9 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 60f793f..3b75a22 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -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();
@@ -3075,8 +3065,9 @@
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.');
diff --git a/frontend/index.html b/frontend/index.html
index 703ad70..c782d61 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -460,6 +460,7 @@
Cross-device change
Choose which Tomorrow plan to keep
@@ -1842,6 +1843,7 @@
+
@@ -1952,6 +1954,7 @@
+
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 71f6e5d..ca08282 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -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',
diff --git a/frontend/week-plan.js b/frontend/week-plan.js
new file mode 100644
index 0000000..9fcda16
--- /dev/null
+++ b/frontend/week-plan.js
@@ -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=>'').join('');
+ root.querySelectorAll('[data-week-plan-date]').forEach(button=>button.addEventListener('click',()=>{
+ selectedDate=button.dataset.weekPlanDate;renderDates();openPlanner(null,false);
+ }));
+ }
+ async function open(trigger) {
+ if(!getLogin()){qs('#my-work-action-status').textContent='Planning is unavailable until your operator identity is restored.';return false;}
+ trigger.disabled=true;qs('#mobile-week-summary').textContent='Loading Week Ahead…';
+ try{await controller.load();selectedDate=controller.dates()[0].date;qs('#mobile-week-summary').textContent=controller.summary();openPlanner(trigger);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;
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 6323fab..8a14251 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -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",
diff --git a/src/main.py b/src/main.py
index 0c1a42a..6dcf3f4 100644
--- a/src/main.py
+++ b/src/main.py
@@ -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()
diff --git a/src/today_store.py b/src/today_store.py
index 485e879..8a503e7 100644
--- a/src/today_store.py
+++ b/src/today_store.py
@@ -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 {
diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py
new file mode 100644
index 0000000..813204d
--- /dev/null
+++ b/tests/e2e/test_mobile_week_ahead_release.py
@@ -0,0 +1,75 @@
+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})
+
+ 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()
+
+ 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)
diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py
index e9c5e2c..01be3a6 100644
--- a/tests/test_ci_workflow.py
+++ b/tests/test_ci_workflow.py
@@ -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
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index e70d68d..45bcc96 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -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",
diff --git a/tests/test_start_day_reminders.py b/tests/test_start_day_reminders.py
index 6744a31..eea69a7 100644
--- a/tests/test_start_day_reminders.py
+++ b/tests/test_start_day_reminders.py
@@ -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
diff --git a/tests/test_tomorrow_plan_frontend.py b/tests/test_tomorrow_plan_frontend.py
index 092bde8..105b4a7 100644
--- a/tests/test_tomorrow_plan_frontend.py
+++ b/tests/test_tomorrow_plan_frontend.py
@@ -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
diff --git a/tests/test_week_plan.py b/tests/test_week_plan.py
new file mode 100644
index 0000000..b8d11b2
--- /dev/null
+++ b/tests/test_week_plan.py
@@ -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}
diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py
new file mode 100644
index 0000000..87764d6
--- /dev/null
+++ b/tests/test_week_plan_frontend.py
@@ -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 '' 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