fix: recover Tomorrow promotion after missed rollover (Closes #1154)
Some checks failed
CI / lint (pull_request) Successful in 3m39s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 3m50s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-19 23:27:07 +00:00
parent 559fd2dc89
commit 7deb1c1de9
6 changed files with 188 additions and 22 deletions

View File

@ -377,6 +377,7 @@
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage }); const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
const todayRollover = createTodayRollover(); const todayRollover = createTodayRollover();
let planningTomorrow = false; let planningTomorrow = false;
let latestTodayPlan = null;
const tomorrowPlan = createTomorrowPlan({ const tomorrowPlan = createTomorrowPlan({
fetchJson:fetchReviewJson, fetchJson:fetchReviewJson,
localDate:todayRollover.localDate, localDate:todayRollover.localDate,
@ -419,6 +420,7 @@
}, },
onRemotePlan: plan => { onRemotePlan: plan => {
if (!planningOwnerLogin) return; if (!planningOwnerLogin) return;
latestTodayPlan = plan;
promoteTomorrowIfDue(plan); promoteTomorrowIfDue(plan);
todayWork.replacePlanning({ todayWork.replacePlanning({
capacity_minutes: plan.capacity_minutes ?? null, capacity_minutes: plan.capacity_minutes ?? null,
@ -453,6 +455,11 @@
}, },
}); });
todaySync.startLifecycle({ window, document }); todaySync.startLifecycle({ window, document });
tomorrowPlan.startLifecycle({
windowObject:window,
documentObject:document,
check:() => latestTodayPlan ? promoteTomorrowIfDue(latestTodayPlan) : false,
});
const todayHandoff = createTodayHandoff({ const todayHandoff = createTodayHandoff({
storage:localStorage, storage:localStorage,
getLogin:() => planningOwnerLogin, getLogin:() => planningOwnerLogin,

View File

@ -1,4 +1,4 @@
function createTomorrowPlan({fetchJson,localDate,timeZone,createId}={}) { function createTomorrowPlan({fetchJson,localDate,timeZone}={}) {
let plan={revision:0,ids:[],capacity_minutes:null,estimates:{}}; let plan={revision:0,ids:[],capacity_minutes:null,estimates:{}};
const state=()=>({...plan,ids:[...plan.ids],estimates:{...plan.estimates}}); const state=()=>({...plan,ids:[...plan.ids],estimates:{...plan.estimates}});
function adopt(value) { 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)})); return adopt(await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
} }
async function promote(today_revision) { async function promote(today_revision) {
if (!plan.plan_date||plan.plan_date!==localDate()||!plan.ids.length) return false; 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}`; const promotion_id=`tomorrow-${plan.plan_date}-r${plan.revision}`;
return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'}, return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({promotion_id,tomorrow_revision:plan.revision,today_revision})}); 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; if(typeof module!=='undefined'&&module.exports)module.exports=createTomorrowPlan;

View File

@ -74,7 +74,7 @@ from src.suggestion_engine import compute
from src.later_store import LaterStore from src.later_store import LaterStore
from src.today_store import ( from src.today_store import (
TodayPlanFull, TodayPromotionConflict, TodaySessionConflict, TodayStore, TodayPlanFull, TodayPromotionConflict, TodaySessionConflict, TodayStore,
TomorrowPlanConflict, TomorrowPlanConflict, TomorrowPlanNotDue,
) )
from src.state_encryption import PrivateStateEncryptionError from src.state_encryption import PrivateStateEncryptionError
from src.views import FRONTEND_BUILD, router as frontend_router from src.views import FRONTEND_BUILD, router as frontend_router
@ -2669,6 +2669,11 @@ async def promote_tomorrow_plan(payload: TomorrowPromotion):
status_code=409, status_code=409,
detail={"code": "today_changed", "today": error.today}, 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: except ValueError as error:
raise HTTPException(status_code=422, detail=str(error)) raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError): except (OSError, sqlite3.Error, PrivateStateEncryptionError):

View File

@ -3,8 +3,9 @@
import json import json
import sqlite3 import sqlite3
import time import time
from datetime import date from datetime import date, datetime
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from src.private_state import connect_private_sqlite from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
@ -30,6 +31,14 @@ class TomorrowPlanConflict(ValueError):
self.snapshot = snapshot 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): class TodayPromotionConflict(ValueError):
"""Raised when rollover would overwrite a changed Today plan.""" """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 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: if not isinstance(timezone, str) or not timezone.strip() or len(timezone) > 100:
raise ValueError("timezone is required and bounded") 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( 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 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) tomorrow = self._tomorrow_snapshot(tomorrow_row, login)
if tomorrow["revision"] != tomorrow_revision or not tomorrow.get("plan_date"): if tomorrow["revision"] != tomorrow_revision or not tomorrow.get("plan_date"):
raise TomorrowPlanConflict(tomorrow) 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( today_row = connection.execute(
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone " "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
"FROM today_plans WHERE login = ?", (login,) "FROM today_plans WHERE login = ?", (login,)

View File

@ -1,15 +1,20 @@
import sqlite3 import sqlite3
from datetime import datetime, timezone
import pytest import pytest
from src import main 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): def test_tomorrow_plan_is_encrypted_scoped_and_promotes_atomically_exactly_once(tmp_path):
path = tmp_path / "today.sqlite3" path = tmp_path / "today.sqlite3"
key = b"n" * 32 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:") store.apply("timmy", "today-1", "add", "issue:r:1:")
tomorrow = store.replace_tomorrow( 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): 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:") original_today = store.apply("timmy", "today", "add", "issue:r:1:")
first = store.replace_tomorrow( first = store.replace_tomorrow(
"timmy", base_revision=0, ids=["issue:r:2:"], capacity_minutes=60, "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 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): def test_tomorrow_validation_is_bounded_and_date_specific(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3") store = TodayStore(tmp_path / "today.sqlite3")
with pytest.raises(ValueError, match="ISO calendar date"): 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) await main.replace_tomorrow_plan(payload)
assert raised.value.status_code == 409 assert raised.value.status_code == 409
assert raised.value.detail == {"code": "tomorrow_changed", "snapshot": saved} 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

View File

@ -57,32 +57,71 @@ console.log(JSON.stringify({loaded,saved,requests}));
assert all(request["url"] != "api/v1/today" for request in result["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(""" result = run_controller("""
const requests=[]; const requests=[];
const fetchJson=async (url, options={})=>{ const fetchJson=async (url, options={})=>{
requests.push({url,body:options.body ? JSON.parse(options.body) : null}); requests.push({url,body:options.body ? JSON.parse(options.body) : null});
return {ids:['issue:r:2:'],revision:8}; 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'}); 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 early=await before.promote(7);
const due=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC',createId:()=> 'rollover-id'}); const overdue=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-22',timeZone:()=> 'UTC'});
due.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'}); overdue.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
const promoted=await due.promote(7); const promoted=await overdue.promote(7);
console.log(JSON.stringify({early,promoted,requests})); const retried=await overdue.promote(7);
console.log(JSON.stringify({early,promoted,retried,requests}));
""") """)
assert result["early"] is False assert result["early"] is False
assert result["promoted"]["revision"] == 8 assert result["promoted"]["revision"] == 8
assert result["requests"] == [{ assert result["retried"]["revision"] == 8
"url": "api/v1/tomorrow/promote", assert result["requests"] == [
"body": { {
"promotion_id": "rollover-id", "url": "api/v1/tomorrow/promote",
"tomorrow_revision": 3, "body": {
"today_revision": 7, "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(): 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 "planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)" in dashboard
assert "tomorrowPlan.load()" in dashboard assert "tomorrowPlan.load()" in dashboard
assert "tomorrowPlan.promote(plan.revision)" in dashboard assert "tomorrowPlan.promote(plan.revision)" in dashboard
assert "tomorrowPlan.startLifecycle" in dashboard