feat: plan the next seven days on mobile (Closes #1177)
This commit is contained in:
1 parent
880d1c4a1f
commit
7d8db5f459
16 files changed
+707
-42
No files matched your search
@@ -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
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user