feat: persist calendar free windows with Week Ahead (Closes #1258)
All checks were successful
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m58s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-22 09:44:28 +00:00
parent 15739f7370
commit fc93620118
10 changed files with 178 additions and 7 deletions

View File

@ -391,6 +391,10 @@ textarea { resize: vertical; min-height: 120px; }
.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; }
.week-free-times-consent { display:flex; align-items:flex-start; gap:10px; min-width:0; margin:12px 0; padding:10px; border:1px solid #31577f; border-radius:10px; }
.week-free-times-consent input { flex:0 0 44px; width:44px; margin:0; }
.week-free-times-consent span { display:grid; min-width:0; gap:4px; overflow-wrap:anywhere; }
.week-free-times-consent small { color:#a9bdd3; }
#apply-week-capacities { width:100%; min-height:48px; position:sticky; bottom:0; }
.week-availability-editor { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
#open-week-availability { min-height:44px; }

View File

@ -578,13 +578,16 @@
<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>
<label class="week-free-times-consent"><input id="keep-week-free-times" type="checkbox" />
<span><strong>Keep these free times with Week Ahead</strong><small>Only seven days of start/end times are encrypted and synced. Calendar events and the file stay on this device.</small></span>
</label>
<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>
<p class="small muted">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.</p>
<p class="small muted">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.</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">

View File

@ -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});

View File

@ -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) {

View File

@ -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,
)

View File

@ -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")

View File

@ -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")

View File

@ -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 '<script src="static/week-calendar-import.js"></script>' in html
assert html.index('static/week-calendar-import.js') < html.index('static/week-plan.js')

View File

@ -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,

View File

@ -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;