Export Week Ahead as timed calendar blocks #1199
|
|
@ -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-status { min-height:1.4em; margin:10px 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 .mobile-plan-today-nav,
|
||||
.plan-today-sheet.week-review-mode #plan-today-fit,
|
||||
|
|
|
|||
|
|
@ -452,11 +452,15 @@
|
|||
storage: localStorage,
|
||||
getLogin: () => planningOwnerLogin,
|
||||
});
|
||||
const weekItem=identity=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===identity);
|
||||
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),
|
||||
escapeHtml,escapeAttribute:escAttr,todayWork,
|
||||
refresh:refreshMyWorkView,warm:warmTodayOffline});
|
||||
const weekCalendar=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekItem,escapeHtml,escapeAttribute:escAttr,
|
||||
onDone:()=>{weekWorkflow.finish();taskOverlayHistory.leave();},
|
||||
});
|
||||
const todaySync = createTodaySync({
|
||||
storage: localStorage,
|
||||
getLogin: () => planningOwnerLogin,
|
||||
|
|
@ -2701,6 +2705,7 @@
|
|||
}
|
||||
planToday.cancel();
|
||||
planningTomorrow = false;
|
||||
weekCalendar.close();
|
||||
weekWorkflow.clear();
|
||||
qs('#tomorrow-conflict-review').hidden = true;
|
||||
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.';
|
||||
return;
|
||||
}
|
||||
qs('#mobile-week-summary').textContent=weekPlan.summary();
|
||||
qs('#my-work-action-status').textContent='Week Ahead reviewed and confirmed.';
|
||||
taskOverlayHistory.leave();
|
||||
weekCalendar.open(weekPlan.state());
|
||||
});
|
||||
qs('#plan-today-sheet').addEventListener('click', event => {
|
||||
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>
|
||||
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
|
||||
</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">
|
||||
<button type="button" data-plan-today-section="fit" aria-current="location">Fit</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-preview.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/search-week-plan.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-preview.js',
|
||||
BASE + 'static/tomorrow-plan.js',
|
||||
BASE + 'static/week-calendar.js',
|
||||
BASE + 'static/week-plan.js',
|
||||
BASE + 'static/search-week-plan.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)
|
||||
.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) {
|
||||
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 pending=Boolean(controller.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.':
|
||||
(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',()=>{
|
||||
const selector=root.querySelector(`[data-week-move-destination="${button.dataset.weekMove}"]`);
|
||||
if(!controller.move(button.dataset.weekMove,selector?.value))return;
|
||||
|
|
@ -474,8 +488,9 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
|
|||
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;
|
||||
return true;
|
||||
}
|
||||
function finish(){reviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;}
|
||||
qs('#back-to-week-review')?.addEventListener('click',returnToReview);
|
||||
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;}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
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,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ FEATURE_SOURCES = {
|
|||
"planning": (
|
||||
"static/plan-today.js", "static/plan-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": (
|
||||
"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()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
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()
|
||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
|
||||
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-preview.js",
|
||||
"/dashboard/static/tomorrow-plan.js",
|
||||
"/dashboard/static/week-calendar.js",
|
||||
"/dashboard/static/week-plan.js",
|
||||
"/dashboard/static/search-week-plan.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)
|
||||
|
||||
|
||||
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():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user