Merge pull request 'Recover Tomorrow plans after missed midnight and interrupted promotion' (#1155) from timmy/1154-tomorrow-rollover-recovery into main
This commit is contained in:
commit
80863182d4
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,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)}));
|
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();
|
||||||
|
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;
|
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.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):
|
||||||
|
|
|
||||||
|
|
@ -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,)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -57,32 +57,85 @@ 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_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():
|
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 "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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user