feat: plan the next seven days on mobile (Closes #1177)
This commit is contained in:
1 parent
880d1c4a1f
commit
e01681af09
16 files changed
+707
-42
No files matched your search
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user