feat: repeat tasks after completion
This commit is contained in:
+65
-63
@@ -7,7 +7,7 @@ import shutil
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -43,8 +43,9 @@ from .models import (
|
||||
User,
|
||||
utcnow,
|
||||
)
|
||||
from .mvp import audit, occurrences
|
||||
from .mvp import audit
|
||||
from .mvp import router as mvp_router
|
||||
from .recurrence_service import apply_task_changes, lock_task
|
||||
from .schemas import (
|
||||
BatchResult,
|
||||
BatchTaskUpdate,
|
||||
@@ -66,6 +67,7 @@ from .schemas import (
|
||||
TaskReorder,
|
||||
TaskUpdate,
|
||||
UserOut,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
|
||||
@@ -193,6 +195,18 @@ async def me(user: User = Depends(current_user)):
|
||||
return user
|
||||
|
||||
|
||||
@app.patch("/api/v1/me", response_model=UserOut)
|
||||
async def update_me(
|
||||
payload: UserUpdate,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user.timezone = payload.timezone
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@app.post("/api/v1/auth/change-password", status_code=204)
|
||||
async def change_password(
|
||||
payload: ChangePasswordRequest,
|
||||
@@ -958,12 +972,12 @@ async def create_task(
|
||||
)
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务")
|
||||
if payload.rrule:
|
||||
if not payload.due_at:
|
||||
raise HTTPException(status_code=422, detail="重复任务需要截止时间")
|
||||
recurrence_requested = payload.rrule or payload.trigger_mode
|
||||
if recurrence_requested:
|
||||
from .mvp import parse_rrule
|
||||
parse_rrule(payload.rrule)
|
||||
data = payload.model_dump(exclude={"rrule"})
|
||||
if payload.rrule:
|
||||
parse_rrule(payload.rrule)
|
||||
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days"})
|
||||
parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None)
|
||||
max_position = await db.scalar(select(func.max(Task.position)).where(
|
||||
Task.user_id == user.id,
|
||||
@@ -974,8 +988,17 @@ async def create_task(
|
||||
task = Task(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **data)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
if payload.rrule:
|
||||
db.add(RecurrenceTemplate(user_id=user.id, task_id=task.id, rrule=payload.rrule.upper(), starts_at=task.due_at))
|
||||
if recurrence_requested:
|
||||
db.add(
|
||||
RecurrenceTemplate(
|
||||
user_id=user.id,
|
||||
task_id=task.id,
|
||||
rrule=payload.rrule.upper() if payload.rrule else None,
|
||||
starts_at=task.due_at,
|
||||
trigger_mode=payload.trigger_mode or "scheduled",
|
||||
after_completion_days=payload.after_completion_days,
|
||||
)
|
||||
)
|
||||
audit(db, user.id, "create", "task", task.id)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
@@ -1142,30 +1165,11 @@ async def update_task(
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None)))
|
||||
task = await lock_task(db, user.id, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
expected_version = data.pop("version")
|
||||
recurrence = None
|
||||
reset_subtasks = False
|
||||
if data.get("completed") is True:
|
||||
recurrence = await db.scalar(select(RecurrenceTemplate).where(
|
||||
RecurrenceTemplate.task_id == task_id, RecurrenceTemplate.user_id == user.id
|
||||
))
|
||||
if recurrence:
|
||||
next_items = occurrences(
|
||||
recurrence.rrule,
|
||||
recurrence.starts_at,
|
||||
recurrence.starts_at + timedelta(microseconds=1),
|
||||
recurrence.starts_at + timedelta(days=3660),
|
||||
recurrence.ends_at,
|
||||
)
|
||||
if next_items:
|
||||
data["completed"] = False
|
||||
data["due_at"] = next_items[0]
|
||||
recurrence.starts_at = next_items[0]
|
||||
reset_subtasks = True
|
||||
if "list_id" in data:
|
||||
await _owned_list(db, user.id, data["list_id"])
|
||||
if task.parent_id:
|
||||
@@ -1174,46 +1178,19 @@ async def update_task(
|
||||
)
|
||||
if parent_list != data["list_id"]:
|
||||
raise HTTPException(status_code=400, detail="子任务必须与父任务属于同一清单")
|
||||
data["version"] = Task.version + 1
|
||||
data["updated_at"] = utcnow()
|
||||
result = await db.execute(
|
||||
update(Task)
|
||||
.where(
|
||||
Task.id == task_id,
|
||||
Task.user_id == user.id,
|
||||
Task.deleted_at.is_(None),
|
||||
Task.version == expected_version,
|
||||
)
|
||||
.values(**data)
|
||||
.returning(Task)
|
||||
task, changed = await apply_task_changes(
|
||||
db,
|
||||
user=user,
|
||||
task_id=task_id,
|
||||
expected_version=expected_version,
|
||||
changes=data,
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
exists_id = await db.scalar(
|
||||
select(Task.id).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||
)
|
||||
if exists_id:
|
||||
raise HTTPException(status_code=409, detail="任务已被更新,请刷新后重试")
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if reset_subtasks:
|
||||
reset_at = utcnow()
|
||||
await db.execute(
|
||||
update(Task)
|
||||
.where(
|
||||
Task.parent_id == task.id,
|
||||
Task.user_id == user.id,
|
||||
Task.deleted_at.is_(None),
|
||||
Task.completed.is_(True),
|
||||
)
|
||||
.values(completed=False, version=Task.version + 1, updated_at=reset_at)
|
||||
)
|
||||
if "list_id" in data and task.parent_id is None:
|
||||
await db.execute(
|
||||
update(Task)
|
||||
.where(Task.parent_id == task.id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||
.values(list_id=task.list_id, version=Task.version + 1, updated_at=utcnow())
|
||||
)
|
||||
changed = {k for k in data if k not in {"version", "updated_at"}}
|
||||
if changed & {"title", "description", "priority", "due_at", "list_id", "completed"}:
|
||||
action = "complete" if data.get("completed") is True else "update"
|
||||
audit(db, user.id, action, "task", task.id, fields=sorted(changed))
|
||||
@@ -1337,7 +1314,32 @@ async def batch_update_tasks(
|
||||
standalone_children = [task for task in tasks if task.parent_id is not None]
|
||||
if standalone_children:
|
||||
raise HTTPException(status_code=400, detail="子任务不能脱离父任务单独移动")
|
||||
changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "soft_delete"})
|
||||
changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "soft_delete", "versions"})
|
||||
if payload.completed is True:
|
||||
versions = payload.versions
|
||||
other_changes = {key: value for key, value in changes.items() if key != "completed"}
|
||||
for task_id in task_ids:
|
||||
await apply_task_changes(
|
||||
db,
|
||||
user=user,
|
||||
task_id=task_id,
|
||||
expected_version=versions[task_id],
|
||||
changes={"completed": True, **other_changes},
|
||||
)
|
||||
if payload.list_id is not None:
|
||||
parent_ids = [task.id for task in tasks if task.parent_id is None]
|
||||
if parent_ids:
|
||||
await db.execute(
|
||||
update(Task)
|
||||
.where(
|
||||
Task.parent_id.in_(parent_ids),
|
||||
Task.user_id == user.id,
|
||||
Task.deleted_at.is_(None),
|
||||
)
|
||||
.values(list_id=payload.list_id, version=Task.version + 1, updated_at=utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
return BatchResult(updated=len(task_ids))
|
||||
if payload.soft_delete:
|
||||
changes["deleted_at"] = utcnow()
|
||||
if changes:
|
||||
|
||||
+4
-1
@@ -148,9 +148,12 @@ class RecurrenceTemplate(Base):
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
task_id: Mapped[UUID] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), unique=True)
|
||||
rrule: Mapped[str] = mapped_column(Text)
|
||||
rrule: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
starts_at: Mapped[datetime] = mapped_column(UTCDateTime())
|
||||
ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
trigger_mode: Mapped[str] = mapped_column(String(32), default="scheduled")
|
||||
after_completion_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
last_completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||
|
||||
|
||||
|
||||
+105
-11
@@ -50,13 +50,27 @@ def audit(db: AsyncSession, user_id: UUID, action: str, entity_type: str, entity
|
||||
|
||||
class RecurrenceCreate(BaseModel):
|
||||
task_id: UUID
|
||||
rrule: str = Field(min_length=5, max_length=1000)
|
||||
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
||||
trigger_mode: str = Field(default="scheduled", pattern="^(scheduled|after_completion)$")
|
||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode(self):
|
||||
if self.trigger_mode == "scheduled" and self.rrule is None:
|
||||
raise ValueError("scheduled recurrence requires rrule")
|
||||
if self.trigger_mode == "after_completion" and (
|
||||
self.after_completion_days is None or self.rrule is not None
|
||||
):
|
||||
raise ValueError("after_completion requires days and no rrule")
|
||||
return self
|
||||
|
||||
|
||||
class RecurrenceChange(BaseModel):
|
||||
title: str | None = Field(None, min_length=1, max_length=500)
|
||||
due_at: datetime | None = None
|
||||
rrule: str | None = None
|
||||
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
|
||||
|
||||
class OccurrenceComplete(BaseModel):
|
||||
@@ -208,21 +222,49 @@ async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user),
|
||||
))
|
||||
if row is None:
|
||||
return None
|
||||
return {"id": row.id, "task_id": row.task_id, "rrule": row.rrule, "starts_at": row.starts_at, "ends_at": row.ends_at}
|
||||
return {
|
||||
"id": row.id,
|
||||
"task_id": row.task_id,
|
||||
"rrule": row.rrule,
|
||||
"starts_at": row.starts_at,
|
||||
"ends_at": row.ends_at,
|
||||
"trigger_mode": row.trigger_mode,
|
||||
"after_completion_days": row.after_completion_days,
|
||||
"last_completed_at": row.last_completed_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/recurrences", status_code=201)
|
||||
async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
task = await owned_task(db, user.id, payload.task_id)
|
||||
if task.parent_id is not None:
|
||||
raise HTTPException(422, "仅顶层任务可设置重复")
|
||||
if not task.due_at:
|
||||
raise HTTPException(422, "重复任务需要截止时间")
|
||||
parse_rrule(payload.rrule)
|
||||
if payload.rrule:
|
||||
parse_rrule(payload.rrule)
|
||||
if await db.scalar(select(RecurrenceTemplate.id).where(RecurrenceTemplate.task_id == task.id)):
|
||||
raise HTTPException(409, "任务已有重复规则")
|
||||
row = RecurrenceTemplate(user_id=user.id, task_id=task.id, rrule=payload.rrule.upper(), starts_at=task.due_at)
|
||||
row = RecurrenceTemplate(
|
||||
user_id=user.id,
|
||||
task_id=task.id,
|
||||
rrule=payload.rrule.upper() if payload.rrule else None,
|
||||
starts_at=task.due_at,
|
||||
trigger_mode=payload.trigger_mode,
|
||||
after_completion_days=payload.after_completion_days,
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit(); await db.refresh(row)
|
||||
return {"id": row.id, "task_id": row.task_id, "rrule": row.rrule, "starts_at": row.starts_at}
|
||||
return {
|
||||
"id": row.id,
|
||||
"task_id": row.task_id,
|
||||
"rrule": row.rrule,
|
||||
"starts_at": row.starts_at,
|
||||
"ends_at": row.ends_at,
|
||||
"trigger_mode": row.trigger_mode,
|
||||
"after_completion_days": row.after_completion_days,
|
||||
"last_completed_at": row.last_completed_at,
|
||||
}
|
||||
|
||||
|
||||
async def upsert_exception(db, template_id, at):
|
||||
@@ -238,6 +280,9 @@ async def upsert_exception(db, template_id, at):
|
||||
async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope: str = Query("all", pattern="^(this|this-and-future|all)$"), occurrence_at: datetime | None = None, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
template = await owned_recurrence(db, user.id, recurrence_id)
|
||||
task = await owned_task(db, user.id, template.task_id)
|
||||
requested_mode = payload.trigger_mode or template.trigger_mode
|
||||
if requested_mode == "after_completion" and scope != "all":
|
||||
raise HTTPException(422, "完成后重复仅支持修改全部规则")
|
||||
if scope == "this":
|
||||
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
|
||||
ensure_real_occurrence(template, occurrence_at)
|
||||
@@ -254,16 +299,49 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
||||
elif payload.title:
|
||||
task.title = payload.title
|
||||
else:
|
||||
if payload.rrule: parse_rrule(payload.rrule); template.rrule = payload.rrule.upper()
|
||||
if payload.title is not None: task.title = payload.title
|
||||
if payload.due_at is not None: task.due_at = payload.due_at; template.starts_at = payload.due_at
|
||||
requested_days = (
|
||||
payload.after_completion_days
|
||||
if "after_completion_days" in payload.model_fields_set
|
||||
else template.after_completion_days
|
||||
)
|
||||
requested_rrule = payload.rrule if payload.rrule is not None else template.rrule
|
||||
if requested_mode == "after_completion":
|
||||
if requested_days is None:
|
||||
raise HTTPException(422, "完成后重复需要间隔天数")
|
||||
template.trigger_mode = requested_mode
|
||||
template.after_completion_days = requested_days
|
||||
template.rrule = None
|
||||
else:
|
||||
if requested_rrule is None:
|
||||
raise HTTPException(422, "定期重复需要 RRULE")
|
||||
parse_rrule(requested_rrule)
|
||||
template.trigger_mode = requested_mode
|
||||
template.after_completion_days = None
|
||||
template.rrule = requested_rrule.upper()
|
||||
if payload.title is not None:
|
||||
task.title = payload.title
|
||||
if payload.due_at is not None:
|
||||
task.due_at = payload.due_at
|
||||
template.starts_at = payload.due_at
|
||||
await db.commit()
|
||||
return {"id": template.id, "scope": scope}
|
||||
await db.refresh(template)
|
||||
return {
|
||||
"id": template.id,
|
||||
"task_id": template.task_id,
|
||||
"rrule": template.rrule,
|
||||
"starts_at": template.starts_at,
|
||||
"ends_at": template.ends_at,
|
||||
"trigger_mode": template.trigger_mode,
|
||||
"after_completion_days": template.after_completion_days,
|
||||
"last_completed_at": template.last_completed_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/recurrences/{recurrence_id}/complete")
|
||||
async def complete_occurrence(recurrence_id: UUID, payload: OccurrenceComplete, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
template = await owned_recurrence(db, user.id, recurrence_id)
|
||||
if template.trigger_mode != "scheduled":
|
||||
raise HTTPException(422, "完成后重复不支持按发生时刻完成")
|
||||
ensure_real_occurrence(template, payload.occurrence_at)
|
||||
row = await upsert_exception(db, template.id, payload.occurrence_at); row.completed = True
|
||||
audit(db, user.id, "complete", "task", template.task_id, occurrence_at=payload.occurrence_at.isoformat())
|
||||
@@ -273,6 +351,8 @@ async def complete_occurrence(recurrence_id: UUID, payload: OccurrenceComplete,
|
||||
@router.delete("/recurrences/{recurrence_id}", status_code=204)
|
||||
async def delete_recurrence(recurrence_id: UUID, scope: str = Query("all", pattern="^(this|this-and-future|all)$"), occurrence_at: datetime | None = None, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
template = await owned_recurrence(db, user.id, recurrence_id)
|
||||
if template.trigger_mode == "after_completion" and scope != "all":
|
||||
raise HTTPException(422, "完成后重复仅支持取消全部规则")
|
||||
if scope == "this":
|
||||
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
|
||||
ensure_real_occurrence(template, occurrence_at)
|
||||
@@ -1031,7 +1111,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns):
|
||||
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
|
||||
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
|
||||
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
|
||||
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at"]) for x in recurrences],
|
||||
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
|
||||
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
|
||||
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
|
||||
}
|
||||
@@ -1228,12 +1308,26 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
||||
task_id = task_id_map.get(raw.get("task_id"))
|
||||
if not task_id:
|
||||
continue
|
||||
trigger_mode = raw.get("trigger_mode", "scheduled")
|
||||
days = raw.get("after_completion_days")
|
||||
rrule = raw.get("rrule")
|
||||
if trigger_mode not in {"scheduled", "after_completion"}:
|
||||
raise HTTPException(422, "无效的重复触发模式")
|
||||
if trigger_mode == "after_completion":
|
||||
if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650 or rrule is not None:
|
||||
raise HTTPException(422, "无效的完成后重复备份")
|
||||
elif not isinstance(rrule, str):
|
||||
raise HTTPException(422, "定期重复缺少 RRULE")
|
||||
db.add(RecurrenceTemplate(
|
||||
user_id=user.id,
|
||||
task_id=task_id,
|
||||
rrule=raw["rrule"],
|
||||
rrule=rrule,
|
||||
starts_at=datetime.fromisoformat(raw["starts_at"]),
|
||||
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
|
||||
trigger_mode=trigger_mode,
|
||||
after_completion_days=days,
|
||||
last_completed_at=datetime.fromisoformat(raw["last_completed_at"])
|
||||
if raw.get("last_completed_at") else None,
|
||||
))
|
||||
for raw in payload.get("habits", []):
|
||||
row = Habit(
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from datetime import UTC, datetime, time, timedelta
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .models import RecurrenceTemplate, Task, User, utcnow
|
||||
from .mvp import occurrences
|
||||
|
||||
|
||||
async def lock_task(db: AsyncSession, user_id: UUID, task_id: UUID) -> Task | None:
|
||||
return await db.scalar(
|
||||
select(Task)
|
||||
.where(Task.id == task_id, Task.user_id == user_id, Task.deleted_at.is_(None))
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def _user_zone(user: User) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(user.timezone)
|
||||
except (ZoneInfoNotFoundError, ValueError) as exc:
|
||||
raise HTTPException(422, "用户时区无效") from exc
|
||||
|
||||
|
||||
def _after_completion_due(task: Task, completed_at: datetime, days: int, user: User) -> datetime:
|
||||
zone = _user_zone(user)
|
||||
completed_local = completed_at.astimezone(zone)
|
||||
target_date = completed_local.date() + timedelta(days=days)
|
||||
if task.due_has_time:
|
||||
due_local = task.due_at.astimezone(zone)
|
||||
wall_time = due_local.timetz().replace(tzinfo=None)
|
||||
target_wall = datetime.combine(target_date, wall_time)
|
||||
candidate = target_wall.replace(tzinfo=zone, fold=0)
|
||||
# Normalize through UTC: DST gaps roll forward by their gap (02:30 -> 03:30),
|
||||
# while ambiguous wall times deterministically keep the first occurrence (fold=0).
|
||||
target_local = candidate.astimezone(UTC).astimezone(zone)
|
||||
else:
|
||||
target_local = datetime.combine(target_date, time(23, 59, 59), tzinfo=zone)
|
||||
return target_local.astimezone(UTC)
|
||||
|
||||
|
||||
async def apply_task_changes(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: User,
|
||||
task_id: UUID,
|
||||
expected_version: int,
|
||||
changes: dict,
|
||||
) -> tuple[Task, set[str]]:
|
||||
task = await lock_task(db, user.id, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
if task.version != expected_version:
|
||||
raise HTTPException(409, "任务已被更新,请刷新后重试")
|
||||
|
||||
changed = set(changes)
|
||||
recurrence = await db.scalar(
|
||||
select(RecurrenceTemplate)
|
||||
.where(RecurrenceTemplate.task_id == task.id, RecurrenceTemplate.user_id == user.id)
|
||||
.with_for_update()
|
||||
)
|
||||
reset_subtasks = False
|
||||
if changes.get("completed") is True and not task.completed and recurrence:
|
||||
if recurrence.trigger_mode == "after_completion":
|
||||
completed_at = utcnow()
|
||||
next_due = _after_completion_due(
|
||||
task, completed_at, recurrence.after_completion_days, user
|
||||
)
|
||||
changes["completed"] = False
|
||||
changes["due_at"] = next_due
|
||||
recurrence.starts_at = next_due
|
||||
recurrence.last_completed_at = completed_at
|
||||
reset_subtasks = True
|
||||
else:
|
||||
next_items = occurrences(
|
||||
recurrence.rrule,
|
||||
recurrence.starts_at,
|
||||
recurrence.starts_at + timedelta(microseconds=1),
|
||||
recurrence.starts_at + timedelta(days=3660),
|
||||
recurrence.ends_at,
|
||||
)
|
||||
if next_items:
|
||||
changes["completed"] = False
|
||||
changes["due_at"] = next_items[0]
|
||||
recurrence.starts_at = next_items[0]
|
||||
reset_subtasks = True
|
||||
|
||||
if "due_at" in changes:
|
||||
if changes["due_at"] is None and recurrence is not None:
|
||||
await db.delete(recurrence)
|
||||
recurrence = None
|
||||
elif recurrence is not None and changes.get("completed") is not True:
|
||||
recurrence.starts_at = changes["due_at"]
|
||||
|
||||
now = utcnow()
|
||||
result = await db.execute(
|
||||
update(Task)
|
||||
.where(
|
||||
Task.id == task.id,
|
||||
Task.user_id == user.id,
|
||||
Task.deleted_at.is_(None),
|
||||
Task.version == expected_version,
|
||||
)
|
||||
.values(**changes, version=Task.version + 1, updated_at=now)
|
||||
.returning(Task)
|
||||
)
|
||||
updated_task = result.scalar_one_or_none()
|
||||
if updated_task is None:
|
||||
raise HTTPException(409, "任务已被更新,请刷新后重试")
|
||||
|
||||
if reset_subtasks:
|
||||
await db.execute(
|
||||
update(Task)
|
||||
.where(
|
||||
Task.parent_id == task.id,
|
||||
Task.user_id == user.id,
|
||||
Task.deleted_at.is_(None),
|
||||
Task.completed.is_(True),
|
||||
)
|
||||
.values(completed=False, version=Task.version + 1, updated_at=now)
|
||||
)
|
||||
return updated_task, changed
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
@@ -29,6 +30,20 @@ class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
username: str
|
||||
timezone: str
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
timezone: str = Field(min_length=1, max_length=64)
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except (ZoneInfoNotFoundError, ValueError) as exc:
|
||||
raise ValueError("timezone must be a valid IANA timezone") from exc
|
||||
return value
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
@@ -108,6 +123,8 @@ class TaskCreate(BaseModel):
|
||||
due_has_time: bool = False
|
||||
parent_id: UUID | None = None
|
||||
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
||||
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
@@ -117,6 +134,22 @@ class TaskCreate(BaseModel):
|
||||
raise ValueError("title cannot be blank")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_recurrence(self):
|
||||
has_recurrence = self.rrule is not None or self.trigger_mode is not None or self.after_completion_days is not None
|
||||
if has_recurrence and self.due_at is None:
|
||||
raise ValueError("recurrence requires due_at")
|
||||
if has_recurrence and self.parent_id is not None:
|
||||
raise ValueError("only top-level tasks can recur")
|
||||
if self.trigger_mode == "after_completion":
|
||||
if self.after_completion_days is None or self.rrule is not None:
|
||||
raise ValueError("after_completion requires days and no rrule")
|
||||
elif self.trigger_mode == "scheduled" and self.rrule is None:
|
||||
raise ValueError("scheduled recurrence requires rrule")
|
||||
elif self.trigger_mode is None and self.after_completion_days is not None:
|
||||
raise ValueError("after_completion_days requires after_completion mode")
|
||||
return self
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
@@ -188,6 +221,7 @@ class BatchTaskUpdate(BaseModel):
|
||||
list_id: UUID | None = None
|
||||
due_at: datetime | None = None
|
||||
soft_delete: bool | None = None
|
||||
versions: dict[UUID, int] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_operation(self):
|
||||
@@ -196,6 +230,13 @@ class BatchTaskUpdate(BaseModel):
|
||||
raise ValueError("at least one batch operation is required")
|
||||
if self.soft_delete is False:
|
||||
raise ValueError("soft_delete can only be true")
|
||||
if self.completed is True and self.versions is None:
|
||||
raise ValueError("completion requires versions")
|
||||
if self.versions is not None:
|
||||
if set(self.versions) != set(self.task_ids):
|
||||
raise ValueError("versions must cover every task")
|
||||
if any(version < 1 for version in self.versions.values()):
|
||||
raise ValueError("versions must be positive")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
+40
-31
@@ -5,7 +5,7 @@ import {
|
||||
Ellipsis, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
|
||||
Settings, Trash2, X, Repeat2,
|
||||
} from 'lucide-vue-next'
|
||||
import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils'
|
||||
import { buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
||||
import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse } from './lib/completion-motion'
|
||||
@@ -23,8 +23,8 @@ import { useTaskDueClock } from './lib/task-due-clock'
|
||||
type FolderItem = { id: string; name: string }
|
||||
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
||||
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
|
||||
type RepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'
|
||||
type Recurrence = { id: string; task_id: string; rrule: string }
|
||||
type RepeatOption = TaskRepeatOption
|
||||
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
|
||||
|
||||
const initialized = ref<boolean | null>(null)
|
||||
@@ -114,7 +114,11 @@ const composeTimePicker = ref<HTMLInputElement | null>(null)
|
||||
const composePriority = ref(0)
|
||||
const composeDescription = ref('')
|
||||
const composeRepeat = ref<RepeatOption>('none')
|
||||
const composeAfterCompletionDays = ref('1')
|
||||
const composeRepeatError = ref('')
|
||||
const selectedTaskRepeat = ref<RepeatOption>('none')
|
||||
const selectedAfterCompletionDays = ref('1')
|
||||
const selectedRepeatError = ref('')
|
||||
const selectedTaskRecurrence = ref<Recurrence | null>(null)
|
||||
const defaultRepeatConfig = (): TaskRepeatConfig => ({ frequency: 'daily', interval: 1, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })
|
||||
const composeRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
|
||||
@@ -140,6 +144,8 @@ function openTaskCompose() {
|
||||
composePriority.value = 0
|
||||
composeDescription.value = ''
|
||||
composeRepeat.value = 'none'
|
||||
composeAfterCompletionDays.value = '1'
|
||||
composeRepeatError.value = ''
|
||||
composeRepeatConfig.value = defaultRepeatConfig()
|
||||
composeCalendarOpen.value = false
|
||||
taskComposeOpen.value = true
|
||||
@@ -171,50 +177,41 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
|
||||
else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin)
|
||||
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
|
||||
}
|
||||
function repeatRrule(value: RepeatOption, config: TaskRepeatConfig) {
|
||||
if (value === 'none') return ''
|
||||
if (value === 'custom') return buildTaskRrule(config)
|
||||
return `FREQ=${value.toUpperCase()}`
|
||||
}
|
||||
function repeatOption(rrule?: string): RepeatOption {
|
||||
if (!rrule) return 'none'
|
||||
const parsed = parseTaskRrule(rrule)
|
||||
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
|
||||
return simple ? parsed.frequency : 'custom'
|
||||
}
|
||||
async function saveRepeat(task: Task, value: RepeatOption, config = selectedRepeatConfig.value) {
|
||||
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
|
||||
if (value === 'none') {
|
||||
if (selectedTaskRecurrence.value) await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' })
|
||||
selectedTaskRecurrence.value = null
|
||||
selectedTaskRepeat.value = 'none'
|
||||
return
|
||||
}
|
||||
const rrule = repeatRrule(value, config)
|
||||
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays: selectedAfterCompletionDays.value, repeatConfig: config })
|
||||
if (selectedTaskRecurrence.value) {
|
||||
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ rrule }) })
|
||||
selectedTaskRecurrence.value = { ...selectedTaskRecurrence.value, rrule }
|
||||
const updated = await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence
|
||||
selectedTaskRecurrence.value = updated
|
||||
} else {
|
||||
selectedTaskRecurrence.value = await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule }) })
|
||||
selectedTaskRecurrence.value = await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, ...recurrencePayload }) }) as Recurrence
|
||||
}
|
||||
selectedTaskRepeat.value = value
|
||||
}
|
||||
async function loadTaskRecurrence(task: Task) {
|
||||
const token = ++recurrenceLoadToken
|
||||
selectedTaskRecurrence.value = null
|
||||
selectedTaskRepeat.value = 'none'
|
||||
selectedAfterCompletionDays.value = '1'
|
||||
selectedRepeatError.value = ''
|
||||
try {
|
||||
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
|
||||
if (token !== recurrenceLoadToken || selectedTask.value?.id !== task.id) return
|
||||
selectedTaskRecurrence.value = recurrence
|
||||
selectedTaskRepeat.value = repeatOption(recurrence?.rrule)
|
||||
selectedRepeatConfig.value = recurrence ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
|
||||
const parsed = parseTaskRecurrence(recurrence)
|
||||
selectedTaskRepeat.value = parsed.option
|
||||
selectedAfterCompletionDays.value = String(parsed.afterCompletionDays)
|
||||
selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
|
||||
} catch (reason) { if (token === recurrenceLoadToken) fail(reason) }
|
||||
}
|
||||
async function updateSelectedTaskRepeat() {
|
||||
if (!selectedTask.value) return
|
||||
try { await saveRepeat(selectedTask.value, selectedTaskRepeat.value); toast('重复设置已保存') }
|
||||
catch (reason) { selectedTaskRepeat.value = repeatOption(selectedTaskRecurrence.value?.rrule); fail(reason) }
|
||||
try { selectedRepeatError.value = ''; await saveRepeat(selectedTask.value, selectedTaskRepeat.value); toast('重复设置已保存') }
|
||||
catch (reason) { selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败'; fail(reason) }
|
||||
}
|
||||
|
||||
async function submitTaskCompose() {
|
||||
@@ -228,8 +225,9 @@ async function submitTaskCompose() {
|
||||
composeTitleError.value = ''
|
||||
if (!composeListId.value) return
|
||||
try {
|
||||
const rrule = composeRepeat.value === 'none' ? null : repeatRrule(composeRepeat.value, composeRepeatConfig.value)
|
||||
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, repeatConfig: composeRepeatConfig.value })
|
||||
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
|
||||
if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间')
|
||||
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({
|
||||
title: taskTitle,
|
||||
list_id: composeListId.value,
|
||||
@@ -237,7 +235,7 @@ async function submitTaskCompose() {
|
||||
due_has_time: composeHasTime.value,
|
||||
priority: composePriority.value,
|
||||
description: composeDescription.value,
|
||||
rrule,
|
||||
...recurrencePayload,
|
||||
}) })
|
||||
if (isTaskView(activeView.value)) {
|
||||
tasks.value.push(task)
|
||||
@@ -246,7 +244,7 @@ async function submitTaskCompose() {
|
||||
if (activeView.value === 'today') void loadTodayTaskSummary()
|
||||
taskComposeOpen.value = false
|
||||
toast('任务已添加')
|
||||
} catch (reason) { fail(reason) }
|
||||
} catch (reason) { composeRepeatError.value = reason instanceof Error ? reason.message : '添加失败'; fail(reason) }
|
||||
}
|
||||
function toggleSidebar() {
|
||||
const compact = window.matchMedia('(max-width: 930px)').matches
|
||||
@@ -367,6 +365,12 @@ function toast(message: string) {
|
||||
}
|
||||
function fail(reason: unknown) { error.value = reason instanceof Error ? reason.message : '请求失败' }
|
||||
|
||||
async function syncBrowserTimezone(currentTimezone?: string) {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
if (!timezone || timezone === currentTimezone) return
|
||||
await api('/me', { method: 'PATCH', body: JSON.stringify({ timezone }) })
|
||||
}
|
||||
|
||||
function restoreNavigation(inboxId: string) {
|
||||
if (restoredNavigation.view === 'tasks' && restoredNavigation.listId) {
|
||||
activeList.value = lists.value.some((item) => item.id === restoredNavigation.listId) ? restoredNavigation.listId : inboxId
|
||||
@@ -397,6 +401,7 @@ async function bootstrap() {
|
||||
navigationLoaded.value = true
|
||||
restoreNavigation(data.inbox_id || lists.value.find((item: TaskList) => item.is_inbox)?.id || lists.value[0]?.id || '')
|
||||
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
|
||||
await syncBrowserTimezone(data.user?.timezone)
|
||||
await loadArchivedLists()
|
||||
void preloadCountdowns()
|
||||
await loadRestoredView()
|
||||
@@ -420,6 +425,7 @@ async function submitAuth() {
|
||||
navigationLoaded.value = true
|
||||
restoreNavigation(data.inbox_id || lists.value.find((item: TaskList) => item.is_inbox)?.id || lists.value[0]?.id || '')
|
||||
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
|
||||
await syncBrowserTimezone(data.user?.timezone)
|
||||
void preloadCountdowns()
|
||||
await loadRestoredView()
|
||||
} catch (reason) { fail(reason) }
|
||||
@@ -816,7 +822,6 @@ async function saveTask() {
|
||||
if (dueAt) {
|
||||
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ due_at: dueAt }) })
|
||||
} else {
|
||||
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' })
|
||||
selectedTaskRecurrence.value = null
|
||||
selectedTaskRepeat.value = 'none'
|
||||
}
|
||||
@@ -1283,8 +1288,10 @@ onUnmounted(() => {
|
||||
<div class="detail-title"><button class="check large" :class="`p${selectedTask.priority}`" @click="toggle(selectedTask)"><Check v-if="selectedTask.completed"/></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题" @blur="saveTask"/></div>
|
||||
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input" @change="saveTask"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
|
||||
<label class="task-detail-due-row">截止时间<input class="task-detail-due-input task-detail-field-input" :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value;saveTask()"></label>
|
||||
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedTask.due_at" @change="updateSelectedTaskRepeat"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="custom">自定义…</option></select><small v-if="!selectedTask.due_at" class="field-hint">设置截止时间后可重复</small></label>
|
||||
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label><button type="button" class="soft-button" @click="updateSelectedTaskRepeat">保存自定义重复</button></section>
|
||||
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedTask.due_at"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedTask.due_at" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
||||
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 天重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
|
||||
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
|
||||
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small><button type="button" class="soft-button repeat-save" :disabled="!selectedTask.due_at" @click="updateSelectedTaskRepeat">保存重复设置</button>
|
||||
<div class="field markdown"><div class="field-label"><span>备注</span><span><button :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div><div v-if="markdownPreview" class="markdown-preview" v-html="renderMarkdown(selectedTask.description)"/><textarea v-else v-model="selectedTask.description" rows="9" placeholder="支持 Markdown…" @blur="saveTask"/></div>
|
||||
<div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><button v-for="subtask in selectedTaskSubtasks" :key="subtask.id" class="subtask-detail" @click="toggle(subtask)"><span class="check"><Check v-if="subtask.completed"/></span><span :class="{strike:subtask.completed}">{{subtask.title}}</span></button><span v-if="!selectedTaskSubtasks.length" class="hint">把这件事拆成更小的步骤</span></div>
|
||||
<details class="more-settings" :open="moreSettingsOpen" @toggle="moreSettingsOpen=($event.target as HTMLDetailsElement).open"><summary>更多设置</summary><div class="more-settings-body">
|
||||
@@ -1315,7 +1322,9 @@ onUnmounted(() => {
|
||||
<button v-if="composeDueAt && !composeHasTime" class="task-compose-time-add" type="button" @click="addComposeTime">添加时间</button>
|
||||
<label v-else-if="composeDueAt" class="task-compose-time-chip"><span>时间</span><input ref="composeTimePicker" v-model="composeTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="composeHasTime=false"><X/></button></label>
|
||||
</div>
|
||||
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">设置截止时间后可重复</small></label>
|
||||
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
||||
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 天重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
|
||||
<small v-if="composeRepeatError" role="alert" class="field-error">{{composeRepeatError}}</small>
|
||||
<section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section>
|
||||
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
import { buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
|
||||
type SearchTask = {
|
||||
id: string
|
||||
@@ -73,6 +73,19 @@ describe('task utilities', () => {
|
||||
expect(() => buildTaskRrule({ frequency: 'daily', interval: 0, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('重复间隔至少为 1')
|
||||
})
|
||||
|
||||
it('builds a distinct completion-trigger payload and parses it without RRULE', () => {
|
||||
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7 })
|
||||
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14 })
|
||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数天数')
|
||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数天数')
|
||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数天数')
|
||||
})
|
||||
|
||||
it('builds scheduled recurrence payloads separately from completion triggers', () => {
|
||||
expect(buildTaskRecurrencePayload('daily', { afterCompletionDays: '1' })).toEqual({ trigger_mode: 'scheduled', rrule: 'FREQ=DAILY' })
|
||||
expect(buildTaskRecurrencePayload('none', { afterCompletionDays: '1' })).toEqual({})
|
||||
})
|
||||
|
||||
it('renders safe basic markdown and strips unsafe html', () => {
|
||||
const html = renderMarkdown('# Plan\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
|
||||
expect(html).toContain('<h1>Plan</h1>')
|
||||
|
||||
@@ -173,6 +173,33 @@ export function parseTaskRrule(rrule = ''): TaskRepeatConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export type TaskRepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'after_completion' | 'custom'
|
||||
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null }
|
||||
|
||||
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; repeatConfig?: TaskRepeatConfig }) {
|
||||
if (option === 'none') return {}
|
||||
if (option === 'after_completion') {
|
||||
const raw = String(values.afterCompletionDays).trim()
|
||||
const days = Number(raw)
|
||||
if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数天数')
|
||||
return { trigger_mode: 'after_completion' as const, after_completion_days: days }
|
||||
}
|
||||
const rrule = option === 'custom'
|
||||
? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' })
|
||||
: `FREQ=${option.toUpperCase()}`
|
||||
return { trigger_mode: 'scheduled' as const, rrule }
|
||||
}
|
||||
|
||||
export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) {
|
||||
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1 }
|
||||
if (recurrence.trigger_mode === 'after_completion') {
|
||||
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1 }
|
||||
}
|
||||
const parsed = parseTaskRrule(recurrence.rrule ?? '')
|
||||
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
|
||||
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1 }
|
||||
}
|
||||
|
||||
export function defaultTaskDueAt(now = new Date()) {
|
||||
const year = now.getFullYear()
|
||||
const month = `${now.getMonth() + 1}`.padStart(2, '0')
|
||||
|
||||
@@ -9,7 +9,7 @@ main{container-type:inline-size;min-width:0;padding:27px 34px 50px;overflow:auto
|
||||
.list-drag-handle{width:44px;min-width:44px;height:44px;display:grid;place-items:center;border:0;background:transparent;color:#ad9f8c;cursor:grab;touch-action:none;border-radius:8px}.list-drag-handle svg{width:15px;height:15px}.list-row{touch-action:pan-y}.list-row.list-dragging{position:relative;z-index:8;opacity:.8;box-shadow:0 10px 24px rgba(78,58,34,.2);transform:translateY(var(--list-drag-y));transition:none;pointer-events:none;background:#fffaf4}.folder-row.list-drop-target{background:var(--accent-soft);box-shadow:inset 3px 0 0 var(--accent)}.list-root-drop.list-drop-target{background:var(--accent-soft);color:#b7421e;border-radius:9px}.list-row.list-reorder-target{box-shadow:inset 0 2px 0 var(--accent)}.list-move-menu{display:grid;gap:4px;padding:6px 0 6px 28px}.list-move-menu button{min-height:42px}.list-move-menu button:disabled,.sidebar-action-sheet .app-sheet__body button:disabled{opacity:.45;cursor:default}
|
||||
.archived-lists{position:relative;margin-top:4px}.archived-lists-toggle{min-height:44px;width:100%;display:flex;align-items:center;gap:8px;border:0;border-radius:9px;background:transparent;padding:0 12px;color:#81786c;text-align:left;font-size:12px;font-weight:650}.archived-lists-toggle:hover:not(:disabled){background:rgba(255,255,255,.52)}.archived-lists-toggle:focus-visible,.archived-row-menu-trigger:focus-visible,.archived-row-actions button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.archived-lists-toggle:disabled{cursor:default;color:#aaa196}.archived-lists-toggle svg{width:15px;height:15px;transition:transform .16s ease}.archived-lists-toggle svg.expanded{transform:rotate(90deg)}.archived-list-items{display:grid;transition:opacity .16s ease}.archived-row{min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) 44px;align-items:center;padding-left:35px;color:#746c61}.archived-row-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.archived-row-menu{display:grid;place-items:center}.archived-row-menu-trigger{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:9px;background:transparent;color:#8e8477}.archived-row-menu-trigger svg{width:15px;height:15px}.archived-action-mask{display:contents}.archived-row-actions{position:fixed;z-index:70;width:164px;display:grid;gap:2px;padding:6px;background:#fffdf8;border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.archived-row-actions button{min-height:44px;display:flex;align-items:center;gap:9px;border:0;border-radius:8px;background:transparent;padding:8px 10px;text-align:left;font-size:12px}.archived-row-actions button:hover{background:#f8f1e8}.archived-row-actions svg{width:15px;height:15px}@media(max-width:930px){.archived-action-mask{display:block;position:fixed;z-index:60;inset:0;background:rgba(45,38,31,.3)}.archived-row-actions{position:fixed;z-index:61;left:0;right:0;top:auto;bottom:0;width:100%;padding:12px 16px calc(16px + env(safe-area-inset-bottom));border-radius:22px 22px 0 0}.archived-row-actions button{font-size:14px}}@media(prefers-reduced-motion:reduce){.archived-lists-toggle svg,.archived-list-items{transition:none}}
|
||||
.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);padding:19px;display:grid;gap:15px}.detail-form>*{min-width:0}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}.task-detail-due-input{padding-inline:9px!important}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{width:100%;min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word;white-space:normal;display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px}
|
||||
.field-hint{color:var(--muted);font-size:11px;font-weight:400}.repeat-custom-fields{display:grid;gap:10px;padding:12px;border:1px solid var(--line);border-radius:11px;background:#fffaf4}.repeat-custom-fields>div,.repeat-custom-fields>label{display:flex;align-items:center;gap:8px;color:#675f54;font-size:12px}.repeat-custom-fields input[type="number"]{width:72px}.repeat-custom-fields input,.repeat-custom-fields select{min-height:40px;border:1px solid var(--line);border-radius:8px;background:#fff;padding:7px 9px}.weekday-picker{display:flex;gap:4px;flex-wrap:wrap}.weekday-picker label{width:34px;height:34px;display:grid;place-items:center;border:1px solid var(--line);border-radius:50%;background:#fff}.weekday-picker input{position:absolute;opacity:0;pointer-events:none}.weekday-picker label:has(input:checked){background:var(--accent);border-color:var(--accent);color:#fff}.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
||||
.field-hint{color:var(--muted);font-size:11px;font-weight:400}.after-completion-fields{display:grid;gap:5px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:#fffaf4;color:#675f54;font-size:12px}.after-completion-fields>div{display:flex;align-items:center;gap:7px;min-height:44px}.after-completion-fields input{width:76px;min-height:44px;border:1px solid var(--line);border-radius:8px;background:#fff;padding:0 10px}.after-completion-fields small{color:var(--muted);line-height:1.5}.repeat-save{width:100%;min-height:44px}.repeat-custom-fields{display:grid;gap:10px;padding:12px;border:1px solid var(--line);border-radius:11px;background:#fffaf4}.repeat-custom-fields>div,.repeat-custom-fields>label{display:flex;align-items:center;gap:8px;color:#675f54;font-size:12px}.repeat-custom-fields input[type="number"]{width:72px}.repeat-custom-fields input,.repeat-custom-fields select{min-height:40px;border:1px solid var(--line);border-radius:8px;background:#fff;padding:7px 9px}.weekday-picker{display:flex;gap:4px;flex-wrap:wrap}.weekday-picker label{width:34px;height:34px;display:grid;place-items:center;border:1px solid var(--line);border-radius:50%;background:#fff}.weekday-picker input{position:absolute;opacity:0;pointer-events:none}.weekday-picker label:has(input:checked){background:var(--accent);border-color:var(--accent);color:#fff}.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
||||
@media(max-width:1050px) and (min-width:931px){.shell.detail-open{grid-template-columns:220px minmax(400px,1fr) minmax(280px,30vw)}.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(400px,1fr) minmax(280px,30vw)}main{padding-inline:24px}}
|
||||
@media(max-width:930px){.topbar:has(.search):has(.topbar-filter){grid-template-columns:44px minmax(0,1fr) auto;grid-template-areas:"menu title filter" ". search search";grid-template-rows:auto auto;column-gap:10px;row-gap:8px}.topbar>.icon{grid-area:menu}.topbar-title{grid-area:title}.topbar-filter{grid-area:filter}.topbar .search{grid-area:search;grid-row:2;justify-self:end;width:min(260px,100%)}}
|
||||
@media(max-width:930px){.shell{height:100dvh;display:block;overflow:auto}.shell.mobile-sidebar-open{overflow:hidden}.shell.mobile-sidebar-open main{overflow:hidden;touch-action:none}.sidebar{position:fixed;z-index:50;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow);left:0;width:min(300px,86vw);transform:translateX(-105%);overscroll-behavior:contain}.sidebar.open{transform:none}.detail{position:fixed;z-index:40;display:block;left:0;right:0;top:auto;bottom:0;width:100%;max-height:min(88dvh,760px);overflow:auto;background:#fffdf8;border:1px solid var(--line);border-bottom:0;border-radius:22px 22px 0 0;box-shadow:0 -14px 38px rgba(56,40,24,.2);transform:translateY(105%);transition:transform .22s ease;padding-bottom:max(16px,env(safe-area-inset-bottom))}.detail.open{transform:none}.detail-head{position:sticky;z-index:2;top:0;height:62px;background:#fffdf8;border-bottom:1px solid var(--line);padding:0 18px}.detail-head .icon{display:grid;width:42px;height:42px;background:var(--accent-soft);color:var(--accent);border-radius:50%}.detail-form{padding:18px}.detail-title textarea{font-size:21px}.more-mask{position:fixed;z-index:45;inset:0;background:rgba(45,38,31,.32);display:flex;align-items:flex-end}.more-sheet{width:100%;background:#fffdf8;border-radius:22px 22px 0 0;padding:14px 16px max(22px,env(safe-area-inset-bottom));display:grid;gap:6px}.more-sheet-head{display:flex;align-items:center;justify-content:space-between;padding:2px 4px 8px}.more-sheet>button{display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}.scrim{position:fixed;z-index:35;inset:0;background:rgba(45,38,31,.32);display:block}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px}.completed-filter-pill{min-width:138px;height:44px}.completed-filter-pill__track{width:36px;height:22px}.completed-filter-pill__thumb{width:18px;height:18px}.today-board{gap:1px;margin:14px 0 4px;padding:5px}.today-track{min-height:58px;padding:8px 9px;gap:7px}.today-track-head{align-items:flex-start;flex-direction:column;gap:2px}.today-track-head strong{font-size:13px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.list-toolbar{height:auto;min-height:36px}.section-heading{margin:12px 0 8px}.empty{min-height:190px}.today-empty-panel{min-height:104px}.row-actions{position:static;opacity:1;pointer-events:auto}.row-actions button{padding:7px;min-height:34px}.list-row{padding:6px 4px}.archived-row{padding:9px 7px;border:1px dashed #e0cfae;border-radius:8px;color:#8a6d3b;background:#fbf3e2}.archived-row .list-row-main{padding-left:14px}.archived-row .list-row-main>svg{color:#c9a45c}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-height:44px;min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
|
||||
@@ -35,7 +35,7 @@ main{container-type:inline-size;min-width:0;padding:27px 34px 50px;overflow:auto
|
||||
.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.tool-card{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.tool-card>h2{margin:0;font-size:1.17em}.tool-card>svg{color:var(--accent);width:25px;height:25px}.tool-card p{margin:0;color:var(--muted);font-size:13px}.tool-card.wide{grid-column:1/-1}.password-form{width:100%;display:grid;gap:10px}.password-form label{display:grid;gap:6px;color:#675f54;font-size:12px;font-weight:650}.password-form input{width:100%;border:1px solid var(--line);background:#fff;border-radius:10px;padding:11px 12px;outline:none}.password-form input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.password-form button{justify-self:start}.file-button input{display:none}.tool-card pre{width:100%;max-height:180px;overflow:auto;background:#f8f3e8;padding:10px;border-radius:8px;font-size:10px}.session-row,.audit-row{width:100%;display:flex;justify-content:space-between;align-items:center;gap:10px;border-top:1px solid var(--line);padding:9px 0}.session-copy{min-width:0;flex:1;display:grid}.session-title{line-height:1.4}.session-meta{min-width:0;display:flex;flex-wrap:wrap;align-items:center;line-height:1.45}.session-device{min-width:0;overflow-wrap:anywhere}.session-revoke{min-width:44px;min-height:44px;flex:0 0 auto;justify-content:center}.audit-copy{min-width:0;display:grid;gap:2px}.audit-action{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow-wrap:anywhere}.session-row small,.audit-row time{color:var(--muted);font-size:11px}.inline-error{padding:9px;border-radius:8px;color:var(--danger);background:#fff0ed}.settings.active{background:var(--accent-soft);color:#b7421e;font-weight:700}
|
||||
@media(max-width:930px){.task-row,.habit-row,.countdown-row{min-height:62px;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:none}.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}.task-row{padding:4px 7px}.task-row:hover,.task-row.selected{background:#fffaf5}.habit-row{padding:4px 7px}.countdown-row,.countdown-row:first-of-type{border:1px solid var(--line)}.countdown-row{grid-template-columns:minmax(0,1fr) 72px;padding:8px 9px;gap:8px}.task-main strong,.habit-name,.countdown-main>b{font-size:14px;font-weight:650}.meta,.countdown-main>small,.countdown-state small{font-size:11px;color:var(--muted)}.habit-progress{font-size:16px}.task-check{width:44px;flex-basis:44px}.countdown-icon{width:36px;height:36px;border-radius:10px}.countdown-state strong{font-size:24px}.countdown-group{gap:8px}.countdown-group>h3{padding-left:3px}.habit-detail-mask{padding:0}.habit-detail-sheet{width:100%;border-radius:22px 22px 0 0;padding:18px 16px calc(18px + env(safe-area-inset-bottom))}}
|
||||
@media(max-width:800px){.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.habit-main{padding:10px 2px}.numeric-action input{width:62px}}
|
||||
@media(max-width:390px){.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}}
|
||||
@media(max-width:390px){.task-compose-sheet{width:100%;max-width:100%;overflow-x:hidden}.task-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.task-compose-sheet input,.task-compose-sheet select,.task-compose-sheet button{min-height:44px}.task-compose-date-clear,.task-compose-time-remove{min-width:44px;min-height:44px}.task-compose-sheet .app-sheet__footer>button{min-height:44px}.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}}
|
||||
.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 8px 20px rgba(241,90,41,.28);transition:transform .16s ease,box-shadow .16s ease;touch-action:none;user-select:none}.unified-fab svg{width:25px;height:25px}.unified-fab:hover{transform:translateY(-2px);box-shadow:0 10px 24px rgba(241,90,41,.32)}.unified-fab:active{transform:scale(.96)}.unified-fab.dragging{transform:scale(1.06);box-shadow:0 12px 28px rgba(241,90,41,.36)}.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim);overscroll-behavior:contain}.app-sheet{min-height:0;overflow:hidden;background:var(--paper)}.app-sheet__header{min-height:64px;flex:0 0 64px;position:sticky;z-index:3;top:0;background:var(--paper);border-bottom:1px solid var(--line);padding:0 18px}.app-sheet__header>div{min-width:0}.app-sheet__header h2,.app-sheet__header h3{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.app-sheet__header>button{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;background:transparent;border-radius:10px}.app-sheet__body{min-height:0;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;display:grid;gap:14px;padding:16px 18px}.app-sheet__footer{position:sticky;bottom:0;z-index:3;margin:0;background:var(--paper);border-top:1px solid var(--line);padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end;gap:8px}.app-sheet__danger{border-top:1px solid #f1d4cd;background:#fff8f6;padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end}.app-sheet--actions .app-sheet__body{gap:6px;padding:8px 16px calc(16px + env(safe-area-inset-bottom))}.app-sheet--actions .app-sheet__body>button{min-height:50px;width:100%;display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}
|
||||
@media(max-width:930px){.app-sheet{width:100%;max-height:min(88dvh,760px);display:flex!important;flex-direction:column!important;overflow:hidden!important;border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important;padding:0!important}.app-sheet--detail,.app-sheet--create{max-width:none!important}.app-sheet-mask{padding:0!important;place-items:end center!important;align-items:flex-end!important}.app-sheet__header{display:flex!important;align-items:center!important;justify-content:space-between!important;width:100%}.app-sheet__body{width:100%;flex:1 1 auto}.app-sheet__body label{display:grid;gap:6px}.app-sheet__footer{width:100%;flex:0 0 auto}.app-sheet__footer .primary-small{min-width:124px}.app-sheet__danger{width:100%;flex:0 0 auto}.app-sheet__danger .danger-text{width:100%;min-height:48px;justify-content:center}}
|
||||
|
||||
|
||||
@@ -209,6 +209,31 @@ describe('completion feedback motion', () => {
|
||||
expect(css).toContain('@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}}')
|
||||
})
|
||||
|
||||
it('syncs the browser IANA timezone before loading user task data', () => {
|
||||
expect(app).toContain("Intl.DateTimeFormat().resolvedOptions().timeZone")
|
||||
expect(app).toContain("await api('/me', { method: 'PATCH', body: JSON.stringify({ timezone }) })")
|
||||
const bootstrapBlock = app.slice(app.indexOf('async function bootstrap()'), app.indexOf('async function submitAuth()'))
|
||||
expect(bootstrapBlock.indexOf("await syncBrowserTimezone(data.user?.timezone)")).toBeLessThan(bootstrapBlock.indexOf('await loadRestoredView()'))
|
||||
const authBlock = app.slice(app.indexOf('async function submitAuth()'), app.indexOf('async function loadTaskPages'))
|
||||
expect(authBlock.indexOf("await syncBrowserTimezone(data.user?.timezone)")).toBeLessThan(authBlock.indexOf('await loadRestoredView()'))
|
||||
})
|
||||
|
||||
it('saves due removal atomically without deleting recurrence a second time', () => {
|
||||
const saveTaskBlock = app.slice(app.indexOf('async function saveTask()'), app.indexOf('async function removeTask('))
|
||||
expect(saveTaskBlock).not.toContain("method: 'DELETE'")
|
||||
expect(saveTaskBlock).toContain('selectedTaskRecurrence.value = null')
|
||||
expect(saveTaskBlock).toContain("selectedTaskRepeat.value = 'none'")
|
||||
})
|
||||
|
||||
it('keeps all task composer touch controls at least 44px on mobile without horizontal overflow', () => {
|
||||
expect(css).toContain('@media(max-width:390px){.task-compose-sheet')
|
||||
expect(css).toContain('.task-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}')
|
||||
expect(css).toContain('.task-compose-sheet input,.task-compose-sheet select,.task-compose-sheet button{min-height:44px}')
|
||||
expect(css).toContain('.task-compose-date-clear,.task-compose-time-remove{min-width:44px;min-height:44px}')
|
||||
expect(css).toContain('.task-compose-sheet .app-sheet__footer>button{min-height:44px}')
|
||||
expect(css).toContain('.task-compose-sheet{width:100%;max-width:100%;overflow-x:hidden}')
|
||||
})
|
||||
|
||||
it('restarts the pulse and ignores a stale timer on rapid repeat completion', () => {
|
||||
vi.useFakeTimers()
|
||||
const active = new Set<string>()
|
||||
@@ -774,7 +799,7 @@ describe('approved habit safety and U2 title hierarchy', () => {
|
||||
})
|
||||
|
||||
it('keeps the 390px habit sheets full width with 44px close and bottom actions', () => {
|
||||
expect(css).toContain('@media(max-width:390px){.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}')
|
||||
expect(css).toContain('.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}')
|
||||
expect(css).toContain('.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}')
|
||||
expect(css).toContain('.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}')
|
||||
})
|
||||
@@ -852,8 +877,19 @@ describe('unified floating add interaction', () => {
|
||||
expect(app).toContain('const selectedTaskRepeat = ref')
|
||||
expect(app).toContain('重复<select v-model="composeRepeat"')
|
||||
expect(app).toContain('重复<select v-model="selectedTaskRepeat"')
|
||||
expect(app).toContain('rrule,')
|
||||
expect(app).toContain('<option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option>')
|
||||
expect(app).toContain('完成后 <input v-model="composeAfterCompletionDays"')
|
||||
expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"')
|
||||
expect(app).toContain('每次完成后,将截止时间顺延对应天数;首版永不结束')
|
||||
expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value')
|
||||
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays: selectedAfterCompletionDays.value')
|
||||
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
|
||||
const createBlock = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()'))
|
||||
expect(createBlock).toContain("api('/tasks',")
|
||||
expect(createBlock).not.toContain("api('/recurrences'")
|
||||
const saveBlock = app.slice(app.indexOf('async function updateSelectedTaskRepeat()'), app.indexOf('async function submitTaskCompose()'))
|
||||
expect(saveBlock).not.toContain('selectedTaskRepeat.value = parseTaskRecurrence')
|
||||
expect(css).toContain('.after-completion-fields input{width:76px;min-height:44px')
|
||||
expect(app).toContain('<option value="custom">自定义…</option>')
|
||||
expect(app).toContain('class="repeat-custom-fields"')
|
||||
expect(app).toContain('每隔')
|
||||
@@ -1078,7 +1114,9 @@ describe('sidebar layout', () => {
|
||||
})
|
||||
|
||||
it('loads archived lists during bootstrap so archived rows are visible after refresh', () => {
|
||||
expect(app).toContain('expandedFolders.value = new Set(folders.value.map((folder) => folder.id))\n await loadArchivedLists()\n void preloadCountdowns()\n await loadRestoredView()')
|
||||
const bootstrapBlock = app.slice(app.indexOf('async function bootstrap()'), app.indexOf('async function submitAuth()'))
|
||||
expect(bootstrapBlock).toContain('await loadArchivedLists()')
|
||||
expect(bootstrapBlock.indexOf('await loadArchivedLists()')).toBeLessThan(bootstrapBlock.indexOf('await loadRestoredView()'))
|
||||
})
|
||||
|
||||
it('styles archived rows through the compact disclosure structure', () => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""add recurrence trigger modes
|
||||
|
||||
Revision ID: 0016_recurrence_trigger_modes
|
||||
Revises: 0015_purge_operations
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0016_recurrence_trigger_modes"
|
||||
down_revision = "0015_purge_operations"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("recurrence_templates") as batch_op:
|
||||
batch_op.alter_column("rrule", existing_type=sa.Text(), nullable=True)
|
||||
batch_op.add_column(
|
||||
sa.Column("trigger_mode", sa.String(length=32), nullable=False, server_default="scheduled")
|
||||
)
|
||||
batch_op.add_column(sa.Column("after_completion_days", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("last_completed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch_op.create_check_constraint(
|
||||
"ck_recurrence_trigger_configuration",
|
||||
"(trigger_mode = 'scheduled' AND rrule IS NOT NULL AND after_completion_days IS NULL) "
|
||||
"OR (trigger_mode = 'after_completion' AND rrule IS NULL "
|
||||
"AND after_completion_days BETWEEN 1 AND 3650)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("recurrence_templates") as batch_op:
|
||||
batch_op.drop_constraint("ck_recurrence_trigger_configuration", type_="check")
|
||||
# Legacy schema requires an RRULE. Completion-relative rows have none, so
|
||||
# preserve them as a compatible daily interval rule before restoring NOT NULL.
|
||||
op.execute(
|
||||
"UPDATE recurrence_templates "
|
||||
"SET rrule = 'FREQ=DAILY;INTERVAL=' || after_completion_days "
|
||||
"WHERE trigger_mode = 'after_completion' AND rrule IS NULL"
|
||||
)
|
||||
with op.batch_alter_table("recurrence_templates") as batch_op:
|
||||
batch_op.drop_column("last_completed_at")
|
||||
batch_op.drop_column("after_completion_days")
|
||||
batch_op.drop_column("trigger_mode")
|
||||
batch_op.alter_column("rrule", existing_type=sa.Text(), nullable=False)
|
||||
@@ -0,0 +1,272 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from backend import recurrence_service
|
||||
from backend.models import Task, User
|
||||
|
||||
|
||||
def boot(client):
|
||||
response = client.post(
|
||||
"/api/v1/setup/initialize",
|
||||
json={"username": "owner", "password": "correct horse battery staple"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return client.get("/api/v1/lists").json()[0]
|
||||
|
||||
|
||||
def create_after_completion_task(client, inbox, **overrides):
|
||||
payload = {
|
||||
"title": "完成后重复",
|
||||
"list_id": inbox["id"],
|
||||
"due_at": "2026-03-08T01:30:00Z",
|
||||
"due_has_time": True,
|
||||
"trigger_mode": "after_completion",
|
||||
"after_completion_days": 2,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return client.post("/api/v1/tasks", json=payload)
|
||||
|
||||
|
||||
def test_browser_timezone_can_be_persisted_and_is_returned_by_bootstrap(client):
|
||||
boot(client)
|
||||
|
||||
updated = client.patch("/api/v1/me", json={"timezone": "America/New_York"})
|
||||
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["timezone"] == "America/New_York"
|
||||
assert client.get("/api/v1/bootstrap").json()["user"]["timezone"] == "America/New_York"
|
||||
assert client.patch("/api/v1/me", json={"timezone": "Not/A_Zone"}).status_code == 422
|
||||
assert client.get("/api/v1/me").json()["timezone"] == "America/New_York"
|
||||
|
||||
|
||||
def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
|
||||
user = User(username="owner", password_hash="hash", timezone="America/New_York")
|
||||
gap_task = Task(
|
||||
title="gap",
|
||||
user_id=None,
|
||||
list_id=None,
|
||||
due_at=datetime(2026, 3, 7, 7, 30, tzinfo=UTC), # local 02:30
|
||||
due_has_time=True,
|
||||
)
|
||||
gap_due = recurrence_service._after_completion_due(
|
||||
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, user
|
||||
)
|
||||
assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap
|
||||
|
||||
fold_task = Task(
|
||||
title="fold",
|
||||
user_id=None,
|
||||
list_id=None,
|
||||
due_at=datetime(2026, 10, 31, 5, 30, tzinfo=UTC), # local 01:30
|
||||
due_has_time=True,
|
||||
)
|
||||
fold_due = recurrence_service._after_completion_due(
|
||||
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, user
|
||||
)
|
||||
assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0
|
||||
|
||||
|
||||
def test_user_timezone_is_the_calendar_contract_for_after_completion(client, monkeypatch):
|
||||
inbox = boot(client)
|
||||
task = create_after_completion_task(
|
||||
client,
|
||||
inbox,
|
||||
due_at="2026-03-08T09:30:00Z",
|
||||
after_completion_days=1,
|
||||
).json()
|
||||
|
||||
assert client.patch("/api/v1/me", json={"timezone": "America/New_York"}).status_code == 200
|
||||
monkeypatch.setattr(
|
||||
"backend.recurrence_service.utcnow",
|
||||
lambda: datetime(2026, 3, 8, 5, 30, tzinfo=UTC), # local 00:30 on DST transition day
|
||||
)
|
||||
|
||||
completed = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
|
||||
)
|
||||
|
||||
assert completed.status_code == 200
|
||||
# Original local due time was 05:30; next local day is DST, therefore UTC is 09:30.
|
||||
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_atomic_task_create_and_read_after_completion_recurrence(client):
|
||||
inbox = boot(client)
|
||||
|
||||
created = create_after_completion_task(client, inbox)
|
||||
|
||||
assert created.status_code == 201
|
||||
recurrence = client.get(f"/api/v1/tasks/{created.json()['id']}/recurrence")
|
||||
assert recurrence.status_code == 200
|
||||
assert recurrence.json() == {
|
||||
"id": recurrence.json()["id"],
|
||||
"task_id": created.json()["id"],
|
||||
"rrule": None,
|
||||
"starts_at": recurrence.json()["starts_at"],
|
||||
"ends_at": None,
|
||||
"trigger_mode": "after_completion",
|
||||
"after_completion_days": 2,
|
||||
"last_completed_at": None,
|
||||
}
|
||||
|
||||
|
||||
def test_after_completion_configuration_validation(client):
|
||||
inbox = boot(client)
|
||||
parent = client.post("/api/v1/tasks", json={"title": "父", "list_id": inbox["id"]}).json()
|
||||
child = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "子", "list_id": inbox["id"], "parent_id": parent["id"]},
|
||||
).json()
|
||||
|
||||
invalid_payloads = [
|
||||
{"title": "无截止", "list_id": inbox["id"], "trigger_mode": "after_completion", "after_completion_days": 1},
|
||||
{"title": "零天", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 0},
|
||||
{"title": "太长", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 3651},
|
||||
{"title": "子任务", "list_id": inbox["id"], "parent_id": child["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 1},
|
||||
]
|
||||
for payload in invalid_payloads:
|
||||
assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422}
|
||||
|
||||
scheduled_without_rule = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "无规则", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "scheduled"},
|
||||
)
|
||||
assert scheduled_without_rule.status_code == 422
|
||||
|
||||
|
||||
def test_after_completion_uses_user_local_calendar_and_preserves_local_time(client, monkeypatch):
|
||||
inbox = boot(client)
|
||||
task = create_after_completion_task(client, inbox).json()
|
||||
completed_at = datetime(2026, 3, 8, 16, 30, tzinfo=UTC) # 2026-03-09 00:30 Asia/Shanghai
|
||||
monkeypatch.setattr("backend.recurrence_service.utcnow", lambda: completed_at)
|
||||
|
||||
completed = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}",
|
||||
json={"completed": True, "version": task["version"]},
|
||||
)
|
||||
|
||||
assert completed.status_code == 200
|
||||
assert completed.json()["completed"] is False
|
||||
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 11, 1, 30, tzinfo=UTC)
|
||||
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
|
||||
assert datetime.fromisoformat(recurrence["last_completed_at"]) == completed_at
|
||||
assert datetime.fromisoformat(recurrence["starts_at"]) == datetime(2026, 3, 11, 1, 30, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_after_completion_date_only_stays_date_only(client, monkeypatch):
|
||||
inbox = boot(client)
|
||||
task = create_after_completion_task(
|
||||
client,
|
||||
inbox,
|
||||
due_at="2026-03-08T15:59:59Z", # local 23:59:59
|
||||
due_has_time=False,
|
||||
after_completion_days=1,
|
||||
).json()
|
||||
monkeypatch.setattr(
|
||||
"backend.recurrence_service.utcnow",
|
||||
lambda: datetime(2026, 3, 8, 16, 30, tzinfo=UTC),
|
||||
)
|
||||
|
||||
completed = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
|
||||
)
|
||||
|
||||
assert completed.status_code == 200
|
||||
assert completed.json()["due_has_time"] is False
|
||||
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 10, 15, 59, 59, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_editing_days_does_not_move_due_and_due_removal_cancels_atomically(client):
|
||||
inbox = boot(client)
|
||||
task = create_after_completion_task(client, inbox).json()
|
||||
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
|
||||
|
||||
changed = client.patch(
|
||||
f"/api/v1/recurrences/{recurrence['id']}",
|
||||
json={"trigger_mode": "after_completion", "after_completion_days": 5},
|
||||
)
|
||||
assert changed.status_code == 200
|
||||
assert changed.json()["after_completion_days"] == 5
|
||||
assert client.get(f"/api/v1/tasks/{task['id']}").json()["due_at"] == task["due_at"]
|
||||
|
||||
stale = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}", json={"due_at": None, "version": task["version"] + 100}
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert client.get(f"/api/v1/tasks/{task['id']}/recurrence").json() is not None
|
||||
|
||||
removed = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}", json={"due_at": None, "version": task["version"]}
|
||||
)
|
||||
assert removed.status_code == 200
|
||||
assert client.get(f"/api/v1/tasks/{task['id']}/recurrence").json() is None
|
||||
|
||||
|
||||
def test_batch_completion_uses_recurrence_service_and_versions_prevent_double_advance(client, monkeypatch):
|
||||
inbox = boot(client)
|
||||
parent = create_after_completion_task(client, inbox, after_completion_days=1).json()
|
||||
assert client.patch("/api/v1/me", json={"timezone": "America/New_York"}).status_code == 200
|
||||
child = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "已完成子任务", "list_id": inbox["id"], "parent_id": parent["id"]},
|
||||
).json()
|
||||
client.patch(
|
||||
f"/api/v1/tasks/{child['id']}", json={"completed": True, "version": child["version"]}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"backend.recurrence_service.utcnow",
|
||||
lambda: datetime(2026, 3, 8, 16, 30, tzinfo=UTC),
|
||||
)
|
||||
payload = {
|
||||
"task_ids": [parent["id"]],
|
||||
"completed": True,
|
||||
"versions": {parent["id"]: parent["version"]},
|
||||
}
|
||||
|
||||
first = client.post("/api/v1/tasks/batch", json=payload)
|
||||
second = client.post("/api/v1/tasks/batch", json=payload)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 409
|
||||
detail = client.get(f"/api/v1/tasks/{parent['id']}").json()
|
||||
assert datetime.fromisoformat(detail["due_at"]) == datetime(2026, 3, 10, 0, 30, tzinfo=UTC)
|
||||
assert detail["completed"] is False
|
||||
assert detail["subtasks"][0]["completed"] is False
|
||||
|
||||
|
||||
def test_after_completion_recurrence_survives_json_and_csv_round_trips(client):
|
||||
inbox = boot(client)
|
||||
task = create_after_completion_task(client, inbox).json()
|
||||
exported = client.get("/api/v1/export").json()
|
||||
recurrence = exported["recurrences"][0]
|
||||
assert recurrence["trigger_mode"] == "after_completion"
|
||||
assert recurrence["after_completion_days"] == 2
|
||||
assert recurrence["last_completed_at"] is None
|
||||
|
||||
restored = client.post("/api/v1/restore?mode=replace", json=exported)
|
||||
assert restored.status_code == 200
|
||||
restored_task = client.get("/api/v1/tasks", params={"q": task["title"]}).json()["items"][0]
|
||||
restored_recurrence = client.get(f"/api/v1/tasks/{restored_task['id']}/recurrence").json()
|
||||
assert restored_recurrence["trigger_mode"] == "after_completion"
|
||||
assert restored_recurrence["after_completion_days"] == 2
|
||||
|
||||
csv_export = client.get("/api/v1/export.csv")
|
||||
assert csv_export.status_code == 200
|
||||
csv_restore = client.post(
|
||||
"/api/v1/restore.csv?mode=replace",
|
||||
files={"file": ("dodo-export.csv", csv_export.content, "text/csv")},
|
||||
)
|
||||
assert csv_restore.status_code == 200
|
||||
csv_task = client.get("/api/v1/tasks", params={"q": task["title"]}).json()["items"][0]
|
||||
assert client.get(f"/api/v1/tasks/{csv_task['id']}/recurrence").json()["trigger_mode"] == "after_completion"
|
||||
|
||||
|
||||
def test_legacy_scheduled_recurrence_response_defaults_are_compatible(client):
|
||||
inbox = boot(client)
|
||||
task = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "旧规则", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z", "rrule": "FREQ=DAILY"},
|
||||
).json()
|
||||
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
|
||||
assert recurrence["trigger_mode"] == "scheduled"
|
||||
assert recurrence["after_completion_days"] is None
|
||||
assert recurrence["last_completed_at"] is None
|
||||
+11
-2
@@ -279,7 +279,12 @@ def test_batch_complete_and_move(client):
|
||||
for i in range(2)
|
||||
]
|
||||
response = client.post(
|
||||
"/api/v1/tasks/batch", json={"task_ids": ids, "completed": True, "list_id": other["id"]}
|
||||
"/api/v1/tasks/batch", json={
|
||||
"task_ids": ids,
|
||||
"completed": True,
|
||||
"list_id": other["id"],
|
||||
"versions": {task_id: 1 for task_id in ids},
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["updated"] == 2
|
||||
@@ -473,7 +478,11 @@ def test_batch_supports_due_date_and_soft_delete_atomically(client):
|
||||
|
||||
failed = client.post(
|
||||
"/api/v1/tasks/batch",
|
||||
json={"task_ids": [ids[0], "00000000-0000-0000-0000-000000000001"], "completed": True},
|
||||
json={
|
||||
"task_ids": [ids[0], "00000000-0000-0000-0000-000000000001"],
|
||||
"completed": True,
|
||||
"versions": {ids[0]: 1, "00000000-0000-0000-0000-000000000001": 1},
|
||||
},
|
||||
)
|
||||
assert failed.status_code == 404
|
||||
assert client.get(f"/api/v1/tasks/{ids[0]}").json()["completed"] is False
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_alembic(repo: Path, database: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["DODO_DATABASE_URL"] = f"sqlite+aiosqlite:///{database}"
|
||||
return subprocess.run(
|
||||
["uv", "run", "alembic", *args],
|
||||
cwd=repo,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_recurrence_trigger_migration_downgrade_and_reupgrade_with_data(tmp_path: Path):
|
||||
repo = Path(__file__).resolve().parents[1]
|
||||
database = tmp_path / "migration.sqlite3"
|
||||
assert run_alembic(repo, database, "upgrade", "0015_purge_operations").returncode == 0
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO recurrence_templates "
|
||||
"(id, user_id, task_id, rrule, starts_at, ends_at, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"00000000000000000000000000000001",
|
||||
"00000000000000000000000000000002",
|
||||
"00000000000000000000000000000003",
|
||||
"FREQ=DAILY",
|
||||
"2026-03-08 07:30:00",
|
||||
None,
|
||||
"2026-03-01 00:00:00",
|
||||
),
|
||||
)
|
||||
|
||||
upgraded = run_alembic(repo, database, "upgrade", "0016_recurrence_trigger_modes")
|
||||
assert upgraded.returncode == 0, upgraded.stderr
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"UPDATE recurrence_templates SET trigger_mode='after_completion', "
|
||||
"after_completion_days=1, rrule=NULL"
|
||||
)
|
||||
|
||||
downgraded = run_alembic(repo, database, "downgrade", "0015_purge_operations")
|
||||
assert downgraded.returncode == 0, downgraded.stderr
|
||||
with sqlite3.connect(database) as connection:
|
||||
row = connection.execute("SELECT rrule FROM recurrence_templates").fetchone()
|
||||
assert row == ("FREQ=DAILY;INTERVAL=1",)
|
||||
|
||||
reupgraded = run_alembic(repo, database, "upgrade", "0016_recurrence_trigger_modes")
|
||||
assert reupgraded.returncode == 0, reupgraded.stderr
|
||||
Reference in New Issue
Block a user