Calendar handoff
Add Week Ahead to calendar
- Titles, references, dates, and times are included in a local calendar file; nothing is uploaded. Imported busy time is used only until this page reloads; otherwise times follow the chosen start.
+ Titles, references, dates, and times are included in a local calendar file; nothing is uploaded. Imported busy time is used only until this page reloads unless you consent to keep free times with the encrypted Week Ahead plan.
diff --git a/frontend/week-calendar-import.js b/frontend/week-calendar-import.js
index 05a8373..32e2cc2 100644
--- a/frontend/week-calendar-import.js
+++ b/frontend/week-calendar-import.js
@@ -228,8 +228,10 @@ function createWeekCalendarImport() {
return reviewed.map(day=>({...day}));
}
async function apply() {
+ const keep=Boolean(qs('#keep-week-free-times')?.checked);
if(!reviewed||reviewed.unsupported_count||!controller.stageCapacities(reviewed.map(day=>({
plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
+ ...(keep?{free_windows:day.free_windows.map(window=>({...window}))}:{}),
}))))return false;
qs('#apply-week-capacities').disabled=true;
try {await controller.flush();activeAvailability=reviewed.map(day=>({plan_date:day.plan_date,
@@ -247,9 +249,14 @@ function createWeekCalendarImport() {
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.';
}));
+ function availability() {
+ const saved=(controller.state?.().days||[]).filter(day=>Array.isArray(day.free_windows));
+ const source=activeAvailability||saved;
+ return source.length?source.map(day=>({plan_date:day.plan_date,
+ free_windows:day.free_windows.map(window=>({...window}))})):null;
+ }
return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null,
- availability:()=>activeAvailability?activeAvailability.map(day=>({plan_date:day.plan_date,
- free_windows:day.free_windows.map(window=>({...window}))})):null};
+ availability};
}
function mount(controller,weekWorkflow,qs) {
const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview});
diff --git a/frontend/week-plan.js b/frontend/week-plan.js
index 90cb098..0704143 100644
--- a/frontend/week-plan.js
+++ b/frontend/week-plan.js
@@ -11,9 +11,13 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
const confirmedPrefix='stackchain.week-confirmed.v1.';
const pullPrefix='stackchain.week-today-pull.v1.';
const cloneDefaults=value=>Array.isArray(value)&&value.length===7?value.slice():null;
+ const cloneWindows=value=>Array.isArray(value)?value.map(window=>({
+ start_time:window.start_time,end_time:window.end_time,
+ })):null;
const cloneDay=day=>({
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
estimates:{...(day.estimates||{})},
+ ...(cloneWindows(day.free_windows)?{free_windows:cloneWindows(day.free_windows)}:{}),
});
const sanitizeItem=value=>value?{kind:value.kind==='pull'?'pull':'issue',
title:String(value.title||'').slice(0,500),repository:String(value.repository||'').slice(0,255),
@@ -207,12 +211,17 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
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]));
+ const capacities=new Map(values.map(value=>[value?.plan_date,value]));
if(capacities.size!==available.length||available.some(date=>{
- const value=capacities.get(date);return !Number.isInteger(value)||value<0||value>1440;
+ const value=capacities.get(date)?.capacity_minutes;
+ 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;
+ const value=day(planDate),capacity=capacities.get(planDate);
+ value.capacity_minutes=capacity.capacity_minutes;
+ if(Array.isArray(capacity.free_windows))value.free_windows=cloneWindows(capacity.free_windows);
+ else delete value.free_windows;
+ return value;
}));
}
function stageAvailabilityDefaults(values) {
diff --git a/src/main.py b/src/main.py
index f74a6bb..a8228ff 100644
--- a/src/main.py
+++ b/src/main.py
@@ -727,11 +727,17 @@ class TomorrowPromotion(BaseModel):
today_revision: int = Field(ge=0)
+class WeekFreeWindow(BaseModel):
+ start_time: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
+ end_time: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
+
+
class WeekPlanDay(BaseModel):
plan_date: str = Field(min_length=10, max_length=10)
ids: list[str] = Field(max_length=5)
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
+ free_windows: list[WeekFreeWindow] | None = Field(default=None, max_length=16)
class WeekPlanUpdate(BaseModel):
@@ -2829,7 +2835,7 @@ async def replace_week_plan(payload: WeekPlanUpdate):
return await asyncio.to_thread(
_today_store().replace_week, login,
base_revision=payload.base_revision,
- days=[day.model_dump() for day in payload.days],
+ days=[day.model_dump(exclude_none=True) for day in payload.days],
timezone=payload.timezone,
availability_defaults=payload.availability_defaults,
)
diff --git a/src/today_store.py b/src/today_store.py
index 834b296..e8b68d5 100644
--- a/src/today_store.py
+++ b/src/today_store.py
@@ -551,6 +551,33 @@ class TodayStore:
ids=day.get("ids", []), capacity_minutes=day.get("capacity_minutes"),
estimates=day.get("estimates", {}), plan_date=plan_date, timezone=timezone,
)
+ if "free_windows" in day:
+ windows = day["free_windows"]
+ if not isinstance(windows, list) or len(windows) > 16:
+ raise ValueError("free windows must be a bounded list")
+ normalized_windows = []
+ previous_end = -1
+ for window in windows:
+ if not isinstance(window, dict) or set(window) != {"start_time", "end_time"}:
+ raise ValueError("free windows must contain only start and end times")
+ clock_values = []
+ for key in ("start_time", "end_time"):
+ value = window[key]
+ if (
+ not isinstance(value, str) or len(value) != 5 or value[2] != ":"
+ or not value[:2].isdigit() or not value[3:].isdigit()
+ or int(value[:2]) > 23 or int(value[3:]) > 59
+ ):
+ raise ValueError("free windows must use HH:mm times")
+ clock_values.append(int(value[:2]) * 60 + int(value[3:]))
+ start, end = clock_values
+ if start < previous_end or end <= start:
+ raise ValueError("free windows must be ordered and non-overlapping")
+ normalized_windows.append({
+ "start_time": window["start_time"], "end_time": window["end_time"],
+ })
+ previous_end = end
+ plan["free_windows"] = normalized_windows
duplicate_ids = seen_ids.intersection(plan["ids"])
if duplicate_ids:
raise ValueError("work must be assigned to only one Week Ahead date")
diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py
index 6bdfdff..3379bb5 100644
--- a/tests/e2e/test_mobile_week_ahead_release.py
+++ b/tests/e2e/test_mobile_week_ahead_release.py
@@ -285,15 +285,20 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
page.locator("#week-capacity-file"),
page.locator("#week-capacity-start"),
page.locator("#week-capacity-end"),
+ page.locator("#keep-week-free-times"),
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("#keep-week-free-times").check()
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("45 of 420 min")
+ assert saved[-1]["days"][0]["free_windows"] == [
+ {"start_time": "10:00", "end_time": "17:00"}
+ ]
page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled")
confirm = page.locator("#confirm-week-plan")
diff --git a/tests/test_week_calendar_import.py b/tests/test_week_calendar_import.py
index b375a60..50ee99d 100644
--- a/tests/test_week_calendar_import.py
+++ b/tests/test_week_calendar_import.py
@@ -303,6 +303,37 @@ console.log(JSON.stringify({reviewed,staged,flushes,appliedCalls,rootHidden:elem
assert "Do not retain me" not in json.dumps(result)
+def test_calendar_import_persists_only_free_windows_with_explicit_consent_and_restores_them():
+ result = run_import("""
+const elements=new Map();
+const element=(value='')=>({value,checked:false,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
+ addEventListener(name,listener){this.listeners[name]=listener;},focus(){}});
+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','#keep-week-free-times',
+ '#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';
+elements.get('#keep-week-free-times').checked=true;
+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=null;
+const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+index})),state:()=>({days:staged||[]}),
+ stageCapacities:value=>{staged=value;return {sync_pending:true};},flush:async()=>({})};
+const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)});
+workflow.review(`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Secret meeting\r\nATTENDEE:private@example.com\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nEND:VCALENDAR`);
+await workflow.apply();
+const restored=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)}).availability();
+console.log(JSON.stringify({staged,restored}));
+""")
+
+ windows = [
+ {"start_time": "09:00", "end_time": "10:00"},
+ {"start_time": "11:00", "end_time": "17:00"},
+ ]
+ assert result["staged"][0]["free_windows"] == windows
+ assert result["restored"][0]["free_windows"] == windows
+ assert "Secret meeting" not in json.dumps(result)
+ assert "private@example.com" 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()
@@ -313,6 +344,7 @@ def test_week_ahead_exposes_private_calendar_capacity_import_in_the_packaged_pla
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="keep-week-free-times"' in html
assert 'id="apply-week-capacities"' in html
assert '' in html
assert html.index('static/week-calendar-import.js') < html.index('static/week-plan.js')
diff --git a/tests/test_week_plan.py b/tests/test_week_plan.py
index a5397f0..d8bb10e 100644
--- a/tests/test_week_plan.py
+++ b/tests/test_week_plan.py
@@ -73,6 +73,34 @@ def test_week_plan_persists_bounded_weekly_availability_with_the_revisioned_plan
)
+def test_week_plan_persists_only_bounded_non_overlapping_calendar_free_windows(tmp_path):
+ path = tmp_path / "today.sqlite3"
+ store = TodayStore(path, encryption_key=b"w" * 32)
+ days = sample_days()
+ days[0]["free_windows"] = [
+ {"start_time": "09:00", "end_time": "10:00"},
+ {"start_time": "11:00", "end_time": "17:00"},
+ ]
+
+ saved = store.replace_week(
+ "timmy", base_revision=0, days=days, timezone="UTC",
+ )
+
+ assert saved["days"][0]["free_windows"] == days[0]["free_windows"]
+ assert store.get_week("timmy") == saved
+ assert b"09:00" not in path.read_bytes()
+
+ invalid = sample_days()
+ invalid[0]["free_windows"] = [
+ {"start_time": "09:00", "end_time": "12:00"},
+ {"start_time": "11:00", "end_time": "13:00"},
+ ]
+ with pytest.raises(ValueError, match="free windows"):
+ store.replace_week(
+ "timmy", base_revision=1, days=invalid, timezone="UTC",
+ )
+
+
def test_week_update_model_accepts_zero_capacity_weekday_defaults():
payload = main.WeekPlanUpdate.model_validate({
"base_revision": 2, "timezone": "UTC", "days": [],
@@ -87,6 +115,29 @@ def test_week_update_model_accepts_zero_capacity_weekday_defaults():
})
+def test_week_update_model_accepts_only_bounded_calendar_free_windows():
+ payload = main.WeekPlanUpdate.model_validate({
+ "base_revision": 2, "timezone": "UTC", "days": [{
+ "plan_date": "2026-08-21", "ids": [], "estimates": {},
+ "free_windows": [
+ {"start_time": "09:00", "end_time": "10:00"},
+ {"start_time": "11:00", "end_time": "17:00"},
+ ],
+ }],
+ })
+
+ assert payload.model_dump()["days"][0]["free_windows"][1] == {
+ "start_time": "11:00", "end_time": "17:00",
+ }
+ with pytest.raises(ValueError):
+ main.WeekPlanUpdate.model_validate({
+ "base_revision": 2, "timezone": "UTC", "days": [{
+ "plan_date": "2026-08-21", "ids": [], "estimates": {},
+ "free_windows": [{"start_time": "nine", "end_time": "10:00"}],
+ }],
+ })
+
+
def test_start_day_plan_selects_earliest_nonempty_week_date_without_mutation(tmp_path):
store = TodayStore(
tmp_path / "today.sqlite3", encryption_key=b"r" * 32,
diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py
index bd7959e..d39288c 100644
--- a/tests/test_week_plan_frontend.py
+++ b/tests/test_week_plan_frontend.py
@@ -249,6 +249,33 @@ console.log(JSON.stringify({applied,writes,state:week.state(),pending:week.pendi
assert result["pending"]["days"] == result["state"]["days"]
+def test_week_controller_keeps_consented_free_windows_through_pending_flush_and_reload():
+ 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={})=>{
+ if(!options.method)return {revision:4,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['one'],capacity_minutes:420,
+ estimates:{one:45},free_windows:[{start_time:'09:00',end_time:'10:00'},{start_time:'11:00',end_time:'17:00'}]}]};
+ const body=JSON.parse(options.body);requests.push(body);return {revision:5,timezone:body.timezone,days:body.days};
+};
+const options={storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
+const first=createWeekPlan(options);await first.load();
+const capacities=first.dates().map((item,index)=>({plan_date:item.date,capacity_minutes:index?480:420,
+ ...(index?{}:{free_windows:[{start_time:'09:00',end_time:'10:00'},{start_time:'11:00',end_time:'17:00'}]})}));
+first.stageCapacities(capacities);const pending=first.pending();await first.flush();
+const restored=createWeekPlan(options);await restored.load();
+console.log(JSON.stringify({pending,request:requests[0],restored:restored.state()}));
+""")
+
+ windows = [
+ {"start_time": "09:00", "end_time": "10:00"},
+ {"start_time": "11:00", "end_time": "17:00"},
+ ]
+ assert result["pending"]["days"][0]["free_windows"] == windows
+ assert result["request"]["days"][0]["free_windows"] == windows
+ assert result["restored"]["days"][0]["free_windows"] == windows
+
+
def test_week_controller_previews_and_atomically_applies_a_capacity_safe_reflow():
result = run_controller("""
const values=new Map();let writes=0;