From 7deb1c1de9e950cf6d09093e9b68b6fbe3e6448c Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 19 Aug 2026 23:27:07 +0000 Subject: [PATCH 1/2] fix: recover Tomorrow promotion after missed rollover (Closes #1154) --- frontend/dashboard.js | 7 +++ frontend/tomorrow-plan.js | 42 +++++++++++++++-- src/main.py | 7 ++- src/today_store.py | 21 ++++++++- tests/test_tomorrow_plan.py | 67 ++++++++++++++++++++++++++-- tests/test_tomorrow_plan_frontend.py | 66 +++++++++++++++++++++------ 6 files changed, 188 insertions(+), 22 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index c093e59..8be1964 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -377,6 +377,7 @@ const outboxCoordinator = createOutboxCoordinator({ storage: localStorage }); const todayRollover = createTodayRollover(); let planningTomorrow = false; + let latestTodayPlan = null; const tomorrowPlan = createTomorrowPlan({ fetchJson:fetchReviewJson, localDate:todayRollover.localDate, @@ -419,6 +420,7 @@ }, onRemotePlan: plan => { if (!planningOwnerLogin) return; + latestTodayPlan = plan; promoteTomorrowIfDue(plan); todayWork.replacePlanning({ capacity_minutes: plan.capacity_minutes ?? null, @@ -453,6 +455,11 @@ }, }); todaySync.startLifecycle({ window, document }); + tomorrowPlan.startLifecycle({ + windowObject:window, + documentObject:document, + check:() => latestTodayPlan ? promoteTomorrowIfDue(latestTodayPlan) : false, + }); const todayHandoff = createTodayHandoff({ storage:localStorage, getLogin:() => planningOwnerLogin, diff --git a/frontend/tomorrow-plan.js b/frontend/tomorrow-plan.js index e694847..fd29455 100644 --- a/frontend/tomorrow-plan.js +++ b/frontend/tomorrow-plan.js @@ -1,4 +1,4 @@ -function createTomorrowPlan({fetchJson,localDate,timeZone,createId}={}) { +function createTomorrowPlan({fetchJson,localDate,timeZone}={}) { let plan={revision:0,ids:[],capacity_minutes:null,estimates:{}}; const state=()=>({...plan,ids:[...plan.ids],estimates:{...plan.estimates}}); function adopt(value) { @@ -19,11 +19,45 @@ function createTomorrowPlan({fetchJson,localDate,timeZone,createId}={}) { return adopt(await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})); } async function promote(today_revision) { - if (!plan.plan_date||plan.plan_date!==localDate()||!plan.ids.length) return false; - const promotion_id=createId?.()||globalThis.crypto?.randomUUID?.()||`${plan.plan_date}-${plan.revision}`; + if (!plan.plan_date||plan.plan_date>localDate()||!plan.ids.length) return false; + const promotion_id=`tomorrow-${plan.plan_date}-r${plan.revision}`; return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({promotion_id,tomorrow_revision:plan.revision,today_revision})}); } - return {adopt,load,save,promote,state,nextLocalDate}; + function millisecondsUntilNextDay() { + const now=Date.now(); + const zone=timeZone(); + const formatter=new Intl.DateTimeFormat('en-CA',{timeZone:zone,year:'numeric',month:'2-digit',day:'2-digit', + hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'}); + const zoned=value=>Object.fromEntries(formatter.formatToParts(new Date(value)) + .filter(part=>part.type!=='literal').map(part=>[part.type,Number(part.value)])); + const current=zoned(now); + const targetWall=Date.UTC(current.year,current.month-1,current.day+1); + let candidate=targetWall; + for(let attempt=0;attempt<2;attempt+=1){ + const parts=zoned(candidate); + const offset=Date.UTC(parts.year,parts.month-1,parts.day,parts.hour,parts.minute,parts.second)-candidate; + candidate=targetWall-offset; + } + return Math.max(1,candidate-now+250); + } + function startLifecycle({windowObject,documentObject,check,setTimer=setTimeout,clearTimer=clearTimeout, + nextDelay=millisecondsUntilNextDay}={}) { + let flight=null; + let timer=null; + const schedule=()=>{ + if(timer!==null) clearTimer(timer); + timer=setTimer(run,nextDelay()); + }; + function run(){ + if(!flight) flight=Promise.resolve().then(check).finally(()=>{flight=null;schedule();}); + return flight; + } + windowObject?.addEventListener?.('online',run); + documentObject?.addEventListener?.('visibilitychange',()=>documentObject.hidden?false:run()); + run(); + return {run,stop(){if(timer!==null)clearTimer(timer);timer=null;}}; + } + return {adopt,load,save,promote,state,nextLocalDate,startLifecycle}; } if(typeof module!=='undefined'&&module.exports)module.exports=createTomorrowPlan; diff --git a/src/main.py b/src/main.py index 653d434..1d6c9d7 100644 --- a/src/main.py +++ b/src/main.py @@ -74,7 +74,7 @@ from src.suggestion_engine import compute from src.later_store import LaterStore from src.today_store import ( TodayPlanFull, TodayPromotionConflict, TodaySessionConflict, TodayStore, - TomorrowPlanConflict, + TomorrowPlanConflict, TomorrowPlanNotDue, ) from src.state_encryption import PrivateStateEncryptionError from src.views import FRONTEND_BUILD, router as frontend_router @@ -2669,6 +2669,11 @@ async def promote_tomorrow_plan(payload: TomorrowPromotion): status_code=409, detail={"code": "today_changed", "today": error.today}, ) + except TomorrowPlanNotDue as error: + raise HTTPException( + status_code=409, + detail={"code": "tomorrow_not_due", "plan_date": error.plan_date}, + ) except ValueError as error: raise HTTPException(status_code=422, detail=str(error)) except (OSError, sqlite3.Error, PrivateStateEncryptionError): diff --git a/src/today_store.py b/src/today_store.py index 2beb359..485e879 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -3,8 +3,9 @@ import json import sqlite3 import time -from datetime import date +from datetime import date, datetime from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from src.private_state import connect_private_sqlite from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key @@ -30,6 +31,14 @@ class TomorrowPlanConflict(ValueError): self.snapshot = snapshot +class TomorrowPlanNotDue(ValueError): + """Raised when Tomorrow is promoted before its saved local date.""" + + def __init__(self, plan_date: str): + super().__init__("Tomorrow plan is not due") + self.plan_date = plan_date + + class TodayPromotionConflict(ValueError): """Raised when rollover would overwrite a changed Today plan.""" @@ -290,6 +299,11 @@ class TodayStore: raise ValueError("plan_date must be an ISO calendar date") from None if not isinstance(timezone, str) or not timezone.strip() or len(timezone) > 100: raise ValueError("timezone is required and bounded") + timezone = timezone.strip() + try: + ZoneInfo(timezone) + except (ZoneInfoNotFoundError, ValueError): + raise ValueError("timezone must be a valid IANA timezone") from None if not isinstance(ids, list) or len(ids) > self.limit or any( not isinstance(item_id, str) or not item_id or len(item_id) > 500 for item_id in ids ): @@ -364,6 +378,11 @@ class TodayStore: tomorrow = self._tomorrow_snapshot(tomorrow_row, login) if tomorrow["revision"] != tomorrow_revision or not tomorrow.get("plan_date"): raise TomorrowPlanConflict(tomorrow) + local_date = datetime.fromtimestamp( + self.clock(), ZoneInfo(tomorrow["timezone"]) + ).date().isoformat() + if local_date < tomorrow["plan_date"]: + raise TomorrowPlanNotDue(tomorrow["plan_date"]) today_row = connection.execute( "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone " "FROM today_plans WHERE login = ?", (login,) diff --git a/tests/test_tomorrow_plan.py b/tests/test_tomorrow_plan.py index e5ebaef..6405dea 100644 --- a/tests/test_tomorrow_plan.py +++ b/tests/test_tomorrow_plan.py @@ -1,15 +1,20 @@ import sqlite3 +from datetime import datetime, timezone import pytest from src import main -from src.today_store import TodayStore, TomorrowPlanConflict, TodayPromotionConflict +from src.today_store import TodayPromotionConflict, TodayStore, TomorrowPlanConflict def test_tomorrow_plan_is_encrypted_scoped_and_promotes_atomically_exactly_once(tmp_path): path = tmp_path / "today.sqlite3" key = b"n" * 32 - store = TodayStore(path, encryption_key=key) + store = TodayStore( + path, + encryption_key=key, + clock=lambda: datetime(2026, 8, 21, tzinfo=timezone.utc).timestamp(), + ) store.apply("timmy", "today-1", "add", "issue:r:1:") tomorrow = store.replace_tomorrow( @@ -53,7 +58,11 @@ def test_tomorrow_plan_is_encrypted_scoped_and_promotes_atomically_exactly_once( def test_tomorrow_edit_and_promotion_reject_stale_revisions_without_touching_today(tmp_path): - store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"s" * 32) + store = TodayStore( + tmp_path / "today.sqlite3", + encryption_key=b"s" * 32, + clock=lambda: datetime(2026, 8, 21, tzinfo=timezone.utc).timestamp(), + ) original_today = store.apply("timmy", "today", "add", "issue:r:1:") first = store.replace_tomorrow( "timmy", base_revision=0, ids=["issue:r:2:"], capacity_minutes=60, @@ -77,6 +86,27 @@ def test_tomorrow_edit_and_promotion_reject_stale_revisions_without_touching_tod assert store.get_tomorrow("timmy") == first +def test_tomorrow_promotion_rejects_the_day_before_in_the_saved_timezone(tmp_path): + store = TodayStore( + tmp_path / "today.sqlite3", + clock=lambda: datetime(2026, 8, 20, 6, 59, tzinfo=timezone.utc).timestamp(), + ) + today = store.apply("timmy", "today", "add", "issue:r:1:") + tomorrow = store.replace_tomorrow( + "timmy", base_revision=0, ids=["issue:r:2:"], capacity_minutes=60, + estimates={}, plan_date="2026-08-20", timezone="America/Los_Angeles", + ) + + with pytest.raises(ValueError, match="Tomorrow plan is not due"): + store.promote_tomorrow( + "timmy", promotion_id="tomorrow-2026-08-20-r1", + tomorrow_revision=tomorrow["revision"], today_revision=today["revision"], + ) + + assert store.get("timmy") == today + assert store.get_tomorrow("timmy") == tomorrow + + def test_tomorrow_validation_is_bounded_and_date_specific(tmp_path): store = TodayStore(tmp_path / "today.sqlite3") with pytest.raises(ValueError, match="ISO calendar date"): @@ -110,3 +140,34 @@ async def test_tomorrow_api_round_trip_and_conflict_contract(monkeypatch, tmp_pa await main.replace_tomorrow_plan(payload) assert raised.value.status_code == 409 assert raised.value.detail == {"code": "tomorrow_changed", "snapshot": saved} + + +@pytest.mark.anyio +async def test_tomorrow_api_reports_an_early_promotion_without_mutation(monkeypatch, tmp_path): + async def user(): + return {"login": "timmy"} + + store = TodayStore( + tmp_path / "today.sqlite3", + clock=lambda: datetime(2026, 8, 20, 6, 59, tzinfo=timezone.utc).timestamp(), + ) + monkeypatch.setattr(main, "current_user", user) + monkeypatch.setattr(main, "_today_store", lambda: store) + today = store.apply("timmy", "today", "add", "issue:r:1:") + tomorrow = store.replace_tomorrow( + "timmy", base_revision=0, ids=["issue:r:2:"], capacity_minutes=60, + estimates={}, plan_date="2026-08-20", timezone="America/Los_Angeles", + ) + + with pytest.raises(main.HTTPException) as raised: + await main.promote_tomorrow_plan(main.TomorrowPromotion( + promotion_id="tomorrow-2026-08-20-r1", + tomorrow_revision=tomorrow["revision"], today_revision=today["revision"], + )) + + assert raised.value.status_code == 409 + assert raised.value.detail == { + "code": "tomorrow_not_due", "plan_date": "2026-08-20" + } + assert store.get("timmy") == today + assert store.get_tomorrow("timmy") == tomorrow diff --git a/tests/test_tomorrow_plan_frontend.py b/tests/test_tomorrow_plan_frontend.py index fef67c1..65ad591 100644 --- a/tests/test_tomorrow_plan_frontend.py +++ b/tests/test_tomorrow_plan_frontend.py @@ -57,32 +57,71 @@ console.log(JSON.stringify({loaded,saved,requests})); assert all(request["url"] != "api/v1/today" for request in result["requests"]) -def test_tomorrow_planner_promotes_only_on_matching_local_date(): +def test_tomorrow_planner_promotes_overdue_plans_with_a_stable_retry_identity(): result = run_controller(""" const requests=[]; const fetchJson=async (url, options={})=>{ requests.push({url,body:options.body ? JSON.parse(options.body) : null}); return {ids:['issue:r:2:'],revision:8}; }; -const before=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-19',timeZone:()=> 'UTC',createId:()=> 'rollover-id'}); +const before=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-19',timeZone:()=> 'UTC'}); before.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'}); const early=await before.promote(7); -const due=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC',createId:()=> 'rollover-id'}); -due.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'}); -const promoted=await due.promote(7); -console.log(JSON.stringify({early,promoted,requests})); +const overdue=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-22',timeZone:()=> 'UTC'}); +overdue.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'}); +const promoted=await overdue.promote(7); +const retried=await overdue.promote(7); +console.log(JSON.stringify({early,promoted,retried,requests})); """) assert result["early"] is False assert result["promoted"]["revision"] == 8 - assert result["requests"] == [{ - "url": "api/v1/tomorrow/promote", - "body": { - "promotion_id": "rollover-id", - "tomorrow_revision": 3, - "today_revision": 7, + assert result["retried"]["revision"] == 8 + assert result["requests"] == [ + { + "url": "api/v1/tomorrow/promote", + "body": { + "promotion_id": "tomorrow-2026-08-20-r3", + "tomorrow_revision": 3, + "today_revision": 7, + }, }, - }] + { + "url": "api/v1/tomorrow/promote", + "body": { + "promotion_id": "tomorrow-2026-08-20-r3", + "tomorrow_revision": 3, + "today_revision": 7, + }, + }, + ] + + +def test_tomorrow_rollover_checks_startup_reconnect_foreground_and_next_midnight_single_flight(): + result = run_controller(""" +const listeners={}; +const timers=[]; +const cleared=[]; +let checks=0; +const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); +const windowObject={addEventListener:(name,fn)=>listeners['window:'+name]=fn}; +const documentObject={hidden:false,addEventListener:(name,fn)=>listeners['document:'+name]=fn}; +const lifecycle=planner.startLifecycle({ + windowObject,documentObject,check:async()=>{checks+=1;await Promise.resolve();return true;}, + setTimer:(fn,delay)=>{timers.push({fn,delay});return timers.length;}, + clearTimer:id=>cleared.push(id),nextDelay:()=>1234, +}); +await lifecycle.run(); +const reconnect=listeners['window:online'](); +const foreground=listeners['document:visibilitychange'](); +await Promise.all([reconnect,foreground]); +await timers[timers.length-1].fn(); +console.log(JSON.stringify({checks,delays:timers.map(timer=>timer.delay),cleared})); +""") + + assert result["checks"] == 3 + assert result["delays"] == [1234, 1234, 1234] + assert result["cleared"] == [1, 2] def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_planner(): @@ -98,3 +137,4 @@ def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_plan assert "planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)" in dashboard assert "tomorrowPlan.load()" in dashboard assert "tomorrowPlan.promote(plan.revision)" in dashboard + assert "tomorrowPlan.startLifecycle" in dashboard -- 2.43.0 From 7c2e9861d7f046906ed2e679731132ac2a2fc448 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 19 Aug 2026 23:39:34 +0000 Subject: [PATCH 2/2] fix: tolerate unavailable browser timezone data --- frontend/tomorrow-plan.js | 11 +++++++++-- tests/test_tomorrow_plan_frontend.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/frontend/tomorrow-plan.js b/frontend/tomorrow-plan.js index fd29455..5657984 100644 --- a/frontend/tomorrow-plan.js +++ b/frontend/tomorrow-plan.js @@ -27,8 +27,15 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) { function millisecondsUntilNextDay() { const now=Date.now(); const zone=timeZone(); - const formatter=new Intl.DateTimeFormat('en-CA',{timeZone:zone,year:'numeric',month:'2-digit',day:'2-digit', - hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'}); + let formatter; + try { + formatter=new Intl.DateTimeFormat('en-CA',{timeZone:zone,year:'numeric',month:'2-digit',day:'2-digit', + hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'}); + } catch (_error) { + const next=new Date(now); + next.setHours(24,0,0,250); + return Math.max(1,next.getTime()-now); + } const zoned=value=>Object.fromEntries(formatter.formatToParts(new Date(value)) .filter(part=>part.type!=='literal').map(part=>[part.type,Number(part.value)])); const current=zoned(now); diff --git a/tests/test_tomorrow_plan_frontend.py b/tests/test_tomorrow_plan_frontend.py index 65ad591..1a621a1 100644 --- a/tests/test_tomorrow_plan_frontend.py +++ b/tests/test_tomorrow_plan_frontend.py @@ -124,6 +124,20 @@ console.log(JSON.stringify({checks,delays:timers.map(timer=>timer.delay),cleared assert result["cleared"] == [1, 2] +def test_tomorrow_rollover_falls_back_when_the_reported_timezone_is_invalid(): + result = run_controller(""" +let scheduled=null; +const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'Etc/Unknown'}); +const lifecycle=planner.startLifecycle({ + check:async()=>false,setTimer:(_fn,delay)=>{scheduled=delay;return 1;},clearTimer:()=>{}, +}); +await lifecycle.run(); +console.log(JSON.stringify({scheduled:Number.isFinite(scheduled)&&scheduled>0})); +""") + + assert result == {"scheduled": True} + + def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_planner(): index = INDEX.read_text() css = CSS.read_text() -- 2.43.0