refactor: simplify dodo core experience
ci / docker (push) Successful in 3m54s

This commit is contained in:
2026-09-06 14:41:08 +08:00
parent fcaabce99f
commit d595af4f52
29 changed files with 187 additions and 955 deletions
-1
View File
@@ -11,7 +11,6 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock alembic.ini ./
RUN uv sync --frozen --no-dev
COPY backend backend
COPY worker worker
COPY migrations migrations
COPY scripts scripts
COPY --from=frontend /build/dist backend/static
+4 -92
View File
@@ -26,7 +26,7 @@ from .auth import (
verify_password,
)
from .db import create_schema, get_db
from .models import AppState, Folder, Session, Tag, Task, TaskList, TaskTag, User, utcnow
from .models import AppState, Folder, Session, Task, TaskList, User, utcnow
from .mvp import audit
from .mvp import router as mvp_router
from .schemas import (
@@ -40,8 +40,6 @@ from .schemas import (
LoginRequest,
NameUpdate,
SessionOut,
TagCreate,
TagOut,
TaskCreate,
TaskDetailOut,
TaskOut,
@@ -186,15 +184,11 @@ async def bootstrap_data(user: User = Depends(current_user), db: AsyncSession =
.where(TaskList.user_id == user.id, TaskList.deleted_at.is_(None))
.order_by(TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at)
)).all())
tags = list((await db.scalars(
select(Tag).where(Tag.user_id == user.id).order_by(Tag.name, Tag.id)
)).all())
inbox = next((item for item in lists if item.is_inbox), None)
return {
"user": UserOut.model_validate(user),
"folders": [FolderOut.model_validate(item) for item in folders],
"lists": [ListOut.model_validate(item) for item in lists],
"tags": [TagOut.model_validate(item) for item in tags],
"inbox_id": inbox.id if inbox else None,
}
@@ -420,51 +414,6 @@ async def restore_list(
return item
@app.post("/api/v1/tags", response_model=TagOut, status_code=201)
async def create_tag(
payload: TagCreate,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
existing = await db.scalar(
select(Tag.id).where(Tag.user_id == user.id, func.lower(Tag.name) == payload.name.lower())
)
if existing:
raise HTTPException(status_code=409, detail="标签名称已存在")
tag = Tag(user_id=user.id, **payload.model_dump())
db.add(tag)
await db.commit()
await db.refresh(tag)
return tag
@app.get("/api/v1/tags", response_model=list[TagOut])
async def list_tags(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
return list(
(await db.scalars(select(Tag).where(Tag.user_id == user.id).order_by(Tag.name, Tag.id))).all()
)
async def _validate_tags(
db: AsyncSession, user_id: UUID, tag_ids: list[UUID] | None
) -> list[UUID] | None:
if tag_ids is None:
return None
unique_ids = list(dict.fromkeys(tag_ids))
if not unique_ids:
return []
found = set(
(await db.scalars(select(Tag.id).where(Tag.user_id == user_id, Tag.id.in_(unique_ids)))).all()
)
if found != set(unique_ids):
raise HTTPException(status_code=404, detail="标签不存在")
return unique_ids
async def _replace_tags(db: AsyncSession, task_ids: list[UUID], tag_ids: list[UUID]) -> None:
await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(task_ids)))
db.add_all(TaskTag(task_id=task_id, tag_id=tag_id) for task_id in task_ids for tag_id in tag_ids)
def _encode_cursor(created_at: datetime, task_id: UUID) -> str:
raw = json.dumps([created_at.isoformat(), str(task_id)]).encode()
@@ -487,7 +436,6 @@ async def create_task(
db: AsyncSession = Depends(get_db),
):
await _owned_list(db, user.id, payload.list_id)
tag_ids = await _validate_tags(db, user.id, payload.tag_ids)
if payload.parent_id:
parent = await db.scalar(
select(Task).where(
@@ -500,13 +448,11 @@ async def create_task(
)
if parent is None:
raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务")
data = payload.model_dump(exclude={"tag_ids"})
data = payload.model_dump()
task = Task(user_id=user.id, **data)
db.add(task)
await db.flush()
audit(db, user.id, "create", "task", task.id)
if tag_ids:
db.add_all(TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tag_ids)
await db.commit()
await db.refresh(task)
return task
@@ -540,11 +486,6 @@ async def list_tasks(
query = query.where(Task.due_at < due_to)
if q:
pattern = f"%{q}%"
tag_match = exists(
select(TaskTag.task_id)
.join(Tag, Tag.id == TaskTag.tag_id)
.where(TaskTag.task_id == Task.id, Tag.user_id == user.id, Tag.name.ilike(pattern))
)
list_match = exists(
select(TaskList.id).where(
TaskList.id == Task.list_id,
@@ -553,7 +494,7 @@ async def list_tasks(
)
)
query = query.where(
or_(Task.title.ilike(pattern), Task.description.ilike(pattern), tag_match, list_match)
or_(Task.title.ilike(pattern), Task.description.ilike(pattern), list_match)
)
total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
ordering = (Task.created_at, Task.id)
@@ -577,14 +518,6 @@ async def _task_details(db: AsyncSession, tasks: list[Task]) -> list[TaskDetailO
if not tasks:
return []
task_ids = [task.id for task in tasks]
tag_rows = (
await db.execute(
select(TaskTag.task_id, Tag)
.join(Tag, Tag.id == TaskTag.tag_id)
.where(TaskTag.task_id.in_(task_ids))
.order_by(Tag.name, Tag.id)
)
).all()
subtasks = list(
(
await db.scalars(
@@ -594,22 +527,17 @@ async def _task_details(db: AsyncSession, tasks: list[Task]) -> list[TaskDetailO
)
).all()
)
tags_by_task: dict[UUID, list[Tag]] = defaultdict(list)
subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list)
for task_id, tag in tag_rows:
tags_by_task[task_id].append(tag)
for subtask in subtasks:
subtasks_by_task[subtask.parent_id].append(subtask)
return [
TaskDetailOut(
**TaskOut.model_validate(task).model_dump(),
tags=tags_by_task[task.id],
subtasks=subtasks_by_task[task.id],
)
for task in tasks
]
async def _task_detail(db: AsyncSession, task: Task) -> TaskDetailOut:
return (await _task_details(db, [task]))[0]
@@ -637,7 +565,6 @@ async def update_task(
):
data = payload.model_dump(exclude_unset=True)
expected_version = data.pop("version")
tag_ids = await _validate_tags(db, user.id, data.pop("tag_ids", None))
if "list_id" in data:
await _owned_list(db, user.id, data["list_id"])
parent_id = await db.scalar(
@@ -670,8 +597,6 @@ async def update_task(
if exists_id:
raise HTTPException(status_code=409, detail="任务已被更新,请刷新后重试")
raise HTTPException(status_code=404, detail="任务不存在")
if tag_ids is not None:
await _replace_tags(db, [task_id], tag_ids)
if "list_id" in data and task.parent_id is None:
await db.execute(
update(Task)
@@ -773,16 +698,6 @@ async def permanently_delete_task(
)
if task is None:
raise HTTPException(status_code=404, detail="回收站中不存在该任务")
ids = list(
(
await db.scalars(
select(Task.id).where(
or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id
)
)
).all()
)
await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(ids)))
await db.execute(delete(Task).where(Task.parent_id == task.id, Task.user_id == user.id))
await db.delete(task)
await db.commit()
@@ -812,8 +727,7 @@ 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="子任务不能脱离父任务单独移动")
tag_ids = await _validate_tags(db, user.id, payload.tag_ids)
changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "tag_ids", "soft_delete"})
changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "soft_delete"})
if payload.soft_delete:
changes["deleted_at"] = utcnow()
if changes:
@@ -856,8 +770,6 @@ async def batch_update_tasks(
updated_at=utcnow(),
)
)
if tag_ids is not None:
await _replace_tags(db, task_ids, tag_ids)
await db.commit()
return BatchResult(updated=len(task_ids))
-26
View File
@@ -77,21 +77,6 @@ class TaskList(Base):
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Tag(Base):
__tablename__ = "tags"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_tags_user_id_name"),)
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(80))
color: Mapped[str] = mapped_column(String(32), default="#f15a29")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class TaskTag(Base):
__tablename__ = "task_tags"
task_id: Mapped[UUID] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True)
tag_id: Mapped[UUID] = mapped_column(ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True)
class Task(Base):
__tablename__ = "tasks"
@@ -188,17 +173,6 @@ class Attachment(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class CalendarSubscription(Base):
__tablename__ = "calendar_subscriptions"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(120))
url: Mapped[str] = mapped_column(Text)
color: Mapped[str] = mapped_column(String(32), default="#f15a29")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
+1 -300
View File
@@ -1,21 +1,12 @@
import asyncio
import csv
import http.client
import io
import ipaddress
import re
import socket
import ssl
import urllib.parse
from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path
from uuid import UUID
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
from fastapi.responses import FileResponse
from icalendar import Calendar
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import case, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,17 +17,14 @@ from .db import get_db
from .models import (
Attachment,
AuditLog,
CalendarSubscription,
Folder,
Habit,
HabitLog,
HabitPause,
RecurrenceException,
RecurrenceTemplate,
Tag,
Task,
TaskList,
TaskTag,
User,
new_id,
utcnow,
@@ -262,265 +250,6 @@ async def delete_recurrence(recurrence_id: UUID, scope: str = Query("all", patte
await db.commit(); return Response(status_code=204)
class CalendarSubscriptionCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
url: str = Field(min_length=1, max_length=2000)
color: str = Field(default="#f15a29", min_length=1, max_length=32)
def calendar_subscription_dict(row: CalendarSubscription):
return {"id": row.id, "name": row.name, "url": row.url, "color": row.color, "enabled": row.enabled}
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
def _is_forbidden_ip(value: str) -> bool:
ip = ipaddress.ip_address(value)
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved
def _localized_ics_datetime(value, default_tz=LOCAL_TZ):
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=default_tz), False
if isinstance(value, date):
return datetime.combine(value, time.min, tzinfo=default_tz), True
raise ValueError("unsupported ICS datetime")
def _as_utc(value, default_tz=LOCAL_TZ):
localized, all_day = _localized_ics_datetime(value, default_tz)
return localized.astimezone(UTC), all_day
def _event_duration(event, starts_at: datetime, all_day: bool):
if event.get("dtend"):
ends_at, _ = _as_utc(event.decoded("dtend"))
return max(ends_at - starts_at, timedelta())
if event.get("duration"):
return event.decoded("duration")
return timedelta(days=1) if all_day else timedelta(hours=1)
def _excluded_starts(event):
excluded = set()
exdates = event.get("exdate")
if not exdates:
return excluded
if not isinstance(exdates, list):
exdates = [exdates]
for exdate in exdates:
for item in getattr(exdate, "dts", []):
excluded.add(_as_utc(item.dt)[0])
return excluded
def _append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day):
uid = str(event.get("uid") or "")
title = str(event.get("summary") or "未命名事件").strip() or "未命名事件"
events.append({
"id": uid or f"{source_name}-{starts_at.isoformat()}-{title}",
"title": title,
"starts_at": starts_at,
"ends_at": ends_at,
"all_day": all_day,
"source_name": source_name,
"color": color,
})
def _overlaps(starts_at: datetime, ends_at: datetime | None, window_start: datetime | None, window_end: datetime | None) -> bool:
if not window_start or not window_end:
return True
return starts_at < window_end and (ends_at or starts_at) > window_start
def parse_ics_events(content: str, source_name: str, color: str, window_start: datetime | None = None, window_end: datetime | None = None):
calendar = Calendar.from_ical(content)
components = list(calendar.walk("VEVENT"))
overrides = {}
for event in components:
recurrence_id = event.get("recurrence-id")
if recurrence_id:
overrides[(str(event.get("uid") or ""), _as_utc(event.decoded("recurrence-id"))[0])] = event
events = []
for event in components:
if not event.get("dtstart") or event.get("recurrence-id") or str(event.get("status") or "").upper() == "CANCELLED":
continue
localized_start, all_day = _localized_ics_datetime(event.decoded("dtstart"))
starts_at = localized_start.astimezone(UTC)
duration = _event_duration(event, starts_at, all_day)
excluded = _excluded_starts(event)
uid = str(event.get("uid") or "")
if event.get("rrule") and window_start and window_end:
rule_text = event.get("rrule").to_ical().decode()
rule = rrulestr(rule_text, dtstart=localized_start)
for occurrence in rule.between(window_start - duration, window_end, inc=True):
occurrence = occurrence.astimezone(UTC) if occurrence.tzinfo else occurrence.replace(tzinfo=LOCAL_TZ).astimezone(UTC)
if occurrence in excluded or (uid, occurrence) in overrides:
continue
ends_at = occurrence + duration
if _overlaps(occurrence, ends_at, window_start, window_end):
_append_calendar_event(events, source_name, color, event, occurrence, ends_at, all_day)
continue
ends_at = starts_at + duration
if _overlaps(starts_at, ends_at, window_start, window_end):
_append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day)
for event in overrides.values():
if not event.get("dtstart") or str(event.get("status") or "").upper() == "CANCELLED":
continue
starts_at, all_day = _as_utc(event.decoded("dtstart"))
duration = _event_duration(event, starts_at, all_day)
ends_at = starts_at + duration
if _overlaps(starts_at, ends_at, window_start, window_end):
_append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day)
return events
def _validated_calendar_target(url: str):
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise HTTPException(422, "日历订阅只支持 http/https 链接")
if parsed.username or parsed.password:
raise HTTPException(422, "日历订阅链接不能包含账号密码")
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
addresses = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise HTTPException(422, "日历订阅域名无法解析") from exc
for item in addresses:
ip = item[4][0]
if not _is_forbidden_ip(ip):
return parsed, ip, port
raise HTTPException(422, "日历订阅不能指向内网地址")
def _validate_public_calendar_url(url: str):
_validated_calendar_target(url)
def _request_pinned_calendar_url(url: str):
parsed, ip, port = _validated_calendar_target(url)
target_host = f"[{ip}]" if ":" in ip else ip
path = urllib.parse.urlunparse(("", "", parsed.path or "/", parsed.params, parsed.query, ""))
headers = {"Host": parsed.hostname or "", "User-Agent": "dodo-calendar-fetch/1.0"}
if parsed.port:
headers["Host"] = f"{headers['Host']}:{parsed.port}"
if parsed.scheme == "https":
connection = http.client.HTTPSConnection(target_host, port=port, timeout=15, context=ssl.create_default_context())
else:
connection = http.client.HTTPConnection(target_host, port=port, timeout=15)
try:
if parsed.scheme == "https":
raw = socket.create_connection((ip, port), timeout=15)
sock = ssl.create_default_context().wrap_socket(raw, server_hostname=parsed.hostname)
connection.sock = sock
connection.request("GET", path, headers=headers)
response = connection.getresponse()
body = response.read(2_000_001)
return response.status, response.getheaders(), response.getheader("Content-Type") or "", body
except OSError as exc:
raise HTTPException(502, f"订阅拉取失败: {exc}") from exc
finally:
connection.close()
_CAL_CACHE: dict[str, tuple[float, list[dict]]] = {}
_CAL_CACHE_TTL = 300.0
def fetch_calendar_events(url: str, source_name: str, color: str, window_start: datetime | None = None, window_end: datetime | None = None):
cache_key = f"{source_name}|{url}|{window_start.isoformat() if window_start else ''}|{window_end.isoformat() if window_end else ''}"
now = datetime.now(UTC).timestamp()
hit = _CAL_CACHE.get(cache_key)
if hit and now - hit[0] < _CAL_CACHE_TTL:
return [dict(item) for item in hit[1]]
current_url = url
for _ in range(4):
status, headers, content_type, body = _request_pinned_calendar_url(current_url)
if status in {301, 302, 303, 307, 308}:
location = dict(headers).get("Location")
if not location:
raise HTTPException(502, "订阅重定向缺少目标地址")
current_url = urllib.parse.urljoin(current_url, location)
continue
if status >= 400:
raise HTTPException(502, f"订阅拉取失败: HTTP {status}")
if content_type.split(";", 1)[0].lower() not in {"text/calendar", "text/plain", "application/octet-stream"}:
raise HTTPException(422, "订阅链接没有返回 ICS 日历内容")
if len(body) > 2_000_000:
raise HTTPException(413, "日历订阅内容超过 2MB")
parsed = parse_ics_events(body.decode("utf-8", errors="ignore"), source_name, color, window_start, window_end)
if len(_CAL_CACHE) >= 64:
_CAL_CACHE.clear()
_CAL_CACHE[cache_key] = (now, parsed)
return [dict(item) for item in parsed]
raise HTTPException(502, "日历订阅重定向次数过多")
@router.get("/calendar-subscriptions")
async def list_calendar_subscriptions(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
rows = (await db.scalars(select(CalendarSubscription).where(CalendarSubscription.user_id == user.id).order_by(CalendarSubscription.created_at))).all()
return [calendar_subscription_dict(row) for row in rows]
@router.post("/calendar-subscriptions", status_code=201)
async def create_calendar_subscription(payload: CalendarSubscriptionCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
_validate_public_calendar_url(payload.url)
row = CalendarSubscription(user_id=user.id, **payload.model_dump())
db.add(row)
await db.commit()
await db.refresh(row)
return calendar_subscription_dict(row)
@router.get("/calendar-subscriptions/today")
async def today_calendar_events(
day: date,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
rows = list((await db.scalars(select(CalendarSubscription).where(CalendarSubscription.user_id == user.id, CalendarSubscription.enabled.is_(True)).order_by(CalendarSubscription.created_at))).all())
if not rows:
return []
tomorrow = day + timedelta(days=1)
day_start = datetime.combine(day, time.min, tzinfo=LOCAL_TZ).astimezone(UTC)
day_end = datetime.combine(tomorrow, time.min, tzinfo=LOCAL_TZ).astimezone(UTC)
async def pull(row: CalendarSubscription):
return await asyncio.to_thread(fetch_calendar_events, row.url, row.name, row.color, day_start, day_end)
results = await asyncio.gather(*(pull(row) for row in rows), return_exceptions=True)
events = []
for row, result in zip(rows, results, strict=True):
if isinstance(result, Exception):
continue
for item in result:
events.append({
"id": item["id"],
"title": item["title"],
"starts_at": item["starts_at"].isoformat(),
"ends_at": item["ends_at"].isoformat() if item.get("ends_at") else None,
"all_day": item["all_day"],
"source_name": row.name,
"color": row.color,
})
events.sort(key=lambda item: (item["all_day"] is False, item["starts_at"], item["title"]))
return events
@router.delete("/calendar-subscriptions/{subscription_id}", status_code=204)
async def delete_calendar_subscription(subscription_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await db.scalar(select(CalendarSubscription).where(CalendarSubscription.id == subscription_id, CalendarSubscription.user_id == user.id))
if not row:
raise HTTPException(404, "日历订阅不存在")
await db.delete(row)
await db.commit()
return Response(status_code=204)
class HabitCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
@@ -763,25 +492,13 @@ async def import_ticktick(file: UploadFile = File(...), user: User = Depends(cur
async def export_json(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
def serialize(row, fields):
return {f: (str(v) if isinstance((v := getattr(row, f)), UUID) else v.isoformat() if isinstance(v, (date, datetime)) else v) for f in fields}
folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tags = list((await db.scalars(select(Tag).where(Tag.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all())
task_tags = [
{"task_id": str(x.task_id), "tag_id": str(x.tag_id)}
for x in (
await db.scalars(
select(TaskTag)
.join(Task, Task.id == TaskTag.task_id)
.where(Task.user_id == user.id)
)
).all()
]
folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all())
return {
"version": 1,
"exported_at": utcnow(),
"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],
"tags": [serialize(x, ["id", "name", "color"]) for x in tags],
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "external_id", "deleted_at"]) for x in tasks],
"task_tags": task_tags,
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at"]) for x in habits],
}
@@ -791,15 +508,12 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
if payload.get("version") != 1:
raise HTTPException(422, "不支持的备份版本")
if mode == "replace":
await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(select(Task.id).where(Task.user_id == user.id))))
await db.execute(delete(Task).where(Task.user_id == user.id))
await db.execute(delete(Tag).where(Tag.user_id == user.id))
await db.execute(delete(Habit).where(Habit.user_id == user.id))
await db.execute(delete(TaskList).where(TaskList.user_id == user.id))
await db.execute(delete(Folder).where(Folder.user_id == user.id))
id_map = {}
task_id_map = {}
tag_id_map = {}
for raw in payload.get("folders", []):
old = raw["id"]
row = Folder(user_id=user.id, name=raw["name"], position=raw.get("position", 0))
@@ -824,11 +538,6 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
inbox = TaskList(user_id=user.id, name="收集箱", is_inbox=True)
db.add(inbox)
await db.flush()
for raw in payload.get("tags", []):
row = Tag(user_id=user.id, name=raw["name"], color=raw.get("color", "#f15a29"))
db.add(row)
await db.flush()
tag_id_map[raw["id"]] = row.id
restored = 0
pending_tasks = []
for raw in payload.get("tasks", []):
@@ -855,14 +564,6 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
for task_id, old_parent_id in pending_tasks:
if old_parent_id and old_parent_id in task_id_map:
await db.execute(update(Task).where(Task.id == task_id, Task.user_id == user.id).values(parent_id=task_id_map[old_parent_id]))
task_tag_rows = []
for raw in payload.get("task_tags", []):
task_id = task_id_map.get(raw.get("task_id"))
tag_id = tag_id_map.get(raw.get("tag_id"))
if task_id and tag_id:
task_tag_rows.append(TaskTag(task_id=task_id, tag_id=tag_id))
if task_tag_rows:
db.add_all(task_tag_rows)
for raw in payload.get("habits", []):
row = Habit(
user_id=user.id,
+1 -16
View File
@@ -58,17 +58,6 @@ class ListOut(BaseModel):
is_inbox: bool
class TagCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
color: str = Field(default="#f15a29", min_length=1, max_length=32)
class TagOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
color: str
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=500)
@@ -77,7 +66,6 @@ class TaskCreate(BaseModel):
priority: int = Field(default=0, ge=0, le=3)
due_at: datetime | None = None
parent_id: UUID | None = None
tag_ids: list[UUID] = Field(default_factory=list)
class TaskUpdate(BaseModel):
@@ -87,7 +75,6 @@ class TaskUpdate(BaseModel):
due_at: datetime | None = None
completed: bool | None = None
list_id: UUID | None = None
tag_ids: list[UUID] | None = None
version: int = Field(ge=1)
@model_validator(mode="after")
@@ -112,7 +99,6 @@ class TaskOut(BaseModel):
class TaskDetailOut(TaskOut):
tags: list[TagOut] = Field(default_factory=list)
subtasks: list[TaskOut] = Field(default_factory=list)
@@ -129,12 +115,11 @@ class BatchTaskUpdate(BaseModel):
completed: bool | None = None
list_id: UUID | None = None
due_at: datetime | None = None
tag_ids: list[UUID] | None = None
soft_delete: bool | None = None
@model_validator(mode="after")
def require_operation(self):
operations = {"completed", "list_id", "due_at", "tag_ids", "soft_delete"}
operations = {"completed", "list_id", "due_at", "soft_delete"}
if not self.model_fields_set.intersection(operations):
raise ValueError("at least one batch operation is required")
if self.soft_delete is False:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-COed2nq9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dhk-VG-k.css">
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-gRSfwY0T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DlZ9vwzJ.css">
</head><body><div id="app"></div></body></html>
+5 -12
View File
@@ -24,24 +24,17 @@ Base path: `/api/v1`. 除初始化、登录和健康检查外均需 `dodo_sessio
- `PATCH /lists/{list_id}` — 重命名;系统收集箱返回 409
- `DELETE /lists/{list_id}` — 软删除并将任务移入系统收集箱;系统收集箱返回 409
## Tags
标签属于当前用户,可跨清单复用。
- `GET /tags`
- `POST /tags``{name, color}`;同一用户名称唯一
## Tasks
- `GET /tasks?q=&limit=&cursor=` — 顶层未删除任务的游标分页;`q` 匹配标题、描述、标签名和清单名
- `POST /tasks` — 创建任务;`parent_id` 只允许指向同清单顶层任务`tag_ids` 必须属于当前用户
- `GET /tasks/{task_id}` — 返回任务、标签和一层子任务
- `GET /tasks?q=&limit=&cursor=` — 顶层未删除任务的游标分页;`q` 匹配标题、描述和清单名
- `POST /tasks` — 创建任务;`parent_id` 只允许指向同清单顶层任务
- `GET /tasks/{task_id}` — 返回任务和一层子任务
- `PATCH /tasks/{task_id}` — 必须携带当前 `version`,原子比较更新;版本冲突返回 409
- `DELETE /tasks/{task_id}` — 软删除任务及其直接子任务
- `POST /tasks/{task_id}/restore` — 恢复任务及其直接子任务
- `POST /tasks/batch` — 原子批量完成、移动、设置截止时间、替换标签或软删除
- `POST /tasks/batch` — 原子批量完成、移动、设置截止时间或软删除
批量请求字段:`task_ids``completed``list_id``due_at``tag_ids``soft_delete`。所有任务目标清单和标签在写入前完成归属校验;任一不存在则整批不修改。
批量请求字段:`task_ids``completed``list_id``due_at``soft_delete`。所有任务目标清单在写入前完成归属校验;任一不存在则整批不修改。
## Recycle bin
+3 -17
View File
@@ -47,22 +47,6 @@
删除普通清单时,其未删除任务原子移动到系统收集箱。系统收集箱不可重命名或删除。
## tags
- `id`
- `user_id` → users
- `name`,同一用户内唯一
- `color`
- `created_at`
标签是用户级全局实体,不隶属于清单。
## task_tags
- `task_id` → tasks
- `tag_id` → tags
- `(task_id, tag_id)` 联合主键
## tasks
- `id`
@@ -85,7 +69,9 @@
## 迁移
- `0001_initial.py`:已部署的初始模式,不修改
- `0002_task_management.py`:新增文件夹/清单软删除列、标签表、任务标签关联表及游标/回收站索引
- `0002_task_management.py`:新增文件夹/清单软删除列、历史标签表及游标/回收站索引
- `0008_remove_calendar_subscriptions.py`:移除日历订阅表
- `0009_remove_tags.py`:移除历史标签表及任务标签关联表
## 后续阶段预留
+1 -4
View File
@@ -14,11 +14,10 @@ dodo 是一个纯自托管的 TickTick-like 任务与习惯管理工具。目标
## 技术栈
- Monorepo`frontend/``backend/``worker/`
- Monorepo`frontend/``backend/`
- 前端:Vue 3 + TypeScript + Vite + Tailwind CSS + Shadcn-vue/Reka UI
- 后端:FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic
- 数据库:PostgreSQL,主键 UUIDv7,时间统一 UTC,用户配置时区
- 后台:独立 Worker + APScheduler
- 包管理:uv + pnpm
- 交付:单 Docker 镜像,外部 PostgreSQL
- 许可证:AGPL-3.0
@@ -45,7 +44,6 @@ postgresql+asyncpg://postgres:***@10.10.100.99:5433/dodo
- Markdown 描述
- 截止日期 + 具体时间
- 多提醒设计预留
- 全局彩色标签
- 删除为软删除,回收站手动清空
- 支持创建、修改、完成、恢复、删除操作历史
- 并发编辑使用原子乐观锁
@@ -86,7 +84,6 @@ postgresql+asyncpg://postgres:***@10.10.100.99:5433/dodo
- 手机底部导航
- 顶部快速输入,手机悬浮新增按钮
- 桌面右侧详情栏,手机底部弹层
- 日历默认月视图
- 习惯首页:今日习惯列表 + 一周打卡格
- 搜索:顶部搜索框 + 全局搜索快捷键
+2 -5
View File
@@ -18,9 +18,6 @@ DODO_SESSION_DAYS=30
- 应用监听 `0.0.0.0:8781`
- 健康检查:`/health/ready`
## 后续补充
## 部署文件
- Dockerfile
- docker-compose.yml 示例
- Alembic 正式迁移目录
- Worker 启动命令
仓库已包含 Dockerfile、compose 示例和 Alembic 迁移。
+31 -95
View File
@@ -3,20 +3,17 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import {
ArchiveRestore, CalendarDays, Check, ChevronDown, ChevronRight, CirclePlus, Folder,
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
Settings, Trash2, X, Repeat2,
Settings, Trash2, X, Repeat2, Ellipsis,
} from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
import { dateKey, defaultView, isTaskView, shouldToggleRowSwipe } from './lib/mvp-utils'
import { defaultView, isTaskView, quickTaskFields, shouldToggleRowSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import MvpPanel from './MvpPanel.vue'
type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
type CalendarSubscription = { id: string; name: string; url: string; color?: string }
type Tag = { id: string; name: string; color: string }
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; recurrence_rule?: string | null; recurrence_end_at?: string | null; tags?: Tag[]; subtasks?: Task[] }
type CalendarEvent = { id: string; title: string; starts_at: string; ends_at?: string | null; all_day: boolean; source_name: string; color?: string }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'calendar' | 'settings'
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; subtasks?: Task[] }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'settings'
const initialized = ref<boolean | null>(null)
const authReady = ref(false)
@@ -26,11 +23,6 @@ const password = ref('')
const folders = ref<FolderItem[]>([])
const lists = ref<TaskList[]>([])
const archivedLists = ref<TaskList[]>([])
const calendarSubscriptions = ref<CalendarSubscription[]>([])
const calendarEvents = ref<CalendarEvent[]>([])
const calendarName = ref('合并日历')
const calendarUrl = ref('https://cal.bboy.app/all.ics')
const tags = ref<Tag[]>([])
const tasks = ref<Task[]>([])
const trash = ref<Task[]>([])
const activeList = ref('')
@@ -43,6 +35,8 @@ const notice = ref('')
const loading = ref(false)
const mobileSidebar = ref(false)
const mobileDetail = ref(false)
const mobileMore = ref(false)
const moreSettingsOpen = ref(false)
const markdownPreview = ref(false)
const showCompleted = ref(true)
const page = ref(1)
@@ -83,7 +77,6 @@ function confirmModal() {
const activeName = computed(() => {
if (activeView.value === 'trash') return '回收站'
if (activeView.value === 'today') return '今天'
if (activeView.value === 'calendar') return '日历'
if (activeView.value === 'upcoming') return '最近 7 天'
if (activeView.value === 'habits') return '习惯'
if (activeView.value === 'settings') return '设置与数据'
@@ -94,7 +87,7 @@ const visibleTasks = computed(() => {
const now = new Date()
const end = new Date(now); end.setDate(end.getDate() + 7)
let result = sourceTasks.value
if (['habits','settings','calendar'].includes(activeView.value)) return []
if (['habits','settings'].includes(activeView.value)) return []
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
return query.value.trim() ? filterTasks(result, query.value) : result
@@ -149,7 +142,6 @@ async function bootstrap() {
authenticated.value = true
folders.value = data.folders ?? []
lists.value = data.lists ?? []
tags.value = data.tags ?? []
navigationLoaded.value = true
activeList.value = 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))
@@ -171,7 +163,6 @@ async function submitAuth() {
const data = await api('/bootstrap')
folders.value = data.folders ?? []
lists.value = data.lists ?? []
tags.value = data.tags ?? []
navigationLoaded.value = true
activeList.value = 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))
@@ -208,10 +199,8 @@ async function loadTasksPage() {
}
async function loadNavigation(force = false) {
if (!force && navigationLoaded.value) return
const [folderData, listData, tagData] = await Promise.all([
api('/folders'), api('/lists'), api('/tags').catch(() => []),
])
folders.value = folderData; lists.value = listData; tags.value = tagData
const [folderData, listData] = await Promise.all([api('/folders'), api('/lists')])
folders.value = folderData; lists.value = listData
navigationLoaded.value = true
if (!lists.value.some((item) => item.id === activeList.value)) {
activeList.value = lists.value.find((item) => item.is_inbox)?.id || lists.value[0]?.id || ''
@@ -244,22 +233,23 @@ async function switchView(view: View, listId?: string) {
activeView.value = view
if (listId) activeList.value = listId
page.value = 1
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false
if (view === 'trash') await loadTrash()
else if (view === 'calendar') await loadCalendarTab()
else if (view === 'today') await loadTodayView()
else if (!isTaskView(view)) tasks.value = []
else await loadAll()
}
async function addTask() {
if (!title.value.trim() || !activeList.value) return
const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value
if (!title.value.trim() || !inboxId) return
try {
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: title.value.trim(), list_id: activeList.value }) })
tasks.value.push(task); title.value = ''; selectTask(task); toast('任务已添加')
const fields = quickTaskFields(activeView.value, activeList.value, inboxId)
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: title.value.trim(), ...fields }) })
tasks.value.push(task); title.value = ''; selectTask(task); mobileDetail.value = true; toast('任务已添加')
} catch (reason) { fail(reason) }
}
async function loadTodayView() {
await Promise.all([loadAll(), loadTodayEvents()])
await loadAll()
}
async function patchTask(task: Task, patch: Partial<Task>) {
const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) })
@@ -327,7 +317,7 @@ async function saveTask() {
if (!selectedTask.value?.title.trim()) return
try {
const task = selectedTask.value
await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: fromDateTimeLocal(toDateTimeLocal(task.due_at)), list_id: task.list_id, recurrence_rule: task.recurrence_rule || null, recurrence_end_at: task.recurrence_end_at || null, tag_ids: (task.tags ?? []).map((tag) => tag.id) } as Partial<Task>)
await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: fromDateTimeLocal(toDateTimeLocal(task.due_at)), list_id: task.list_id } as Partial<Task>)
toast('已保存')
} catch (reason) { fail(reason) }
}
@@ -349,8 +339,8 @@ async function addSubtask() {
try { const child = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: subtaskTitle, list_id: selectedTask.value.list_id, parent_id: selectedTask.value.id }) }); if (selectedTask.value) selectedTask.value.subtasks = [...(selectedTask.value.subtasks ?? []), child]; tasks.value.push(child); toast('子任务已添加') } catch (reason) { fail(reason) }
}
function selectTask(task: Task) {
selectedTask.value = { ...task, tags: task.tags ? [...task.tags] : [] }
markdownPreview.value = false; mobileDetail.value = true
selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] }
markdownPreview.value = false; moreSettingsOpen.value = false; mobileDetail.value = true
}
async function createFolder() {
const name = (await askText('新建文件夹', '文件夹名称', '', '创建'))?.trim(); if (!name) return
@@ -364,11 +354,6 @@ async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskLi
const name = (await askText('重命名', kind === 'lists' ? '清单名称' : '文件夹名称', item.name, '保存'))?.trim(); if (!name || name === item.name) return
try { const updated = await api(`/${kind}/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) }); Object.assign(item, updated); toast('已重命名') } catch (reason) { fail(reason) }
}
async function createTag() {
const name = (await askText('新建标签', '标签名称', '', '创建'))?.trim(); if (!name) return
const color = (await askText('标签颜色', '颜色(十六进制)', '#F15A29', '创建'))?.trim() || '#F15A29'
try { tags.value.push(await api('/tags', { method: 'POST', body: JSON.stringify({ name, color }) })); toast('标签已创建') } catch (reason) { fail(reason) }
}
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
if (kind === 'lists') {
const answer = await askText(`归档清单「${item.name}」?`, '', '', '归档')
@@ -386,45 +371,8 @@ async function loadArchivedLists() {
async function restoreList(item: TaskList) {
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
}
async function loadCalendarTab() {
try {
calendarSubscriptions.value = await api('/calendar-subscriptions')
if (calendarSubscriptions.value.length) {
calendarEvents.value = await api(`/calendar-subscriptions/today?day=${dateKey(new Date())}`)
} else {
calendarEvents.value = []
}
} catch { calendarSubscriptions.value = []; calendarEvents.value = [] }
}
async function addCalendarSubscription() {
if (!calendarUrl.value.trim()) return
try {
await api('/calendar-subscriptions', { method: 'POST', body: JSON.stringify({ name: calendarName.value.trim() || calendarUrl.value, url: calendarUrl.value.trim(), color: '#f15a29' }) })
await loadCalendarTab(); toast('日历已订阅')
} catch (reason) { fail(reason) }
}
async function removeCalendarSubscription(id: string) {
if (!window.confirm('取消这个日历订阅?')) return
try { await api(`/calendar-subscriptions/${id}`, { method: 'DELETE' }); await loadCalendarTab(); toast('已取消订阅') } catch (reason) { fail(reason) }
}
function taskHasTag(tag: Tag) { return !!selectedTask.value?.tags?.some((item) => item.id === tag.id) }
function toggleTag(tag: Tag) {
if (!selectedTask.value) return
selectedTask.value.tags = taskHasTag(tag) ? (selectedTask.value.tags ?? []).filter((item) => item.id !== tag.id) : [...(selectedTask.value.tags ?? []), tag]
}
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
function formatDue(value: string | null) { return value ? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(value)) : '' }
function formatEventTime(item: CalendarEvent) {
if (item.all_day) return '全天'
const date = new Date(item.starts_at)
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
async function loadTodayEvents() {
try {
calendarSubscriptions.value = await api('/calendar-subscriptions')
calendarEvents.value = calendarSubscriptions.value.length ? await api(`/calendar-subscriptions/today?day=${dateKey(new Date())}`) : []
} catch { calendarEvents.value = [] }
}
function focusQuick() { nextTick(() => document.querySelector<HTMLInputElement>('.quick-input')?.focus()) }
function previousPage() {
if (page.value <= 1 || loading.value) return
@@ -459,7 +407,6 @@ onMounted(bootstrap)
<button :class="{ active: activeView==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
<button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </button>
<button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarDays />日历</button>
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
</nav>
@@ -481,18 +428,11 @@ onMounted(bootstrap)
<main>
<header class="topbar">
<button class="icon mobile-only" aria-label="打开菜单" @click="mobileSidebar=true"><Menu /></button>
<div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div>
<div><h1>{{ activeName }}</h1></div>
<label v-if="['tasks','today','upcoming','trash'].includes(activeView)" class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd> K</kbd></label>
</header>
<template v-if="['habits','settings','calendar'].includes(activeView)">
<MvpPanel v-if="['habits','settings'].includes(activeView)" :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
<template v-else-if="activeView === 'calendar'">
<header class="view-intro"><div><small>订阅外部日历每天自动同步</small><h2>日历</h2></div><button class="soft-button" @click="loadCalendarTab"><RefreshCw />刷新</button></header>
<div class="settings-grid">
<article class="tool-card wide"><Upload /><h3>添加订阅</h3><p>粘贴 ICS / webcal 链接例如 cal.bboy.app 的合并日历</p><div class="sub-add"><input v-model="calendarName" placeholder="名称(可选)" aria-label="订阅名称"><input v-model="calendarUrl" placeholder="https://…/all.ics" aria-label="订阅地址"><button class="primary-small" @click="addCalendarSubscription">订阅</button></div><div v-if="calendarSubscriptions.length"><div v-for="sub in calendarSubscriptions" :key="sub.id" class="session-row"><span><b>{{sub.name}}</b><small>{{sub.url}}</small></span><button class="danger-text" @click="removeCalendarSubscription(sub.id)">取消订阅</button></div></div><p v-else class="hint">还没有日历订阅</p></article>
<article v-if="calendarSubscriptions.length" class="tool-card wide"><CalendarDays /><h3>今日事件</h3><div v-if="calendarEvents.length" class="calendar-events"><div v-for="ev in calendarEvents" :key="ev.id + ev.starts_at" class="calendar-event"><span class="ev-time">{{ formatEventTime(ev) }}</span><b>{{ ev.title }}</b><small>{{ ev.source_name }}</small></div></div><p v-else class="hint">今天没有日历事件</p></article>
</div>
</template>
<template v-if="['habits','settings'].includes(activeView)">
<MvpPanel :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
</template>
<template v-else>
<template v-if="activeView==='today'">
@@ -505,21 +445,17 @@ onMounted(bootstrap)
<template v-for="node in taskTree" :key="node.task.id">
<article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--swipe-width': `${Math.abs(taskSwipeOffsets[node.task.id] ?? 0)}px` }" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
<span class="swipe-bg" :class="{ cancel: node.task.completed }">{{ node.task.completed ? '↩ 未完成' : '✓ 完成' }}</span>
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span><i v-for="tag in node.task.tags" :key="tag.id" class="tag-dot" :style="{background:tag.color}" :title="tag.name"/></span></div>
<button v-if="activeView!=='trash'" class="task-swipe-hint" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)">{{ node.task.completed ? '已完成' : '完成' }}</button>
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div>
<button v-if="activeView!=='trash'" class="sr-only" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)">切换任务状态</button>
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','','',''][node.task.priority]}}</span>
<button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button>
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
</article>
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--swipe-width': `${Math.abs(taskSwipeOffsets[subtask.id] ?? 0)}px` }" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><span class="swipe-bg" :class="{ cancel: subtask.completed }">{{ subtask.completed ? '↩ 未完成' : '✓ 完成' }}</span><GripVertical/><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div><button class="task-swipe-hint" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">{{ subtask.completed ? '已完成' : '完成' }}</button></article>
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--swipe-width': `${Math.abs(taskSwipeOffsets[subtask.id] ?? 0)}px` }" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><span class="swipe-bg" :class="{ cancel: subtask.completed }">{{ subtask.completed ? '↩ 未完成' : '✓ 完成' }}</span><GripVertical/><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div><button class="sr-only" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">切换任务状态</button></article>
</template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section>
<div v-if="activeView==='today' && calendarEvents.length">
<h3 class="section-heading"><CalendarDays/>今日日历事件</h3>
<div class="calendar-events"><div v-for="ev in calendarEvents" :key="ev.id + ev.starts_at" class="calendar-event"><span class="ev-time">{{ formatEventTime(ev) }}</span><b>{{ ev.title }}</b><small>{{ ev.source_name }}</small></div></div>
</div>
<div v-if="activeView==='today'">
<h3 class="section-heading"><Repeat2/>今日习惯</h3>
<MvpPanel view="today-habits" @changed="refreshAll" @notice="toast" />
@@ -533,18 +469,18 @@ onMounted(bootstrap)
<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" @change="saveTask"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
<label>截止时间<input :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value;saveTask()"></label>
<label>优先级<select v-model.number="selectedTask.priority" @change="saveTask"><option :value="0"></option><option :value="1"></option><option :value="2"></option><option :value="3"></option></select></label>
<label>重复<select v-model="selectedTask.recurrence_rule" @change="saveTask"><option value="">不重复</option><option value="FREQ=DAILY">每天</option><option value="FREQ=WEEKLY">每周</option><option value="FREQ=MONTHLY">每月</option></select></label>
<label v-if="selectedTask.recurrence_rule">重复截止<input v-model="selectedTask.recurrence_end_at" type="date" @change="saveTask"></label>
<div class="field"><div class="field-label"><span>标签</span><button class="link" @click="createTag"><Plus/>新建</button></div><div class="tag-picker"><button v-for="tag in tags" :key="tag.id" :class="{chosen:taskHasTag(tag)}" @click="toggleTag(tag);saveTask()"><i :style="{background:tag.color}"/>{{tag.name}}</button><span v-if="!tags.length" class="hint">还没有标签</span></div></div>
<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">
<label>优先级<select v-model.number="selectedTask.priority" @change="saveTask"><option :value="0"></option><option :value="1"></option><option :value="2"></option><option :value="3"></option></select></label>
</div></details>
<div class="detail-actions"><button class="secondary" @click="saveTask">保存更改</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
</div>
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期优先级标签子任务和 Markdown 备注会出现在这里</p></div>
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期优先级子任务和 Markdown 备注会出现在这里</p></div>
</aside>
<nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='calendar'}" @click="switchView('calendar')"><CalendarDays/><span>日历</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
<div v-if="mobileMore" class="more-mask" @click.self="mobileMore=false"><section id="mobile-more-menu" class="more-sheet" role="dialog" aria-modal="true" aria-label="更多导航"><div class="more-sheet-head"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><button @click="switchView('upcoming')"><CalendarDays/>最近 7 天</button><button @click="switchView('trash')"><Trash2/>回收站</button><button @click="switchView('settings')"><Settings/>设置与数据</button><button @click="mobileMore=false;mobileSidebar=true"><Menu/>全部清单</button></section></div>
<nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',lists.find(l=>l.is_inbox)?.id)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:mobileMore||['upcoming','trash','settings'].includes(activeView)}" aria-haspopup="dialog" aria-controls="mobile-more-menu" :aria-expanded="mobileMore" @click="mobileMore=!mobileMore"><Ellipsis/><span>更多</span></button></nav>
<button v-if="['tasks','today','upcoming'].includes(activeView)" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
+6 -72
View File
@@ -6,18 +6,12 @@ import { csrfHeader } from './lib/csrf'
type View = 'habits' | 'today-habits' | 'settings'
type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; unit?: string; cells?: Array<{ day: string; scheduled?:boolean; paused?:boolean; value: number | boolean }>; stats?: Record<string, number> }
type CalendarSubscription = { id: string; name: string; url: string; color: string; enabled?: boolean }
type CalendarEvent = { id: string; title: string; starts_at: string; ends_at?: string | null; all_day: boolean; source_name: string; color?: string }
type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string }
const props = defineProps<{ view: View }>()
const emit = defineEmits<{ changed: []; notice: [message: string] }>()
const habits = ref<Habit[]>([])
const sessions = ref<Session[]>([])
const audit = ref<any[]>([])
const calendarSubscriptions = ref<CalendarSubscription[]>([])
const todayEvents = ref<CalendarEvent[]>([])
const calendarName = ref('合并日历')
const calendarUrl = ref('https://cal.bboy.app/all.ics')
const busy = ref(false)
const error = ref('')
const habitName = ref('')
@@ -68,16 +62,6 @@ async function safe(work: () => Promise<void>) {
function logFor(h: Habit, day: string) { return (h.cells ?? []).find((c) => c.day === day) }
function isDone(h: Habit, day: string) { return isHabitComplete(h.kind, logFor(h, day)?.value, h.target ?? 1) }
async function toggleHabit(h: Habit, day: string) {
if (busy.value) return
const current = logFor(h, day)
const next = current?.value ? 0 : 1
await safe(async () => {
await request(`/habits/${h.id}/logs/${day}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
await loadHabits()
emit('notice', next ? '打卡成功 🎉' : '已取消打卡')
})
}
function isInteractiveTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
}
@@ -105,13 +89,7 @@ function moveHabitSwipe(h: Habit, event: TouchEvent) {
}
}
function habitProgressText(h: Habit) {
if (h.kind !== 'numeric') return isDone(h, todayKey.value) ? '今天已完成 · 左滑取消' : '右滑整行打卡'
const value = Number(logFor(h, todayKey.value)?.value ?? 0)
return `${value} / ${h.target ?? 1} · ${isDone(h, todayKey.value) ? '左滑减少一次' : '右滑完成一次'}`
}
function habitActionText(h: Habit) {
if (h.kind !== 'numeric') return isDone(h, todayKey.value) ? '已完成' : '打卡'
if (h.kind !== 'numeric') return ''
return `${Number(logFor(h, todayKey.value)?.value ?? 0)} / ${h.target ?? 1}`
}
@@ -195,50 +173,6 @@ async function loadSettings() {
audit.value = mergePage<any>(a).items
})
}
async function loadCalendarTab() {
await safe(async () => {
const [subs] = await Promise.all([request('/calendar-subscriptions').catch(() => [])])
calendarSubscriptions.value = (subs as CalendarSubscription[]) ?? []
await loadTodayEvents()
})
}
async function addCalendarSubscription() {
if (!calendarUrl.value.trim()) return
await safe(async () => {
await request('/calendar-subscriptions', {
method: 'POST',
body: JSON.stringify({ name: calendarName.value.trim() || calendarUrl.value, url: calendarUrl.value.trim(), color: '#f15a29' }),
})
await loadCalendarTab()
emit('notice', '日历已订阅')
})
}
async function removeCalendarSubscription(id: string) {
if (!confirm('取消这个日历订阅?')) return
await safe(async () => {
await request(`/calendar-subscriptions/${id}`, { method: 'DELETE' })
await loadCalendarTab()
emit('notice', '已取消订阅')
})
}
function habitTimeText(h: Habit) {
return isDone(h, todayKey.value) ? '✓ 已完成' : '未完成'
}
async function loadTodayEvents() {
if (!calendarSubscriptions.value.length) {
todayEvents.value = []
return
}
try {
const events = await request(`/calendar-subscriptions/today?day=${todayKey.value}`) as CalendarEvent[]
todayEvents.value = events ?? []
} catch { todayEvents.value = [] }
}
function formatEventTime(item: CalendarEvent) {
if (item.all_day) return '全天'
const date = new Date(item.starts_at)
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
async function revoke(id: string) {
await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') })
}
@@ -300,8 +234,8 @@ onBeforeUnmount(() => {
<article v-for="h in todayHabits" :key="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--swipe-width': `${Math.abs(habitSwipeOffsets[h.id] ?? 0)}px` }" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<span class="swipe-bg" :class="{ cancel: isDone(h, todayKey) }">{{ isDone(h, todayKey) ? (h.kind === 'numeric' ? ' 一次' : '↩ 未完成') : (h.kind === 'numeric' ? '+ 一次' : '✓ 打卡') }}</span>
<div class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small>{{ habitProgressText(h) }}</small></span>
<button class="habit-swipe-hint" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" @click.stop="toggleHabitFromButton(h)">{{ habitActionText(h) }}</button>
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
<button class="sr-only" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" @click.stop="toggleHabitFromButton(h)">切换习惯状态</button>
</div>
</article>
<div v-if="!todayHabits.length" class="empty-panel">今天没有安排习惯轻松一下吧</div>
@@ -320,13 +254,13 @@ onBeforeUnmount(() => {
</form>
<!-- 习惯列表布尔习惯整行右滑打卡数值习惯保留明确输入记录 -->
<!-- 习惯列表支持整行滑动记录 -->
<div v-if="view === 'habits'" class="habit-list">
<article v-for="h in habits" :key="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--swipe-width': `${Math.abs(habitSwipeOffsets[h.id] ?? 0)}px` }" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<span class="swipe-bg" :class="{ cancel: isDone(h, todayKey) }">{{ isDone(h, todayKey) ? (h.kind === 'numeric' ? ' 一次' : '↩ 未完成') : (h.kind === 'numeric' ? '+ 一次' : '✓ 打卡') }}</span>
<div class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small>{{ habitProgressText(h) }}</small></span>
<button class="habit-swipe-hint" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)">{{ habitActionText(h) }}</button>
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
<button class="sr-only" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)">切换习惯状态</button>
</div>
<button class="icon ghost" aria-label="归档习惯" @click="archiveHabit(h)"><Trash2 /></button>
</article>
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { dateKey, defaultView, habitWeek, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, shouldToggleRowSwipe } from './mvp-utils'
import { dateKey, defaultView, habitWeek, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, shouldToggleRowSwipe } from './mvp-utils'
describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => {
@@ -33,6 +33,20 @@ describe('MVP view utilities', () => {
expect(defaultView()).toBe('today')
})
it('quick-adds Today tasks to Inbox at noon in Asia/Shanghai', () => {
const now = new Date('2026-09-05T16:30:00.000Z')
expect(quickTaskFields('today', 'selected-list', 'inbox-list', now)).toEqual({
list_id: 'inbox-list',
due_at: '2026-09-06T12:00:00+08:00',
})
})
it('keeps quick-add tasks in the selected list outside Today', () => {
expect(quickTaskFields('tasks', 'selected-list', 'inbox-list', new Date('2026-09-05T16:30:00.000Z'))).toEqual({
list_id: 'selected-list',
})
})
it('only includes scheduled, unpaused habits today regardless of kind or completion', () => {
const day = '2026-09-06'
expect(isHabitScheduledToday({ kind: 'boolean', cells: [{ day, scheduled: true, paused: false, value: true }] }, day)).toBe(true)
+15
View File
@@ -28,6 +28,21 @@ export function defaultView() {
return 'today' as const
}
export function quickTaskFields(view: string, activeList: string, inboxList: string, now = new Date()) {
if (view !== 'today') return { list_id: activeList }
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now)
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value
return {
list_id: inboxList,
due_at: `${value('year')}-${value('month')}-${value('day')}T12:00:00+08:00`,
}
}
export function isHabitComplete(kind: string | undefined, value: number | boolean | undefined, target = 1) {
if (kind === 'numeric') return Number(value ?? 0) >= target
return Boolean(value)
+2 -4
View File
@@ -7,7 +7,6 @@ type SearchTask = {
description?: string
parent_id?: string | null
completed?: boolean
tags?: { name: string }[]
list_name?: string
}
@@ -35,12 +34,11 @@ describe('task utilities', () => {
expect(html).not.toContain('<script>')
})
it('filters titles, descriptions, tags, and list names', () => {
it('filters titles, descriptions, and list names', () => {
const searchable: SearchTask[] = [
...tasks,
{ id: '4', title: 'Plan', tags: [{ name: '重要' }], list_name: '工作清单' },
{ id: '4', title: 'Plan', list_name: '工作清单' },
]
expect(filterTasks(searchable, '重要').map((task) => task.id)).toEqual(['4'])
expect(filterTasks(searchable, '工作').map((task) => task.id)).toEqual(['4'])
})
-2
View File
@@ -4,7 +4,6 @@ export type MinimalTask = {
description?: string
parent_id?: string | null
completed?: boolean
tags?: { name: string }[]
list_name?: string
}
@@ -75,7 +74,6 @@ export function filterTasks<T extends MinimalTask>(tasks: T[], query: string) {
task.title,
task.description ?? '',
task.list_name ?? '',
...(task.tags ?? []).map((tag) => tag.name),
].join(' ')
return haystack.toLowerCase().includes(q)
})
+5 -7
View File
@@ -4,23 +4,21 @@
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%;text-size-adjust:100%}body{margin:0;background:#f8f3e8}button,input,textarea,select{font:inherit;color:inherit}@media(max-width:800px){input,textarea,select{font-size:16px}}button{cursor:pointer}svg{width:18px;height:18px;stroke-width:1.8}.center,.auth-shell{min-height:100vh;display:grid;place-items:center}.center{gap:12px;align-content:center;color:var(--muted)}.loader{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
.auth-shell{background:radial-gradient(circle at 20% 10%,#ffe3d5 0,transparent 28%),linear-gradient(135deg,#f8f3e8,#fffaf0)}.auth-card{width:min(390px,90vw);padding:38px;background:var(--paper);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);display:grid;gap:16px}.brand{font-size:40px;font-weight:850;letter-spacing:-3px}.brand span{color:var(--accent)}.brand.small{font-size:29px}.auth-card p{margin:0 0 8px;color:var(--muted)}.auth-card label,.detail-form>label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.auth-card input,.detail-form input,.detail-form select{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none}.auth-card input:focus,.detail-form input:focus,.detail-form select:focus,.detail-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.primary{border:0;background:var(--accent);color:#fff;padding:12px;border-radius:9px;font-weight:700;box-shadow:0 5px 12px rgba(241,90,41,.2)}.auth-card small{color:var(--danger)}
.shell{height:100vh;display:grid;grid-template-columns:238px minmax(430px,1fr) 350px;background:var(--paper);overflow:hidden}.sidebar{border-right:1px solid var(--line);background:var(--sidebar);display:flex;flex-direction:column;min-height:0}.brand-row{height:76px;padding:0 20px;display:flex;align-items:center;justify-content:space-between}.primary-nav{display:grid;padding:4px 12px 12px;gap:3px}.primary-nav button,.settings{display:flex;align-items:center;gap:10px;border:0;background:transparent;padding:10px 12px;border-radius:9px;color:#665e52;text-align:left}.primary-nav button:hover,.list-row:hover,.folder-row>button:hover{background:rgba(255,255,255,.52)}.primary-nav button.active,.list-row.active{background:var(--accent-soft);color:#b7421e;font-weight:700}.section-title{padding:17px 17px 7px 22px;display:flex;justify-content:space-between;align-items:center;color:#958b7d;text-transform:uppercase;letter-spacing:.08em;font-size:11px;font-weight:750}.section-title>span:last-child{display:flex}.mini-icon,.row-actions button{border:0;background:transparent;padding:4px;color:#8e8477}.mini-icon svg,.row-actions svg{width:14px;height:14px}.folders{padding:0 10px;overflow:auto}.folder-row{display:flex;align-items:center}.folder-row>button{flex:1;min-width:0;border:0;background:transparent;padding:8px;display:flex;align-items:center;gap:7px;color:#6d6559;text-align:left}.folder-row>button svg{width:14px}.row-actions{display:flex;opacity:0;transition:opacity .15s}.folder-row:hover .row-actions,.list-row:hover .row-actions{opacity:1}.list-row{width:100%;border:0;background:transparent;padding:8px 7px 8px 31px;border-radius:8px;display:flex;align-items:center;gap:9px;text-align:left;color:#665f55}.list-row>span:nth-child(2){flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.list-row i{width:8px;height:8px;background:#d89b62;border-radius:3px}.settings{margin:auto 12px 14px}.mobile-only,.bottom,.fab{display:none}
main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient(rgba(255,253,248,.92),rgba(255,253,248,.92)),repeating-linear-gradient(0deg,transparent,transparent 31px,#eee3d2 32px)}.topbar{display:flex;align-items:center;gap:14px}.topbar>div{flex:1}.topbar p{margin:0;color:var(--muted);font-size:12px}.topbar h1{font-size:27px;margin:3px 0 21px;letter-spacing:-.03em}.search{margin-bottom:18px;display:flex;align-items:center;gap:8px;width:min(260px,36%);padding:8px 10px;background:#faf7f0;border:1px solid var(--line);border-radius:9px;color:var(--muted)}.search input{min-width:0;width:100%;border:0;outline:0;background:transparent}.search kbd{font-size:10px;white-space:nowrap;border:1px solid var(--line);padding:2px 4px;border-radius:4px}.quick{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:7px 7px 7px 13px;box-shadow:0 2px 10px rgba(81,61,38,.05);transition:.18s}.quick:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.09)}.quick>svg{color:var(--accent)}.quick input{border:0;outline:0;background:transparent;min-width:0;flex:1}.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:8px 15px;font-weight:650}.list-toolbar{height:42px;display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px}.list-toolbar label{margin-right:auto}.link{border:0;background:transparent;color:var(--accent);padding:3px;display:inline-flex;gap:3px;align-items:center}.link svg{width:14px}.task-list{transition:opacity .2s}.task-list.loading{opacity:.45}.task-row{--swipe-x:0px;min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}.task-row.swipeable{touch-action:pan-y;transform:translateX(var(--swipe-x))}.task-row.swipeable:not([style*="0px"]){transition:none}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(calc(var(--swipe-x) + 2px))}.swipe-bg{position:absolute;z-index:-1;inset:0 auto 0 calc(-1 * var(--swipe-x));width:var(--swipe-width);min-width:0;display:flex;align-items:center;padding-left:14px;background:#e9f7ee;color:#24814f;font-size:12px;font-weight:800;white-space:nowrap;overflow:hidden}.swipe-bg.cancel{left:auto;right:var(--swipe-x);justify-content:flex-end;padding:0 14px 0 0;background:#fff2e8;color:#c65a23}.task-row.ready .swipe-bg,.habit-row.ready .swipe-bg{background:#dff3e7;color:#187142}.task-row.ready .swipe-bg.cancel,.habit-row.ready .swipe-bg.cancel{background:#ffe4d3;color:#b94716}.check{width:19px;height:19px;flex:0 0 19px;border:1.6px solid #c6baa8;background:#fff;border-radius:5px;padding:0;display:grid;place-items:center}.check svg{width:13px}.check.p1{border-color:#4b93d1}.check.p2{border-color:#d79b25}.check.p3{border-color:#dc4b30}.done .check{background:#afa595;color:white}.task-main{border:0;background:transparent;flex:1;min-width:0;text-align:left;padding:8px 0}.task-main strong{display:block;font-size:14px;font-weight:590;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.done .task-main strong{text-decoration:line-through;color:#9b9388}.task-swipe-hint{min-height:44px;flex:0 0 auto;border:0;border-radius:999px;background:#f3ede3;color:#9a8f80;padding:8px 11px;font-size:11px;font-weight:700}.task-row.done .task-swipe-hint{background:var(--accent-soft);color:#b7421e}.meta{display:flex;align-items:center;gap:9px;color:#9b9286;font-size:11px;margin-top:3px}.meta span{display:flex;align-items:center;gap:3px}.meta svg{width:12px}.tag-dot{width:7px;height:7px;border-radius:50%}.priority{font-size:10px;font-weight:750;padding:3px 6px;border-radius:5px}.priority.p1{color:#3f80ba;background:#e5f2fc}.priority.p2{color:#a56b05;background:#fff1cb}.priority.p3{color:#bd3827;background:#fde2dc}.icon,.ghost{border:0;background:transparent;display:grid;place-items:center;padding:5px;border-radius:6px}.ghost{opacity:0;color:#9d9387}.task-row:hover .ghost{opacity:1}.ghost:hover{color:var(--danger);background:#fce7e2}.restore{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 8px;font-size:12px}.restore svg{width:14px}.subtask{padding-left:53px;min-height:42px;color:#6d655b}.subtask>svg{width:13px;color:#bbb0a2}.empty{min-height:300px;display:grid;place-items:center;align-content:center;gap:8px;color:#aaa094;text-align:center}.empty>svg{width:38px;height:38px;color:#d8cabb}.empty b{color:#6f675c}.empty span{font-size:13px}
.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{padding:19px;display:grid;gap:15px}.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}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.tag-picker{display:flex;flex-wrap:wrap;gap:6px}.tag-picker button{border:1px solid var(--line);background:#fff;border-radius:999px;padding:5px 8px;display:flex;align-items:center;gap:5px;font-size:11px}.tag-picker button.chosen{border-color:#c89077;background:#fff1e9}.tag-picker i{width:8px;height:8px;border-radius:50%}.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{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}
main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient(rgba(255,253,248,.92),rgba(255,253,248,.92)),repeating-linear-gradient(0deg,transparent,transparent 31px,#eee3d2 32px)}.topbar{display:flex;align-items:center;gap:14px}.topbar>div{flex:1}.topbar p{margin:0;color:var(--muted);font-size:12px}.topbar h1{font-size:27px;margin:3px 0 21px;letter-spacing:-.03em}.search{margin-bottom:18px;display:flex;align-items:center;gap:8px;width:min(260px,36%);padding:8px 10px;background:#faf7f0;border:1px solid var(--line);border-radius:9px;color:var(--muted)}.search input{min-width:0;width:100%;border:0;outline:0;background:transparent}.search kbd{font-size:10px;white-space:nowrap;border:1px solid var(--line);padding:2px 4px;border-radius:4px}.quick{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:7px 7px 7px 13px;box-shadow:0 2px 10px rgba(81,61,38,.05);transition:.18s}.quick:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.09)}.quick>svg{color:var(--accent)}.quick input{border:0;outline:0;background:transparent;min-width:0;flex:1}.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:8px 15px;font-weight:650}.list-toolbar{height:42px;display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px}.list-toolbar label{margin-right:auto}.link{border:0;background:transparent;color:var(--accent);padding:3px;display:inline-flex;gap:3px;align-items:center}.link svg{width:14px}.task-list{transition:opacity .2s}.task-list.loading{opacity:.45}.task-row{--swipe-x:0px;min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}.task-row.swipeable{touch-action:pan-y;transform:translateX(var(--swipe-x))}.task-row.swipeable:not([style*="0px"]){transition:none}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(calc(var(--swipe-x) + 2px))}.swipe-bg{position:absolute;z-index:-1;inset:0 auto 0 calc(-1 * var(--swipe-x));width:var(--swipe-width);min-width:0;display:flex;align-items:center;padding-left:14px;background:#e9f7ee;color:#24814f;font-size:12px;font-weight:800;white-space:nowrap;overflow:hidden}.swipe-bg.cancel{left:auto;right:var(--swipe-x);justify-content:flex-end;padding:0 14px 0 0;background:#fff2e8;color:#c65a23}.task-row.ready .swipe-bg,.habit-row.ready .swipe-bg{background:#dff3e7;color:#187142}.task-row.ready .swipe-bg.cancel,.habit-row.ready .swipe-bg.cancel{background:#ffe4d3;color:#b94716}.check{width:19px;height:19px;flex:0 0 19px;border:1.6px solid #c6baa8;background:#fff;border-radius:5px;padding:0;display:grid;place-items:center}.check svg{width:13px}.check.p1{border-color:#4b93d1}.check.p2{border-color:#d79b25}.check.p3{border-color:#dc4b30}.done .check{background:#afa595;color:white}.task-main{border:0;background:transparent;flex:1;min-width:0;text-align:left;padding:8px 0}.task-main strong{display:block;font-size:14px;font-weight:590;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.done .task-main strong{text-decoration:line-through;color:#9b9388}.meta{display:flex;align-items:center;gap:9px;color:#9b9286;font-size:11px;margin-top:3px}.meta span{display:flex;align-items:center;gap:3px}.meta svg{width:12px}.priority{font-size:10px;font-weight:750;padding:3px 6px;border-radius:5px}.priority.p1{color:#3f80ba;background:#e5f2fc}.priority.p2{color:#a56b05;background:#fff1cb}.priority.p3{color:#bd3827;background:#fde2dc}.icon,.ghost{border:0;background:transparent;display:grid;place-items:center;padding:5px;border-radius:6px}.ghost{opacity:0;color:#9d9387}.task-row:hover .ghost{opacity:1}.ghost:hover{color:var(--danger);background:#fce7e2}.restore{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 8px;font-size:12px}.restore svg{width:14px}.subtask{padding-left:53px;min-height:42px;color:#6d655b}.subtask>svg{width:13px;color:#bbb0a2}.empty{min-height:300px;display:grid;place-items:center;align-content:center;gap:8px;color:#aaa094;text-align:center}.empty>svg{width:38px;height:38px;color:#d8cabb}.empty b{color:#6f675c}.empty span{font-size:13px}
.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{padding:19px;display:grid;gap:15px}.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}.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{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}
.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){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}}
@media(max-width:800px){.shell{height:100dvh;display:block;overflow:auto}.sidebar,.detail{position:fixed;z-index:30;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow)}.sidebar{left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{right:0;width:min(430px,94vw);transform:translateX(105%)}.detail.open{transform:none}.scrim{position:fixed;z-index:20;inset:0;background:rgba(45,38,31,.26)}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px;margin-bottom:17px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.quick button{display:none}.row-actions{opacity:1}.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>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-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}.fab{display:grid;place-items:center;position:fixed;z-index:16;right:18px;bottom:76px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 7px 20px rgba(241,90,41,.38);transition:transform .15s}.fab:active{transform:scale(.94)}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
@media(max-width:800px){.shell{height:100dvh;display:block;overflow:auto}.sidebar{position:fixed;z-index:30;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow);left:0;width:min(300px,86vw);transform:translateX(-105%)}.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)}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px;margin:0 0 17px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.quick button{display:none}.row-actions{opacity:1}.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>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-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}.fab{display:grid;place-items:center;position:fixed;z-index:16;right:18px;bottom:76px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 7px 20px rgba(241,90,41,.38);transition:transform .15s}.fab:active{transform:scale(.94)}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
.mvp-view{display:grid;gap:16px;padding-bottom:36px}.view-intro{display:flex;align-items:end;justify-content:space-between;border-bottom:1px dashed var(--line);padding-bottom:12px}.view-intro h2{margin:2px 0 0;font-size:22px}.view-intro small,.view-intro>span{color:var(--muted);font-size:12px}.tool-card,.empty-panel{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 3px 14px rgba(81,61,38,.05)}.soft-button,.primary-small,.danger-button,.file-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 11px;font-size:12px}.soft-button svg,.file-button svg{width:15px}.primary-small{background:var(--accent);border-color:var(--accent);color:#fff}.danger-button{color:var(--danger);border-color:#e5b7ad}.habit-create{display:grid;grid-template-columns:1fr 150px 90px auto;gap:8px}.habit-create input,.habit-create select{min-width:0;border:1px solid var(--line);border-radius:8px;background:#fff;padding:9px}.habit-create button{border:0;border-radius:8px;background:var(--accent);color:#fff;padding:8px 13px;display:flex;align-items:center;gap:5px}.habit-list{display:grid;gap:10px}.tool-card h3{margin:0 0 4px}
.habit-row{--swipe-x:0px;display:flex;align-items:center;gap:8px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:5px 5px 5px 12px;box-shadow:0 3px 14px rgba(81,61,38,.05);transition:border-color .15s,box-shadow .15s,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}
.habit-row.done{border-color:var(--accent)}
.habit-row.done{background:#fffaf5}
.habit-row.swipeable{touch-action:pan-y;transform:translateX(var(--swipe-x))}
.habit-row.swipeable:not([style*="0px"]){transition:none}
.habit-main{flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:10px;border:0;background:transparent;padding:13px 4px;text-align:left;border-radius:10px}
.habit-name{font-size:15px;font-weight:700;color:#3c372f;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.habit-main>span:first-child{display:grid;gap:3px}.habit-main small{font-size:11px;color:var(--muted)}
.habit-row.done .habit-name{color:var(--accent)}
.habit-swipe-hint{flex:0 0 auto;border-radius:999px;background:#f3ede3;color:#9a8f80;padding:6px 10px;font-size:11px;font-weight:700}
.habit-row.done .habit-swipe-hint{background:var(--accent-soft);color:#b7421e}
.habit-row .icon.ghost{color:#b3a795;margin-left:4px}
.habit-row .icon.ghost:hover{color:var(--danger)}
.numeric-habit>span:first-child{display:grid;gap:3px}.numeric-habit small{font-size:11px;color:var(--muted)}
@@ -30,4 +28,4 @@ main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient
.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>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}.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;border-top:1px solid var(--line);padding:9px 0}.session-row span{display:grid}.session-row small,.audit-row small{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:800px){.habit-create{grid-template-columns:1fr 1fr}.habit-create button{justify-content:center}.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.habit-row{padding:5px 4px 5px 8px}.habit-main{padding:10px 2px}.numeric-action input{width:62px}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
.pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin:0 0 14px}.pager button:disabled{opacity:.4;cursor:not-allowed}.pager span{color:var(--muted);font-size:13px}.modal-mask{position:fixed;inset:0;z-index:80;background:rgba(45,38,31,.4);display:grid;place-items:center;padding:24px}.modal-box{width:min(360px,92vw);background:#fffdf8;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);padding:22px;display:grid;gap:14px}.modal-box h3{margin:0;font-size:17px}.modal-box label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.modal-input{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none;font-size:16px}.modal-actions{display:flex;justify-content:flex-end;gap:8px}.calendar-events{display:grid;gap:8px}.calendar-event{display:grid;grid-template-columns:auto 1fr;align-items:baseline;gap:3px 10px;padding:9px 4px;border-bottom:1px dashed var(--line)}.calendar-event b{font-size:14px}.calendar-event small{grid-column:2;color:var(--muted);font-size:11px}.calendar-event .ev-time{grid-row:1/3;align-self:center;min-width:46px;color:var(--accent);font-weight:800;font-size:12px}
.pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin:0 0 14px}.pager button:disabled{opacity:.4;cursor:not-allowed}.pager span{color:var(--muted);font-size:13px}.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.more-settings{border:1px solid var(--line);border-radius:10px;background:#fff}.more-settings summary{cursor:pointer;padding:11px 13px;color:#756d61;font-size:12px;font-weight:700}.more-settings-body{display:grid;gap:12px;padding:0 13px 13px}.more-settings-body>label{display:grid;grid-template-columns:80px 1fr;align-items:center;gap:7px;color:#756d61;font-size:12px;font-weight:650}.more-mask{display:none}.modal-mask{position:fixed;inset:0;z-index:80;background:rgba(45,38,31,.4);display:grid;place-items:center;padding:24px}.modal-box{width:min(360px,92vw);background:#fffdf8;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);padding:22px;display:grid;gap:14px}.modal-box h3{margin:0;font-size:17px}.modal-box label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.modal-input{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none;font-size:16px}.modal-actions{display:flex;justify-content:flex-end;gap:8px}
@@ -0,0 +1,32 @@
"""remove calendar subscriptions
Revision ID: 0008
Revises: 0007
"""
import sqlalchemy as sa
from alembic import op
revision = "0008"
down_revision = "0007"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_index("ix_calendar_subscriptions_user_id", table_name="calendar_subscriptions")
op.drop_table("calendar_subscriptions")
def downgrade() -> None:
op.create_table(
"calendar_subscriptions",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("color", sa.String(32), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_calendar_subscriptions_user_id", "calendar_subscriptions", ["user_id"])
+41
View File
@@ -0,0 +1,41 @@
"""remove tags
Revision ID: 0009
Revises: 0008
"""
import sqlalchemy as sa
from alembic import op
revision = "0009"
down_revision = "0008"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_table("task_tags")
op.drop_index("ix_tags_user_id", table_name="tags")
op.drop_table("tags")
def downgrade() -> None:
op.create_table(
"tags",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("name", sa.String(80), nullable=False),
sa.Column("color", sa.String(32), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.UniqueConstraint("user_id", "name", name="uq_tags_user_id_name"),
)
op.create_index("ix_tags_user_id", "tags", ["user_id"])
op.create_table(
"task_tags",
sa.Column("task_id", sa.Uuid(), nullable=False),
sa.Column("tag_id", sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("task_id", "tag_id"),
)
-2
View File
@@ -14,8 +14,6 @@ dependencies = [
"python-multipart>=0.0.20,<1",
"uuid-utils>=0.11,<1",
"structlog>=25,<26",
"apscheduler>=3.11,<4",
"icalendar>=6,<7",
]
[dependency-groups]
+14 -36
View File
@@ -155,13 +155,12 @@ def test_folder_and_list_lifecycle(client):
assert client.delete(f"/api/v1/folders/{folder['id']}").status_code == 204
def test_task_search_tags_subtasks_and_recycle_bin(client):
def test_task_search_subtasks_and_recycle_bin(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
tag = client.post("/api/v1/tags", json={"name": "重要", "color": "#f15a29"}).json()
parent = client.post(
"/api/v1/tasks",
json={"title": "购买咖啡豆", "description": "手冲用", "list_id": inbox["id"], "tag_ids": [tag["id"]]},
json={"title": "购买咖啡豆", "description": "手冲用", "list_id": inbox["id"]},
).json()
child = client.post(
"/api/v1/tasks",
@@ -170,11 +169,11 @@ def test_task_search_tags_subtasks_and_recycle_bin(client):
assert child.status_code == 201
detail = client.get(f"/api/v1/tasks/{parent['id']}")
assert detail.status_code == 200
assert detail.json()["tags"][0]["name"] == "重要"
assert "tags" not in detail.json()
assert len(detail.json()["subtasks"]) == 1
listed = client.get("/api/v1/tasks", params={"q": "咖啡"}).json()["items"]
assert len(listed) == 1
assert listed[0]["tags"][0]["name"] == "重要"
assert "tags" not in listed[0]
assert listed[0]["subtasks"][0]["title"] == "比较价格"
assert client.delete(f"/api/v1/tasks/{parent['id']}").status_code == 204
@@ -264,33 +263,13 @@ def test_nested_subtasks_are_rejected(client):
assert nested.status_code == 400
def test_tags_are_global_searchable_and_validated(client):
def test_restore_replace_recovers_habits_and_task_links_without_tags(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
errands = client.post("/api/v1/lists", json={"name": "跑腿清单"}).json()
tag = client.post("/api/v1/tags", json={"name": "蓝色标签", "color": "#123abc"})
assert tag.status_code == 201
assert client.get("/api/v1/tags").json()[0]["color"] == "#123abc"
task = client.post(
"/api/v1/tasks",
json={"title": "普通标题", "description": "特殊说明", "list_id": errands["id"], "tag_ids": [tag.json()["id"]]},
).json()
for query in ("普通", "特殊", "蓝色", "跑腿"):
assert [row["id"] for row in client.get("/api/v1/tasks", params={"q": query}).json()["items"]] == [task["id"]]
invalid = client.post(
"/api/v1/tasks",
json={"title": "非法标签", "list_id": inbox["id"], "tag_ids": ["00000000-0000-0000-0000-000000000001"]},
)
assert invalid.status_code == 404
def test_restore_replace_recovers_tags_habits_and_task_links(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
tag = client.post("/api/v1/tags", json={"name": "备份标签", "color": "#f15a29"}).json()
client.post(
"/api/v1/tasks",
json={"title": "备份任务", "list_id": inbox["id"], "tag_ids": [tag["id"]]},
json={"title": "备份任务", "list_id": inbox["id"]},
).json()
client.post(
"/api/v1/habits",
@@ -298,8 +277,9 @@ def test_restore_replace_recovers_tags_habits_and_task_links(client):
).json()
exported = client.get("/api/v1/export")
assert exported.status_code == 200
assert "tags" not in exported.json()
assert "task_tags" not in exported.json()
client.post("/api/v1/tags", json={"name": "现有标签", "color": "#123abc"})
client.post(
"/api/v1/habits",
json={"name": "深蹲", "kind": "boolean", "schedule_type": "daily"},
@@ -307,12 +287,11 @@ def test_restore_replace_recovers_tags_habits_and_task_links(client):
restored = client.post("/api/v1/restore?mode=replace", json=exported.json())
assert restored.status_code == 200
tags = client.get("/api/v1/tags").json()
assert [row["name"] for row in tags] == ["备份标签"]
assert client.get("/api/v1/tags").status_code == 404
habits = client.get("/api/v1/habits").json()
assert [row["name"] for row in habits] == ["俯卧撑"]
listed = client.get("/api/v1/tasks", params={"q": "备份任务"}).json()["items"]
assert listed[0]["tags"][0]["name"] == "备份标签"
assert "tags" not in listed[0]
def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client):
@@ -333,10 +312,9 @@ def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client):
assert client.post(f"/api/v1/tasks/{parent['id']}/restore").status_code == 404
def test_batch_supports_due_date_tags_and_soft_delete_atomically(client):
def test_batch_supports_due_date_and_soft_delete_atomically(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
tag = client.post("/api/v1/tags", json={"name": "批量", "color": "red"}).json()
ids = [
client.post("/api/v1/tasks", json={"title": str(i), "list_id": inbox["id"]}).json()["id"]
for i in range(2)
@@ -344,10 +322,10 @@ def test_batch_supports_due_date_tags_and_soft_delete_atomically(client):
due_at = "2026-09-10T08:00:00+08:00"
response = client.post(
"/api/v1/tasks/batch",
json={"task_ids": ids, "due_at": due_at, "tag_ids": [tag["id"]]},
json={"task_ids": ids, "due_at": due_at},
)
assert response.status_code == 200 and response.json()["updated"] == 2
assert all(client.get(f"/api/v1/tasks/{task_id}").json()["tags"] for task_id in ids)
assert all(client.get(f"/api/v1/tasks/{task_id}").json()["due_at"] is not None for task_id in ids)
parent = client.post("/api/v1/tasks", json={"title": "", "list_id": inbox["id"]}).json()
client.post(
+1 -164
View File
@@ -15,7 +15,6 @@ def boot(client):
def test_bootstrap_returns_navigation_and_current_user(client):
inbox = boot(client)
client.post("/api/v1/folders", json={"name": "工作"})
client.post("/api/v1/tags", json={"name": "重要", "color": "#ff0000"})
client.post("/api/v1/tasks", json={"title": "首屏任务", "list_id": inbox["id"]})
response = client.get("/api/v1/bootstrap")
@@ -25,172 +24,10 @@ def test_bootstrap_returns_navigation_and_current_user(client):
assert data["user"]["username"] == "owner"
assert any(row["id"] == inbox["id"] for row in data["lists"])
assert [row["name"] for row in data["folders"]] == ["工作"]
assert [row["name"] for row in data["tags"]] == ["重要"]
assert "tags" not in data
assert data["inbox_id"] == inbox["id"]
def test_calendar_endpoint_is_removed(client):
boot(client)
assert client.get("/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}).status_code == 404
def test_calendar_parser_handles_tzid_recurrence_folding_and_multiday():
from backend.mvp import parse_ics_events
ics = """BEGIN:VCALENDAR\r
VERSION:2.0\r
BEGIN:VEVENT\r
UID:tzid-recurring\r
DTSTART;TZID=Asia/Shanghai:20260906T090000\r
DTEND;TZID=Asia/Shanghai:20260906T100000\r
RRULE:FREQ=DAILY;COUNT=2\r
EXDATE;TZID=Asia/Shanghai:20260907T090000\r
SUMMARY:折叠\r
标题\r
END:VEVENT\r
BEGIN:VEVENT\r
UID:multi-day\r
DTSTART;TZID=Asia/Shanghai:20260905T230000\r
DTEND;TZID=Asia/Shanghai:20260906T010000\r
SUMMARY:跨天事件\r
END:VEVENT\r
END:VCALENDAR\r
"""
events = parse_ics_events(
ics,
"测试日历",
"#fff",
datetime(2026, 9, 5, 16, tzinfo=UTC),
datetime(2026, 9, 6, 16, tzinfo=UTC),
)
assert [(row["title"], row["starts_at"].isoformat()) for row in events] == [
("折叠标题", "2026-09-06T01:00:00+00:00"),
("跨天事件", "2026-09-05T15:00:00+00:00"),
]
def test_calendar_parser_keeps_recurrence_in_wall_clock_timezone_across_dst():
from backend.mvp import parse_ics_events
ics = """BEGIN:VCALENDAR\r
VERSION:2.0\r
BEGIN:VEVENT\r
UID:dst-recurring\r
DTSTART;TZID=America/New_York:20260307T090000\r
DTEND;TZID=America/New_York:20260307T100000\r
RRULE:FREQ=DAILY;COUNT=3\r
SUMMARY:Morning meeting\r
END:VEVENT\r
END:VCALENDAR\r
"""
events = parse_ics_events(
ics,
"DST calendar",
"#fff",
datetime(2026, 3, 7, tzinfo=UTC),
datetime(2026, 3, 11, tzinfo=UTC),
)
assert [row["starts_at"].isoformat() for row in events] == [
"2026-03-07T14:00:00+00:00",
"2026-03-08T13:00:00+00:00",
"2026-03-09T13:00:00+00:00",
]
def test_calendar_parser_replaces_master_occurrence_with_override():
from backend.mvp import parse_ics_events
ics = """BEGIN:VCALENDAR\r
VERSION:2.0\r
BEGIN:VEVENT\r
UID:overridden-series\r
DTSTART;TZID=Asia/Shanghai:20260906T090000\r
DTEND;TZID=Asia/Shanghai:20260906T100000\r
RRULE:FREQ=DAILY;COUNT=2\r
SUMMARY:原始事件\r
END:VEVENT\r
BEGIN:VEVENT\r
UID:overridden-series\r
RECURRENCE-ID;TZID=Asia/Shanghai:20260907T090000\r
DTSTART;TZID=Asia/Shanghai:20260907T110000\r
DTEND;TZID=Asia/Shanghai:20260907T120000\r
SUMMARY:改期事件\r
END:VEVENT\r
END:VCALENDAR\r
"""
events = parse_ics_events(
ics,
"测试日历",
"#fff",
datetime(2026, 9, 5, tzinfo=UTC),
datetime(2026, 9, 8, tzinfo=UTC),
)
assert [(row["title"], row["starts_at"].isoformat()) for row in events] == [
("原始事件", "2026-09-06T01:00:00+00:00"),
("改期事件", "2026-09-07T03:00:00+00:00"),
]
def test_calendar_parser_skips_cancelled_events_and_cancelled_overrides():
from backend.mvp import parse_ics_events
ics = """BEGIN:VCALENDAR\r
VERSION:2.0\r
BEGIN:VEVENT\r
UID:cancelled-single\r
DTSTART:20260906T090000\r
STATUS:CANCELLED\r
SUMMARY:已取消单次事件\r
END:VEVENT\r
BEGIN:VEVENT\r
UID:partly-cancelled-series\r
DTSTART;TZID=Asia/Shanghai:20260906T090000\r
RRULE:FREQ=DAILY;COUNT=2\r
SUMMARY:重复事件\r
END:VEVENT\r
BEGIN:VEVENT\r
UID:partly-cancelled-series\r
RECURRENCE-ID;TZID=Asia/Shanghai:20260907T090000\r
STATUS:CANCELLED\r
SUMMARY:已取消实例\r
END:VEVENT\r
END:VCALENDAR\r
"""
events = parse_ics_events(
ics,
"测试日历",
"#fff",
datetime(2026, 9, 5, tzinfo=UTC),
datetime(2026, 9, 8, tzinfo=UTC),
)
assert [(row["title"], row["starts_at"].isoformat()) for row in events] == [
("重复事件", "2026-09-06T01:00:00+00:00"),
]
def test_calendar_url_rejects_private_addresses(monkeypatch):
from fastapi import HTTPException
from backend.mvp import _validated_calendar_target
monkeypatch.setattr(
"backend.mvp.socket.getaddrinfo",
lambda *_args, **_kwargs: [(None, None, None, None, ("127.0.0.1", 80))],
)
try:
_validated_calendar_target("http://example.com/calendar.ics")
except HTTPException as exc:
assert exc.status_code == 422
else:
raise AssertionError("private calendar target should be rejected")
def test_recurrence_mutations_keep_exact_timestamp_validation(client):
inbox = boot(client)
Generated
-71
View File
@@ -60,18 +60,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/21/a6/2b21ce5ebe4d8938a247c9b0dbb7271566ae559b01795c83ea4bb2660ed7/anyio-4.15.0-py3-none-any.whl", hash = "sha256:7ecd9937369ffce8bba0b5ccb9b3a9507b101b0ed50256aecfbab27e6c2acb99", size = 131908 },
]
[[package]]
name = "apscheduler"
version = "3.11.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzlocal" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/6b/eeff360196bb20b312c9e762a820fd1b2c6d809466c755ef57863478e454/apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a", size = 110312 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl", hash = "sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30", size = 66024 },
]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
@@ -285,11 +273,9 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },
{ name = "apscheduler" },
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "icalendar" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "sqlalchemy", extra = ["asyncio"] },
@@ -310,11 +296,9 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.16,<2" },
{ name = "apscheduler", specifier = ">=3.11,<4" },
{ name = "argon2-cffi", specifier = ">=25,<26" },
{ name = "asyncpg", specifier = ">=0.30,<1" },
{ name = "fastapi", specifier = ">=0.116,<1" },
{ name = "icalendar", specifier = ">=6,<7" },
{ name = "pydantic-settings", specifier = ">=2.10,<3" },
{ name = "python-multipart", specifier = ">=0.0.20,<1" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" },
@@ -488,19 +472,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "icalendar"
version = "6.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/70/458092b3e7c15783423fe64d07e63ea3311a597e723be6a1060513e3db93/icalendar-6.3.2.tar.gz", hash = "sha256:e0c10ecbfcebe958d33af7d491f6e6b7580d11d475f2eeb29532d0424f9110a1", size = 178422 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/ee/2ff96bb5bd88fe03ab90aedf5180f96dc0f3ae4648ca264b473055bcaaff/icalendar-6.3.2-py3-none-any.whl", hash = "sha256:d400e9c9bb8c025e5a3c77c236941bb690494be52528a0b43cc7e8b7c9505064", size = 242403 },
]
[[package]]
name = "idna"
version = "3.19"
@@ -763,18 +734,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "python-dotenv"
version = "1.2.3"
@@ -864,15 +823,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850 },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "sqlalchemy"
version = "2.0.52"
@@ -956,27 +906,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750 },
]
[[package]]
name = "tzdata"
version = "2026.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168 },
]
[[package]]
name = "tzlocal"
version = "5.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115 },
]
[[package]]
name = "uuid-utils"
version = "0.17.0"
View File
-20
View File
@@ -1,20 +0,0 @@
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
async def scan_due_reminders() -> None:
# Reminder delivery is implemented in phase 2. Keeping the scheduler isolated
# ensures API restarts never own reminder timing.
return None
async def main() -> None:
scheduler = AsyncIOScheduler(timezone="UTC")
scheduler.add_job(scan_due_reminders, "interval", seconds=30, max_instances=1)
scheduler.start()
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(main())