[verified] feat: support monthly completion recurrence
This commit is contained in:
@@ -354,6 +354,9 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
|
|||||||
"rrule": row.get("rrule"),
|
"rrule": row.get("rrule"),
|
||||||
"trigger_mode": row.get("trigger_mode", "scheduled"),
|
"trigger_mode": row.get("trigger_mode", "scheduled"),
|
||||||
"after_completion_days": row.get("after_completion_days"),
|
"after_completion_days": row.get("after_completion_days"),
|
||||||
|
"after_completion_unit": row.get("after_completion_unit")
|
||||||
|
if row.get("trigger_mode", "scheduled") == "after_completion"
|
||||||
|
else None,
|
||||||
})
|
})
|
||||||
starts_at = datetime.fromisoformat(row["starts_at"])
|
starts_at = datetime.fromisoformat(row["starts_at"])
|
||||||
ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None
|
ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None
|
||||||
@@ -523,6 +526,8 @@ def _coerce(model, raw: dict, mapping: dict[str, dict[str, UUID]], user_id: UUID
|
|||||||
elif isinstance(effective_type, Date) and isinstance(value, str):
|
elif isinstance(effective_type, Date) and isinstance(value, str):
|
||||||
value = date.fromisoformat(value)
|
value = date.fromisoformat(value)
|
||||||
values[name] = value
|
values[name] = value
|
||||||
|
if model is RecurrenceTemplate and "after_completion_unit" not in raw:
|
||||||
|
values["after_completion_unit"] = "days" if raw.get("trigger_mode") == "after_completion" else None
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -999,7 +999,7 @@ async def create_task(
|
|||||||
from .mvp import parse_rrule
|
from .mvp import parse_rrule
|
||||||
if payload.rrule:
|
if payload.rrule:
|
||||||
parse_rrule(payload.rrule)
|
parse_rrule(payload.rrule)
|
||||||
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days"})
|
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days", "after_completion_unit"})
|
||||||
parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None)
|
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(
|
max_position = await db.scalar(select(func.max(Task.position)).where(
|
||||||
Task.user_id == user.id,
|
Task.user_id == user.id,
|
||||||
@@ -1019,6 +1019,9 @@ async def create_task(
|
|||||||
starts_at=task.due_at,
|
starts_at=task.due_at,
|
||||||
trigger_mode=payload.trigger_mode or "scheduled",
|
trigger_mode=payload.trigger_mode or "scheduled",
|
||||||
after_completion_days=payload.after_completion_days,
|
after_completion_days=payload.after_completion_days,
|
||||||
|
after_completion_unit=(payload.after_completion_unit or "days")
|
||||||
|
if payload.trigger_mode == "after_completion"
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
audit(db, user.id, "create", "task", task.id)
|
audit(db, user.id, "create", "task", task.id)
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class RecurrenceTemplate(Base):
|
|||||||
ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||||
trigger_mode: Mapped[str] = mapped_column(String(32), default="scheduled")
|
trigger_mode: Mapped[str] = mapped_column(String(32), default="scheduled")
|
||||||
after_completion_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
after_completion_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
after_completion_unit: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||||
last_completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
last_completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||||
|
|
||||||
|
|||||||
+30
-4
@@ -251,17 +251,22 @@ class RecurrenceCreate(BaseModel):
|
|||||||
rrule: str | None = Field(default=None, 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)$")
|
trigger_mode: str = Field(default="scheduled", pattern="^(scheduled|after_completion)$")
|
||||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||||
|
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_mode(self):
|
def validate_mode(self):
|
||||||
if self.trigger_mode == "scheduled" and (
|
if self.trigger_mode == "scheduled" and (
|
||||||
self.rrule is None or self.after_completion_days is not None
|
self.rrule is None
|
||||||
|
or self.after_completion_days is not None
|
||||||
|
or self.after_completion_unit is not None
|
||||||
):
|
):
|
||||||
raise ValueError("scheduled recurrence requires rrule and no completion interval")
|
raise ValueError("scheduled recurrence requires rrule and no completion interval")
|
||||||
if self.trigger_mode == "after_completion" and (
|
if self.trigger_mode == "after_completion" and (
|
||||||
self.after_completion_days is None or self.rrule is not None
|
self.after_completion_days is None or self.rrule is not None
|
||||||
):
|
):
|
||||||
raise ValueError("after_completion requires days and no rrule")
|
raise ValueError("after_completion requires days and no rrule")
|
||||||
|
if self.trigger_mode == "after_completion" and self.after_completion_unit is None:
|
||||||
|
self.after_completion_unit = "days"
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -271,6 +276,7 @@ class RecurrenceChange(BaseModel):
|
|||||||
rrule: str | None = None
|
rrule: str | None = None
|
||||||
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
||||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||||
|
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
|
||||||
|
|
||||||
|
|
||||||
class OccurrenceComplete(BaseModel):
|
class OccurrenceComplete(BaseModel):
|
||||||
@@ -430,6 +436,7 @@ async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user),
|
|||||||
"ends_at": row.ends_at,
|
"ends_at": row.ends_at,
|
||||||
"trigger_mode": row.trigger_mode,
|
"trigger_mode": row.trigger_mode,
|
||||||
"after_completion_days": row.after_completion_days,
|
"after_completion_days": row.after_completion_days,
|
||||||
|
"after_completion_unit": row.after_completion_unit,
|
||||||
"last_completed_at": row.last_completed_at,
|
"last_completed_at": row.last_completed_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +459,9 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
|
|||||||
starts_at=task.due_at,
|
starts_at=task.due_at,
|
||||||
trigger_mode=payload.trigger_mode,
|
trigger_mode=payload.trigger_mode,
|
||||||
after_completion_days=payload.after_completion_days,
|
after_completion_days=payload.after_completion_days,
|
||||||
|
after_completion_unit=(payload.after_completion_unit or "days")
|
||||||
|
if payload.trigger_mode == "after_completion"
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
await db.commit(); await db.refresh(row)
|
await db.commit(); await db.refresh(row)
|
||||||
@@ -463,6 +473,7 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
|
|||||||
"ends_at": row.ends_at,
|
"ends_at": row.ends_at,
|
||||||
"trigger_mode": row.trigger_mode,
|
"trigger_mode": row.trigger_mode,
|
||||||
"after_completion_days": row.after_completion_days,
|
"after_completion_days": row.after_completion_days,
|
||||||
|
"after_completion_unit": row.after_completion_unit,
|
||||||
"last_completed_at": row.last_completed_at,
|
"last_completed_at": row.last_completed_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,12 +515,18 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
|||||||
if "after_completion_days" in payload.model_fields_set
|
if "after_completion_days" in payload.model_fields_set
|
||||||
else template.after_completion_days
|
else template.after_completion_days
|
||||||
)
|
)
|
||||||
|
requested_unit = (
|
||||||
|
payload.after_completion_unit
|
||||||
|
if "after_completion_unit" in payload.model_fields_set
|
||||||
|
else template.after_completion_unit
|
||||||
|
)
|
||||||
requested_rrule = payload.rrule if payload.rrule is not None else template.rrule
|
requested_rrule = payload.rrule if payload.rrule is not None else template.rrule
|
||||||
if requested_mode == "after_completion":
|
if requested_mode == "after_completion":
|
||||||
if requested_days is None:
|
if requested_days is None:
|
||||||
raise HTTPException(422, "完成后重复需要间隔天数")
|
raise HTTPException(422, "完成后重复需要间隔天数")
|
||||||
template.trigger_mode = requested_mode
|
template.trigger_mode = requested_mode
|
||||||
template.after_completion_days = requested_days
|
template.after_completion_days = requested_days
|
||||||
|
template.after_completion_unit = requested_unit or "days"
|
||||||
template.rrule = None
|
template.rrule = None
|
||||||
else:
|
else:
|
||||||
if requested_rrule is None:
|
if requested_rrule is None:
|
||||||
@@ -517,6 +534,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
|||||||
parse_rrule(requested_rrule)
|
parse_rrule(requested_rrule)
|
||||||
template.trigger_mode = requested_mode
|
template.trigger_mode = requested_mode
|
||||||
template.after_completion_days = None
|
template.after_completion_days = None
|
||||||
|
template.after_completion_unit = None
|
||||||
template.rrule = requested_rrule.upper()
|
template.rrule = requested_rrule.upper()
|
||||||
if payload.title is not None:
|
if payload.title is not None:
|
||||||
task.title = payload.title
|
task.title = payload.title
|
||||||
@@ -533,6 +551,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
|||||||
"ends_at": template.ends_at,
|
"ends_at": template.ends_at,
|
||||||
"trigger_mode": template.trigger_mode,
|
"trigger_mode": template.trigger_mode,
|
||||||
"after_completion_days": template.after_completion_days,
|
"after_completion_days": template.after_completion_days,
|
||||||
|
"after_completion_unit": template.after_completion_unit,
|
||||||
"last_completed_at": template.last_completed_at,
|
"last_completed_at": template.last_completed_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1358,7 +1377,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memo
|
|||||||
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
|
"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],
|
"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", "completed_at", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
|
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "completed_at", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
|
||||||
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
|
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "after_completion_unit", "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],
|
"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],
|
"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],
|
||||||
"memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos],
|
"memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos],
|
||||||
@@ -1570,11 +1589,17 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
|||||||
rrule = raw.get("rrule")
|
rrule = raw.get("rrule")
|
||||||
if trigger_mode not in {"scheduled", "after_completion"}:
|
if trigger_mode not in {"scheduled", "after_completion"}:
|
||||||
raise HTTPException(422, "无效的重复触发模式")
|
raise HTTPException(422, "无效的重复触发模式")
|
||||||
|
unit = raw.get("after_completion_unit")
|
||||||
|
if unit is not None and unit not in {"days", "months"}:
|
||||||
|
raise HTTPException(422, "无效的完成后重复单位")
|
||||||
if trigger_mode == "after_completion":
|
if trigger_mode == "after_completion":
|
||||||
if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650 or rrule is not None:
|
if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650 or rrule is not None:
|
||||||
raise HTTPException(422, "无效的完成后重复备份")
|
raise HTTPException(422, "无效的完成后重复备份")
|
||||||
elif not isinstance(rrule, str):
|
unit = unit or "days"
|
||||||
raise HTTPException(422, "定期重复缺少 RRULE")
|
else:
|
||||||
|
if not isinstance(rrule, str) or days is not None or unit is not None:
|
||||||
|
raise HTTPException(422, "定期重复备份包含无效字段")
|
||||||
|
unit = None
|
||||||
db.add(RecurrenceTemplate(
|
db.add(RecurrenceTemplate(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
@@ -1583,6 +1608,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
|||||||
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
|
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
|
||||||
trigger_mode=trigger_mode,
|
trigger_mode=trigger_mode,
|
||||||
after_completion_days=days,
|
after_completion_days=days,
|
||||||
|
after_completion_unit=unit,
|
||||||
last_completed_at=datetime.fromisoformat(raw["last_completed_at"])
|
last_completed_at=datetime.fromisoformat(raw["last_completed_at"])
|
||||||
if raw.get("last_completed_at") else None,
|
if raw.get("last_completed_at") else None,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from datetime import UTC, datetime, time, timedelta
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -25,10 +26,15 @@ def _user_zone(user: User) -> ZoneInfo:
|
|||||||
raise HTTPException(422, "用户时区无效") from exc
|
raise HTTPException(422, "用户时区无效") from exc
|
||||||
|
|
||||||
|
|
||||||
def _after_completion_due(task: Task, completed_at: datetime, days: int, user: User) -> datetime:
|
def _after_completion_due(
|
||||||
|
task: Task, completed_at: datetime, value: int, unit: str | None, user: User
|
||||||
|
) -> datetime:
|
||||||
zone = _user_zone(user)
|
zone = _user_zone(user)
|
||||||
completed_local = completed_at.astimezone(zone)
|
completed_local = completed_at.astimezone(zone)
|
||||||
target_date = completed_local.date() + timedelta(days=days)
|
if unit == "months":
|
||||||
|
target_date = completed_local.date() + relativedelta(months=value)
|
||||||
|
else:
|
||||||
|
target_date = completed_local.date() + timedelta(days=value)
|
||||||
if task.due_has_time:
|
if task.due_has_time:
|
||||||
due_local = task.due_at.astimezone(zone)
|
due_local = task.due_at.astimezone(zone)
|
||||||
wall_time = due_local.timetz().replace(tzinfo=None)
|
wall_time = due_local.timetz().replace(tzinfo=None)
|
||||||
@@ -67,7 +73,11 @@ async def apply_task_changes(
|
|||||||
if recurrence.trigger_mode == "after_completion":
|
if recurrence.trigger_mode == "after_completion":
|
||||||
completed_at = utcnow()
|
completed_at = utcnow()
|
||||||
next_due = _after_completion_due(
|
next_due = _after_completion_due(
|
||||||
task, completed_at, recurrence.after_completion_days, user
|
task,
|
||||||
|
completed_at,
|
||||||
|
recurrence.after_completion_days,
|
||||||
|
recurrence.after_completion_unit,
|
||||||
|
user,
|
||||||
)
|
)
|
||||||
changes["completed"] = False
|
changes["completed"] = False
|
||||||
changes["due_at"] = next_due
|
changes["due_at"] = next_due
|
||||||
|
|||||||
+16
-5
@@ -125,6 +125,7 @@ class TaskCreate(BaseModel):
|
|||||||
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
||||||
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
||||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||||
|
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
|
||||||
|
|
||||||
@field_validator("title")
|
@field_validator("title")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -136,7 +137,12 @@ class TaskCreate(BaseModel):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_recurrence(self):
|
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
|
has_recurrence = (
|
||||||
|
self.rrule is not None
|
||||||
|
or self.trigger_mode is not None
|
||||||
|
or self.after_completion_days is not None
|
||||||
|
or self.after_completion_unit is not None
|
||||||
|
)
|
||||||
if has_recurrence and self.due_at is None:
|
if has_recurrence and self.due_at is None:
|
||||||
raise ValueError("recurrence requires due_at")
|
raise ValueError("recurrence requires due_at")
|
||||||
if has_recurrence and self.parent_id is not None:
|
if has_recurrence and self.parent_id is not None:
|
||||||
@@ -144,10 +150,15 @@ class TaskCreate(BaseModel):
|
|||||||
if self.trigger_mode == "after_completion":
|
if self.trigger_mode == "after_completion":
|
||||||
if self.after_completion_days is None or self.rrule is not None:
|
if self.after_completion_days is None or self.rrule is not None:
|
||||||
raise ValueError("after_completion requires days and no rrule")
|
raise ValueError("after_completion requires days and no rrule")
|
||||||
elif self.trigger_mode == "scheduled" and self.rrule is None:
|
elif self.trigger_mode == "scheduled":
|
||||||
raise ValueError("scheduled recurrence requires rrule")
|
if (
|
||||||
elif self.trigger_mode is None and self.after_completion_days is not None:
|
self.rrule is None
|
||||||
raise ValueError("after_completion_days requires after_completion mode")
|
or self.after_completion_days is not None
|
||||||
|
or self.after_completion_unit is not None
|
||||||
|
):
|
||||||
|
raise ValueError("scheduled recurrence requires rrule and no completion interval")
|
||||||
|
elif self.after_completion_days is not None or self.after_completion_unit is not None:
|
||||||
|
raise ValueError("completion interval requires after_completion mode")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+14
-8
@@ -5,7 +5,7 @@ import {
|
|||||||
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote,
|
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote,
|
||||||
Settings, Trash2, X, Repeat2, StickyNote,
|
Settings, Trash2, X, Repeat2, StickyNote,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
||||||
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||||
@@ -32,7 +32,7 @@ type FolderItem = { id: string; name: string }
|
|||||||
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
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; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
|
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
|
||||||
type RepeatOption = TaskRepeatOption
|
type RepeatOption = TaskRepeatOption
|
||||||
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
|
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null; after_completion_unit: AfterCompletionUnit | null }
|
||||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
|
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
|
||||||
|
|
||||||
const initialized = ref<boolean | null>(null)
|
const initialized = ref<boolean | null>(null)
|
||||||
@@ -158,9 +158,11 @@ const composePriority = ref(0)
|
|||||||
const composeDescription = ref('')
|
const composeDescription = ref('')
|
||||||
const composeRepeat = ref<RepeatOption>('none')
|
const composeRepeat = ref<RepeatOption>('none')
|
||||||
const composeAfterCompletionDays = ref('1')
|
const composeAfterCompletionDays = ref('1')
|
||||||
|
const composeAfterCompletionUnit = ref<AfterCompletionUnit>('days')
|
||||||
const composeRepeatError = ref('')
|
const composeRepeatError = ref('')
|
||||||
const selectedTaskRepeat = ref<RepeatOption>('none')
|
const selectedTaskRepeat = ref<RepeatOption>('none')
|
||||||
const selectedAfterCompletionDays = ref('1')
|
const selectedAfterCompletionDays = ref('1')
|
||||||
|
const selectedAfterCompletionUnit = ref<AfterCompletionUnit>('days')
|
||||||
const selectedRepeatError = ref('')
|
const selectedRepeatError = ref('')
|
||||||
const selectedTaskRecurrence = ref<Recurrence | null>(null)
|
const selectedTaskRecurrence = ref<Recurrence | null>(null)
|
||||||
const recurrenceLoading = ref(false)
|
const recurrenceLoading = ref(false)
|
||||||
@@ -205,6 +207,7 @@ function openTaskCompose() {
|
|||||||
composeDescription.value = ''
|
composeDescription.value = ''
|
||||||
composeRepeat.value = 'none'
|
composeRepeat.value = 'none'
|
||||||
composeAfterCompletionDays.value = '1'
|
composeAfterCompletionDays.value = '1'
|
||||||
|
composeAfterCompletionUnit.value = 'days'
|
||||||
composeRepeatError.value = ''
|
composeRepeatError.value = ''
|
||||||
composeRepeatConfig.value = defaultRepeatConfig()
|
composeRepeatConfig.value = defaultRepeatConfig()
|
||||||
composeCalendarOpen.value = false
|
composeCalendarOpen.value = false
|
||||||
@@ -243,13 +246,13 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
|
|||||||
else if (activeView.value === 'memos') void memoPanel.value?.createMemo()
|
else if (activeView.value === 'memos') void memoPanel.value?.createMemo()
|
||||||
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
|
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
|
||||||
}
|
}
|
||||||
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, recurrence: Recurrence | null) {
|
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, afterCompletionUnit: AfterCompletionUnit, recurrence: Recurrence | null) {
|
||||||
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
|
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
|
||||||
if (value === 'none') {
|
if (value === 'none') {
|
||||||
if (recurrence) await api(`/recurrences/${recurrence.id}`, { method: 'DELETE' })
|
if (recurrence) await api(`/recurrences/${recurrence.id}`, { method: 'DELETE' })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })
|
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config })
|
||||||
if (recurrence) {
|
if (recurrence) {
|
||||||
return await api(`/recurrences/${recurrence.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence
|
return await api(`/recurrences/${recurrence.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence
|
||||||
}
|
}
|
||||||
@@ -263,6 +266,7 @@ async function loadTaskRecurrence(task: Task) {
|
|||||||
selectedTaskRecurrence.value = null
|
selectedTaskRecurrence.value = null
|
||||||
selectedTaskRepeat.value = 'none'
|
selectedTaskRepeat.value = 'none'
|
||||||
selectedAfterCompletionDays.value = '1'
|
selectedAfterCompletionDays.value = '1'
|
||||||
|
selectedAfterCompletionUnit.value = 'days'
|
||||||
selectedRepeatError.value = ''
|
selectedRepeatError.value = ''
|
||||||
try {
|
try {
|
||||||
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
|
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
|
||||||
@@ -271,6 +275,7 @@ async function loadTaskRecurrence(task: Task) {
|
|||||||
const parsed = parseTaskRecurrence(recurrence)
|
const parsed = parseTaskRecurrence(recurrence)
|
||||||
selectedTaskRepeat.value = parsed.option
|
selectedTaskRepeat.value = parsed.option
|
||||||
selectedAfterCompletionDays.value = String(parsed.afterCompletionDays)
|
selectedAfterCompletionDays.value = String(parsed.afterCompletionDays)
|
||||||
|
selectedAfterCompletionUnit.value = parsed.afterCompletionUnit
|
||||||
selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
|
selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (selectionIsCurrent()) fail(reason)
|
if (selectionIsCurrent()) fail(reason)
|
||||||
@@ -293,7 +298,7 @@ async function submitTaskCompose() {
|
|||||||
const targetListId = composeListId.value
|
const targetListId = composeListId.value
|
||||||
creatingTask.value = true
|
creatingTask.value = true
|
||||||
try {
|
try {
|
||||||
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, repeatConfig: composeRepeatConfig.value })
|
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, afterCompletionUnit: composeAfterCompletionUnit.value, repeatConfig: composeRepeatConfig.value })
|
||||||
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
|
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
|
||||||
if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间')
|
if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间')
|
||||||
await taskMutationReconciler.run(
|
await taskMutationReconciler.run(
|
||||||
@@ -1093,6 +1098,7 @@ async function saveSelectedTaskChanges() {
|
|||||||
const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'
|
const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'
|
||||||
const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig
|
const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig
|
||||||
const afterCompletionDays = selectedAfterCompletionDays.value
|
const afterCompletionDays = selectedAfterCompletionDays.value
|
||||||
|
const afterCompletionUnit = selectedAfterCompletionUnit.value
|
||||||
const recurrence = selectedTaskRecurrence.value
|
const recurrence = selectedTaskRecurrence.value
|
||||||
selectedRepeatError.value = ''
|
selectedRepeatError.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -1102,7 +1108,7 @@ async function saveSelectedTaskChanges() {
|
|||||||
selectedTaskRecurrence.value = null
|
selectedTaskRecurrence.value = null
|
||||||
selectedTaskRepeat.value = 'none'
|
selectedTaskRepeat.value = 'none'
|
||||||
} else {
|
} else {
|
||||||
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)
|
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence)
|
||||||
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
|
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
|
||||||
selectedTaskRecurrence.value = updatedRecurrence
|
selectedTaskRecurrence.value = updatedRecurrence
|
||||||
}
|
}
|
||||||
@@ -1749,7 +1755,7 @@ onUnmounted(() => {
|
|||||||
<div class="task-detail-field"><span class="task-detail-field-label">时间</span><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add task-detail-time-control" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip task-detail-time-control"><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label><span v-else class="task-detail-time-empty" aria-hidden="true">—</span></div>
|
<div class="task-detail-field"><span class="task-detail-field-label">时间</span><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add task-detail-time-control" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip task-detail-time-control"><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label><span v-else class="task-detail-time-empty" aria-hidden="true">—</span></div>
|
||||||
</div>
|
</div>
|
||||||
<label class="task-detail-field"><span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><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="!selectedDueDate" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
<label class="task-detail-field"><span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><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="!selectedDueDate" 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==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="selectedAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months">月</option></select>重复</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>
|
<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>
|
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
|
||||||
</section>
|
</section>
|
||||||
@@ -1788,7 +1794,7 @@ onUnmounted(() => {
|
|||||||
<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>
|
<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>
|
</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="after_completion">完成后重复</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>
|
<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="完成后重复间隔"><select v-model="composeAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months">月</option></select>重复</div><small>每次完成后,将截止日期按所选间隔顺延;月末会自动取目标月最后一天</small></section>
|
||||||
<small v-if="composeRepeatError" role="alert" class="field-error">{{composeRepeatError}}</small>
|
<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>
|
<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>
|
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
||||||
|
|||||||
@@ -75,12 +75,14 @@ describe('task utilities', () => {
|
|||||||
expect(() => buildTaskRrule({ frequency: 'daily', interval: 0, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('重复间隔至少为 1')
|
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', () => {
|
it('builds and parses completion-trigger intervals in days or months', () => {
|
||||||
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7 })
|
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7, after_completion_unit: 'days' })
|
||||||
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14 })
|
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '2', afterCompletionUnit: 'months' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 2, after_completion_unit: 'months' })
|
||||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数天数')
|
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14, afterCompletionUnit: 'days' })
|
||||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数天数')
|
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 3, after_completion_unit: 'months' })).toEqual({ option: 'after_completion', afterCompletionDays: 3, afterCompletionUnit: 'months' })
|
||||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数天数')
|
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', () => {
|
it('builds scheduled recurrence payloads separately from completion triggers', () => {
|
||||||
|
|||||||
@@ -172,15 +172,16 @@ export function parseTaskRrule(rrule = ''): TaskRepeatConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TaskRepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'after_completion' | 'custom'
|
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 type AfterCompletionUnit = 'days' | 'months'
|
||||||
|
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null; after_completion_unit?: AfterCompletionUnit | null }
|
||||||
|
|
||||||
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; repeatConfig?: TaskRepeatConfig }) {
|
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; afterCompletionUnit?: AfterCompletionUnit; repeatConfig?: TaskRepeatConfig }) {
|
||||||
if (option === 'none') return {}
|
if (option === 'none') return {}
|
||||||
if (option === 'after_completion') {
|
if (option === 'after_completion') {
|
||||||
const raw = String(values.afterCompletionDays).trim()
|
const raw = String(values.afterCompletionDays).trim()
|
||||||
const days = Number(raw)
|
const days = Number(raw)
|
||||||
if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数天数')
|
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 }
|
return { trigger_mode: 'after_completion' as const, after_completion_days: days, after_completion_unit: values.afterCompletionUnit ?? 'days' }
|
||||||
}
|
}
|
||||||
const rrule = option === 'custom'
|
const rrule = option === 'custom'
|
||||||
? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' })
|
? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' })
|
||||||
@@ -189,13 +190,13 @@ export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { a
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) {
|
export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) {
|
||||||
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1 }
|
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
|
||||||
if (recurrence.trigger_mode === 'after_completion') {
|
if (recurrence.trigger_mode === 'after_completion') {
|
||||||
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1 }
|
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1, afterCompletionUnit: recurrence.after_completion_unit ?? 'days' as AfterCompletionUnit }
|
||||||
}
|
}
|
||||||
const parsed = parseTaskRrule(recurrence.rrule ?? '')
|
const parsed = parseTaskRrule(recurrence.rrule ?? '')
|
||||||
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
|
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
|
||||||
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1 }
|
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultTaskDueAt(now = new Date()) {
|
export function defaultTaskDueAt(now = new Date()) {
|
||||||
|
|||||||
@@ -1370,10 +1370,13 @@ describe('unified floating add interaction', () => {
|
|||||||
expect(app).toContain('<span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat"')
|
expect(app).toContain('<span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat"')
|
||||||
expect(app).toContain('<option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option>')
|
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="composeAfterCompletionDays"')
|
||||||
|
expect(app).toContain('v-model="composeAfterCompletionUnit"')
|
||||||
expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"')
|
expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"')
|
||||||
expect(app).toContain('每次完成后,将截止时间顺延对应天数;首版永不结束')
|
expect(app).toContain('v-model="selectedAfterCompletionUnit"')
|
||||||
|
expect(app).toContain('<option value="days">天</option><option value="months">月</option>')
|
||||||
|
expect(app).toContain('月末会自动取目标月最后一天')
|
||||||
expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value')
|
expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value')
|
||||||
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })')
|
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config })')
|
||||||
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
|
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
|
||||||
const createBlock = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()'))
|
const createBlock = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()'))
|
||||||
expect(createBlock).toContain("api('/tasks',")
|
expect(createBlock).toContain("api('/tasks',")
|
||||||
@@ -1388,7 +1391,7 @@ describe('unified floating add interaction', () => {
|
|||||||
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
|
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
|
||||||
expect(saveBlock).toContain('selectedTaskRecurrence.value = null')
|
expect(saveBlock).toContain('selectedTaskRecurrence.value = null')
|
||||||
expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'")
|
expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'")
|
||||||
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)')
|
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence)')
|
||||||
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
|
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
|
||||||
expect(dueRemovalBlock).not.toContain('saveRepeat(')
|
expect(dueRemovalBlock).not.toContain('saveRepeat(')
|
||||||
expect(saveBlock).toContain("selectedRepeatError.value = ''")
|
expect(saveBlock).toContain("selectedRepeatError.value = ''")
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add after_completion unit
|
||||||
|
|
||||||
|
Revision ID: 0021_after_completion_unit
|
||||||
|
Revises: 0020_calendar_subscriptions
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0021_after_completion_unit"
|
||||||
|
down_revision = "0020_calendar_subscriptions"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("recurrence_templates") as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("after_completion_unit", sa.String(length=8), nullable=True)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE recurrence_templates SET after_completion_unit = 'days' "
|
||||||
|
"WHERE trigger_mode = 'after_completion'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("recurrence_templates") as batch_op:
|
||||||
|
batch_op.drop_column("after_completion_unit")
|
||||||
@@ -48,7 +48,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
|
|||||||
due_has_time=True,
|
due_has_time=True,
|
||||||
)
|
)
|
||||||
gap_due = recurrence_service._after_completion_due(
|
gap_due = recurrence_service._after_completion_due(
|
||||||
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, user
|
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, "days", user
|
||||||
)
|
)
|
||||||
assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap
|
assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
|
|||||||
due_has_time=True,
|
due_has_time=True,
|
||||||
)
|
)
|
||||||
fold_due = recurrence_service._after_completion_due(
|
fold_due = recurrence_service._after_completion_due(
|
||||||
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, user
|
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, "days", user
|
||||||
)
|
)
|
||||||
assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0
|
assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0
|
||||||
|
|
||||||
@@ -89,6 +89,30 @@ def test_user_timezone_is_the_calendar_contract_for_after_completion(client, mon
|
|||||||
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC)
|
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_interval_uses_calendar_month_clamping(client, monkeypatch):
|
||||||
|
inbox = boot(client)
|
||||||
|
task = create_after_completion_task(
|
||||||
|
client,
|
||||||
|
inbox,
|
||||||
|
due_at="2026-01-31T01:30:00Z",
|
||||||
|
after_completion_days=1,
|
||||||
|
after_completion_unit="months",
|
||||||
|
).json()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.recurrence_service.utcnow",
|
||||||
|
lambda: datetime(2026, 1, 30, 16, 30, tzinfo=UTC), # 2026-01-31 00:30 Asia/Shanghai
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = client.patch(
|
||||||
|
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert completed.status_code == 200
|
||||||
|
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 2, 28, 1, 30, tzinfo=UTC)
|
||||||
|
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
|
||||||
|
assert recurrence["after_completion_unit"] == "months"
|
||||||
|
|
||||||
|
|
||||||
def test_atomic_task_create_and_read_after_completion_recurrence(client):
|
def test_atomic_task_create_and_read_after_completion_recurrence(client):
|
||||||
inbox = boot(client)
|
inbox = boot(client)
|
||||||
|
|
||||||
@@ -105,6 +129,7 @@ def test_atomic_task_create_and_read_after_completion_recurrence(client):
|
|||||||
"ends_at": None,
|
"ends_at": None,
|
||||||
"trigger_mode": "after_completion",
|
"trigger_mode": "after_completion",
|
||||||
"after_completion_days": 2,
|
"after_completion_days": 2,
|
||||||
|
"after_completion_unit": "days",
|
||||||
"last_completed_at": None,
|
"last_completed_at": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +147,8 @@ def test_after_completion_configuration_validation(client):
|
|||||||
{"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": 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"], "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},
|
{"title": "子任务", "list_id": inbox["id"], "parent_id": child["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 1},
|
||||||
|
{"title": "只有单位", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "after_completion_unit": "months"},
|
||||||
|
{"title": "定期带月份", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "scheduled", "rrule": "FREQ=DAILY", "after_completion_unit": "months"},
|
||||||
]
|
]
|
||||||
for payload in invalid_payloads:
|
for payload in invalid_payloads:
|
||||||
assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422}
|
assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422}
|
||||||
|
|||||||
@@ -200,6 +200,36 @@ def test_merge_same_backup_is_idempotent_via_import_ledger(client):
|
|||||||
assert asyncio.run(counts()) == (1, 1)
|
assert asyncio.run(counts()) == (1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_backup_without_completion_unit_is_idempotent(client):
|
||||||
|
inbox = boot(client)
|
||||||
|
client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={
|
||||||
|
"title": "旧版完成后重复",
|
||||||
|
"list_id": inbox["id"],
|
||||||
|
"due_at": "2026-03-08T01:30:00Z",
|
||||||
|
"trigger_mode": "after_completion",
|
||||||
|
"after_completion_days": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
recurrences = _archive_rows(content, "recurrences")
|
||||||
|
for recurrence in recurrences:
|
||||||
|
recurrence.pop("after_completion_unit", None)
|
||||||
|
legacy_content = _replace_entities(content, {"recurrences": recurrences})
|
||||||
|
|
||||||
|
first_token = _preflight(client, legacy_content).json()["preflight_token"]
|
||||||
|
assert client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}
|
||||||
|
).status_code == 200
|
||||||
|
second_token = _preflight(client, legacy_content).json()["preflight_token"]
|
||||||
|
second = client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": second_token, "mode": "merge"}
|
||||||
|
)
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["already_imported"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_zip_variants_are_rejected_before_any_write(client):
|
def test_invalid_zip_variants_are_rejected_before_any_write(client):
|
||||||
inbox = boot(client)
|
inbox = boot(client)
|
||||||
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
|
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
|
||||||
|
|||||||
Reference in New Issue
Block a user