feat: persist weekly availability defaults (Closes #1256)
This commit is contained in:
parent
ea3204c78c
commit
1ae8533702
|
|
@ -392,6 +392,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.week-capacity-day span { color:#bfdbfe; font-weight:700; }
|
.week-capacity-day span { color:#bfdbfe; font-weight:700; }
|
||||||
.week-capacity-day small { color:#a9bdd3; }
|
.week-capacity-day small { color:#a9bdd3; }
|
||||||
#apply-week-capacities { width:100%; min-height:48px; position:sticky; bottom:0; }
|
#apply-week-capacities { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||||
|
.week-availability-editor { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||||
|
#open-week-availability { min-height:44px; }
|
||||||
|
.week-availability-editor h3 { margin-top:0; }
|
||||||
|
.week-availability-grid { display:grid; grid-template-columns:1fr; gap:8px; }
|
||||||
|
.week-availability-grid label { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,120px); gap:10px; align-items:center; }
|
||||||
|
.week-availability-grid input { min-height:44px; min-width:0; box-sizing:border-box; }
|
||||||
|
.week-availability-actions { display:grid; grid-template-columns:1fr; gap:8px; margin-top:12px; }
|
||||||
|
.week-availability-actions button { min-height:44px; }
|
||||||
|
@media (min-width:600px) { .week-availability-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .week-availability-actions { grid-template-columns:repeat(3,minmax(0,1fr)); } }
|
||||||
@media (max-width:359px) { .week-capacity-hours { grid-template-columns:1fr; } }
|
@media (max-width:359px) { .week-capacity-hours { grid-template-columns:1fr; } }
|
||||||
.week-calendar-handoff h2 { margin-bottom:6px; }
|
.week-calendar-handoff h2 { margin-bottom:6px; }
|
||||||
.week-calendar-days { display:grid; gap:12px; margin:14px 0; }
|
.week-calendar-days { display:grid; gap:12px; margin:14px 0; }
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,26 @@
|
||||||
<p class="week-offline-snapshot" id="week-offline-snapshot" role="status" aria-live="polite" hidden></p>
|
<p class="week-offline-snapshot" id="week-offline-snapshot" role="status" aria-live="polite" hidden></p>
|
||||||
<button id="retry-week-live" type="button" hidden>Retry live Week Ahead</button>
|
<button id="retry-week-live" type="button" hidden>Retry live Week Ahead</button>
|
||||||
<button id="open-week-capacity-import" type="button">Set capacity from calendar</button>
|
<button id="open-week-capacity-import" type="button">Set capacity from calendar</button>
|
||||||
|
<button id="open-week-availability" type="button">Set weekly availability</button>
|
||||||
|
<section class="week-availability-editor" id="week-availability-editor" aria-labelledby="week-availability-title" hidden>
|
||||||
|
<h3 id="week-availability-title">Weekly availability</h3>
|
||||||
|
<p class="small muted">New Week Ahead dates use these defaults. Capacity set on a specific date remains an override.</p>
|
||||||
|
<div class="week-availability-grid">
|
||||||
|
<label>Monday <input data-weekday-capacity="0" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||||
|
<label>Tuesday <input data-weekday-capacity="1" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||||
|
<label>Wednesday <input data-weekday-capacity="2" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||||
|
<label>Thursday <input data-weekday-capacity="3" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||||
|
<label>Friday <input data-weekday-capacity="4" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||||
|
<label>Saturday <input data-weekday-capacity="5" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||||
|
<label>Sunday <input data-weekday-capacity="6" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="week-availability-actions">
|
||||||
|
<button id="cancel-week-availability" type="button">Cancel</button>
|
||||||
|
<button id="disable-week-availability" type="button">Disable defaults</button>
|
||||||
|
<button id="save-week-availability" type="button">Save weekly availability</button>
|
||||||
|
</div>
|
||||||
|
<div id="week-availability-status" class="small" role="status" aria-live="assertive"></div>
|
||||||
|
</section>
|
||||||
<button id="open-week-reflow" type="button" hidden>Reflow remaining week</button>
|
<button id="open-week-reflow" type="button" hidden>Reflow remaining week</button>
|
||||||
<section class="week-reflow-review" id="week-reflow-review" aria-labelledby="week-reflow-title" hidden>
|
<section class="week-reflow-review" id="week-reflow-review" aria-labelledby="week-reflow-title" hidden>
|
||||||
<div class="small">Capacity-safe preview</div>
|
<div class="small">Capacity-safe preview</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
const storagePrefix='stackchain.week-sync.v1.';
|
const storagePrefix='stackchain.week-sync.v1.';
|
||||||
const confirmedPrefix='stackchain.week-confirmed.v1.';
|
const confirmedPrefix='stackchain.week-confirmed.v1.';
|
||||||
const pullPrefix='stackchain.week-today-pull.v1.';
|
const pullPrefix='stackchain.week-today-pull.v1.';
|
||||||
|
const cloneDefaults=value=>Array.isArray(value)&&value.length===7?value.slice():null;
|
||||||
const cloneDay=day=>({
|
const cloneDay=day=>({
|
||||||
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
|
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
|
||||||
estimates:{...(day.estimates||{})},
|
estimates:{...(day.estimates||{})},
|
||||||
|
|
@ -25,6 +26,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay),
|
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay),
|
||||||
|
...(cloneDefaults(week.availability_defaults)?{availability_defaults:cloneDefaults(week.availability_defaults)}:{}),
|
||||||
offline_snapshot:offlineSnapshot,...(refreshedAt?{refreshed_at:refreshedAt}:{}),
|
offline_snapshot:offlineSnapshot,...(refreshedAt?{refreshed_at:refreshedAt}:{}),
|
||||||
...(week.sync_pending?{base_revision:week.base_revision,sync_pending:true}:{})});
|
...(week.sync_pending?{base_revision:week.base_revision,sync_pending:true}:{})});
|
||||||
function storageKey() {
|
function storageKey() {
|
||||||
|
|
@ -81,7 +83,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
const key=confirmedKey();
|
const key=confirmedKey();
|
||||||
if(!key||!storage||week.sync_pending)return false;
|
if(!key||!storage||week.sync_pending)return false;
|
||||||
const record={refreshed_at:new Date(now()).toISOString(),week:{revision:week.revision,
|
const record={refreshed_at:new Date(now()).toISOString(),week:{revision:week.revision,
|
||||||
timezone:week.timezone,days:week.days.map(cloneDay)},items:{...confirmedItems}};
|
timezone:week.timezone,days:week.days.map(cloneDay),
|
||||||
|
...(cloneDefaults(week.availability_defaults)?{availability_defaults:cloneDefaults(week.availability_defaults)}:{})},items:{...confirmedItems}};
|
||||||
try {storage.setItem(key,JSON.stringify(record));refreshedAt=record.refreshed_at;return true;}
|
try {storage.setItem(key,JSON.stringify(record));refreshedAt=record.refreshed_at;return true;}
|
||||||
catch(_error){return false;}
|
catch(_error){return false;}
|
||||||
}
|
}
|
||||||
|
|
@ -96,6 +99,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
const value=JSON.parse(storage.getItem(key)||'null');
|
const value=JSON.parse(storage.getItem(key)||'null');
|
||||||
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{
|
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{
|
||||||
revision:value.base_revision,base_revision:value.base_revision,timezone:value.timezone||null,
|
revision:value.base_revision,base_revision:value.base_revision,timezone:value.timezone||null,
|
||||||
|
...(cloneDefaults(value.availability_defaults)?{availability_defaults:cloneDefaults(value.availability_defaults)}:{}),
|
||||||
days:value.days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),sync_pending:true,
|
days:value.days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),sync_pending:true,
|
||||||
items:sanitizeItems(value.days,value.items||{}),
|
items:sanitizeItems(value.days,value.items||{}),
|
||||||
...(Array.isArray(value.base_days)?{base_days:value.base_days.map(cloneDay)
|
...(Array.isArray(value.base_days)?{base_days:value.base_days.map(cloneDay)
|
||||||
|
|
@ -108,6 +112,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
if(!Number.isInteger(value?.revision)||!Array.isArray(value?.days)) return false;
|
if(!Number.isInteger(value?.revision)||!Array.isArray(value?.days)) return false;
|
||||||
week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay)
|
week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay)
|
||||||
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
|
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
|
||||||
|
if(cloneDefaults(value.availability_defaults))week.availability_defaults=cloneDefaults(value.availability_defaults);
|
||||||
offlineSnapshot=false;
|
offlineSnapshot=false;
|
||||||
pendingItems={};
|
pendingItems={};
|
||||||
lastConflict=null;
|
lastConflict=null;
|
||||||
|
|
@ -131,7 +136,12 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
}
|
}
|
||||||
function day(planDate) {
|
function day(planDate) {
|
||||||
const found=week.days.find(item=>item.plan_date===planDate);
|
const found=week.days.find(item=>item.plan_date===planDate);
|
||||||
return found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}};
|
if(found&&found.capacity_minutes!==null&&found.capacity_minutes!==undefined)
|
||||||
|
return {...cloneDay(found),capacity_source:'override'};
|
||||||
|
const parsed=new Date(planDate+'T12:00:00Z'),weekday=(parsed.getUTCDay()+6)%7;
|
||||||
|
const inherited=cloneDefaults(week.availability_defaults)?.[weekday];
|
||||||
|
return {...(found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}}),
|
||||||
|
...(Number.isInteger(inherited)?{capacity_minutes:inherited,capacity_source:'default'}:{capacity_source:'unset'})};
|
||||||
}
|
}
|
||||||
function pass(planDate) {
|
function pass(planDate) {
|
||||||
const available=dates(),index=available.findIndex(item=>item.date===planDate);
|
const available=dates(),index=available.findIndex(item=>item.date===planDate);
|
||||||
|
|
@ -166,6 +176,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
const queued={base_revision:Number.isInteger(week.base_revision)?week.base_revision:week.revision,
|
const queued={base_revision:Number.isInteger(week.base_revision)?week.base_revision:week.revision,
|
||||||
timezone:timeZone(),days:days.map(cloneDay).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)};
|
base_days:(week.base_days||week.days).map(cloneDay)};
|
||||||
|
if(cloneDefaults(week.availability_defaults))queued.availability_defaults=cloneDefaults(week.availability_defaults);
|
||||||
queued.items=sanitizeItems(queued.days,{...confirmedItems,...pendingItems,...(pending()?.items||{})});
|
queued.items=sanitizeItems(queued.days,{...confirmedItems,...pendingItems,...(pending()?.items||{})});
|
||||||
try { storage.setItem(key,JSON.stringify(queued)); }
|
try { storage.setItem(key,JSON.stringify(queued)); }
|
||||||
catch(_error) { return false; }
|
catch(_error) { return false; }
|
||||||
|
|
@ -204,6 +215,22 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
const value=day(planDate);value.capacity_minutes=capacities.get(planDate);return value;
|
const value=day(planDate);value.capacity_minutes=capacities.get(planDate);return value;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
function stageAvailabilityDefaults(values) {
|
||||||
|
if(!cloneDefaults(values)||values.some(value=>!Number.isInteger(value)||value<0||value>1440)||offlineSnapshot)return false;
|
||||||
|
const previous=week.availability_defaults;
|
||||||
|
week.availability_defaults=values.slice();
|
||||||
|
const staged=stageDays(week.days);
|
||||||
|
if(!staged)week.availability_defaults=previous;
|
||||||
|
return staged;
|
||||||
|
}
|
||||||
|
function disableAvailabilityDefaults() {
|
||||||
|
if(offlineSnapshot)return false;
|
||||||
|
const previous=week.availability_defaults;
|
||||||
|
delete week.availability_defaults;
|
||||||
|
const staged=stageDays(week.days);
|
||||||
|
if(!staged&&previous)week.availability_defaults=previous;
|
||||||
|
return staged;
|
||||||
|
}
|
||||||
function review() {
|
function review() {
|
||||||
const assigned=new Map();
|
const assigned=new Map();
|
||||||
const days=dates().map(item=>{
|
const days=dates().map(item=>{
|
||||||
|
|
@ -322,7 +349,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
return Boolean(stageDays(changed));
|
return Boolean(stageDays(changed));
|
||||||
}
|
}
|
||||||
function deliveryBody(value) {
|
function deliveryBody(value) {
|
||||||
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay)};
|
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay),
|
||||||
|
...(cloneDefaults(value.availability_defaults)?{availability_defaults:cloneDefaults(value.availability_defaults)}:{})};
|
||||||
}
|
}
|
||||||
function reconcile(local,remote) {
|
function reconcile(local,remote) {
|
||||||
if(!Array.isArray(local.base_days)) return null;
|
if(!Array.isArray(local.base_days)) return null;
|
||||||
|
|
@ -359,14 +387,16 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
const merged=local&&reconcile(local,remote);
|
const merged=local&&reconcile(local,remote);
|
||||||
if(merged&&!merged.conflicts.length){
|
if(merged&&!merged.conflicts.length){
|
||||||
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})});
|
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days,
|
||||||
|
...(cloneDefaults(local.availability_defaults)?{availability_defaults:cloneDefaults(local.availability_defaults)}:{})})});
|
||||||
const current=pending();
|
const current=pending();
|
||||||
if(current&&sameBody(current,deliveryBody(local)))storage.removeItem(key);
|
if(current&&sameBody(current,deliveryBody(local)))storage.removeItem(key);
|
||||||
if(!pending())adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
|
if(!pending())adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
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),...(cloneDefaults(remote.availability_defaults)?
|
||||||
|
{availability_defaults:cloneDefaults(remote.availability_defaults)}:{})},merged,choices:{...(local?.resolutions||{})}};
|
||||||
} else {
|
} else {
|
||||||
const current=pending();
|
const current=pending();
|
||||||
if(current&&!sameBody(current,body))return deliver();
|
if(current&&!sameBody(current,body))return deliver();
|
||||||
|
|
@ -399,13 +429,16 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
|
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
|
||||||
estimates:{...(value.estimates||{})},
|
estimates:{...(value.estimates||{})},
|
||||||
}]).sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
|
}]).sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
|
||||||
const body={base_revision:local.revision,timezone:local.timezone,days:local.days};
|
const body={base_revision:local.revision,timezone:local.timezone,days:local.days,
|
||||||
|
...(cloneDefaults(week.availability_defaults)?{availability_defaults:cloneDefaults(week.availability_defaults)}:{})};
|
||||||
try {
|
try {
|
||||||
return adoptConfirmed(await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
|
return adoptConfirmed(await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
if(error?.status===409) {
|
if(error?.status===409) {
|
||||||
const remote=await fetchJson('api/v1/week');
|
const remote=await fetchJson('api/v1/week');
|
||||||
lastConflict={local,remote:{revision:remote.revision,timezone:remote.timezone,days:remote.days.map(cloneDay)}};
|
lastConflict={local:{...local,...(cloneDefaults(week.availability_defaults)?{availability_defaults:cloneDefaults(week.availability_defaults)}:{})},
|
||||||
|
remote:{revision:remote.revision,timezone:remote.timezone,days:remote.days.map(cloneDay),
|
||||||
|
...(cloneDefaults(remote.availability_defaults)?{availability_defaults:cloneDefaults(remote.availability_defaults)}:{})}};
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -443,7 +476,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
const item=lastConflict.merged.conflicts.find(value=>value.plan_date===day.plan_date);
|
const item=lastConflict.merged.conflicts.find(value=>value.plan_date===day.plan_date);
|
||||||
return item?cloneDay(choices[day.plan_date]==='account'?item.remote:item.local):cloneDay(day);
|
return item?cloneDay(choices[day.plan_date]==='account'?item.remote:item.local):cloneDay(day);
|
||||||
});
|
});
|
||||||
const local=lastConflict.local,body={base_revision:lastConflict.remote.revision,timezone:local.timezone,days};
|
const local=lastConflict.local,body={base_revision:lastConflict.remote.revision,timezone:local.timezone,days,
|
||||||
|
...(cloneDefaults(local.availability_defaults)?{availability_defaults:cloneDefaults(local.availability_defaults)}:{})};
|
||||||
try {
|
try {
|
||||||
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||||
const current=pending();
|
const current=pending();
|
||||||
|
|
@ -455,7 +489,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
if(error?.status===409){
|
if(error?.status===409){
|
||||||
const remote=await fetchJson('api/v1/week'),merged=reconcile(local,remote);
|
const remote=await fetchJson('api/v1/week'),merged=reconcile(local,remote);
|
||||||
lastConflict={key:storageKey(),local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
lastConflict={key:storageKey(),local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
||||||
days:remote.days.map(cloneDay)},merged,choices:{...(local.resolutions||{})}};
|
days:remote.days.map(cloneDay),...(cloneDefaults(remote.availability_defaults)?
|
||||||
|
{availability_defaults:cloneDefaults(remote.availability_defaults)}:{})},merged,choices:{...(local.resolutions||{})}};
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -476,7 +511,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
if(error?.status===409){
|
if(error?.status===409){
|
||||||
const remote=await fetchJson('api/v1/week');
|
const remote=await fetchJson('api/v1/week');
|
||||||
lastConflict={key:conflictKey,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
lastConflict={key:conflictKey,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
||||||
days:remote.days.map(cloneDay)}};
|
days:remote.days.map(cloneDay),...(cloneDefaults(remote.availability_defaults)?
|
||||||
|
{availability_defaults:cloneDefaults(remote.availability_defaults)}:{})}};
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -638,7 +674,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
|
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 label+(pending()?' · sync pending':'');
|
||||||
}
|
}
|
||||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,stageAvailabilityDefaults,disableAvailabilityDefaults,review,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||||
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,pullConflict,resolvePullConflict,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,pullConflict,resolvePullConflict,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
||||||
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
||||||
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
|
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
|
||||||
|
|
@ -669,7 +705,40 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
|
||||||
}
|
}
|
||||||
if(retry)retry.hidden=!offline;
|
if(retry)retry.hidden=!offline;
|
||||||
if(capacity)capacity.hidden=offline;
|
if(capacity)capacity.hidden=offline;
|
||||||
|
const availability=qs('#open-week-availability');if(availability)availability.hidden=offline;
|
||||||
}
|
}
|
||||||
|
function closeAvailability({focus=false}={}) {
|
||||||
|
const panel=qs('#week-availability-editor');if(panel)panel.hidden=true;
|
||||||
|
if(focus)qs('#open-week-availability')?.focus?.();
|
||||||
|
}
|
||||||
|
function openAvailability() {
|
||||||
|
const panel=qs('#week-availability-editor'),defaults=controller.state().availability_defaults||[0,0,0,0,0,0,0];
|
||||||
|
panel?.querySelectorAll?.('[data-weekday-capacity]').forEach(input=>{input.value=String(defaults[Number(input.dataset.weekdayCapacity)]||0);});
|
||||||
|
if(panel){panel.hidden=false;panel.querySelector?.('[data-weekday-capacity]')?.focus?.();}
|
||||||
|
}
|
||||||
|
async function saveAvailability() {
|
||||||
|
const panel=qs('#week-availability-editor'),inputs=[...(panel?.querySelectorAll?.('[data-weekday-capacity]')||[])];
|
||||||
|
const values=inputs.map(input=>Number(input.value));
|
||||||
|
const status=qs('#week-availability-status');
|
||||||
|
if(values.length!==7||values.some(value=>!Number.isInteger(value)||value<0||value>1440)){
|
||||||
|
if(status)status.textContent='Enter 0 to 1440 minutes for every weekday.';return false;
|
||||||
|
}
|
||||||
|
if(!controller.stageAvailabilityDefaults(values))return false;
|
||||||
|
closeAvailability();renderReview();
|
||||||
|
try{await controller.flush();renderReview();if(status)status.textContent='Weekly availability saved.';
|
||||||
|
qs('#week-review-status').textContent='Weekly availability saved.';return true;}
|
||||||
|
catch(error){if(status)status.textContent=(error.message||'Sync is unavailable.')+' Weekly availability remains saved on this phone.';return false;}
|
||||||
|
}
|
||||||
|
async function disableAvailability() {
|
||||||
|
if(!controller.disableAvailabilityDefaults?.())return false;
|
||||||
|
closeAvailability();renderReview();
|
||||||
|
try{await controller.flush();renderReview();qs('#week-review-status').textContent='Weekly availability disabled.';return true;}
|
||||||
|
catch(error){qs('#week-review-status').textContent=(error.message||'Sync is unavailable.')+' Change remains saved on this phone.';return false;}
|
||||||
|
}
|
||||||
|
qs('#open-week-availability')?.addEventListener('click',openAvailability);
|
||||||
|
qs('#cancel-week-availability')?.addEventListener('click',()=>closeAvailability({focus:true}));
|
||||||
|
qs('#save-week-availability')?.addEventListener('click',saveAvailability);
|
||||||
|
qs('#disable-week-availability')?.addEventListener('click',disableAvailability);
|
||||||
function reconciliationDay(){
|
function reconciliationDay(){
|
||||||
if(!reconciliation)return null;
|
if(!reconciliation)return null;
|
||||||
const due=(reconciliation.week?.days||[]).find(day=>day.ids?.length);
|
const due=(reconciliation.week?.days||[]).find(day=>day.ids?.length);
|
||||||
|
|
@ -831,7 +900,8 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
|
||||||
};
|
};
|
||||||
root.innerHTML=value.days.map(day=>{
|
root.innerHTML=value.days.map(day=>{
|
||||||
const capacity=Number(day.capacity_minutes)||0;
|
const capacity=Number(day.capacity_minutes)||0;
|
||||||
const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+(day.overloaded?' · over capacity':'');
|
const source=day.capacity_source==='default'?' · Default':(day.capacity_source==='override'?' · Override':'');
|
||||||
|
const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+source+(day.overloaded?' · over capacity':'');
|
||||||
const move=id=>overviewing?'':'<div class="week-review-move"><label>Move to <select data-week-move-destination="'+escapeAttribute(id)+'">'+destinations+
|
const move=id=>overviewing?'':'<div class="week-review-move"><label>Move to <select data-week-move-destination="'+escapeAttribute(id)+'">'+destinations+
|
||||||
'</select></label><button type="button" data-week-move="'+escapeAttribute(id)+'">Move</button></div>';
|
'</select></label><button type="button" data-week-move="'+escapeAttribute(id)+'">Move</button></div>';
|
||||||
const unplan=id=>overviewing&&!readOnly?'<button type="button" class="week-review-unplan" data-week-unplan="'+escapeAttribute(id)+'">Remove from week</button>':'';
|
const unplan=id=>overviewing&&!readOnly?'<button type="button" class="week-review-unplan" data-week-unplan="'+escapeAttribute(id)+'">Remove from week</button>':'';
|
||||||
|
|
|
||||||
|
|
@ -738,6 +738,9 @@ class WeekPlanUpdate(BaseModel):
|
||||||
base_revision: int = Field(ge=0)
|
base_revision: int = Field(ge=0)
|
||||||
days: list[WeekPlanDay] = Field(max_length=7)
|
days: list[WeekPlanDay] = Field(max_length=7)
|
||||||
timezone: str = Field(min_length=1, max_length=100)
|
timezone: str = Field(min_length=1, max_length=100)
|
||||||
|
availability_defaults: list[int] | None = Field(
|
||||||
|
default=None, min_length=7, max_length=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WeekPromotion(BaseModel):
|
class WeekPromotion(BaseModel):
|
||||||
|
|
@ -2828,6 +2831,7 @@ async def replace_week_plan(payload: WeekPlanUpdate):
|
||||||
base_revision=payload.base_revision,
|
base_revision=payload.base_revision,
|
||||||
days=[day.model_dump() for day in payload.days],
|
days=[day.model_dump() for day in payload.days],
|
||||||
timezone=payload.timezone,
|
timezone=payload.timezone,
|
||||||
|
availability_defaults=payload.availability_defaults,
|
||||||
)
|
)
|
||||||
except WeekPlanConflict as error:
|
except WeekPlanConflict as error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -519,7 +519,10 @@ class TodayStore:
|
||||||
)
|
)
|
||||||
return {"revision": 1, **payload}
|
return {"revision": 1, **payload}
|
||||||
|
|
||||||
def _normalize_week(self, *, days: list[dict], timezone: str) -> dict:
|
def _normalize_week(
|
||||||
|
self, *, days: list[dict], timezone: str,
|
||||||
|
availability_defaults: list[int] | None = None,
|
||||||
|
) -> dict:
|
||||||
if not isinstance(days, list) or len(days) > 7:
|
if not isinstance(days, list) or len(days) > 7:
|
||||||
raise ValueError("Week Ahead is limited to seven dates")
|
raise ValueError("Week Ahead is limited to seven dates")
|
||||||
if not isinstance(timezone, str) or not timezone.strip() or len(timezone) > 100:
|
if not isinstance(timezone, str) or not timezone.strip() or len(timezone) > 100:
|
||||||
|
|
@ -555,13 +558,32 @@ class TodayStore:
|
||||||
plan.pop("timezone")
|
plan.pop("timezone")
|
||||||
normalized.append(plan)
|
normalized.append(plan)
|
||||||
normalized.sort(key=lambda item: item["plan_date"])
|
normalized.sort(key=lambda item: item["plan_date"])
|
||||||
return {"timezone": timezone, "days": normalized}
|
result = {"timezone": timezone, "days": normalized}
|
||||||
|
if availability_defaults is not None:
|
||||||
|
if (
|
||||||
|
not isinstance(availability_defaults, list)
|
||||||
|
or len(availability_defaults) != 7
|
||||||
|
or any(
|
||||||
|
not isinstance(value, int) or isinstance(value, bool)
|
||||||
|
or value < 0 or value > 1440
|
||||||
|
for value in availability_defaults
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError("availability defaults must contain seven weekday capacities")
|
||||||
|
result["availability_defaults"] = list(availability_defaults)
|
||||||
|
return result
|
||||||
|
|
||||||
def replace_week(self, login: str, *, base_revision: int, days: list[dict], timezone: str) -> dict:
|
def replace_week(
|
||||||
|
self, login: str, *, base_revision: int, days: list[dict], timezone: str,
|
||||||
|
availability_defaults: list[int] | None = None,
|
||||||
|
) -> dict:
|
||||||
login = self._normalize_login(login)
|
login = self._normalize_login(login)
|
||||||
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
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")
|
raise ValueError("base_revision must be a non-negative integer")
|
||||||
normalized = self._normalize_week(days=days, timezone=timezone)
|
normalized = self._normalize_week(
|
||||||
|
days=days, timezone=timezone,
|
||||||
|
availability_defaults=availability_defaults,
|
||||||
|
)
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
|
|
@ -649,7 +671,10 @@ class TodayStore:
|
||||||
and not allow_over_capacity
|
and not allow_over_capacity
|
||||||
):
|
):
|
||||||
raise ValueError("explicit overload confirmation is required")
|
raise ValueError("explicit overload confirmation is required")
|
||||||
normalized_week = self._normalize_week(days=days, timezone=week["timezone"])
|
normalized_week = self._normalize_week(
|
||||||
|
days=days, timezone=week["timezone"],
|
||||||
|
availability_defaults=week.get("availability_defaults"),
|
||||||
|
)
|
||||||
|
|
||||||
today_ids = [item_id for item_id in today["ids"] if item_id != identity]
|
today_ids = [item_id for item_id in today["ids"] if item_id != identity]
|
||||||
today_estimates = {
|
today_estimates = {
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||||
"revision": body["base_revision"] + 1,
|
"revision": body["base_revision"] + 1,
|
||||||
"timezone": body["timezone"], "days": body["days"],
|
"timezone": body["timezone"], "days": body["days"],
|
||||||
|
**({"availability_defaults": body["availability_defaults"]}
|
||||||
|
if "availability_defaults" in body else {}),
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||||
|
|
@ -107,6 +109,29 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||||
"Week Ahead overview · no changes made."
|
"Week Ahead overview · no changes made."
|
||||||
)
|
)
|
||||||
expect(page.locator("#confirm-week-plan")).to_be_hidden()
|
expect(page.locator("#confirm-week-plan")).to_be_hidden()
|
||||||
|
availability = page.locator("#open-week-availability")
|
||||||
|
expect(availability).to_be_visible()
|
||||||
|
availability_bounds = availability.bounding_box()
|
||||||
|
assert availability_bounds and availability_bounds["height"] >= 44
|
||||||
|
availability.click()
|
||||||
|
editor = page.locator("#week-availability-editor")
|
||||||
|
expect(editor).to_be_visible()
|
||||||
|
capacities = editor.locator("[data-weekday-capacity]")
|
||||||
|
expect(capacities).to_have_count(7)
|
||||||
|
for index, minutes in enumerate((480, 480, 480, 480, 360, 0, 0)):
|
||||||
|
capacities.nth(index).fill(str(minutes))
|
||||||
|
bounds = capacities.nth(index).bounding_box()
|
||||||
|
assert bounds and bounds["height"] >= 44
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
page.locator("#save-week-availability").click()
|
||||||
|
expect(editor).to_be_hidden()
|
||||||
|
expect(page.locator("#week-review-status")).to_have_text("Weekly availability saved.")
|
||||||
|
assert saved[-1]["availability_defaults"] == [480, 480, 480, 480, 360, 0, 0]
|
||||||
|
availability.click()
|
||||||
|
page.locator("#disable-week-availability").click()
|
||||||
|
expect(editor).to_be_hidden()
|
||||||
|
expect(page.locator("#week-review-status")).to_have_text("Weekly availability disabled.")
|
||||||
|
assert "availability_defaults" not in saved[-1]
|
||||||
edit_week = page.locator("#edit-week-plan")
|
edit_week = page.locator("#edit-week-plan")
|
||||||
expect(edit_week).to_be_visible()
|
expect(edit_week).to_be_visible()
|
||||||
remove_actions = cards.first.locator("[data-week-unplan]")
|
remove_actions = cards.first.locator("[data-week-unplan]")
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,42 @@ def test_week_plan_is_encrypted_account_scoped_revisioned_and_preserves_other_da
|
||||||
assert conflict.value.snapshot == updated
|
assert conflict.value.snapshot == updated
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_plan_persists_bounded_weekly_availability_with_the_revisioned_plan(tmp_path):
|
||||||
|
path = tmp_path / "today.sqlite3"
|
||||||
|
store = TodayStore(path, encryption_key=b"w" * 32)
|
||||||
|
defaults = [0, 480, 420, 360, 300, 240, 0]
|
||||||
|
|
||||||
|
saved = store.replace_week(
|
||||||
|
"timmy", base_revision=0, days=sample_days(), timezone="UTC",
|
||||||
|
availability_defaults=defaults,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert saved["availability_defaults"] == defaults
|
||||||
|
assert store.get_week("timmy")["availability_defaults"] == defaults
|
||||||
|
retained = path.read_bytes()
|
||||||
|
assert b'"availability_defaults"' not in retained
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="seven weekday capacities"):
|
||||||
|
store.replace_week(
|
||||||
|
"timmy", base_revision=1, days=sample_days(), timezone="UTC",
|
||||||
|
availability_defaults=[60] * 6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_update_model_accepts_zero_capacity_weekday_defaults():
|
||||||
|
payload = main.WeekPlanUpdate.model_validate({
|
||||||
|
"base_revision": 2, "timezone": "UTC", "days": [],
|
||||||
|
"availability_defaults": [0, 480, 480, 480, 480, 300, 0],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert payload.model_dump()["availability_defaults"] == [0, 480, 480, 480, 480, 300, 0]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
main.WeekPlanUpdate.model_validate({
|
||||||
|
"base_revision": 2, "timezone": "UTC", "days": [],
|
||||||
|
"availability_defaults": [60] * 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def test_start_day_plan_selects_earliest_nonempty_week_date_without_mutation(tmp_path):
|
def test_start_day_plan_selects_earliest_nonempty_week_date_without_mutation(tmp_path):
|
||||||
store = TodayStore(
|
store = TodayStore(
|
||||||
tmp_path / "today.sqlite3", encryption_key=b"r" * 32,
|
tmp_path / "today.sqlite3", encryption_key=b"r" * 32,
|
||||||
|
|
@ -507,6 +543,7 @@ def test_reschedule_today_item_atomically_moves_it_into_selected_week_day(tmp_pa
|
||||||
"timmy",
|
"timmy",
|
||||||
base_revision=0,
|
base_revision=0,
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
|
availability_defaults=[0, 480, 480, 480, 480, 0, 0],
|
||||||
days=[{
|
days=[{
|
||||||
"plan_date": "2026-08-24",
|
"plan_date": "2026-08-24",
|
||||||
"ids": [active_id],
|
"ids": [active_id],
|
||||||
|
|
@ -531,6 +568,7 @@ def test_reschedule_today_item_atomically_moves_it_into_selected_week_day(tmp_pa
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["today"]["ids"] == [other_id]
|
assert result["today"]["ids"] == [other_id]
|
||||||
|
assert result["week"]["availability_defaults"] == [0, 480, 480, 480, 480, 0, 0]
|
||||||
assert result["week"]["days"] == [{
|
assert result["week"]["days"] == [{
|
||||||
"plan_date": "2026-08-24", "ids": [], "capacity_minutes": 120, "estimates": {},
|
"plan_date": "2026-08-24", "ids": [], "capacity_minutes": 120, "estimates": {},
|
||||||
}, {
|
}, {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,23 @@ const createOutboxCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||||
return json.loads(completed.stdout)
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_availability_editor_is_mobile_safe_and_wired_to_durable_defaults():
|
||||||
|
markup = INDEX.read_text()
|
||||||
|
styles = CSS.read_text()
|
||||||
|
source = CONTROLLER.read_text()
|
||||||
|
|
||||||
|
assert 'id="open-week-availability"' in markup
|
||||||
|
assert 'id="week-availability-editor"' in markup
|
||||||
|
assert markup.count('data-weekday-capacity=') == 7
|
||||||
|
assert 'id="save-week-availability"' in markup
|
||||||
|
assert 'id="disable-week-availability"' in markup
|
||||||
|
assert 'aria-labelledby="week-availability-title"' in markup
|
||||||
|
assert ".week-availability-grid input { min-height:44px" in styles
|
||||||
|
assert "grid-template-columns:1fr" in styles
|
||||||
|
assert "stageAvailabilityDefaults(values)" in source
|
||||||
|
assert "Weekly availability saved" in source
|
||||||
|
|
||||||
|
|
||||||
def test_conflicted_pull_review_has_accessible_touch_safe_recovery_actions():
|
def test_conflicted_pull_review_has_accessible_touch_safe_recovery_actions():
|
||||||
markup = INDEX.read_text()
|
markup = INDEX.read_text()
|
||||||
styles = CSS.read_text()
|
styles = CSS.read_text()
|
||||||
|
|
@ -177,6 +194,36 @@ console.log(JSON.stringify({before,moved,after,pending:week.pending()}));
|
||||||
assert result["pending"]["days"][2]["estimates"] == {"shared": 30}
|
assert result["pending"]["days"][2]["estimates"] == {"shared": 30}
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_applies_weekday_defaults_to_new_dates_and_keeps_date_overrides():
|
||||||
|
result = run_controller("""
|
||||||
|
const values=new Map();const requests=[];
|
||||||
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||||
|
const fetchJson=async(url,options={})=>{
|
||||||
|
requests.push(options.body?JSON.parse(options.body):null);
|
||||||
|
if(!options.method)return {revision:3,timezone:'UTC',availability_defaults:[60,120,180,240,300,0,0],days:[
|
||||||
|
{plan_date:'2026-08-22',ids:['override'],capacity_minutes:45,estimates:{override:30}}
|
||||||
|
]};
|
||||||
|
return {revision:4,...JSON.parse(options.body)};
|
||||||
|
};
|
||||||
|
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,
|
||||||
|
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||||
|
await week.load();
|
||||||
|
const inherited=week.day('2026-08-21');
|
||||||
|
const overridden=week.day('2026-08-22');
|
||||||
|
const staged=week.stageAvailabilityDefaults([90,120,180,240,300,0,0]);
|
||||||
|
await week.flush();
|
||||||
|
console.log(JSON.stringify({inherited,overridden,staged,request:requests.at(-1),state:week.state()}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["inherited"]["capacity_minutes"] == 300
|
||||||
|
assert result["inherited"]["capacity_source"] == "default"
|
||||||
|
assert result["overridden"]["capacity_minutes"] == 45
|
||||||
|
assert result["overridden"]["capacity_source"] == "override"
|
||||||
|
assert result["staged"]["sync_pending"] is True
|
||||||
|
assert result["request"]["availability_defaults"] == [90, 120, 180, 240, 300, 0, 0]
|
||||||
|
assert result["state"]["availability_defaults"] == [90, 120, 180, 240, 300, 0, 0]
|
||||||
|
|
||||||
|
|
||||||
def test_week_controller_applies_seven_imported_capacities_in_one_local_transition():
|
def test_week_controller_applies_seven_imported_capacities_in_one_local_transition():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
const values=new Map();let writes=0;
|
const values=new Map();let writes=0;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user