feat: review and rebalance Week Ahead (Closes #1188)
This commit is contained in:
1 parent
69532bdc1f
commit
d5f13c4afb
21 files changed
+351
-53
No files matched your search
@@ -294,6 +294,30 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.week-conflict-plans { grid-template-columns:1fr; }
|
||||
.week-conflict-actions { flex-direction:column; }
|
||||
}
|
||||
.week-review { margin-top:14px; }
|
||||
.week-review h2 { margin-bottom:6px; }
|
||||
.week-review-days { display:grid; gap:12px; }
|
||||
.week-review-day { padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-review-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
|
||||
.week-review-day h3, .week-review-day p { margin:0 0 8px; }
|
||||
.week-review-load { color:#bfdbfe; font-weight:700; }
|
||||
.week-review-day ul { display:grid; gap:10px; margin:0; padding:0; list-style:none; }
|
||||
.week-review-day li { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; padding-top:8px; border-top:1px solid #31577f; overflow-wrap:anywhere; }
|
||||
.week-review-day label { display:grid; gap:4px; font-size:12px; }
|
||||
.week-review-day select, .week-review-day button { min-height:44px; }
|
||||
.week-review-day button { min-height:44px; }
|
||||
.week-review-duplicates { margin:12px 0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; }
|
||||
.week-review-status { min-height:1.4em; margin:10px 0; }
|
||||
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
.plan-today-sheet.week-review-mode .week-plan-dates,
|
||||
.plan-today-sheet.week-review-mode .mobile-plan-today-nav,
|
||||
.plan-today-sheet.week-review-mode #plan-today-fit,
|
||||
.plan-today-sheet.week-review-mode #plan-today-selected,
|
||||
.plan-today-sheet.week-review-mode #plan-today-available-work,
|
||||
.plan-today-sheet.week-review-mode .plan-today-actions { display:none; }
|
||||
@media (max-width:480px) {
|
||||
.week-review-day li { grid-template-columns:1fr; }
|
||||
}
|
||||
.mobile-plan-today-nav { position:sticky; top:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px; margin:0 -18px 10px; padding:4px 18px; background:rgba(11,21,38,.98); border-block:1px solid #2a496e; }
|
||||
.mobile-plan-today-nav button { min-width:0; min-height:44px; padding:4px; border-color:transparent; font-size:12px; }
|
||||
.mobile-plan-today-nav button[aria-current="location"] { color:#bfdbfe; background:#17365a; border-color:#31577f; }
|
||||
|
||||
+11
-1
@@ -453,7 +453,8 @@
|
||||
getLogin: () => planningOwnerLogin,
|
||||
});
|
||||
const weekWorkflow=createWeekPlanWorkflow({controller:weekPlan,qs,getLogin:()=>planningOwnerLogin,
|
||||
openPlanner:openPlanToday,escapeHtml,escapeAttribute:escAttr,todayWork,
|
||||
openPlanner:openPlanToday,setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value),
|
||||
escapeHtml,escapeAttribute:escAttr,todayWork,
|
||||
refresh:refreshMyWorkView,warm:warmTodayOffline});
|
||||
const todaySync = createTodaySync({
|
||||
storage: localStorage,
|
||||
@@ -7896,6 +7897,15 @@
|
||||
}
|
||||
});
|
||||
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
|
||||
qs('#confirm-week-plan').addEventListener('click', () => {
|
||||
if(!weekWorkflow.confirm()){
|
||||
qs('#week-review-status').textContent=weekPlan.pending()?'Wait for Week Ahead to finish syncing before confirming.':'Move duplicated work to one date before confirming.';
|
||||
return;
|
||||
}
|
||||
qs('#mobile-week-summary').textContent=weekPlan.summary();
|
||||
qs('#my-work-action-status').textContent='Week Ahead reviewed and confirmed.';
|
||||
taskOverlayHistory.leave();
|
||||
});
|
||||
qs('#plan-today-sheet').addEventListener('click', event => {
|
||||
if (event.target === qs('#plan-today-sheet')) closePlanToday();
|
||||
});
|
||||
|
||||
@@ -504,6 +504,15 @@
|
||||
</div>
|
||||
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden>
|
||||
<div class="small">Final planning step</div>
|
||||
<h2 id="week-review-title">Review & rebalance Week Ahead</h2>
|
||||
<p class="small muted">Check each day’s load. Move work instead of copying it, then confirm one executable week.</p>
|
||||
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
|
||||
<div class="week-review-days" id="week-review-days"></div>
|
||||
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
|
||||
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
|
||||
</section>
|
||||
<nav class="mobile-plan-today-nav" aria-label="Plan Today sections">
|
||||
<button type="button" data-plan-today-section="fit" aria-current="location">Fit</button>
|
||||
<button type="button" data-plan-today-section="today">Today</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v127';
|
||||
const CACHE = 'stackchain-dashboard-shell-v128';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
||||
+124
-26
@@ -60,14 +60,11 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||
if(queued){week=queued;return state();}
|
||||
return adopt(await fetchJson('api/v1/week'));
|
||||
}
|
||||
function stageDay(planDate,value) {
|
||||
function stageDays(days) {
|
||||
const key=storageKey();
|
||||
if(!key||!storage) return false;
|
||||
const queued={base_revision:Number.isInteger(week.base_revision)?week.base_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)),
|
||||
timezone:timeZone(),days:days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),
|
||||
base_days:(week.base_days||week.days).map(cloneDay)};
|
||||
try { storage.setItem(key,JSON.stringify(queued)); }
|
||||
catch(_error) { return false; }
|
||||
@@ -75,6 +72,41 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||
week={revision:queued.base_revision,...queued,sync_pending:true};
|
||||
return state();
|
||||
}
|
||||
function stageDay(planDate,value) {
|
||||
return stageDays(week.days.filter(item=>item.plan_date!==planDate).concat([{
|
||||
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
|
||||
estimates:{...(value.estimates||{})},
|
||||
}]));
|
||||
}
|
||||
function review() {
|
||||
const assigned=new Map();
|
||||
const days=dates().map(item=>{
|
||||
const value=day(item.date),estimates=value.estimates||{};
|
||||
value.ids.forEach(id=>assigned.set(id,[...(assigned.get(id)||[]),item.date]));
|
||||
const planned_minutes=value.ids.reduce((total,id)=>total+(Number(estimates[id])||0),0);
|
||||
const capacity_minutes=Number(value.capacity_minutes)||0;
|
||||
return {...value,label:item.label,planned_minutes,
|
||||
overloaded:Boolean(capacity_minutes&&planned_minutes>capacity_minutes)};
|
||||
});
|
||||
const duplicates=[...assigned.entries()].filter(([_id,assignedDates])=>assignedDates.length>1)
|
||||
.map(([id,assignedDates])=>({id,dates:assignedDates}));
|
||||
return {days,duplicates,can_confirm:duplicates.length===0};
|
||||
}
|
||||
function move(id,toDate) {
|
||||
if(!dates().some(item=>item.date===toDate)) return false;
|
||||
const sources=week.days.filter(item=>(item.ids||[]).includes(id));
|
||||
if(!sources.length) return false;
|
||||
const estimate=sources.map(item=>Number(item.estimates?.[id])).find(Number.isFinite);
|
||||
const moved=week.days.map(item=>{
|
||||
const estimates={...(item.estimates||{})};delete estimates[id];
|
||||
return {...cloneDay(item),ids:(item.ids||[]).filter(value=>value!==id),estimates};
|
||||
});
|
||||
let destination=moved.find(item=>item.plan_date===toDate);
|
||||
if(!destination){destination={plan_date:toDate,ids:[],capacity_minutes:null,estimates:{}};moved.push(destination);}
|
||||
destination.ids.push(id);
|
||||
if(Number.isFinite(estimate)) destination.estimates[id]=estimate;
|
||||
return Boolean(stageDays(moved));
|
||||
}
|
||||
function deliveryBody(value) {
|
||||
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay)};
|
||||
}
|
||||
@@ -96,34 +128,53 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||
}
|
||||
function flush() {
|
||||
if(flushing) return flushing;
|
||||
const queued=pending();
|
||||
const key=storageKey();
|
||||
if(!queued||!key) return Promise.resolve(false);
|
||||
const body=deliveryBody(queued);
|
||||
flushing=fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
||||
.then(saved=>{
|
||||
const current=pending();
|
||||
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
|
||||
if(!pending()) adopt(saved);
|
||||
return saved;
|
||||
}).catch(async error=>{
|
||||
if(!pending()||!key) return Promise.resolve(false);
|
||||
const sameBody=(value,body)=>JSON.stringify(deliveryBody(value))===JSON.stringify(body);
|
||||
const deliver=async()=>{
|
||||
const queued=pending();
|
||||
if(!queued)return false;
|
||||
const body=deliveryBody(queued);
|
||||
let saved;
|
||||
try {
|
||||
saved=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');
|
||||
const local=pending();
|
||||
const merged=local&&reconcile(local,remote);
|
||||
if(merged&&!merged.conflicts.length){
|
||||
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days})});
|
||||
const current=pending();
|
||||
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
|
||||
if(!pending()) adopt(saved);
|
||||
if(current&&sameBody(current,deliveryBody(local)))storage.removeItem(key);
|
||||
if(!pending())adopt(saved);
|
||||
return saved;
|
||||
}
|
||||
lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
||||
days:remote.days.map(cloneDay)},merged,choices:{...(local.resolutions||{})}};
|
||||
days:remote.days.map(cloneDay)},merged,choices:{...(local?.resolutions||{})}};
|
||||
} else {
|
||||
const current=pending();
|
||||
if(current&&!sameBody(current,body))return deliver();
|
||||
}
|
||||
throw error;
|
||||
}).finally(()=>{flushing=null;});
|
||||
}
|
||||
const current=pending();
|
||||
if(current&&sameBody(current,body)){
|
||||
storage.removeItem(key);adopt(saved);return saved;
|
||||
}
|
||||
if(current){
|
||||
try {
|
||||
const raw=JSON.parse(storage.getItem(key));
|
||||
raw.base_revision=saved.revision;raw.base_days=saved.days.map(cloneDay);
|
||||
storage.setItem(key,JSON.stringify(raw));
|
||||
week={revision:raw.base_revision,...raw,sync_pending:true};
|
||||
} catch(_error){return saved;}
|
||||
return deliver();
|
||||
}
|
||||
adopt(saved);return saved;
|
||||
};
|
||||
flushing=deliver().finally(()=>{flushing=null;});
|
||||
return flushing;
|
||||
}
|
||||
async function saveDay(planDate, value) {
|
||||
@@ -237,15 +288,22 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||
const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
|
||||
return label+(pending()?' · sync pending':'');
|
||||
}
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,pending,flush,conflict,chooseDay,saveMerged,
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,review,move,pending,flush,conflict,chooseDay,saveMerged,
|
||||
keepLocal,useRemote,promote,summary};
|
||||
}
|
||||
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute,
|
||||
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
|
||||
todayWork,refresh,warm}={}) {
|
||||
let selectedDate=null;
|
||||
let reviewing=false;
|
||||
let blockedReviewOpen=false;
|
||||
function renderPass() {
|
||||
const progress=qs('#week-plan-progress'),save=qs('#save-today-plan');
|
||||
if(reviewing){
|
||||
const value=controller.review();
|
||||
progress.hidden=false;
|
||||
progress.textContent=`Review week · ${value.days.filter(day=>day.ids.length).length} planned`;
|
||||
return;
|
||||
}
|
||||
if(!selectedDate){progress.hidden=true;return;}
|
||||
const current=controller.pass(selectedDate);
|
||||
progress.hidden=false;
|
||||
@@ -296,8 +354,38 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
||||
selectedDate=button.dataset.weekPlanDate;renderDates();openPlanner(null,false);
|
||||
}));
|
||||
}
|
||||
function renderReview() {
|
||||
const value=controller.review(),root=qs('#week-review-days'),duplicates=qs('#week-review-duplicates');
|
||||
const destinations=value.days.map(day=>'<option value="'+escapeAttribute(day.plan_date)+'">'+escapeHtml(day.label)+'</option>').join('');
|
||||
root.innerHTML=value.days.map(day=>{
|
||||
const capacity=Number(day.capacity_minutes)||0;
|
||||
const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+(day.overloaded?' · over capacity':'');
|
||||
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li><code>'+escapeHtml(id)+'</code><label>Move to <select data-week-move-destination="'+
|
||||
escapeAttribute(id)+'">'+destinations+'</select></label><button type="button" data-week-move="'+escapeAttribute(id)+'">Move</button></li>').join('')+'</ul>':
|
||||
'<p class="small muted">Nothing planned.</p>';
|
||||
return '<article class="week-review-day'+(day.overloaded?' is-overloaded':'')+'"><h3>'+escapeHtml(day.label)+'</h3><p class="week-review-load">'+
|
||||
escapeHtml(load)+'</p>'+items+'</article>';
|
||||
}).join('');
|
||||
duplicates.hidden=!value.duplicates.length;
|
||||
duplicates.innerHTML=value.duplicates.length?'<strong>Choose one date for duplicated work before confirming.</strong><ul>'+value.duplicates.map(item=>
|
||||
'<li><code>'+escapeHtml(item.id)+'</code> · '+escapeHtml(item.dates.join(', '))+'</li>').join('')+'</ul>':'';
|
||||
const confirm=qs('#confirm-week-plan');
|
||||
const pending=Boolean(controller.pending?.());
|
||||
confirm.disabled=!value.can_confirm||pending;
|
||||
qs('#week-review-status').textContent=value.duplicates.length?'Duplicate work must be moved to one date.':
|
||||
(pending?'Saving the latest week to your account…':'Week Ahead is balanced and ready.');
|
||||
root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{
|
||||
const selector=root.querySelector(`[data-week-move-destination="${button.dataset.weekMove}"]`);
|
||||
if(!controller.move(button.dataset.weekMove,selector?.value))return;
|
||||
renderReview();renderPass();
|
||||
controller.flush().then(()=>{renderReview();qs('#mobile-week-summary').textContent=controller.summary();})
|
||||
.catch(error=>{qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Changes remain on this phone.';});
|
||||
}));
|
||||
renderPass();
|
||||
}
|
||||
async function open(trigger) {
|
||||
trigger.disabled=true;selectedDate=controller.dates()[0].date;renderDates();openPlanner(trigger);
|
||||
trigger.disabled=true;reviewing=false;setReviewMode(false);const reviewRoot=qs('#week-review');if(reviewRoot)reviewRoot.hidden=true;
|
||||
selectedDate=controller.dates()[0].date;renderDates();openPlanner(trigger);
|
||||
qs('#mobile-week-summary').textContent='Loading Week Ahead…';
|
||||
try{await controller.load();qs('#mobile-week-summary').textContent=controller.summary();renderDates();openPlanner(null,false);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;}
|
||||
@@ -309,7 +397,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
||||
if(!staged){qs('#my-work-action-status').textContent='Week Ahead could not be saved on this phone. Free browser storage and retry.';return false;}
|
||||
qs('#mobile-week-summary').textContent=controller.summary();
|
||||
qs('#my-work-action-status').textContent='Week Ahead saved on this phone · sync pending.';
|
||||
controller.flush().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+'.';})
|
||||
controller.flush().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+'.';if(reviewing)renderReview();})
|
||||
.catch(error=>{
|
||||
const conflict=controller.conflict();
|
||||
qs('#mobile-week-summary').textContent=conflict?'Conflict · review required':controller.summary();
|
||||
@@ -321,12 +409,21 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
||||
}
|
||||
function advance() {
|
||||
const current=selectedDate&&controller.pass(selectedDate);
|
||||
if(current?.last){
|
||||
reviewing=true;selectedDate=null;setReviewMode(true);qs('#week-review').hidden=false;
|
||||
renderDates();renderReview();openPlanner(null,false);qs('#confirm-week-plan').focus?.();return true;
|
||||
}
|
||||
if(!current?.next_date) return false;
|
||||
selectedDate=current.next_date;renderDates();openPlanner(null,false);
|
||||
const selected=qs('#week-plan-dates').querySelector(`[data-week-plan-date="${selectedDate}"]`);
|
||||
selected?.scrollIntoView({block:'nearest',inline:'center'});selected?.focus?.();
|
||||
return true;
|
||||
}
|
||||
function confirm() {
|
||||
const value=controller.review();
|
||||
if(!reviewing||!value.can_confirm||controller.pending()) return false;
|
||||
reviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;
|
||||
}
|
||||
async function promote(plan){
|
||||
try{await controller.load();const promoted=await controller.promote(plan.revision);if(!promoted)return false;blockedReviewOpen=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){
|
||||
@@ -339,10 +436,11 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
||||
qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;
|
||||
}
|
||||
}
|
||||
return {open,save,advance,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate),selectedDate:()=>selectedDate,
|
||||
return {open,save,advance,confirm,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate)||reviewing,
|
||||
reviewing:()=>reviewing,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();}};
|
||||
clear(){selectedDate=null;reviewing=false;setReviewMode(false);qs('#week-review').hidden=true;renderDates();}};
|
||||
}
|
||||
if(typeof module!=='undefined'&&module.exports){
|
||||
module.exports=createWeekPlan;
|
||||
|
||||
Reference in new issue
Block a user