feat: plan the next seven days on mobile (Closes #1177)
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 4m21s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-20 11:28:58 +00:00
1 parent 880d1c4a1f
commit e01681af09
16 files changed
+707 -42

No files matched your search

+2 -2
View File
@@ -35,8 +35,8 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"planning": (
"static/plan-today.js", "static/plan-today-readiness.js",
"static/plan-today-preview.js", "static/today-rollover.js",
"static/tomorrow-plan.js", "static/mobile-plan-today-nav.js",
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
"static/tomorrow-plan.js", "static/week-plan.js", "static/mobile-plan-today-nav.js",
),
"today-timer": (
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
+85 -2
View File
@@ -75,7 +75,7 @@ from src.suggestion_engine import compute
from src.later_store import LaterStore
from src.today_store import (
TodayPlanFull, TodayPromotionConflict, TodaySessionConflict, TodayStore,
TomorrowPlanConflict, TomorrowPlanNotDue,
TomorrowPlanConflict, TomorrowPlanNotDue, WeekPlanConflict,
)
from src.state_encryption import PrivateStateEncryptionError
from src.views import FRONTEND_BUILD, router as frontend_router
@@ -727,6 +727,26 @@ class TomorrowPromotion(BaseModel):
today_revision: int = Field(ge=0)
class WeekPlanDay(BaseModel):
plan_date: str = Field(min_length=10, max_length=10)
ids: list[str] = Field(max_length=5)
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
class WeekPlanUpdate(BaseModel):
base_revision: int = Field(ge=0)
days: list[WeekPlanDay] = Field(max_length=7)
timezone: str = Field(min_length=1, max_length=100)
class WeekPromotion(BaseModel):
promotion_id: str = Field(min_length=1, max_length=100)
week_revision: int = Field(ge=0)
plan_date: str = Field(min_length=10, max_length=10)
today_revision: int = Field(ge=0)
class LaterOperationBatch(BaseModel):
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
@@ -1431,7 +1451,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
path = dashboard_auth.application_path(request)
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@@ -2752,6 +2772,69 @@ async def promote_tomorrow_plan(payload: TomorrowPromotion):
)
@app.get("/api/v1/week")
async def get_week_plan():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_today_store().get_week, login)
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Week Ahead synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.put("/api/v1/week")
async def replace_week_plan(payload: WeekPlanUpdate):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().replace_week, login,
base_revision=payload.base_revision,
days=[day.model_dump() for day in payload.days],
timezone=payload.timezone,
)
except WeekPlanConflict as error:
raise HTTPException(
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Week Ahead synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.post("/api/v1/week/promote")
async def promote_week_plan(payload: WeekPromotion):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().promote_week, login, **payload.model_dump()
)
except WeekPlanConflict as error:
raise HTTPException(
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
)
except TodayPromotionConflict as error:
raise HTTPException(
status_code=409, detail={"code": "today_changed", "today": error.today}
)
except TomorrowPlanNotDue as error:
raise HTTPException(
status_code=409, detail={"code": "week_not_due", "plan_date": error.plan_date}
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Week Ahead promotion is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/today/session")
async def get_today_session():
login = await _confirmed_login()
+181 -2
View File
@@ -39,6 +39,14 @@ class TomorrowPlanNotDue(ValueError):
self.plan_date = plan_date
class WeekPlanConflict(ValueError):
"""Raised when Week Ahead was edited from an obsolete revision."""
def __init__(self, snapshot: dict):
super().__init__("Week Ahead changed on another device")
self.snapshot = snapshot
class TodayPromotionConflict(ValueError):
"""Raised when rollover would overwrite a changed Today plan."""
@@ -75,7 +83,7 @@ class TodayStore:
def _initialize(self) -> None:
connection = connect_private_sqlite(self.path, timeout=self.timeout)
if connection.execute("PRAGMA user_version").fetchone()[0] >= 4:
if connection.execute("PRAGMA user_version").fetchone()[0] >= 5:
connection.close()
return
connection.execute("PRAGMA journal_mode=WAL")
@@ -171,7 +179,16 @@ class TodayStore:
"login TEXT NOT NULL, promotion_id TEXT NOT NULL, result TEXT NOT NULL, "
"created_at REAL NOT NULL, PRIMARY KEY (login, promotion_id))"
)
connection.execute("PRAGMA user_version = 4")
connection.execute(
"CREATE TABLE IF NOT EXISTS week_plans ("
"login TEXT PRIMARY KEY, revision INTEGER NOT NULL, payload TEXT NOT NULL)"
)
connection.execute(
"CREATE TABLE IF NOT EXISTS week_promotions ("
"login TEXT NOT NULL, promotion_id TEXT NOT NULL, result TEXT NOT NULL, "
"created_at REAL NOT NULL, PRIMARY KEY (login, promotion_id))"
)
connection.execute("PRAGMA user_version = 5")
connection.commit()
connection.close()
@@ -420,6 +437,168 @@ class TodayStore:
)
return result
@staticmethod
def _empty_week(revision: int = 0) -> dict:
return {"revision": revision, "timezone": None, "days": []}
def _week_snapshot(self, row, login: str) -> dict:
if row is None:
return self._empty_week()
payload, _legacy = self._cipher.open(row[1], binding=f"week:{login}")
if not isinstance(payload, dict) or not isinstance(payload.get("days"), list):
raise PrivateStateEncryptionError("private state could not be decrypted")
return {"revision": int(row[0]), **payload}
def get_week(self, login: str) -> dict:
login = self._normalize_login(login)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
).fetchone()
if row is not None:
return self._week_snapshot(row, login)
tomorrow_row = connection.execute(
"SELECT revision, payload FROM tomorrow_plans WHERE login = ?", (login,)
).fetchone()
tomorrow = self._tomorrow_snapshot(tomorrow_row, login)
if not tomorrow.get("plan_date"):
return self._empty_week()
day = {key: tomorrow[key] for key in (
"plan_date", "ids", "capacity_minutes", "estimates"
)}
payload = {"timezone": tomorrow["timezone"], "days": [day]}
connection.execute(
"INSERT INTO week_plans(login, revision, payload) VALUES (?, 1, ?)",
(login, self._cipher.seal(payload, binding=f"week:{login}")),
)
empty = self._empty_tomorrow(tomorrow["revision"] + 1)
connection.execute(
"UPDATE tomorrow_plans SET revision = ?, payload = ? WHERE login = ?",
(empty["revision"], self._cipher.seal(
{key: empty[key] for key in ("ids", "capacity_minutes", "estimates")},
binding=f"tomorrow:{login}",
), login),
)
return {"revision": 1, **payload}
def _normalize_week(self, *, days: list[dict], timezone: str) -> dict:
if not isinstance(days, list) or len(days) > 7:
raise ValueError("Week Ahead is limited to seven dates")
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
normalized = []
seen_dates = set()
for day in days:
if not isinstance(day, dict):
raise ValueError("each Week Ahead date must be an object")
plan_date = day.get("plan_date")
try:
if date.fromisoformat(plan_date or "").isoformat() != plan_date:
raise ValueError
except (TypeError, ValueError):
raise ValueError("plan_date must be an ISO calendar date") from None
if plan_date in seen_dates:
raise ValueError("Week Ahead dates must be unique")
seen_dates.add(plan_date)
plan = self._normalize_tomorrow(
ids=day.get("ids", []), capacity_minutes=day.get("capacity_minutes"),
estimates=day.get("estimates", {}), plan_date=plan_date, timezone=timezone,
)
plan.pop("timezone")
normalized.append(plan)
normalized.sort(key=lambda item: item["plan_date"])
return {"timezone": timezone, "days": normalized}
def replace_week(self, login: str, *, base_revision: int, days: list[dict], timezone: str) -> dict:
login = self._normalize_login(login)
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
raise ValueError("base_revision must be a non-negative integer")
normalized = self._normalize_week(days=days, timezone=timezone)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
).fetchone()
current = self._week_snapshot(row, login)
if current["revision"] != base_revision:
raise WeekPlanConflict(current)
snapshot = {"revision": base_revision + 1, **normalized}
connection.execute(
"INSERT INTO week_plans(login, revision, payload) VALUES (?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, payload=excluded.payload",
(login, snapshot["revision"], self._cipher.seal(normalized, binding=f"week:{login}")),
)
return snapshot
def promote_week(
self, login: str, *, promotion_id: str, week_revision: int,
plan_date: str, today_revision: int,
) -> dict:
login = self._normalize_login(login)
if not isinstance(promotion_id, str) or not promotion_id.strip() or len(promotion_id) > 100:
raise ValueError("promotion_id is required and bounded")
promotion_id = promotion_id.strip()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
receipt = connection.execute(
"SELECT result FROM week_promotions WHERE login = ? AND promotion_id = ?",
(login, promotion_id),
).fetchone()
if receipt:
result, _legacy = self._cipher.open(
receipt[0], binding=f"week-promotion:{login}:{promotion_id}"
)
if not isinstance(result, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
return result
row = connection.execute(
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
).fetchone()
week = self._week_snapshot(row, login)
if week["revision"] != week_revision:
raise WeekPlanConflict(week)
day = next((item for item in week["days"] if item["plan_date"] == plan_date), None)
if day is None:
raise WeekPlanConflict(week)
local_date = datetime.fromtimestamp(self.clock(), ZoneInfo(week["timezone"])).date().isoformat()
if local_date < plan_date:
raise TomorrowPlanNotDue(plan_date)
today_row = connection.execute(
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
"FROM today_plans WHERE login = ?", (login,)
).fetchone()
today, _legacy = self._snapshot(today_row, login)
if today["revision"] != today_revision:
raise TodayPromotionConflict(today)
result = {"revision": today_revision + 1, **day, "timezone": week["timezone"]}
connection.execute(
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
"VALUES (?, ?, ?, NULL, '{}', NULL, NULL) ON CONFLICT(login) DO UPDATE SET "
"revision=excluded.revision, ids=excluded.ids, capacity_minutes=NULL, estimates='{}', "
"plan_date=NULL, timezone=NULL",
(login, result["revision"], self._sealed_plan(login, result)),
)
remaining = {"timezone": week["timezone"], "days": [
item for item in week["days"] if item["plan_date"] != plan_date
]}
connection.execute(
"UPDATE week_plans SET revision = ?, payload = ? WHERE login = ?",
(week_revision + 1, self._cipher.seal(remaining, binding=f"week:{login}"), login),
)
connection.execute(
"INSERT INTO week_promotions(login, promotion_id, result, created_at) VALUES (?, ?, ?, ?)",
(login, promotion_id, self._cipher.seal(
result, binding=f"week-promotion:{login}:{promotion_id}"
), self.clock()),
)
return result
@staticmethod
def _empty_session() -> dict:
return {