feat: plan the next seven days on mobile (Closes #1177)
CI / lint (pull_request) Successful in 3m39s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 4m42s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-20 11:17:41 +00:00
1 parent 880d1c4a1f
commit 7d8db5f459
16 files changed
+707 -42

No files matched your search

+4
View File
@@ -241,6 +241,10 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.plan-today-header h2, .plan-today-header p { margin-top:0; }
.plan-today-header button { min-width:44px; min-height:44px; }
.week-plan-dates { display:flex; gap:8px; margin:8px 0 12px; padding:2px 0 8px; overflow-x:auto; overscroll-behavior-inline:contain; }
.week-plan-dates[hidden] { display:none; }
.week-plan-dates button { min-height:44px; min-width:86px; flex:0 0 auto; padding:6px 10px; }
.week-plan-dates button[aria-current="date"] { color:#bfdbfe; background:#17365a; border-color:#60a5fa; }
.tomorrow-conflict-review { margin-top:14px; }
.tomorrow-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
.tomorrow-conflict-plans > section { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
+26 -33
View File
@@ -229,6 +229,7 @@
deadlineBadge: qs('#mobile-deadline-count'),
onSelectQueue:(name, row) => name === 'recaps' ? qs('#open-today-recaps').click() :
name === 'find' ? qs('#find-work').click() :
name === 'week' ? openWeekPlanner(row) :
name === 'tomorrow' ? openTomorrowPlanner(row) : mobileQueueLauncher.open(name),
detour:() => timerView,
overlays: mobileTaskOverlays,
@@ -380,6 +381,7 @@
const todayRollover = createTodayRollover();
let planningTomorrow = false;
let latestTodayPlan = null;
const tomorrowPlan = createTomorrowPlan({
fetchJson:fetchReviewJson,
localDate:todayRollover.localDate,
@@ -387,6 +389,10 @@
storage:localStorage,
getLogin:() => planningOwnerLogin,
});
const weekPlan = createWeekPlan({
fetchJson:fetchReviewJson,localDate:todayRollover.localDate,timeZone:todayRollover.timeZone,
});
function openWeekPlanner(trigger) { planningTomorrow=false; return weekWorkflow.open(trigger); }
function renderTomorrowQueueSummary(value) {
qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary();
}
@@ -445,27 +451,9 @@
storage: localStorage,
getLogin: () => planningOwnerLogin,
});
async function promoteTomorrowIfDue(plan) {
try {
await tomorrowPlan.load();
const promoted = await tomorrowPlan.promote(plan.revision);
if (!promoted) return false;
todayWork.replace(promoted.ids);
todayWork.replacePlanning({
capacity_minutes:promoted.capacity_minutes ?? null,
estimates:promoted.estimates || {},
});
refreshMyWorkView();
warmTodayOffline();
qs('#my-work-action-status').textContent =
'Your saved Tomorrow plan is now Today.';
return true;
} catch (error) {
qs('#my-work-action-status').textContent =
`${error.message || 'Tomorrow needs review before promotion.'} Open Plan Tomorrow to review.`;
return false;
}
}
const weekWorkflow=createWeekPlanWorkflow({controller:weekPlan,qs,getLogin:()=>planningOwnerLogin,
openPlanner:openPlanToday,escapeHtml,escapeAttribute:escAttr,todayWork,
refresh:refreshMyWorkView,warm:warmTodayOffline});
const todaySync = createTodaySync({
storage: localStorage,
getLogin: () => planningOwnerLogin,
@@ -480,7 +468,7 @@
if (!planningOwnerLogin) return;
latestTodayPlan = plan;
const startDayLaunch = window.location.hash === '#/my-work/start-day';
promoteTomorrowIfDue(plan).finally(() => {
weekWorkflow.promote(plan).finally(() => {
if (!startDayLaunch) return;
window.history.replaceState({}, '', '#/my-work/today');
openMobileStartDay();
@@ -523,7 +511,7 @@
documentObject:document,
check:() => Promise.all([
syncPendingTomorrow(),
latestTodayPlan ? promoteTomorrowIfDue(latestTodayPlan) : false,
latestTodayPlan ? weekWorkflow.promote(latestTodayPlan) : false,
]),
});
const todayHandoff = createTodayHandoff({
@@ -2709,6 +2697,7 @@
}
planToday.cancel();
planningTomorrow = false;
weekWorkflow.clear();
qs('#tomorrow-conflict-review').hidden = true;
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
qs('#plan-today-sheet').hidden = true;
@@ -2765,7 +2754,8 @@
const planToday = createPlanToday({
identity: item => todayWork.identity(item),
limit: todayWork.limit,
save: plan => planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan),
save: plan => weekWorkflow.active() ? weekWorkflow.save(plan) :
(planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)),
start: () => {
qs('[data-work-filter="today"]').click();
startTodaySession();
@@ -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.');
+3
View File
@@ -460,6 +460,7 @@
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
<button id="cancel-plan-today" type="button">Cancel</button>
</div>
<nav class="week-plan-dates" id="week-plan-dates" aria-label="Week Ahead dates" hidden></nav>
<section class="tomorrow-conflict-review" id="tomorrow-conflict-review" aria-labelledby="tomorrow-conflict-title" hidden>
<div class="small">Cross-device change</div>
<h3 id="tomorrow-conflict-title">Choose which Tomorrow plan to keep</h3>
@@ -1842,6 +1843,7 @@
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="week" type="button"><span><strong>Week Ahead</strong><small id="mobile-week-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
@@ -1952,6 +1954,7 @@
<script src="static/plan-today-readiness.js"></script>
<script src="static/plan-today-preview.js"></script>
<script src="static/tomorrow-plan.js"></script>
<script src="static/week-plan.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/today-rollover.js"></script>
<script src="static/update-ownership.js"></script>
+1
View File
@@ -124,6 +124,7 @@ const SHELL = [
BASE + 'static/plan-today-readiness.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/tomorrow-plan.js',
BASE + 'static/week-plan.js',
BASE + 'static/today-sync.js',
BASE + 'static/today-rollover.js',
BASE + 'static/update-ownership.js',
+106
View File
@@ -0,0 +1,106 @@
function createWeekPlan({fetchJson,localDate,timeZone}={}) {
let week={revision:0,timezone:null,days:[]};
let lastConflict=null;
const cloneDay=day=>({
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
estimates:{...(day.estimates||{})},
});
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay)});
function adopt(value) {
if(!Number.isInteger(value?.revision)||!Array.isArray(value?.days)) return false;
week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay)
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
lastConflict=null;
return state();
}
function addDays(value, amount) {
const [year,month,day]=value.split('-').map(Number);
return new Date(Date.UTC(year,month-1,day+amount)).toISOString().slice(0,10);
}
function dates() {
return Array.from({length:7},(_,index)=>{
const date=addDays(localDate(),index+1);
const parsed=new Date(date+'T12:00:00Z');
return {date,label:new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(parsed)};
});
}
function day(planDate) {
const found=week.days.find(item=>item.plan_date===planDate);
return found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}};
}
async function load() { return adopt(await fetchJson('api/v1/week')); }
async function saveDay(planDate, value) {
const local={revision:week.revision,timezone:timeZone(),days:week.days
.filter(item=>item.plan_date!==planDate).concat([{
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
estimates:{...(value.estimates||{})},
}]).sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
const body={base_revision:local.revision,timezone:local.timezone,days:local.days};
try {
return adopt(await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
} catch(error) {
if(error?.status===409) {
const remote=await fetchJson('api/v1/week');
lastConflict={local,remote:{revision:remote.revision,timezone:remote.timezone,days:remote.days.map(cloneDay)}};
}
throw error;
}
}
function conflict() {
return lastConflict?{
local:{...lastConflict.local,days:lastConflict.local.days.map(cloneDay)},
remote:{...lastConflict.remote,days:lastConflict.remote.days.map(cloneDay)},
}:null;
}
async function promote(todayRevision) {
const due=week.days.find(item=>item.plan_date<=localDate()&&item.ids.length);
if(!due) return false;
return fetchJson('api/v1/week/promote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
promotion_id:`week-${due.plan_date}-r${week.revision}`,week_revision:week.revision,
plan_date:due.plan_date,today_revision:todayRevision,
})});
}
function summary() {
const planned=week.days.filter(item=>item.ids.length);
const items=planned.reduce((total,item)=>total+item.ids.length,0);
return planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
}
return {adopt,state,dates,day,load,saveDay,conflict,promote,summary};
}
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute,
todayWork,refresh,warm}={}) {
let selectedDate=null;
function renderDates() {
const root=qs('#week-plan-dates');
root.hidden=!selectedDate;
if(!selectedDate)return;
root.innerHTML=controller.dates().map(item=>'<button type="button" data-week-plan-date="'+
escapeAttribute(item.date)+'"'+(item.date===selectedDate?' aria-current="date"':'')+'><strong>'+escapeHtml(item.label)+
'</strong><small>'+escapeHtml(controller.day(item.date).ids.length+' planned')+'</small></button>').join('');
root.querySelectorAll('[data-week-plan-date]').forEach(button=>button.addEventListener('click',()=>{
selectedDate=button.dataset.weekPlanDate;renderDates();openPlanner(null,false);
}));
}
async function open(trigger) {
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;