feat: sync first-task completion across devices (Closes #1148)
This commit is contained in:
1 parent
e2be14d7c7
commit
fa47ded3ab
7 files changed
+184
-12
No files matched your search
+9
-1
@@ -553,7 +553,7 @@ class NotificationReadBatch(BaseModel):
|
||||
|
||||
class TodayOperation(BaseModel):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
action: Literal["add", "remove", "move", "configure", "rollover"]
|
||||
action: Literal["add", "remove", "move", "configure", "rollover", "activate"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
direction: Literal["up", "down"] | None = None
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
@@ -562,6 +562,7 @@ class TodayOperation(BaseModel):
|
||||
plan_date: str | None = Field(default=None, min_length=10, max_length=10)
|
||||
timezone: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
ids: list[str] = Field(default_factory=list, max_length=5)
|
||||
activation_state: Literal["coaching", "complete"] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_action_fields(self):
|
||||
@@ -580,6 +581,11 @@ class TodayOperation(BaseModel):
|
||||
raise ValueError("rollover IDs must be unique")
|
||||
elif self.plan_date is not None or self.timezone is not None or self.ids:
|
||||
raise ValueError("date, timezone, and IDs are only valid for rollover")
|
||||
if self.action == "activate":
|
||||
if self.item_id != "first-task" or self.activation_state is None:
|
||||
raise ValueError("activate requires a first-task activation state")
|
||||
elif self.activation_state is not None:
|
||||
raise ValueError("activation state is only valid for activate")
|
||||
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
|
||||
for item_id, minutes in self.estimates.items()):
|
||||
raise ValueError("estimates must use bounded item IDs and minutes")
|
||||
@@ -591,6 +597,8 @@ class TodayOperation(BaseModel):
|
||||
data.pop("plan_date", None)
|
||||
data.pop("timezone", None)
|
||||
data.pop("ids", None)
|
||||
if self.action != "activate":
|
||||
data.pop("activation_state", None)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
+22
-2
@@ -180,6 +180,7 @@ class TodayStore:
|
||||
capacity_minutes = payload.get("capacity_minutes")
|
||||
plan_date = payload.get("plan_date")
|
||||
timezone = payload.get("timezone")
|
||||
first_task_state = payload.get("first_task_state", "")
|
||||
else:
|
||||
legacy = True
|
||||
ids = json.loads(row[1])
|
||||
@@ -187,6 +188,7 @@ class TodayStore:
|
||||
capacity_minutes = row[2]
|
||||
plan_date = row[4] if len(row) > 4 else None
|
||||
timezone = row[5] if len(row) > 5 else None
|
||||
first_task_state = ""
|
||||
if not isinstance(ids, list) or not isinstance(estimates, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
snapshot = {
|
||||
@@ -198,6 +200,8 @@ class TodayStore:
|
||||
if plan_date:
|
||||
snapshot["plan_date"] = plan_date
|
||||
snapshot["timezone"] = timezone
|
||||
if first_task_state in {"coaching", "complete"}:
|
||||
snapshot["first_task_state"] = first_task_state
|
||||
return snapshot, legacy
|
||||
|
||||
def _sealed_plan(self, login: str, snapshot: dict) -> str:
|
||||
@@ -207,6 +211,7 @@ class TodayStore:
|
||||
"estimates": snapshot["estimates"],
|
||||
"plan_date": snapshot.get("plan_date"),
|
||||
"timezone": snapshot.get("timezone"),
|
||||
"first_task_state": snapshot.get("first_task_state", ""),
|
||||
}, binding=f"plan:{login}")
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
@@ -598,6 +603,7 @@ class TodayStore:
|
||||
"ids": ids, "capacity_minutes": snapshot["capacity_minutes"],
|
||||
"estimates": estimates, "plan_date": snapshot.get("plan_date"),
|
||||
"timezone": snapshot.get("timezone"),
|
||||
"first_task_state": snapshot.get("first_task_state", ""),
|
||||
})
|
||||
if row is None:
|
||||
connection.execute(
|
||||
@@ -621,6 +627,8 @@ class TodayStore:
|
||||
if snapshot.get("plan_date"):
|
||||
result["plan_date"] = snapshot["plan_date"]
|
||||
result["timezone"] = snapshot["timezone"]
|
||||
if snapshot.get("first_task_state"):
|
||||
result["first_task_state"] = snapshot["first_task_state"]
|
||||
return result
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
@@ -638,6 +646,7 @@ class TodayStore:
|
||||
estimates = dict(snapshot["estimates"])
|
||||
plan_date = snapshot.get("plan_date")
|
||||
timezone = snapshot.get("timezone")
|
||||
first_task_state = snapshot.get("first_task_state", "")
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
@@ -650,7 +659,7 @@ class TodayStore:
|
||||
direction = operation.get("direction")
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"add", "remove", "move", "configure", "rollover"}:
|
||||
if action not in {"add", "remove", "move", "configure", "rollover", "activate"}:
|
||||
raise ValueError("unsupported Today action")
|
||||
if action == "move" and direction not in {"up", "down"}:
|
||||
raise ValueError("move direction must be up or down")
|
||||
@@ -667,7 +676,7 @@ class TodayStore:
|
||||
base_revision = snapshot["revision"]
|
||||
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")
|
||||
if base_revision < snapshot["revision"]:
|
||||
if action != "activate" and base_revision < snapshot["revision"]:
|
||||
self._record_operation(connection, login, operation_id)
|
||||
rejected.append({"operation_id": operation_id, "reason": "stale_intent"})
|
||||
continue
|
||||
@@ -714,6 +723,14 @@ class TodayStore:
|
||||
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
|
||||
capacity_minutes = proposed_capacity
|
||||
estimates = normalized_estimates
|
||||
elif action == "activate":
|
||||
proposed_state = operation.get("activation_state")
|
||||
if item_id != "first-task" or proposed_state not in {"coaching", "complete"}:
|
||||
raise ValueError("first-task activation state is invalid")
|
||||
rank = {"": 0, "coaching": 1, "complete": 2}
|
||||
changed = rank[proposed_state] > rank[first_task_state]
|
||||
if changed:
|
||||
first_task_state = proposed_state
|
||||
else:
|
||||
proposed_date = operation.get("plan_date")
|
||||
proposed_timezone = operation.get("timezone")
|
||||
@@ -764,6 +781,7 @@ class TodayStore:
|
||||
sealed = self._sealed_plan(login, {
|
||||
"ids": ids, "capacity_minutes": capacity_minutes, "estimates": estimates,
|
||||
"plan_date": plan_date, "timezone": timezone,
|
||||
"first_task_state": first_task_state,
|
||||
})
|
||||
if row is None:
|
||||
connection.execute(
|
||||
@@ -789,4 +807,6 @@ class TodayStore:
|
||||
if plan_date:
|
||||
result["plan_date"] = plan_date
|
||||
result["timezone"] = timezone
|
||||
if first_task_state:
|
||||
result["first_task_state"] = first_task_state
|
||||
return result
|
||||
Reference in new issue
Block a user