Merge pull request 'Recover Tomorrow plans after missed midnight and interrupted promotion' (#1155) from timmy/1154-tomorrow-rollover-recovery into main
Some checks failed
CI / lint (push) Successful in 3m14s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 4m30s
CI / release-candidate (push) Has been skipped

This commit is contained in:
rockachopa 2026-08-19 23:56:19 +00:00
commit 80863182d4
6 changed files with 209 additions and 22 deletions

View File

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

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:{}};
const state=()=>({...plan,ids:[...plan.ids],estimates:{...plan.estimates}});
function adopt(value) {
@ -19,11 +19,52 @@ 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();
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);
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;

View File

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

View File

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

View File

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

View File

@ -57,32 +57,85 @@ 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_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():
@ -98,3 +151,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