feat: import private calendar capacity for Week Ahead (Closes #1206)
This commit is contained in:
parent
aa46196d1e
commit
e77ad7c809
|
|
@ -173,7 +173,10 @@ and flags work assigned to more than one date. Operators can move an item to ano
|
|||
the estimate follows the item and the server rejects duplicate cross-day assignments without advancing the
|
||||
week revision. Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
|
||||
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
|
||||
are not stranded behind an earlier request.
|
||||
are not stranded behind an earlier request. From the same review, **Set capacity from calendar** reads a local
|
||||
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and previews seven daily
|
||||
availability totals before one atomic apply. Raw calendar data and event metadata are never persisted or sent;
|
||||
only the reviewed capacity-minute totals use the existing encrypted, account-bound Week Ahead sync.
|
||||
Planning edits can remain offline for up to 30 days. After that, the
|
||||
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
||||
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||
|
|
|
|||
|
|
@ -337,6 +337,20 @@ 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; }
|
||||
#open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; }
|
||||
.week-capacity-import { position:fixed; z-index:121; inset:0; box-sizing:border-box; width:100%; max-width:560px; margin-inline:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow:auto; overflow-x:hidden; }
|
||||
.week-capacity-import[hidden] { display:none; }
|
||||
.week-capacity-import > header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.week-capacity-import h2 { margin:.2rem 0; }
|
||||
.week-capacity-import > label, .week-capacity-hours label { display:grid; min-width:0; gap:6px; margin:12px 0; }
|
||||
.week-capacity-import input, .week-capacity-import button { box-sizing:border-box; min-width:0; min-height:44px; max-width:100%; }
|
||||
.week-capacity-hours { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
||||
.week-capacity-days { display:grid; gap:8px; margin:10px 0 14px; }
|
||||
.week-capacity-day { display:grid; min-width:0; gap:3px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.week-capacity-day span { color:#bfdbfe; font-weight:700; }
|
||||
.week-capacity-day small { color:#a9bdd3; }
|
||||
#apply-week-capacities { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
@media (max-width:359px) { .week-capacity-hours { grid-template-columns:1fr; } }
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -509,11 +509,30 @@
|
|||
<div class="small">Final planning step</div>
|
||||
<h2 id="week-review-title">Review & rebalance Week Ahead</h2>
|
||||
<p class="small muted">Check each day’s load. Move work instead of copying it, then confirm one executable week.</p>
|
||||
<button id="open-week-capacity-import" type="button">Set capacity from calendar</button>
|
||||
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
|
||||
<div class="week-review-days" id="week-review-days"></div>
|
||||
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
|
||||
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
|
||||
</section>
|
||||
<section class="week-capacity-import" id="week-capacity-import" aria-labelledby="week-capacity-title" hidden>
|
||||
<header><div><div class="small">Private calendar import</div><h2 id="week-capacity-title">Set Week Ahead capacity</h2></div>
|
||||
<button id="cancel-week-capacity-import" type="button">Cancel</button></header>
|
||||
<p class="small muted">Your calendar is read only on this device. Event titles, people, locations, and the file are never saved or uploaded.</p>
|
||||
<label>Calendar file
|
||||
<input id="week-capacity-file" type="file" accept=".ics,text/calendar" />
|
||||
</label>
|
||||
<div class="week-capacity-hours">
|
||||
<label>Workday starts <input id="week-capacity-start" type="time" value="09:00" required /></label>
|
||||
<label>Workday ends <input id="week-capacity-end" type="time" value="17:00" required /></label>
|
||||
</div>
|
||||
<div id="week-capacity-status" class="small" role="status" aria-live="assertive"></div>
|
||||
<section id="week-capacity-review" aria-labelledby="week-capacity-review-title" hidden>
|
||||
<h3 id="week-capacity-review-title">Review available time</h3>
|
||||
<div id="week-capacity-days" class="week-capacity-days"></div>
|
||||
<button id="apply-week-capacities" type="button">Apply seven capacities</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>
|
||||
|
|
@ -2028,6 +2047,7 @@
|
|||
<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-calendar-import.js"></script>
|
||||
<script src="static/week-plan.js"></script>
|
||||
<script src="static/search-week-plan.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ const SHELL = [
|
|||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/tomorrow-plan.js',
|
||||
BASE + 'static/week-calendar.js',
|
||||
BASE + 'static/week-calendar-import.js',
|
||||
BASE + 'static/week-plan.js',
|
||||
BASE + 'static/search-week-plan.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
|
|
|
|||
111
frontend/week-calendar-import.js
Normal file
111
frontend/week-calendar-import.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
function createWeekCalendarImport() {
|
||||
const maxBytes=1024*1024;
|
||||
function clock(value) {
|
||||
const match=/^(\d{2}):(\d{2})$/.exec(String(value||''));
|
||||
if(!match)return null;
|
||||
const minutes=Number(match[1])*60+Number(match[2]);
|
||||
return Number(match[1])<24&&Number(match[2])<60?minutes:null;
|
||||
}
|
||||
function instant(value) {
|
||||
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value||'');
|
||||
if(!match)return null;
|
||||
const parts=match.slice(1,7).map(Number);
|
||||
const milliseconds=match[7]?Date.UTC(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]):
|
||||
new Date(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]).getTime();
|
||||
return Number.isFinite(milliseconds)?milliseconds:null;
|
||||
}
|
||||
function events(source) {
|
||||
if(typeof source!=='string'||!source.includes('BEGIN:VCALENDAR'))throw new Error('Choose a valid .ics calendar file.');
|
||||
if(new TextEncoder().encode(source).length>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
|
||||
const lines=source.replace(/\r\n[ \t]/g,'').split(/\r?\n/);
|
||||
const result=[];let current=null;
|
||||
lines.forEach(line=>{
|
||||
if(line==='BEGIN:VEVENT'){current={};return;}
|
||||
if(line==='END:VEVENT'){
|
||||
if(current?.start!=null&¤t?.end>current.start)result.push(current);
|
||||
current=null;return;
|
||||
}
|
||||
if(!current)return;
|
||||
const separator=line.indexOf(':');if(separator<0)return;
|
||||
const name=line.slice(0,separator).split(';')[0],value=line.slice(separator+1);
|
||||
if(name==='DTSTART')current.start=instant(value);
|
||||
if(name==='DTEND')current.end=instant(value);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
function dayBoundary(date,minutes) {
|
||||
const [year,month,day]=date.split('-').map(Number);
|
||||
return new Date(year,month-1,day,Math.floor(minutes/60),minutes%60).getTime();
|
||||
}
|
||||
function review(source,{dates,workdayStart='09:00',workdayEnd='17:00'}={}) {
|
||||
if(!Array.isArray(dates)||dates.length!==7)throw new Error('Week Ahead must contain seven dates.');
|
||||
const startMinute=clock(workdayStart),endMinute=clock(workdayEnd);
|
||||
if(startMinute==null||endMinute==null||endMinute<=startMinute)throw new Error('Working hours must end after they start.');
|
||||
const calendarEvents=events(source);
|
||||
return dates.map(plan_date=>{
|
||||
const start=dayBoundary(plan_date,startMinute),end=dayBoundary(plan_date,endMinute);
|
||||
const ranges=calendarEvents.map(event=>[Math.max(start,event.start),Math.min(end,event.end)])
|
||||
.filter(range=>range[1]>range[0]).sort((left,right)=>left[0]-right[0]);
|
||||
const merged=[];
|
||||
ranges.forEach(range=>{
|
||||
const previous=merged[merged.length-1];
|
||||
if(previous&&range[0]<=previous[1])previous[1]=Math.max(previous[1],range[1]);
|
||||
else merged.push([...range]);
|
||||
});
|
||||
const busy_minutes=Math.round(merged.reduce((total,range)=>total+range[1]-range[0],0)/60000);
|
||||
return {plan_date,busy_minutes,capacity_minutes:endMinute-startMinute-busy_minutes};
|
||||
});
|
||||
}
|
||||
function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
|
||||
let reviewed=null;
|
||||
const root=()=>qs('#week-capacity-import');
|
||||
function clear() {
|
||||
reviewed=null;
|
||||
qs('#week-capacity-file').value='';
|
||||
qs('#week-capacity-review').hidden=true;
|
||||
qs('#week-capacity-days').innerHTML='';
|
||||
}
|
||||
function open() {
|
||||
clear();root().hidden=false;qs('#week-capacity-status').textContent='';
|
||||
qs('#week-capacity-file').focus?.();return true;
|
||||
}
|
||||
function cancel() {clear();root().hidden=true;return true;}
|
||||
function reviewSource(source) {
|
||||
const display=controller.dates();
|
||||
reviewed=review(source,{dates:display.map(item=>item.date),workdayStart:qs('#week-capacity-start').value,
|
||||
workdayEnd:qs('#week-capacity-end').value});
|
||||
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>'<article class="week-capacity-day"><strong>'+
|
||||
display[index].label+'</strong><span>'+day.capacity_minutes+' min available</span><small>'+day.busy_minutes+
|
||||
' min busy during working hours</small></article>').join('');
|
||||
qs('#week-capacity-review').hidden=false;
|
||||
qs('#week-capacity-status').textContent='Review seven capacity totals. Calendar details stay on this device.';
|
||||
return reviewed.map(day=>({...day}));
|
||||
}
|
||||
async function apply() {
|
||||
if(!reviewed||!controller.stageCapacities(reviewed))return false;
|
||||
qs('#apply-week-capacities').disabled=true;
|
||||
try {await controller.flush();cancel();onApplied();return true;}
|
||||
finally {qs('#apply-week-capacities').disabled=false;}
|
||||
}
|
||||
qs('#week-capacity-file')?.addEventListener('change',async event=>{
|
||||
const file=event.currentTarget.files?.[0];if(!file)return;
|
||||
try {
|
||||
if(file.size>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
|
||||
reviewSource(await file.text());
|
||||
} catch(error) {clear();qs('#week-capacity-status').textContent=error.message||'Calendar could not be read.';}
|
||||
});
|
||||
qs('#cancel-week-capacity-import')?.addEventListener('click',cancel);
|
||||
qs('#apply-week-capacities')?.addEventListener('click',()=>apply().catch(error=>{
|
||||
qs('#week-capacity-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Capacities remain saved on this phone.';
|
||||
}));
|
||||
return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null};
|
||||
}
|
||||
function mount(controller,weekWorkflow,qs) {
|
||||
const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview});
|
||||
qs('#open-week-capacity-import').addEventListener('click',workflow.open);
|
||||
return workflow;
|
||||
}
|
||||
return {review,createWorkflow,mount};
|
||||
}
|
||||
const weekCalendarImport=createWeekCalendarImport();
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=weekCalendarImport;
|
||||
|
|
@ -78,6 +78,17 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
|||
estimates:{...(value.estimates||{})},
|
||||
}]));
|
||||
}
|
||||
function stageCapacities(values) {
|
||||
const available=dates().map(item=>item.date);
|
||||
if(!Array.isArray(values)||values.length!==available.length)return false;
|
||||
const capacities=new Map(values.map(value=>[value?.plan_date,value?.capacity_minutes]));
|
||||
if(capacities.size!==available.length||available.some(date=>{
|
||||
const value=capacities.get(date);return !Number.isInteger(value)||value<0||value>1440;
|
||||
}))return false;
|
||||
return stageDays(available.map(planDate=>{
|
||||
const value=day(planDate);value.capacity_minutes=capacities.get(planDate);return value;
|
||||
}));
|
||||
}
|
||||
function review() {
|
||||
const assigned=new Map();
|
||||
const days=dates().map(item=>{
|
||||
|
|
@ -330,7 +341,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
|||
const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
|
||||
return label+(pending()?' · sync pending':'');
|
||||
}
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
keepLocal,useRemote,promote,reconcile:reconcilePromotion,summary};
|
||||
}
|
||||
function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
|
||||
|
|
@ -538,13 +549,15 @@ 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,finish,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
|
||||
const workflow={open,save,advance,confirm,finish,promote,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
|
||||
reviewing:()=>reviewing,selectedDate:()=>selectedDate,
|
||||
day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):null),
|
||||
copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}:
|
||||
(selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null),
|
||||
clear(){selectedDate=null;reviewing=false;editingFromReview=false;reconciliation=null;setReviewMode(false);qs('#week-review').hidden=true;
|
||||
const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}};
|
||||
if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs);
|
||||
return workflow;
|
||||
}
|
||||
if(typeof module!=='undefined'&&module.exports){
|
||||
module.exports=createWeekPlan;
|
||||
|
|
|
|||
|
|
@ -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-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",
|
||||
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.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",
|
||||
|
|
|
|||
|
|
@ -97,6 +97,40 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
|||
page.locator("#back-to-week-review").click()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
expect(first_day).to_contain_text("Ship mobile capture")
|
||||
|
||||
page.locator("#open-week-capacity-import").click()
|
||||
capacity_import = page.locator("#week-capacity-import")
|
||||
expect(capacity_import).to_be_visible()
|
||||
private_title = "Private customer planning"
|
||||
tomorrow = (date.today() + timedelta(days=1)).strftime("%Y%m%d")
|
||||
calendar = (
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
|
||||
f"SUMMARY:{private_title}\r\nDTSTART:{tomorrow}T100000\r\n"
|
||||
f"DTEND:{tomorrow}T110000\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
||||
)
|
||||
page.locator("#week-capacity-file").set_input_files({
|
||||
"name": "availability.ics", "mimeType": "text/calendar", "buffer": calendar.encode()
|
||||
})
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day")).to_have_count(7)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").first).to_contain_text(
|
||||
"420 min available"
|
||||
)
|
||||
expect(capacity_import).not_to_contain_text(private_title)
|
||||
for control in (
|
||||
page.locator("#cancel-week-capacity-import"),
|
||||
page.locator("#week-capacity-file"),
|
||||
page.locator("#week-capacity-start"),
|
||||
page.locator("#week-capacity-end"),
|
||||
page.locator("#apply-week-capacities"),
|
||||
):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#apply-week-capacities").click()
|
||||
expect(capacity_import).to_be_hidden()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
expect(first_day).to_contain_text("30 of 420 min")
|
||||
|
||||
page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled")
|
||||
confirm = page.locator("#confirm-week-plan")
|
||||
bounds = confirm.bounding_box()
|
||||
|
|
@ -118,7 +152,7 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
|||
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
|
||||
assert saved and len(saved[-1]["days"]) == 7
|
||||
assert [day["plan_date"] for day in saved[-1]["days"]] == sorted(
|
||||
day["plan_date"] for day in saved[-1]["days"]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1329,6 +1329,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/tomorrow-plan.js",
|
||||
"/dashboard/static/week-calendar.js",
|
||||
"/dashboard/static/week-calendar-import.js",
|
||||
"/dashboard/static/week-plan.js",
|
||||
"/dashboard/static/search-week-plan.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
|
|
|
|||
|
|
@ -107,4 +107,4 @@ def test_week_calendar_handoff_is_wired_into_confirmation_with_mobile_privacy_co
|
|||
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
|
||||
assert '"static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js"' in bundle
|
||||
|
|
|
|||
108
tests/test_week_calendar_import.py
Normal file
108
tests/test_week_calendar_import.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "week-calendar-import.js"
|
||||
FRONTEND = MODULE.parent
|
||||
|
||||
|
||||
def run_import(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const calendarImport = require({json.dumps(str(MODULE))});
|
||||
(async() => {{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}})
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_calendar_import_unions_busy_time_within_working_hours_across_local_days():
|
||||
result = run_import("""
|
||||
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:one\r\nSUMMARY:Private planning\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:two\r\nSUMMARY:Secret customer call\r\nATTENDEE:mailto:private@example.com\r\nDTSTART:20260821T103000\r\nDTEND:20260821T120000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:outside\r\nDTSTART:20260821T070000\r\nDTEND:20260821T080000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:overnight\r\nLOCATION:Private office\r\nDTSTART:20260821T163000\r\nDTEND:20260822T100000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
||||
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
||||
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
||||
console.log(JSON.stringify({days,serialized:JSON.stringify(days)}));
|
||||
""")
|
||||
|
||||
assert result["days"][0] == {
|
||||
"plan_date": "2026-08-21", "busy_minutes": 150, "capacity_minutes": 330
|
||||
}
|
||||
assert result["days"][1] == {
|
||||
"plan_date": "2026-08-22", "busy_minutes": 60, "capacity_minutes": 420
|
||||
}
|
||||
assert [day["capacity_minutes"] for day in result["days"][2:]] == [480] * 5
|
||||
for private_value in ("Private planning", "Secret customer call", "private@example.com", "Private office"):
|
||||
assert private_value not in result["serialized"]
|
||||
|
||||
|
||||
def test_calendar_import_rejects_invalid_and_oversized_files_without_returning_capacity():
|
||||
result = run_import("""
|
||||
const errors=[];
|
||||
for(const source of ['not a calendar','BEGIN:VCALENDAR\\r\\n'+('X'.repeat(1024*1024))+'\\r\\nEND:VCALENDAR']) {
|
||||
try { calendarImport.review(source,{dates:['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27']}); }
|
||||
catch(error) { errors.push(error.message); }
|
||||
}
|
||||
console.log(JSON.stringify({errors}));
|
||||
""")
|
||||
|
||||
assert result["errors"] == [
|
||||
"Choose a valid .ics calendar file.",
|
||||
"Calendar files must be 1 MB or smaller.",
|
||||
]
|
||||
|
||||
|
||||
def test_calendar_import_workflow_reviews_then_applies_once_without_persisting_calendar_text():
|
||||
result = run_import("""
|
||||
const elements=new Map();
|
||||
const element=(value='')=>({value,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
|
||||
addEventListener(name,listener){this.listeners[name]=listener;},focus(){this.focused=true;}});
|
||||
for(const selector of ['#week-capacity-import','#week-capacity-review','#week-capacity-days','#week-capacity-status',
|
||||
'#week-capacity-file','#week-capacity-start','#week-capacity-end','#apply-week-capacities','#cancel-week-capacity-import'])
|
||||
elements.set(selector,element());
|
||||
elements.get('#week-capacity-start').value='09:00';elements.get('#week-capacity-end').value='17:00';
|
||||
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
||||
let staged=[],flushes=0,appliedCalls=0;
|
||||
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),
|
||||
stageCapacities:value=>{staged.push(value);return {sync_pending:true};},flush:async()=>{flushes+=1;}};
|
||||
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector),onApplied:()=>{appliedCalls+=1;}});
|
||||
workflow.open();
|
||||
const source=`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Do not retain me\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
||||
const reviewed=workflow.review(source);
|
||||
await workflow.apply();
|
||||
console.log(JSON.stringify({reviewed,staged,flushes,appliedCalls,rootHidden:elements.get('#week-capacity-import').hidden,
|
||||
status:elements.get('#week-capacity-status').textContent,markup:elements.get('#week-capacity-days').innerHTML,
|
||||
fileValue:elements.get('#week-capacity-file').value,workflowState:workflow.state()}));
|
||||
""")
|
||||
|
||||
assert result["reviewed"][0]["capacity_minutes"] == 420
|
||||
assert len(result["staged"]) == 1
|
||||
assert len(result["staged"][0]) == 7
|
||||
assert result["flushes"] == 1
|
||||
assert result["appliedCalls"] == 1
|
||||
assert result["rootHidden"] is True
|
||||
assert result["fileValue"] == ""
|
||||
assert result["workflowState"] is None
|
||||
assert "Do not retain me" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_week_ahead_exposes_private_calendar_capacity_import_in_the_packaged_planning_flow():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="open-week-capacity-import"' in html
|
||||
assert 'id="week-capacity-import"' in html
|
||||
assert 'id="week-capacity-file"' in html and 'accept=".ics,text/calendar"' in html
|
||||
assert 'id="week-capacity-start"' in html and 'id="week-capacity-end"' in html
|
||||
assert 'id="week-capacity-review"' in html
|
||||
assert 'id="apply-week-capacities"' in html
|
||||
assert '<script src="static/week-calendar-import.js"></script>' in html
|
||||
assert html.index('static/week-calendar-import.js') < html.index('static/week-plan.js')
|
||||
week_plan = (FRONTEND / "week-plan.js").read_text()
|
||||
assert "weekCalendarImport.mount" in week_plan
|
||||
assert "weekCalendarImport.mount" not in dashboard
|
||||
assert "open-week-capacity-import" in MODULE.read_text()
|
||||
assert ".week-capacity-import" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
|
@ -114,6 +114,31 @@ console.log(JSON.stringify({before,moved,after,pending:week.pending()}));
|
|||
assert result["pending"]["days"][2]["estimates"] == {"shared": 30}
|
||||
|
||||
|
||||
def test_week_controller_applies_seven_imported_capacities_in_one_local_transition():
|
||||
result = run_controller("""
|
||||
const values=new Map();let writes=0;
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>{writes+=1;values.set(key,value);},removeItem:key=>values.delete(key)};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>({}),
|
||||
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:6,timezone:'UTC',days:[
|
||||
{plan_date:'2026-08-21',ids:['one'],capacity_minutes:60,estimates:{one:30}},
|
||||
{plan_date:'2026-08-23',ids:['three'],capacity_minutes:90,estimates:{three:45}}
|
||||
]});
|
||||
const capacities=week.dates().map((item,index)=>({plan_date:item.date,capacity_minutes:300+index*15}));
|
||||
const applied=week.stageCapacities(capacities);
|
||||
console.log(JSON.stringify({applied,writes,state:week.state(),pending:week.pending()}));
|
||||
""")
|
||||
|
||||
assert result["writes"] == 1
|
||||
assert result["applied"]["sync_pending"] is True
|
||||
assert [day["capacity_minutes"] for day in result["state"]["days"]] == [
|
||||
300, 315, 330, 345, 360, 375, 390
|
||||
]
|
||||
assert result["state"]["days"][0]["ids"] == ["one"]
|
||||
assert result["state"]["days"][2]["ids"] == ["three"]
|
||||
assert result["pending"]["days"] == result["state"]["days"]
|
||||
|
||||
|
||||
def test_week_controller_places_new_work_or_explicitly_moves_existing_work_without_duplicates():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user