feat: repeat tasks after completion
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m31s

This commit is contained in:
2026-09-10 21:21:28 +08:00
parent 84592db098
commit 64b8525720
14 changed files with 849 additions and 114 deletions
+65 -63
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+125
View File
@@ -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
+41
View File
@@ -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
View File
@@ -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>
+14 -1
View File
@@ -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>')
+27
View File
@@ -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')
+2 -2
View File
@@ -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}}
+41 -3
View File
@@ -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)
+272
View File
@@ -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
View File
@@ -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