fix: recover Tomorrow promotion after missed rollover (Closes #1154)
This commit is contained in:
parent
559fd2dc89
commit
7deb1c1de9
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user