feat: plan the next seven days on mobile (Closes #1177)
This commit is contained in:
parent
880d1c4a1f
commit
e01681af09
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
106
frontend/week-plan.js
Normal file
106
frontend/week-plan.js
Normal 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) {
|
||||
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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
87
src/main.py
87
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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
75
tests/e2e/test_mobile_week_ahead_release.py
Normal file
75
tests/e2e/test_mobile_week_ahead_release.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
112
tests/test_week_plan.py
Normal 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}
|
||||
107
tests/test_week_plan_frontend.py
Normal file
107
tests/test_week_plan_frontend.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user