feat: export Week Ahead as calendar blocks (Closes #1198)
This commit is contained in:
parent
875f4eea29
commit
dd3016b96b
|
|
@ -337,6 +337,19 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.week-review-duplicates { margin:12px 0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; }
|
.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; }
|
.week-review-status { min-height:1.4em; margin:10px 0; }
|
||||||
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
|
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||||
|
.week-calendar-handoff h2 { margin-bottom:6px; }
|
||||||
|
.week-calendar-days { display:grid; gap:12px; margin:14px 0; }
|
||||||
|
.week-calendar-day { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||||
|
.week-calendar-day header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||||
|
.week-calendar-day h3 { margin:0; }
|
||||||
|
.week-calendar-day input[type="time"] { min-height:44px; font-size:16px; }
|
||||||
|
.week-calendar-item { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; padding:8px 0; overflow-wrap:anywhere; }
|
||||||
|
.week-calendar-item input { width:22px; height:22px; }
|
||||||
|
.week-calendar-item span { display:grid; gap:3px; }
|
||||||
|
.week-calendar-item small { color:#a9bdd3; }
|
||||||
|
.week-calendar-status { min-height:1.4em; margin:10px 0; }
|
||||||
|
.week-calendar-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 2fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#0b1526; }
|
||||||
|
.week-calendar-actions button { min-width:0; min-height:48px; }
|
||||||
.plan-today-sheet.week-review-mode .week-plan-dates,
|
.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 .mobile-plan-today-nav,
|
||||||
.plan-today-sheet.week-review-mode #plan-today-fit,
|
.plan-today-sheet.week-review-mode #plan-today-fit,
|
||||||
|
|
|
||||||
|
|
@ -452,11 +452,15 @@
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin: () => planningOwnerLogin,
|
getLogin: () => planningOwnerLogin,
|
||||||
});
|
});
|
||||||
|
const weekItem=identity=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===identity);
|
||||||
const weekWorkflow=createWeekPlanWorkflow({controller:weekPlan,qs,getLogin:()=>planningOwnerLogin,
|
const weekWorkflow=createWeekPlanWorkflow({controller:weekPlan,qs,getLogin:()=>planningOwnerLogin,
|
||||||
getItem:identity=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===identity),
|
getItem:weekItem,
|
||||||
openPlanner:openPlanToday,setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value),
|
openPlanner:openPlanToday,setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value),
|
||||||
escapeHtml,escapeAttribute:escAttr,todayWork,
|
escapeHtml,escapeAttribute:escAttr,todayWork,
|
||||||
refresh:refreshMyWorkView,warm:warmTodayOffline});
|
refresh:refreshMyWorkView,warm:warmTodayOffline});
|
||||||
|
const weekCalendar=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekItem,escapeHtml,escapeAttribute:escAttr,
|
||||||
|
onDone:()=>{weekWorkflow.finish();taskOverlayHistory.leave();},
|
||||||
|
});
|
||||||
const todaySync = createTodaySync({
|
const todaySync = createTodaySync({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin: () => planningOwnerLogin,
|
getLogin: () => planningOwnerLogin,
|
||||||
|
|
@ -2701,6 +2705,7 @@
|
||||||
}
|
}
|
||||||
planToday.cancel();
|
planToday.cancel();
|
||||||
planningTomorrow = false;
|
planningTomorrow = false;
|
||||||
|
weekCalendar.close();
|
||||||
weekWorkflow.clear();
|
weekWorkflow.clear();
|
||||||
qs('#tomorrow-conflict-review').hidden = true;
|
qs('#tomorrow-conflict-review').hidden = true;
|
||||||
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
||||||
|
|
@ -7919,9 +7924,7 @@
|
||||||
qs('#week-review-status').textContent=weekPlan.pending()?'Wait for Week Ahead to finish syncing before confirming.':'Move duplicated work to one date before confirming.';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
qs('#mobile-week-summary').textContent=weekPlan.summary();
|
weekCalendar.open(weekPlan.state());
|
||||||
qs('#my-work-action-status').textContent='Week Ahead reviewed and confirmed.';
|
|
||||||
taskOverlayHistory.leave();
|
|
||||||
});
|
});
|
||||||
qs('#plan-today-sheet').addEventListener('click', event => {
|
qs('#plan-today-sheet').addEventListener('click', event => {
|
||||||
if (event.target === qs('#plan-today-sheet')) closePlanToday();
|
if (event.target === qs('#plan-today-sheet')) closePlanToday();
|
||||||
|
|
|
||||||
|
|
@ -514,6 +514,17 @@
|
||||||
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></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>
|
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="week-calendar-handoff" id="week-calendar-handoff" aria-labelledby="week-calendar-title" hidden>
|
||||||
|
<div class="small">Calendar handoff</div>
|
||||||
|
<h2 id="week-calendar-title">Add Week Ahead to calendar</h2>
|
||||||
|
<p class="small muted">Choose each day’s start time and exclude anything private. Titles, references, dates, and times are included in a local calendar file; nothing is uploaded.</p>
|
||||||
|
<div class="week-calendar-days" id="week-calendar-days"></div>
|
||||||
|
<div class="small week-calendar-status" id="week-calendar-status" role="status" aria-live="assertive"></div>
|
||||||
|
<div class="week-calendar-actions">
|
||||||
|
<button id="back-to-week-review-from-calendar" type="button">Back to review</button>
|
||||||
|
<button id="share-week-calendar" type="button">Share calendar blocks</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<nav class="mobile-plan-today-nav" aria-label="Plan Today sections">
|
<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="fit" aria-current="location">Fit</button>
|
||||||
<button type="button" data-plan-today-section="today">Today</button>
|
<button type="button" data-plan-today-section="today">Today</button>
|
||||||
|
|
@ -2016,6 +2027,7 @@
|
||||||
<script src="static/plan-today-readiness.js"></script>
|
<script src="static/plan-today-readiness.js"></script>
|
||||||
<script src="static/plan-today-preview.js"></script>
|
<script src="static/plan-today-preview.js"></script>
|
||||||
<script src="static/tomorrow-plan.js"></script>
|
<script src="static/tomorrow-plan.js"></script>
|
||||||
|
<script src="static/week-calendar.js"></script>
|
||||||
<script src="static/week-plan.js"></script>
|
<script src="static/week-plan.js"></script>
|
||||||
<script src="static/search-week-plan.js"></script>
|
<script src="static/search-week-plan.js"></script>
|
||||||
<script src="static/today-sync.js"></script>
|
<script src="static/today-sync.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ const SHELL = [
|
||||||
BASE + 'static/plan-today-readiness.js',
|
BASE + 'static/plan-today-readiness.js',
|
||||||
BASE + 'static/plan-today-preview.js',
|
BASE + 'static/plan-today-preview.js',
|
||||||
BASE + 'static/tomorrow-plan.js',
|
BASE + 'static/tomorrow-plan.js',
|
||||||
|
BASE + 'static/week-calendar.js',
|
||||||
BASE + 'static/week-plan.js',
|
BASE + 'static/week-plan.js',
|
||||||
BASE + 'static/search-week-plan.js',
|
BASE + 'static/search-week-plan.js',
|
||||||
BASE + 'static/today-sync.js',
|
BASE + 'static/today-sync.js',
|
||||||
|
|
|
||||||
109
frontend/week-calendar.js
Normal file
109
frontend/week-calendar.js
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
(function(root){
|
||||||
|
'use strict';
|
||||||
|
function escapeText(value){
|
||||||
|
return String(value||'').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/,/g,'\\,').replace(/;/g,'\\;');
|
||||||
|
}
|
||||||
|
function compact(value){return String(value||'').replace(/[-:]/g,'');}
|
||||||
|
function addMinutes(value,minutes){
|
||||||
|
const [hours,mins]=String(value).split(':').map(Number),total=hours*60+mins+Number(minutes);
|
||||||
|
return String(Math.floor(total/60)%24).padStart(2,'0')+':'+String(total%60).padStart(2,'0');
|
||||||
|
}
|
||||||
|
function stableId(value){return String(value||'work').replace(/[^a-z0-9]+/gi,'-').replace(/^-|-$/g,'').toLowerCase();}
|
||||||
|
function foldLine(line){
|
||||||
|
const encoder=new TextEncoder(),chunks=[];let chunk='',bytes=0,limit=75;
|
||||||
|
for(const character of String(line)){
|
||||||
|
const width=encoder.encode(character).length;
|
||||||
|
if(chunk&&bytes+width>limit){chunks.push(chunk);chunk=character;bytes=width;limit=74;}
|
||||||
|
else {chunk+=character;bytes+=width;}
|
||||||
|
}
|
||||||
|
chunks.push(chunk);return chunks.join('\r\n ');
|
||||||
|
}
|
||||||
|
function buildWeekBlocks(plan,startTimes,getItem,selected){
|
||||||
|
const blocks=[];
|
||||||
|
(plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{
|
||||||
|
let cursor=startTimes?.[day.plan_date]||'09:00';
|
||||||
|
(day.ids||[]).forEach(id=>{
|
||||||
|
if(selected&& !selected.has(id))return;
|
||||||
|
const minutes=Number(day.estimates?.[id]),item=getItem?.(id);
|
||||||
|
if(!item||!Number.isFinite(minutes)||minutes<=0)return;
|
||||||
|
const end=addMinutes(cursor,minutes);
|
||||||
|
blocks.push({...item,id,plan_date:day.plan_date,start_time:cursor,end_time:end,minutes});
|
||||||
|
cursor=end;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
function serializeWeekCalendar(blocks,{timezone='UTC',revision=0,generatedAt}={}){
|
||||||
|
const stamp=generatedAt||new Date().toISOString().replace(/[-:]/g,'').replace(/\.\d{3}/,'');
|
||||||
|
const lines=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Stackchain//Week Ahead//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','X-WR-CALNAME:Stackchain Week Ahead',`X-WR-TIMEZONE:${timezone}`,'BEGIN:VTIMEZONE',`TZID:${timezone}`,'END:VTIMEZONE'];
|
||||||
|
(blocks||[]).forEach(block=>{
|
||||||
|
const day=compact(block.plan_date),reference=block.repository+(block.number!=null?' #'+block.number:'');
|
||||||
|
lines.push('BEGIN:VEVENT',`UID:week-${revision}-${day}-${stableId(block.id)}@stackchain`,`DTSTAMP:${stamp}`,
|
||||||
|
`DTSTART;TZID=${timezone}:${day}T${compact(block.start_time)}00`,`DTEND;TZID=${timezone}:${day}T${compact(block.end_time)}00`,
|
||||||
|
`SUMMARY:${escapeText(block.title||'Untitled work')}`,`DESCRIPTION:${escapeText(reference+' · Stackchain Week Ahead')}`,
|
||||||
|
`URL:${String(block.url||'')}`,'TRANSP:OPAQUE','END:VEVENT');
|
||||||
|
});
|
||||||
|
lines.push('END:VCALENDAR');return lines.map(foldLine).join('\r\n')+'\r\n';
|
||||||
|
}
|
||||||
|
async function deliverWeekCalendar({text,filename,navigator,document,urlApi,FileCtor}){
|
||||||
|
const file=new FileCtor([text],filename,{type:'text/calendar;charset=utf-8'});
|
||||||
|
const payload={files:[file],title:'Stackchain Week Ahead',text:'Timed Week Ahead calendar blocks'};
|
||||||
|
if(typeof navigator?.share==='function'&&typeof navigator?.canShare==='function'&&navigator.canShare(payload)){
|
||||||
|
await navigator.share(payload);return 'shared';
|
||||||
|
}
|
||||||
|
const href=urlApi.createObjectURL(file);
|
||||||
|
try{const anchor=document.createElement('a');anchor.href=href;anchor.download=filename;anchor.click();}
|
||||||
|
finally{urlApi.revokeObjectURL(href);}
|
||||||
|
return 'downloaded';
|
||||||
|
}
|
||||||
|
function mountWeekCalendarHandoff({qs,getItem,escapeHtml,escapeAttribute,onDone,windowObject=root,navigatorObject=root.navigator,
|
||||||
|
documentObject=root.document,urlApi=root.URL,FileCtor=root.File}={}){
|
||||||
|
const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days');
|
||||||
|
let plan=null;
|
||||||
|
const selected=()=>new Set(Array.from(daysRoot.querySelectorAll('[data-week-calendar-item]')).filter(input=>input.checked).map(input=>input.value));
|
||||||
|
const starts=()=>Object.fromEntries(Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]));
|
||||||
|
const blocks=()=>buildWeekBlocks(plan,starts(),getItem,selected());
|
||||||
|
function update(){
|
||||||
|
const current=blocks(),byId=new Map(current.map(block=>[block.id,block]));
|
||||||
|
daysRoot.querySelectorAll('[data-week-calendar-preview]').forEach(node=>{
|
||||||
|
const block=byId.get(node.dataset.weekCalendarPreview);
|
||||||
|
node.textContent=block?block.start_time+'–'+block.end_time+' · '+block.minutes+' min':'Excluded from calendar';
|
||||||
|
});
|
||||||
|
qs('#share-week-calendar').disabled=!current.length;
|
||||||
|
qs('#week-calendar-status').textContent=current.length+' calendar block'+(current.length===1?'':'s')+' selected.';
|
||||||
|
}
|
||||||
|
function close({back=false}={}){
|
||||||
|
handoff.hidden=true;
|
||||||
|
if(back){review.hidden=false;qs('#confirm-week-plan').focus?.();}
|
||||||
|
}
|
||||||
|
function open(value){
|
||||||
|
plan=value;review.hidden=true;handoff.hidden=false;
|
||||||
|
const labels=new Map((value.days||[]).map(day=>[day.plan_date,new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(new Date(day.plan_date+'T12:00:00Z'))]));
|
||||||
|
daysRoot.innerHTML=(value.days||[]).filter(day=>day.ids?.length).map(day=>'<article class="week-calendar-day"><header><h3>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
|
||||||
|
'</h3><label>Start <input type="time" value="09:00" data-week-calendar-start="'+escapeAttribute(day.plan_date)+'"></label></header>'+
|
||||||
|
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96);
|
||||||
|
return '<label class="week-calendar-item"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
|
||||||
|
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label>';}).join('')+'</article>').join('');
|
||||||
|
daysRoot.querySelectorAll('input').forEach(input=>input.addEventListener('change',update));
|
||||||
|
update();qs('#back-to-week-review-from-calendar').focus?.();return true;
|
||||||
|
}
|
||||||
|
qs('#back-to-week-review-from-calendar').addEventListener('click',()=>close({back:true}));
|
||||||
|
qs('#share-week-calendar').addEventListener('click',async()=>{
|
||||||
|
const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;
|
||||||
|
button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…';
|
||||||
|
const day=new Date().toISOString().slice(0,10);
|
||||||
|
try{
|
||||||
|
const text=serializeWeekCalendar(current,{timezone:plan.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone,revision:plan.revision});
|
||||||
|
const result=await deliverWeekCalendar({text,filename:'stackchain-week-ahead-'+day+'.ics',navigator:navigatorObject,
|
||||||
|
document:documentObject,urlApi,FileCtor});
|
||||||
|
close();onDone?.(result);
|
||||||
|
}catch(error){
|
||||||
|
qs('#week-calendar-status').textContent=error?.name==='AbortError'?'Share cancelled. Your Week Ahead is still ready.':'Calendar export failed. Retry without leaving Week Ahead.';
|
||||||
|
button.disabled=false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {open,close,blocks};
|
||||||
|
}
|
||||||
|
const api={buildWeekBlocks,deliverWeekCalendar,escapeText,foldLine,mountWeekCalendarHandoff,serializeWeekCalendar};
|
||||||
|
if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.StackchainWeekCalendar=api;
|
||||||
|
})(typeof window!=='undefined'?window:globalThis);
|
||||||
|
|
@ -90,7 +90,16 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||||
});
|
});
|
||||||
const duplicates=[...assigned.entries()].filter(([_id,assignedDates])=>assignedDates.length>1)
|
const duplicates=[...assigned.entries()].filter(([_id,assignedDates])=>assignedDates.length>1)
|
||||||
.map(([id,assignedDates])=>({id,dates:assignedDates}));
|
.map(([id,assignedDates])=>({id,dates:assignedDates}));
|
||||||
return {days,duplicates,can_confirm:duplicates.length===0};
|
const blockers=[];
|
||||||
|
days.forEach(value=>{
|
||||||
|
if(value.ids.length&&!Number(value.capacity_minutes))blockers.push({type:'missing-capacity',plan_date:value.plan_date});
|
||||||
|
value.ids.forEach(id=>{if(!(Number(value.estimates?.[id])>0))blockers.push({type:'missing-estimate',plan_date:value.plan_date,id});});
|
||||||
|
if(value.overloaded)blockers.push({type:'over-capacity',plan_date:value.plan_date,
|
||||||
|
minutes:value.planned_minutes-Number(value.capacity_minutes)});
|
||||||
|
});
|
||||||
|
blockers.sort((left,right)=>left.plan_date.localeCompare(right.plan_date)||
|
||||||
|
['over-capacity','missing-estimate','missing-capacity'].indexOf(left.type)-['over-capacity','missing-estimate','missing-capacity'].indexOf(right.type));
|
||||||
|
return {days,duplicates,blockers,can_confirm:duplicates.length===0&&blockers.length===0};
|
||||||
}
|
}
|
||||||
function move(id,toDate) {
|
function move(id,toDate) {
|
||||||
if(!dates().some(item=>item.date===toDate)) return false;
|
if(!dates().some(item=>item.date===toDate)) return false;
|
||||||
|
|
@ -402,8 +411,13 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
|
||||||
const confirm=qs('#confirm-week-plan');
|
const confirm=qs('#confirm-week-plan');
|
||||||
const pending=Boolean(controller.pending?.());
|
const pending=Boolean(controller.pending?.());
|
||||||
confirm.disabled=!value.can_confirm||pending;
|
confirm.disabled=!value.can_confirm||pending;
|
||||||
|
const blocker=value.blockers?.[0];
|
||||||
qs('#week-review-status').textContent=value.duplicates.length?'Duplicate work must be moved to one date.':
|
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.');
|
(pending?'Saving the latest week to your account…':blocker?(
|
||||||
|
blocker.type==='over-capacity'?blocker.plan_date+' is '+blocker.minutes+' min over capacity. Edit that day before confirming.':
|
||||||
|
blocker.type==='missing-estimate'?'Add an estimate for work on '+blocker.plan_date+' before confirming.':
|
||||||
|
'Add capacity for '+blocker.plan_date+' before confirming.'
|
||||||
|
):'Week Ahead is balanced and ready.');
|
||||||
root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{
|
root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{
|
||||||
const selector=root.querySelector(`[data-week-move-destination="${button.dataset.weekMove}"]`);
|
const selector=root.querySelector(`[data-week-move-destination="${button.dataset.weekMove}"]`);
|
||||||
if(!controller.move(button.dataset.weekMove,selector?.value))return;
|
if(!controller.move(button.dataset.weekMove,selector?.value))return;
|
||||||
|
|
@ -474,8 +488,9 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
|
||||||
function confirm() {
|
function confirm() {
|
||||||
const value=controller.review();
|
const value=controller.review();
|
||||||
if(!reviewing||!value.can_confirm||controller.pending()) return false;
|
if(!reviewing||!value.can_confirm||controller.pending()) return false;
|
||||||
reviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
function finish(){reviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;}
|
||||||
qs('#back-to-week-review')?.addEventListener('click',returnToReview);
|
qs('#back-to-week-review')?.addEventListener('click',returnToReview);
|
||||||
async function promote(plan){
|
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;}
|
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;}
|
||||||
|
|
@ -489,7 +504,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
|
||||||
qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;
|
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,confirm,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate)||reviewing,
|
return {open,save,advance,confirm,finish,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate)||reviewing,
|
||||||
reviewing:()=>reviewing,selectedDate:()=>selectedDate,
|
reviewing:()=>reviewing,selectedDate:()=>selectedDate,
|
||||||
day:()=>selectedDate?controller.day(selectedDate):null,
|
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,
|
copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ FEATURE_SOURCES = {
|
||||||
"planning": (
|
"planning": (
|
||||||
"static/plan-today.js", "static/plan-today-readiness.js",
|
"static/plan-today.js", "static/plan-today-readiness.js",
|
||||||
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
|
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
|
||||||
"static/tomorrow-plan.js", "static/week-plan.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-plan.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
||||||
),
|
),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,20 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||||
bounds = confirm.bounding_box()
|
bounds = confirm.bounding_box()
|
||||||
assert bounds and bounds["height"] >= 44
|
assert bounds and bounds["height"] >= 44
|
||||||
confirm.click()
|
confirm.click()
|
||||||
|
expect(page.locator("#week-calendar-handoff")).to_be_visible()
|
||||||
|
expect(page.locator("#week-review")).to_be_hidden()
|
||||||
|
start = page.locator("[data-week-calendar-start]").first
|
||||||
|
expect(start).to_have_value("09:00")
|
||||||
|
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("09:00–09:30 · 30 min")
|
||||||
|
for control in (start, page.locator("#back-to-week-review-from-calendar"), page.locator("#share-week-calendar")):
|
||||||
|
bounds = control.bounding_box()
|
||||||
|
assert bounds and bounds["height"] >= 44
|
||||||
|
page.locator("#back-to-week-review-from-calendar").click()
|
||||||
|
expect(page.locator("#week-review")).to_be_visible()
|
||||||
|
confirm.click()
|
||||||
|
with page.expect_download() as download_info:
|
||||||
|
page.locator("#share-week-calendar").click()
|
||||||
|
assert download_info.value.suggested_filename.startswith("stackchain-week-ahead-")
|
||||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
|
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
|
||||||
assert saved and len(saved[-1]["days"]) == 3
|
assert saved and len(saved[-1]["days"]) == 3
|
||||||
|
|
|
||||||
|
|
@ -1328,6 +1328,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/plan-today-readiness.js",
|
"/dashboard/static/plan-today-readiness.js",
|
||||||
"/dashboard/static/plan-today-preview.js",
|
"/dashboard/static/plan-today-preview.js",
|
||||||
"/dashboard/static/tomorrow-plan.js",
|
"/dashboard/static/tomorrow-plan.js",
|
||||||
|
"/dashboard/static/week-calendar.js",
|
||||||
"/dashboard/static/week-plan.js",
|
"/dashboard/static/week-plan.js",
|
||||||
"/dashboard/static/search-week-plan.js",
|
"/dashboard/static/search-week-plan.js",
|
||||||
"/dashboard/static/today-sync.js",
|
"/dashboard/static/today-sync.js",
|
||||||
|
|
|
||||||
110
tests/test_week_calendar.py
Normal file
110
tests/test_week_calendar.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
WEEK_CALENDAR = Path(__file__).parents[1] / "frontend" / "week-calendar.js"
|
||||||
|
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||||
|
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||||
|
BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(source: str):
|
||||||
|
completed = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_calendar_builds_ordered_local_time_blocks_and_serializes_opaque_events():
|
||||||
|
plan = {
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"revision": 7,
|
||||||
|
"days": [{
|
||||||
|
"plan_date": "2026-08-21",
|
||||||
|
"ids": ["issue:stackchain/dashboard:12:", "pull:stackchain/api:7:"],
|
||||||
|
"capacity_minutes": 120,
|
||||||
|
"estimates": {
|
||||||
|
"issue:stackchain/dashboard:12:": 45,
|
||||||
|
"pull:stackchain/api:7:": 30,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
items = {
|
||||||
|
"issue:stackchain/dashboard:12:": {
|
||||||
|
"id": 401,
|
||||||
|
"title": "Plan, ship; verify",
|
||||||
|
"repository": "stackchain/dashboard",
|
||||||
|
"number": 12,
|
||||||
|
"url": "https://forge.alexanderwhitestone.com/git/stackchain/stackchain-dashboard/issues/12",
|
||||||
|
},
|
||||||
|
"pull:stackchain/api:7:": {
|
||||||
|
"title": "API release",
|
||||||
|
"repository": "stackchain/api",
|
||||||
|
"number": 7,
|
||||||
|
"url": "https://forge.alexanderwhitestone.com/git/stackchain/api/pulls/7",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
source = f"""
|
||||||
|
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
|
||||||
|
const plan={json.dumps(plan)}, items={json.dumps(items)};
|
||||||
|
const blocks=calendar.buildWeekBlocks(plan, {{'2026-08-21':'09:00'}}, id=>items[id]);
|
||||||
|
const text=calendar.serializeWeekCalendar(blocks, {{timezone:plan.timezone,revision:plan.revision,generatedAt:'20260820T120000Z'}});
|
||||||
|
console.log(JSON.stringify({{blocks,text}}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_node(source)
|
||||||
|
assert [(block["start_time"], block["end_time"]) for block in result["blocks"]] == [
|
||||||
|
("09:00", "09:45"), ("09:45", "10:15")
|
||||||
|
]
|
||||||
|
text = result["text"]
|
||||||
|
assert "TZID:America/New_York" in text
|
||||||
|
assert "DTSTART;TZID=America/New_York:20260821T090000" in text
|
||||||
|
assert "DTEND;TZID=America/New_York:20260821T094500" in text
|
||||||
|
assert "DTSTART;TZID=America/New_York:20260821T094500" in text
|
||||||
|
assert "DTEND;TZID=America/New_York:20260821T101500" in text
|
||||||
|
assert "TRANSP:OPAQUE" in text
|
||||||
|
assert "UID:week-7-20260821-issue-stackchain-dashboard-12@stackchain" in text
|
||||||
|
assert "SUMMARY:Plan\\, ship\\; verify" in text
|
||||||
|
unfolded = text.replace("\r\n ", "")
|
||||||
|
assert "URL:https://forge.alexanderwhitestone.com/git/stackchain/stackchain-dashboard/issues/12" in unfolded
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_calendar_excludes_unselected_items_and_downloads_when_native_share_is_unavailable():
|
||||||
|
source = f"""
|
||||||
|
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
|
||||||
|
const plan={{timezone:'UTC',revision:2,days:[{{plan_date:'2026-08-21',ids:['one','two'],capacity_minutes:60,estimates:{{one:20,two:25}}}}]}};
|
||||||
|
const items={{one:{{title:'Private',repository:'r',number:1,url:'https://forge/r/1'}},two:{{title:'Public',repository:'r',number:2,url:'https://forge/r/2'}}}};
|
||||||
|
const blocks=calendar.buildWeekBlocks(plan,{{'2026-08-21':'13:00'}},id=>items[id],new Set(['two']));
|
||||||
|
class FakeFile {{constructor(parts,name,options){{this.parts=parts;this.name=name;this.type=options.type;}}}}
|
||||||
|
const clicks=[],revoked=[];
|
||||||
|
(async()=>{{
|
||||||
|
const result=await calendar.deliverWeekCalendar({{text:'calendar',filename:'week.ics',navigator:{{}},FileCtor:FakeFile,
|
||||||
|
document:{{createElement:()=>({{click(){{clicks.push([this.download,this.href]);}}}})}},
|
||||||
|
urlApi:{{createObjectURL:()=> 'blob:week',revokeObjectURL:value=>revoked.push(value)}}}});
|
||||||
|
console.log(JSON.stringify({{blocks,result,clicks,revoked}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_node(source)
|
||||||
|
assert [block["id"] for block in result["blocks"]] == ["two"]
|
||||||
|
assert result["blocks"][0]["start_time"] == "13:00"
|
||||||
|
assert result["result"] == "downloaded"
|
||||||
|
assert result["clicks"] == [["week.ics", "blob:week"]]
|
||||||
|
assert result["revoked"] == ["blob:week"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_calendar_handoff_is_wired_into_confirmation_with_mobile_privacy_controls():
|
||||||
|
index = INDEX.read_text()
|
||||||
|
dashboard = DASHBOARD.read_text()
|
||||||
|
bundle = BUNDLE.read_text()
|
||||||
|
|
||||||
|
assert '<script src="static/week-calendar.js"></script>' in index
|
||||||
|
assert 'id="week-calendar-handoff"' in index
|
||||||
|
assert 'id="week-calendar-days"' in index
|
||||||
|
assert 'id="share-week-calendar"' in index
|
||||||
|
assert 'id="back-to-week-review-from-calendar"' in index
|
||||||
|
assert "Titles, references, dates, and times are included" in index
|
||||||
|
assert "mountWeekCalendarHandoff" in WEEK_CALENDAR.read_text()
|
||||||
|
assert "weekCalendar.open(weekPlan.state())" in dashboard
|
||||||
|
assert "weekCalendar.close()" in dashboard
|
||||||
|
assert '"static/week-calendar.js", "static/week-plan.js"' in bundle
|
||||||
|
|
@ -16,6 +16,25 @@ const createWeekPlan = require({json.dumps(str(CONTROLLER))});
|
||||||
return json.loads(completed.stdout)
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_requires_executable_days_before_calendar_handoff():
|
||||||
|
result = run_controller("""
|
||||||
|
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||||
|
week.adopt({revision:7,timezone:'UTC',days:[
|
||||||
|
{plan_date:'2026-08-21',ids:['over'],capacity_minutes:30,estimates:{over:45}},
|
||||||
|
{plan_date:'2026-08-22',ids:['missing-estimate'],capacity_minutes:60,estimates:{}},
|
||||||
|
{plan_date:'2026-08-23',ids:['missing-capacity'],capacity_minutes:null,estimates:{'missing-capacity':20}},
|
||||||
|
]});
|
||||||
|
console.log(JSON.stringify(week.review()));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["can_confirm"] is False
|
||||||
|
assert result["blockers"] == [
|
||||||
|
{"type": "over-capacity", "plan_date": "2026-08-21", "minutes": 15},
|
||||||
|
{"type": "missing-estimate", "plan_date": "2026-08-22", "id": "missing-estimate"},
|
||||||
|
{"type": "missing-capacity", "plan_date": "2026-08-23"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_week_controller_lists_seven_local_dates_and_saves_one_without_changing_other_days():
|
def test_week_controller_lists_seven_local_dates_and_saves_one_without_changing_other_days():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
const requests=[];
|
const requests=[];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user