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
|
pip install -r requirements-e2e.txt
|
||||||
python3 -m playwright install --with-deps chromium
|
python3 -m playwright install --with-deps chromium
|
||||||
- name: Exercise packaged mobile work journeys
|
- 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:
|
release-candidate:
|
||||||
runs-on: ubuntu-latest
|
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 { 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 h2, .plan-today-header p { margin-top:0; }
|
||||||
.plan-today-header button { min-width:44px; min-height:44px; }
|
.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-review { margin-top:14px; }
|
||||||
.tomorrow-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
.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; }
|
.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'),
|
deadlineBadge: qs('#mobile-deadline-count'),
|
||||||
onSelectQueue:(name, row) => name === 'recaps' ? qs('#open-today-recaps').click() :
|
onSelectQueue:(name, row) => name === 'recaps' ? qs('#open-today-recaps').click() :
|
||||||
name === 'find' ? qs('#find-work').click() :
|
name === 'find' ? qs('#find-work').click() :
|
||||||
|
name === 'week' ? openWeekPlanner(row) :
|
||||||
name === 'tomorrow' ? openTomorrowPlanner(row) : mobileQueueLauncher.open(name),
|
name === 'tomorrow' ? openTomorrowPlanner(row) : mobileQueueLauncher.open(name),
|
||||||
detour:() => timerView,
|
detour:() => timerView,
|
||||||
overlays: mobileTaskOverlays,
|
overlays: mobileTaskOverlays,
|
||||||
|
|
@ -380,6 +381,7 @@
|
||||||
const todayRollover = createTodayRollover();
|
const todayRollover = createTodayRollover();
|
||||||
let planningTomorrow = false;
|
let planningTomorrow = false;
|
||||||
let latestTodayPlan = null;
|
let latestTodayPlan = null;
|
||||||
|
|
||||||
const tomorrowPlan = createTomorrowPlan({
|
const tomorrowPlan = createTomorrowPlan({
|
||||||
fetchJson:fetchReviewJson,
|
fetchJson:fetchReviewJson,
|
||||||
localDate:todayRollover.localDate,
|
localDate:todayRollover.localDate,
|
||||||
|
|
@ -387,6 +389,10 @@
|
||||||
storage:localStorage,
|
storage:localStorage,
|
||||||
getLogin:() => planningOwnerLogin,
|
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) {
|
function renderTomorrowQueueSummary(value) {
|
||||||
qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary();
|
qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary();
|
||||||
}
|
}
|
||||||
|
|
@ -445,27 +451,9 @@
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin: () => planningOwnerLogin,
|
getLogin: () => planningOwnerLogin,
|
||||||
});
|
});
|
||||||
async function promoteTomorrowIfDue(plan) {
|
const weekWorkflow=createWeekPlanWorkflow({controller:weekPlan,qs,getLogin:()=>planningOwnerLogin,
|
||||||
try {
|
openPlanner:openPlanToday,escapeHtml,escapeAttribute:escAttr,todayWork,
|
||||||
await tomorrowPlan.load();
|
refresh:refreshMyWorkView,warm:warmTodayOffline});
|
||||||
const promoted = await tomorrowPlan.promote(plan.revision);
|
|
||||||
if (!promoted) return false;
|
|
||||||
todayWork.replace(promoted.ids);
|
|
||||||
todayWork.replacePlanning({
|
|
||||||
capacity_minutes:promoted.capacity_minutes ?? null,
|
|
||||||
estimates:promoted.estimates || {},
|
|
||||||
});
|
|
||||||
refreshMyWorkView();
|
|
||||||
warmTodayOffline();
|
|
||||||
qs('#my-work-action-status').textContent =
|
|
||||||
'Your saved Tomorrow plan is now Today.';
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
qs('#my-work-action-status').textContent =
|
|
||||||
`${error.message || 'Tomorrow needs review before promotion.'} Open Plan Tomorrow to review.`;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const todaySync = createTodaySync({
|
const todaySync = createTodaySync({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin: () => planningOwnerLogin,
|
getLogin: () => planningOwnerLogin,
|
||||||
|
|
@ -480,7 +468,7 @@
|
||||||
if (!planningOwnerLogin) return;
|
if (!planningOwnerLogin) return;
|
||||||
latestTodayPlan = plan;
|
latestTodayPlan = plan;
|
||||||
const startDayLaunch = window.location.hash === '#/my-work/start-day';
|
const startDayLaunch = window.location.hash === '#/my-work/start-day';
|
||||||
promoteTomorrowIfDue(plan).finally(() => {
|
weekWorkflow.promote(plan).finally(() => {
|
||||||
if (!startDayLaunch) return;
|
if (!startDayLaunch) return;
|
||||||
window.history.replaceState({}, '', '#/my-work/today');
|
window.history.replaceState({}, '', '#/my-work/today');
|
||||||
openMobileStartDay();
|
openMobileStartDay();
|
||||||
|
|
@ -523,7 +511,7 @@
|
||||||
documentObject:document,
|
documentObject:document,
|
||||||
check:() => Promise.all([
|
check:() => Promise.all([
|
||||||
syncPendingTomorrow(),
|
syncPendingTomorrow(),
|
||||||
latestTodayPlan ? promoteTomorrowIfDue(latestTodayPlan) : false,
|
latestTodayPlan ? weekWorkflow.promote(latestTodayPlan) : false,
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
const todayHandoff = createTodayHandoff({
|
const todayHandoff = createTodayHandoff({
|
||||||
|
|
@ -2709,6 +2697,7 @@
|
||||||
}
|
}
|
||||||
planToday.cancel();
|
planToday.cancel();
|
||||||
planningTomorrow = false;
|
planningTomorrow = false;
|
||||||
|
weekWorkflow.clear();
|
||||||
qs('#tomorrow-conflict-review').hidden = true;
|
qs('#tomorrow-conflict-review').hidden = true;
|
||||||
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
||||||
qs('#plan-today-sheet').hidden = true;
|
qs('#plan-today-sheet').hidden = true;
|
||||||
|
|
@ -2765,7 +2754,8 @@
|
||||||
const planToday = createPlanToday({
|
const planToday = createPlanToday({
|
||||||
identity: item => todayWork.identity(item),
|
identity: item => todayWork.identity(item),
|
||||||
limit: todayWork.limit,
|
limit: todayWork.limit,
|
||||||
save: plan => planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan),
|
save: plan => weekWorkflow.active() ? weekWorkflow.save(plan) :
|
||||||
|
(planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)),
|
||||||
start: () => {
|
start: () => {
|
||||||
qs('[data-work-filter="today"]').click();
|
qs('[data-work-filter="today"]').click();
|
||||||
startTodaySession();
|
startTodaySession();
|
||||||
|
|
@ -3075,8 +3065,9 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trigger) planTodayTrigger = trigger;
|
if (trigger) planTodayTrigger = trigger;
|
||||||
qs('#plan-today-title').textContent = planningTomorrow ? 'Plan Tomorrow' :
|
const weekCopy=weekWorkflow.copy();
|
||||||
(pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today'));
|
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 (actualMinutes) pendingPlanActualMinutes = actualMinutes;
|
||||||
if (navigate) {
|
if (navigate) {
|
||||||
taskOverlayHistory.open('plan-today');
|
taskOverlayHistory.open('plan-today');
|
||||||
|
|
@ -3093,22 +3084,24 @@
|
||||||
pendingPlanActualMinutes = null;
|
pendingPlanActualMinutes = null;
|
||||||
const protectProposal = pendingProtectToday;
|
const protectProposal = pendingProtectToday;
|
||||||
const tomorrow = planningTomorrow ? tomorrowPlan.state() : null;
|
const tomorrow = planningTomorrow ? tomorrowPlan.state() : null;
|
||||||
|
const weekDay = weekWorkflow.day();
|
||||||
const availablePlanningItems = [...todayMyWork, ...activeMyWork].filter((item, index, items) =>
|
const availablePlanningItems = [...todayMyWork, ...activeMyWork].filter((item, index, items) =>
|
||||||
items.findIndex(candidate => todayWork.identity(candidate) === todayWork.identity(item)) === index
|
items.findIndex(candidate => todayWork.identity(candidate) === todayWork.identity(item)) === index
|
||||||
);
|
);
|
||||||
const selected = planningTomorrow ? availablePlanningItems.filter(item =>
|
const selected = (planningTomorrow || weekCopy) ? availablePlanningItems.filter(item =>
|
||||||
tomorrow.ids.includes(todayWork.identity(item))
|
(weekDay || tomorrow).ids.includes(todayWork.identity(item))
|
||||||
) : (protectProposal?.selected || todayMyWork);
|
) : (protectProposal?.selected || todayMyWork);
|
||||||
planToday.open(selected, activeMyWork, planningTomorrow ? tomorrow : todayWork.planning(), recommendations);
|
planToday.open(selected, activeMyWork, weekDay || (planningTomorrow ? tomorrow : todayWork.planning()), recommendations);
|
||||||
qs('#today-plan-heading').textContent = planningTomorrow ? 'Tomorrow, in order' : 'Today, in order';
|
qs('#today-plan-heading').textContent = weekCopy ? weekCopy.heading : (planningTomorrow ? 'Tomorrow, in order' : 'Today, in order');
|
||||||
qs('.plan-today-available').firstChild.textContent = planningTomorrow ? 'Available tomorrow' : 'Available today';
|
qs('.plan-today-available').firstChild.textContent = weekCopy ? weekCopy.available : (planningTomorrow ? 'Available this day' : 'Available today');
|
||||||
qs('#build-today-plan').textContent = planningTomorrow ? 'Build my Tomorrow' : 'Build my Today';
|
qs('#build-today-plan').textContent = weekCopy ? weekCopy.build : (planningTomorrow ? 'Build my Tomorrow' : 'Build my Today');
|
||||||
qs('#save-and-start-today').hidden = planningTomorrow;
|
qs('#save-and-start-today').hidden = planningTomorrow || Boolean(weekCopy);
|
||||||
pendingProtectToday = null;
|
pendingProtectToday = null;
|
||||||
qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan();
|
qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan();
|
||||||
qs('#plan-today-error').textContent = '';
|
qs('#plan-today-error').textContent = '';
|
||||||
qs('#plan-today-sheet').hidden = false;
|
qs('#plan-today-sheet').hidden = false;
|
||||||
document.body.classList.add('task-overlay-open');
|
document.body.classList.add('task-overlay-open');
|
||||||
|
weekWorkflow.renderDates();
|
||||||
renderPlanToday();
|
renderPlanToday();
|
||||||
if (protectProposal) qs('#plan-today-build-status').textContent = protectProposal.summary +
|
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.');
|
(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>
|
<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>
|
<button id="cancel-plan-today" type="button">Cancel</button>
|
||||||
</div>
|
</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>
|
<section class="tomorrow-conflict-review" id="tomorrow-conflict-review" aria-labelledby="tomorrow-conflict-title" hidden>
|
||||||
<div class="small">Cross-device change</div>
|
<div class="small">Cross-device change</div>
|
||||||
<h3 id="tomorrow-conflict-title">Choose which Tomorrow plan to keep</h3>
|
<h3 id="tomorrow-conflict-title">Choose which Tomorrow plan to keep</h3>
|
||||||
|
|
@ -1842,6 +1843,7 @@
|
||||||
<div class="mobile-queue-list">
|
<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="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="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="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="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>
|
<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-readiness.js"></script>
|
||||||
<script src="static/plan-today-preview.js"></script>
|
<script src="static/plan-today-preview.js"></script>
|
||||||
<script src="static/tomorrow-plan.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-sync.js"></script>
|
||||||
<script src="static/today-rollover.js"></script>
|
<script src="static/today-rollover.js"></script>
|
||||||
<script src="static/update-ownership.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-readiness.js',
|
||||||
BASE + 'static/plan-today-preview.js',
|
BASE + 'static/plan-today-preview.js',
|
||||||
BASE + 'static/tomorrow-plan.js',
|
BASE + 'static/tomorrow-plan.js',
|
||||||
|
BASE + 'static/week-plan.js',
|
||||||
BASE + 'static/today-sync.js',
|
BASE + 'static/today-sync.js',
|
||||||
BASE + 'static/today-rollover.js',
|
BASE + 'static/today-rollover.js',
|
||||||
BASE + 'static/update-ownership.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",),
|
"security-center": ("static/security-center.js",),
|
||||||
"planning": (
|
"planning": (
|
||||||
"static/plan-today.js", "static/plan-today-readiness.js",
|
"static/plan-today.js", "static/plan-today-readiness.js",
|
||||||
"static/plan-today-preview.js", "static/today-rollover.js",
|
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
|
||||||
"static/tomorrow-plan.js", "static/mobile-plan-today-nav.js",
|
"static/tomorrow-plan.js", "static/week-plan.js", "static/mobile-plan-today-nav.js",
|
||||||
),
|
),
|
||||||
"today-timer": (
|
"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",
|
"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.later_store import LaterStore
|
||||||
from src.today_store import (
|
from src.today_store import (
|
||||||
TodayPlanFull, TodayPromotionConflict, TodaySessionConflict, TodayStore,
|
TodayPlanFull, TodayPromotionConflict, TodaySessionConflict, TodayStore,
|
||||||
TomorrowPlanConflict, TomorrowPlanNotDue,
|
TomorrowPlanConflict, TomorrowPlanNotDue, WeekPlanConflict,
|
||||||
)
|
)
|
||||||
from src.state_encryption import PrivateStateEncryptionError
|
from src.state_encryption import PrivateStateEncryptionError
|
||||||
from src.views import FRONTEND_BUILD, router as frontend_router
|
from src.views import FRONTEND_BUILD, router as frontend_router
|
||||||
|
|
@ -727,6 +727,26 @@ class TomorrowPromotion(BaseModel):
|
||||||
today_revision: int = Field(ge=0)
|
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):
|
class LaterOperationBatch(BaseModel):
|
||||||
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
|
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):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
path = dashboard_auth.application_path(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/")
|
path.startswith("/api/v1/repos/")
|
||||||
and path.endswith("/review")
|
and path.endswith("/review")
|
||||||
) or path.startswith("/api/v1/notifications") or (
|
) 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")
|
@app.get("/api/v1/today/session")
|
||||||
async def get_today_session():
|
async def get_today_session():
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,14 @@ class TomorrowPlanNotDue(ValueError):
|
||||||
self.plan_date = plan_date
|
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):
|
class TodayPromotionConflict(ValueError):
|
||||||
"""Raised when rollover would overwrite a changed Today plan."""
|
"""Raised when rollover would overwrite a changed Today plan."""
|
||||||
|
|
||||||
|
|
@ -75,7 +83,7 @@ class TodayStore:
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
def _initialize(self) -> None:
|
||||||
connection = connect_private_sqlite(self.path, timeout=self.timeout)
|
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()
|
connection.close()
|
||||||
return
|
return
|
||||||
connection.execute("PRAGMA journal_mode=WAL")
|
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, "
|
"login TEXT NOT NULL, promotion_id TEXT NOT NULL, result TEXT NOT NULL, "
|
||||||
"created_at REAL NOT NULL, PRIMARY KEY (login, promotion_id))"
|
"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.commit()
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
@ -420,6 +437,168 @@ class TodayStore:
|
||||||
)
|
)
|
||||||
return result
|
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
|
@staticmethod
|
||||||
def _empty_session() -> dict:
|
def _empty_session() -> dict:
|
||||||
return {
|
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_wrap_up_release.py "
|
||||||
"tests/e2e/test_mobile_today_summary_release.py "
|
"tests/e2e/test_mobile_today_summary_release.py "
|
||||||
"tests/e2e/test_mobile_tomorrow_conflict_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"
|
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
||||||
) in browser
|
) in browser
|
||||||
assert "needs: [lint, build-release, browser-journey]" in release
|
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-readiness.js",
|
||||||
"/dashboard/static/plan-today-preview.js",
|
"/dashboard/static/plan-today-preview.js",
|
||||||
"/dashboard/static/tomorrow-plan.js",
|
"/dashboard/static/tomorrow-plan.js",
|
||||||
|
"/dashboard/static/week-plan.js",
|
||||||
"/dashboard/static/today-sync.js",
|
"/dashboard/static/today-sync.js",
|
||||||
"/dashboard/static/today-rollover.js",
|
"/dashboard/static/today-rollover.js",
|
||||||
"/dashboard/static/update-ownership.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 "startDayControl:qs('#push-start-day')" in dashboard
|
||||||
assert "startDayHour:qs('#push-start-day-hour')" in dashboard
|
assert "startDayHour:qs('#push-start-day-hour')" in dashboard
|
||||||
assert "window.location.hash === '#/my-work/start-day'" 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 "onRemotePlan: async plan =>" not in dashboard
|
||||||
assert "openMobileStartDay()" 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 "qs('#plan-tomorrow').addEventListener('click'" in dashboard
|
||||||
assert "planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)" in dashboard
|
assert "planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)" in dashboard
|
||||||
assert "tomorrowPlan.load()" 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
|
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