Merge pull request 'Persist privacy-safe calendar free windows with Week Ahead' (#1259)
Closes #1258
This commit is contained in:
commit
9eac3bb958
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user