Author SHA1 Message Date
bboysoul c160e188e3 feat: add calendar frontend 2026-09-20 12:42:40 +08:00
29 changed files with 86 additions and 1367 deletions
+1 -17
View File
@@ -152,18 +152,6 @@ def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES
content_names = set(names) - {"manifest.json"} content_names = set(names) - {"manifest.json"}
if set(checksums) != content_names: if set(checksums) != content_names:
raise backup_error("backup_manifest_mismatch", "manifest 与 ZIP 条目不一致") raise backup_error("backup_manifest_mismatch", "manifest 与 ZIP 条目不一致")
# Calendar subscriptions are an additive backup-v2 entity. Accept archives
# produced by older helpers that checksum the new file but omit its count.
optional_entities = {"calendar_subscriptions"}
undeclared_optional = {
f"data/{name}.json" for name in optional_entities - set(declared_entities)
}
if set(declared_entities) != {
name[5:-5]
for name in content_names - undeclared_optional
if name.startswith("data/") and name.endswith(".json")
}:
raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
entities: dict[str, list[dict]] = {} entities: dict[str, list[dict]] = {}
files: dict[str, StagedBlob] = {} files: dict[str, StagedBlob] = {}
for index, name in enumerate(sorted(content_names)): for index, name in enumerate(sorted(content_names)):
@@ -182,11 +170,7 @@ def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES
if not isinstance(checksums[name], str) or actual_digest != checksums[name]: if not isinstance(checksums[name], str) or actual_digest != checksums[name]:
raise backup_error("backup_checksum_mismatch", "备份校验和不匹配") raise backup_error("backup_checksum_mismatch", "备份校验和不匹配")
if set(declared_entities) != set(entities): if set(declared_entities) != set(entities):
required_declared = set(entities) - optional_entities raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
if set(declared_entities) != required_declared or any(
entities.get(name) for name in optional_entities - set(declared_entities)
):
raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
if any(type(count) is not int or count < 0 or count != len(entities[name]) for name, count in declared_entities.items()): if any(type(count) is not int or count < 0 or count != len(entities[name]) for name, count in declared_entities.items()):
raise backup_error("backup_manifest_mismatch", "manifest 实体数量不一致") raise backup_error("backup_manifest_mismatch", "manifest 实体数量不一致")
except HTTPException: except HTTPException:
+1 -20
View File
@@ -19,7 +19,6 @@ from backend.models import (
BackupImport, BackupImport,
BackupImportEntity, BackupImportEntity,
BackupPreflight, BackupPreflight,
CalendarSubscription,
Countdown, Countdown,
Folder, Folder,
Habit, Habit,
@@ -66,7 +65,6 @@ ENTITY_MODELS = {
"habit_pauses": HabitPause, "habit_pauses": HabitPause,
"countdowns": Countdown, "countdowns": Countdown,
"memos": Memo, "memos": Memo,
"calendar_subscriptions": CalendarSubscription,
"attachments": Attachment, "attachments": Attachment,
} }
RELATIONS = { RELATIONS = {
@@ -354,9 +352,6 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
"rrule": row.get("rrule"), "rrule": row.get("rrule"),
"trigger_mode": row.get("trigger_mode", "scheduled"), "trigger_mode": row.get("trigger_mode", "scheduled"),
"after_completion_days": row.get("after_completion_days"), "after_completion_days": row.get("after_completion_days"),
"after_completion_unit": row.get("after_completion_unit")
if row.get("trigger_mode", "scheduled") == "after_completion"
else None,
}) })
starts_at = datetime.fromisoformat(row["starts_at"]) starts_at = datetime.fromisoformat(row["starts_at"])
ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None
@@ -397,9 +392,6 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
def validate_archive(parsed: ParsedArchive) -> None: def validate_archive(parsed: ParsedArchive) -> None:
unknown = set(parsed.entities) - set(ENTITY_MODELS) unknown = set(parsed.entities) - set(ENTITY_MODELS)
# Calendar subscriptions were introduced after backup v2. Treat their
# absence as an empty collection so archives from older Dodo releases remain restorable.
parsed.entities.setdefault("calendar_subscriptions", [])
missing = set(ENTITY_MODELS) - set(parsed.entities) missing = set(ENTITY_MODELS) - set(parsed.entities)
if unknown: if unknown:
raise backup_error("backup_entity_unknown", "备份包含未知实体") raise backup_error("backup_entity_unknown", "备份包含未知实体")
@@ -526,8 +518,6 @@ def _coerce(model, raw: dict, mapping: dict[str, dict[str, UUID]], user_id: UUID
elif isinstance(effective_type, Date) and isinstance(value, str): elif isinstance(effective_type, Date) and isinstance(value, str):
value = date.fromisoformat(value) value = date.fromisoformat(value)
values[name] = value values[name] = value
if model is RecurrenceTemplate and "after_completion_unit" not in raw:
values["after_completion_unit"] = "days" if raw.get("trigger_mode") == "after_completion" else None
return values return values
@@ -621,16 +611,7 @@ async def restore_v2(
await db.execute(delete(HabitPause).where( await db.execute(delete(HabitPause).where(
HabitPause.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id)) HabitPause.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id))
)) ))
for model in ( for model in (Attachment, Memo, Countdown, Task, Habit, TaskList, Folder):
Attachment,
CalendarSubscription,
Memo,
Countdown,
Task,
Habit,
TaskList,
Folder,
):
await db.execute(delete(model).where(model.user_id == user.id)) await db.execute(delete(model).where(model.user_id == user.id))
await db.execute(delete(BackupImportEntity).where(BackupImportEntity.user_id == user.id)) await db.execute(delete(BackupImportEntity).where(BackupImportEntity.user_id == user.id))
await db.execute(delete(BackupImport).where(BackupImport.user_id == user.id)) await db.execute(delete(BackupImport).where(BackupImport.user_id == user.id))
-254
View File
@@ -1,254 +0,0 @@
from __future__ import annotations
import http.client
import ipaddress
import socket
import ssl
import urllib.parse
from dataclasses import dataclass
from datetime import UTC, date, datetime, time, timedelta
from itertools import islice
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from dateutil.rrule import rrulestr
from fastapi import HTTPException
from icalendar import Calendar
MAX_ICS_BYTES = 2_000_000
MAX_REDIRECTS = 3
DEFAULT_RECURRENCE_LIMIT = 10_000
MAX_DESCRIPTION_LENGTH = 2_000
MAX_LOCATION_LENGTH = 500
TIMEOUT_SECONDS = 10
_ALLOWED_CONTENT_TYPES = {"text/calendar", "text/plain", "application/octet-stream"}
@dataclass(frozen=True)
class FetchResult:
content: bytes | None
etag: str | None
last_modified: str | None
not_modified: bool
def _is_global(value: str) -> bool:
return ipaddress.ip_address(value.split("%", 1)[0]).is_global
def validate_calendar_url(url: str) -> tuple[str, str, int]:
try:
parsed = urllib.parse.urlsplit(url)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
except ValueError as exc:
raise HTTPException(422, "invalid calendar URL") from exc
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.fragment
):
raise HTTPException(422, "calendar URL must be HTTP(S) without userinfo or fragment")
try:
infos = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise HTTPException(422, "calendar host cannot be resolved") from exc
addresses = {item[4][0] for item in infos}
if not addresses or not all(_is_global(address) for address in addresses):
raise HTTPException(422, "calendar host must resolve only to public addresses")
return url, min(addresses), port
def _request(url: str, headers: dict[str, str]) -> tuple[int, list[tuple[str, str]], str, bytes]:
_, ip, port = validate_calendar_url(url)
parsed = urllib.parse.urlsplit(url)
target = f"[{ip}]" if ":" in ip else ip
host = parsed.hostname or ""
if parsed.port:
host = f"{host}:{parsed.port}"
request_headers = {"Host": host, "User-Agent": "dodo-calendar-fetch/1.0", **headers}
path = urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, ""))
connection: http.client.HTTPConnection
if parsed.scheme == "https":
connection = http.client.HTTPSConnection(target, port=port, timeout=TIMEOUT_SECONDS)
else:
connection = http.client.HTTPConnection(target, port=port, timeout=TIMEOUT_SECONDS)
try:
if parsed.scheme == "https":
raw = socket.create_connection((ip, port), timeout=TIMEOUT_SECONDS)
connection.sock = ssl.create_default_context().wrap_socket(
raw, server_hostname=parsed.hostname
)
connection.request("GET", path, headers=request_headers)
response = connection.getresponse()
length = response.getheader("Content-Length")
if length and int(length) > MAX_ICS_BYTES:
raise HTTPException(413, "calendar exceeds 2MB")
body = response.read(MAX_ICS_BYTES + 1)
return response.status, response.getheaders(), response.getheader("Content-Type") or "", body
except HTTPException:
raise
except (OSError, http.client.HTTPException, ValueError) as exc:
raise HTTPException(502, "calendar upstream unavailable") from exc
finally:
connection.close()
def fetch_calendar(url: str, *, etag: str | None = None, last_modified: str | None = None) -> FetchResult:
headers = {}
if etag:
headers["If-None-Match"] = etag
if last_modified:
headers["If-Modified-Since"] = last_modified
current = url
for redirect_count in range(MAX_REDIRECTS + 1):
status, response_headers, content_type, body = _request(current, headers)
header_map = {key.lower(): value for key, value in response_headers}
if status in {301, 302, 303, 307, 308}:
if redirect_count == MAX_REDIRECTS or not header_map.get("location"):
raise HTTPException(502, "calendar redirect limit exceeded")
current = urllib.parse.urljoin(current, header_map["location"])
validate_calendar_url(current)
continue
if status == 304:
return FetchResult(None, etag, last_modified, True)
if status >= 400:
raise HTTPException(502, f"calendar upstream returned HTTP {status}")
if len(body) > MAX_ICS_BYTES:
raise HTTPException(413, "calendar exceeds 2MB")
if content_type.split(";", 1)[0].lower() not in _ALLOWED_CONTENT_TYPES:
raise HTTPException(422, "URL did not return an iCalendar document")
return FetchResult(body, header_map.get("etag"), header_map.get("last-modified"), False)
raise HTTPException(502, "calendar redirect limit exceeded")
def _localize(value: date | datetime, timezone: ZoneInfo) -> tuple[datetime, bool]:
if isinstance(value, datetime):
return (value if value.tzinfo else value.replace(tzinfo=timezone)), False
return datetime.combine(value, time.min, tzinfo=timezone), True
def _utc(value: date | datetime, timezone: ZoneInfo) -> tuple[datetime, bool]:
localized, all_day = _localize(value, timezone)
return localized.astimezone(UTC), all_day
def _duration(event: Any, starts_at: datetime, all_day: bool, timezone: ZoneInfo) -> timedelta:
if event.get("dtend"):
ends_at, _ = _utc(event.decoded("dtend"), timezone)
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 _exdates(event: Any, timezone: ZoneInfo) -> set[datetime]:
values = event.get("exdate")
if not values:
return set()
excluded = set()
for item in values if isinstance(values, list) else [values]:
for value in getattr(item, "dts", []):
excluded.add(_utc(value.dt, timezone)[0])
return excluded
def _overlaps(start: datetime, end: datetime, window_start: datetime, window_end: datetime) -> bool:
return start < window_end and end > window_start
def _event_dict(event: Any, source_id: str, source: str, color: str, start: datetime, end: datetime, all_day: bool) -> dict:
uid = str(event.get("uid") or "")
title = str(event.get("summary") or "Untitled event").strip() or "Untitled event"
description = str(event.get("description") or "").strip()[:MAX_DESCRIPTION_LENGTH]
location = str(event.get("location") or "").strip()[:MAX_LOCATION_LENGTH]
return {
"id": f"{uid or title}:{start.isoformat()}",
"title": title,
"description": description or None,
"location": location or None,
"starts_at": start,
"ends_at": end,
"all_day": all_day,
"source_id": source_id,
"source_name": source,
"color": color,
}
def parse_ics_events(
content: bytes | str,
source_name: str,
color: str,
window_start: datetime,
window_end: datetime,
timezone_name: str,
*,
source_id: str = "",
recurrence_limit: int = DEFAULT_RECURRENCE_LIMIT,
) -> list[dict]:
try:
timezone = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError as exc:
raise ValueError("invalid timezone") from exc
try:
calendar = Calendar.from_ical(content)
except Exception as exc:
raise ValueError("invalid iCalendar document") from exc
components = list(calendar.walk("VEVENT"))
master_durations = {}
for event in components:
if event.get("dtstart") and not event.get("recurrence-id"):
master_start, master_all_day = _utc(event.decoded("dtstart"), timezone)
master_durations[str(event.get("uid") or "")] = _duration(
event, master_start, master_all_day, timezone
)
overrides = {}
for event in components:
recurrence_id = event.get("recurrence-id")
if recurrence_id:
overrides[(str(event.get("uid") or ""), _utc(event.decoded("recurrence-id"), timezone)[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
local_start, all_day = _localize(event.decoded("dtstart"), timezone)
start = local_start.astimezone(UTC)
duration = _duration(event, start, all_day, timezone)
uid = str(event.get("uid") or "")
if event.get("rrule"):
try:
rule = rrulestr(event.get("rrule").to_ical().decode(), dtstart=local_start)
bounded = rule.xafter(window_start - duration, count=recurrence_limit + 1, inc=True)
occurrences = [item for item in islice(bounded, recurrence_limit + 1) if item < window_end]
except Exception as exc:
raise ValueError("invalid recurrence rule") from exc
if len(occurrences) > recurrence_limit:
raise ValueError("recurrence limit exceeded")
excluded = _exdates(event, timezone)
for occurrence in occurrences:
occurrence = (occurrence if occurrence.tzinfo else occurrence.replace(tzinfo=timezone)).astimezone(UTC)
if occurrence in excluded or (uid, occurrence) in overrides:
continue
end = occurrence + duration
if _overlaps(occurrence, end, window_start, window_end):
events.append(_event_dict(event, source_id, source_name, color, occurrence, end, all_day))
else:
end = start + duration
if _overlaps(start, end, window_start, window_end):
events.append(_event_dict(event, source_id, source_name, color, start, end, all_day))
for event in overrides.values():
if not event.get("dtstart") or str(event.get("status") or "").upper() == "CANCELLED":
continue
start, all_day = _utc(event.decoded("dtstart"), timezone)
uid = str(event.get("uid") or "")
duration = (
_duration(event, start, all_day, timezone)
if event.get("dtend") or event.get("duration")
else master_durations.get(uid, timedelta(days=1) if all_day else timedelta(hours=1))
)
end = start + duration
if _overlaps(start, end, window_start, window_end):
events.append(_event_dict(event, source_id, source_name, color, start, end, all_day))
return sorted(events, key=lambda item: (item["starts_at"], item["title"], item["id"]))
-224
View File
@@ -1,224 +0,0 @@
from datetime import UTC, datetime, timedelta
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from . import calendar as calendar_service
from .auth import current_user
from .db import get_db
from .models import CalendarSubscription, User, utcnow
router = APIRouter(prefix="/api/v1", tags=["calendar"])
MAX_WINDOW = timedelta(days=366)
class SubscriptionCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
url: str = Field(min_length=1, max_length=2000)
color: str = Field(default="#f15a29", pattern=r"^#[0-9A-Fa-f]{6}$")
enabled: bool = True
@field_validator("name")
@classmethod
def clean_name(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("name cannot be blank")
return value
class SubscriptionUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
url: str | None = Field(default=None, min_length=1, max_length=2000)
color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$")
enabled: bool | None = None
@field_validator("name")
@classmethod
def clean_name(cls, value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
if not value:
raise ValueError("name cannot be blank")
return value
@model_validator(mode="after")
def reject_nulls(self):
for field in self.model_fields_set:
if getattr(self, field) is None:
raise ValueError(f"{field} cannot be null")
return self
class SubscriptionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
url: str
color: str
enabled: bool
refreshed_at: datetime | None
last_error: str | None
stale: bool
def _out(row: CalendarSubscription) -> dict:
return {
"id": row.id,
"name": row.name,
"url": row.url,
"color": row.color,
"enabled": row.enabled,
"refreshed_at": row.refreshed_at,
"last_error": row.last_error,
"stale": bool(row.last_error and row.ics_cache),
}
async def _owned(db: AsyncSession, user_id: UUID, subscription_id: UUID) -> CalendarSubscription:
row = await db.scalar(select(CalendarSubscription).where(
CalendarSubscription.id == subscription_id,
CalendarSubscription.user_id == user_id,
))
if row is None:
raise HTTPException(404, "calendar subscription not found")
return row
async def _refresh(db: AsyncSession, row: CalendarSubscription) -> None:
try:
result = await __import__("asyncio").to_thread(
calendar_service.fetch_calendar, row.url, etag=row.etag, last_modified=row.last_modified
)
if result.not_modified:
if not row.ics_cache:
raise HTTPException(502, "calendar returned not modified without cache")
elif result.content is not None:
# Parse before replacing a known-good cache.
calendar_service.parse_ics_events(
result.content,
row.name,
row.color,
datetime.now(UTC) - timedelta(days=1),
datetime.now(UTC) + timedelta(days=1),
"UTC",
)
row.ics_cache = result.content.decode("utf-8-sig")
row.etag = result.etag
row.last_modified = result.last_modified
row.refreshed_at = utcnow()
row.last_error = None
except Exception as exc:
row.last_error = exc.detail if isinstance(exc, HTTPException) else str(exc)
if not row.ics_cache:
await db.rollback()
raise HTTPException(502, row.last_error) from exc
await db.commit()
@router.get("/calendar-subscriptions", response_model=list[SubscriptionOut])
async def list_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, CalendarSubscription.id))).all()
return [_out(row) for row in rows]
@router.post("/calendar-subscriptions", response_model=SubscriptionOut, status_code=201)
async def create_subscription(
payload: SubscriptionCreate,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
calendar_service.validate_calendar_url(payload.url)
row = CalendarSubscription(user_id=user.id, **payload.model_dump())
db.add(row)
await db.flush()
await _refresh(db, row)
await db.refresh(row)
return _out(row)
@router.patch("/calendar-subscriptions/{subscription_id}", response_model=SubscriptionOut)
async def update_subscription(
subscription_id: UUID,
payload: SubscriptionUpdate,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
row = await _owned(db, user.id, subscription_id)
changes = payload.model_dump(exclude_unset=True)
if "url" in changes:
calendar_service.validate_calendar_url(changes["url"])
if changes["url"] != row.url:
row.ics_cache = row.etag = row.last_modified = row.refreshed_at = row.last_error = None
for key, value in changes.items():
setattr(row, key, value)
await db.commit()
await db.refresh(row)
return _out(row)
@router.delete("/calendar-subscriptions/{subscription_id}", status_code=204)
async def delete_subscription(
subscription_id: UUID,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
row = await _owned(db, user.id, subscription_id)
await db.delete(row)
await db.commit()
return Response(status_code=204)
@router.post("/calendar-subscriptions/{subscription_id}/refresh", response_model=SubscriptionOut)
async def refresh_subscription(
subscription_id: UUID,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
row = await _owned(db, user.id, subscription_id)
await _refresh(db, row)
await db.refresh(row)
return _out(row)
@router.get("/calendar-events")
async def calendar_events(
start: datetime = Query(),
end: datetime = Query(),
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
if start.tzinfo is None or end.tzinfo is None or end <= start or end - start > MAX_WINDOW:
raise HTTPException(422, "start/end must be timezone-aware and span at most 366 days")
try:
ZoneInfo(user.timezone)
except ZoneInfoNotFoundError as exc:
raise HTTPException(422, "user timezone is invalid") from exc
rows = (await db.scalars(select(CalendarSubscription).where(
CalendarSubscription.user_id == user.id,
CalendarSubscription.enabled.is_(True),
).order_by(CalendarSubscription.created_at, CalendarSubscription.id))).all()
events = []
sources = []
for row in rows:
if not row.ics_cache:
await _refresh(db, row)
try:
parsed = calendar_service.parse_ics_events(
row.ics_cache or "", row.name, row.color, start, end, user.timezone,
source_id=str(row.id),
)
events.extend(parsed)
except ValueError as exc:
row.last_error = str(exc)
await db.commit()
sources.append({"id": row.id, "name": row.name, "stale": bool(row.last_error)})
events.sort(key=lambda item: (item["starts_at"], item["title"], item["id"]))
return {"events": events, "sources": sources}
+1 -6
View File
@@ -30,7 +30,6 @@ from .auth import (
verify_password, verify_password,
) )
from .backup import router as backup_router from .backup import router as backup_router
from .calendar_router import router as calendar_router
from .db import create_schema, get_db from .db import create_schema, get_db
from .models import ( from .models import (
AppState, AppState,
@@ -121,7 +120,6 @@ async def openapi(_: User = Depends(current_user)):
app.include_router(mvp_router) app.include_router(mvp_router)
app.include_router(backup_router) app.include_router(backup_router)
app.include_router(calendar_router)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque) _login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
@@ -999,7 +997,7 @@ async def create_task(
from .mvp import parse_rrule from .mvp import parse_rrule
if payload.rrule: if payload.rrule:
parse_rrule(payload.rrule) parse_rrule(payload.rrule)
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days", "after_completion_unit"}) data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days"})
parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None) parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None)
max_position = await db.scalar(select(func.max(Task.position)).where( max_position = await db.scalar(select(func.max(Task.position)).where(
Task.user_id == user.id, Task.user_id == user.id,
@@ -1019,9 +1017,6 @@ async def create_task(
starts_at=task.due_at, starts_at=task.due_at,
trigger_mode=payload.trigger_mode or "scheduled", trigger_mode=payload.trigger_mode or "scheduled",
after_completion_days=payload.after_completion_days, after_completion_days=payload.after_completion_days,
after_completion_unit=(payload.after_completion_unit or "days")
if payload.trigger_mode == "after_completion"
else None,
) )
) )
audit(db, user.id, "create", "task", task.id) audit(db, user.id, "create", "task", task.id)
-18
View File
@@ -154,7 +154,6 @@ class RecurrenceTemplate(Base):
ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
trigger_mode: Mapped[str] = mapped_column(String(32), default="scheduled") trigger_mode: Mapped[str] = mapped_column(String(32), default="scheduled")
after_completion_days: Mapped[int | None] = mapped_column(Integer, nullable=True) after_completion_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
after_completion_unit: Mapped[str | None] = mapped_column(String(8), nullable=True)
last_completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) last_completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow) created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
@@ -302,23 +301,6 @@ class BackupImportEntity(Base):
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow) created_at: Mapped[datetime] = mapped_column(UTCDateTime(), 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)
ics_cache: Mapped[str | None] = mapped_column(Text, nullable=True)
etag: Mapped[str | None] = mapped_column(String(512), nullable=True)
last_modified: Mapped[str | None] = mapped_column(String(512), nullable=True)
refreshed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow, onupdate=utcnow)
class AuditLog(Base): class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
+4 -30
View File
@@ -251,22 +251,17 @@ class RecurrenceCreate(BaseModel):
rrule: str | None = Field(default=None, min_length=5, max_length=1000) rrule: str | None = Field(default=None, min_length=5, max_length=1000)
trigger_mode: str = Field(default="scheduled", pattern="^(scheduled|after_completion)$") trigger_mode: str = Field(default="scheduled", pattern="^(scheduled|after_completion)$")
after_completion_days: int | None = Field(default=None, ge=1, le=3650) after_completion_days: int | None = Field(default=None, ge=1, le=3650)
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
@model_validator(mode="after") @model_validator(mode="after")
def validate_mode(self): def validate_mode(self):
if self.trigger_mode == "scheduled" and ( if self.trigger_mode == "scheduled" and (
self.rrule is None self.rrule is None or self.after_completion_days is not None
or self.after_completion_days is not None
or self.after_completion_unit is not None
): ):
raise ValueError("scheduled recurrence requires rrule and no completion interval") raise ValueError("scheduled recurrence requires rrule and no completion interval")
if self.trigger_mode == "after_completion" and ( if self.trigger_mode == "after_completion" and (
self.after_completion_days is None or self.rrule is not None self.after_completion_days is None or self.rrule is not None
): ):
raise ValueError("after_completion requires days and no rrule") raise ValueError("after_completion requires days and no rrule")
if self.trigger_mode == "after_completion" and self.after_completion_unit is None:
self.after_completion_unit = "days"
return self return self
@@ -276,7 +271,6 @@ class RecurrenceChange(BaseModel):
rrule: str | None = None rrule: str | None = None
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$") trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
after_completion_days: int | None = Field(default=None, ge=1, le=3650) after_completion_days: int | None = Field(default=None, ge=1, le=3650)
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
class OccurrenceComplete(BaseModel): class OccurrenceComplete(BaseModel):
@@ -436,7 +430,6 @@ async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user),
"ends_at": row.ends_at, "ends_at": row.ends_at,
"trigger_mode": row.trigger_mode, "trigger_mode": row.trigger_mode,
"after_completion_days": row.after_completion_days, "after_completion_days": row.after_completion_days,
"after_completion_unit": row.after_completion_unit,
"last_completed_at": row.last_completed_at, "last_completed_at": row.last_completed_at,
} }
@@ -459,9 +452,6 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
starts_at=task.due_at, starts_at=task.due_at,
trigger_mode=payload.trigger_mode, trigger_mode=payload.trigger_mode,
after_completion_days=payload.after_completion_days, after_completion_days=payload.after_completion_days,
after_completion_unit=(payload.after_completion_unit or "days")
if payload.trigger_mode == "after_completion"
else None,
) )
db.add(row) db.add(row)
await db.commit(); await db.refresh(row) await db.commit(); await db.refresh(row)
@@ -473,7 +463,6 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
"ends_at": row.ends_at, "ends_at": row.ends_at,
"trigger_mode": row.trigger_mode, "trigger_mode": row.trigger_mode,
"after_completion_days": row.after_completion_days, "after_completion_days": row.after_completion_days,
"after_completion_unit": row.after_completion_unit,
"last_completed_at": row.last_completed_at, "last_completed_at": row.last_completed_at,
} }
@@ -515,18 +504,12 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
if "after_completion_days" in payload.model_fields_set if "after_completion_days" in payload.model_fields_set
else template.after_completion_days else template.after_completion_days
) )
requested_unit = (
payload.after_completion_unit
if "after_completion_unit" in payload.model_fields_set
else template.after_completion_unit
)
requested_rrule = payload.rrule if payload.rrule is not None else template.rrule requested_rrule = payload.rrule if payload.rrule is not None else template.rrule
if requested_mode == "after_completion": if requested_mode == "after_completion":
if requested_days is None: if requested_days is None:
raise HTTPException(422, "完成后重复需要间隔天数") raise HTTPException(422, "完成后重复需要间隔天数")
template.trigger_mode = requested_mode template.trigger_mode = requested_mode
template.after_completion_days = requested_days template.after_completion_days = requested_days
template.after_completion_unit = requested_unit or "days"
template.rrule = None template.rrule = None
else: else:
if requested_rrule is None: if requested_rrule is None:
@@ -534,7 +517,6 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
parse_rrule(requested_rrule) parse_rrule(requested_rrule)
template.trigger_mode = requested_mode template.trigger_mode = requested_mode
template.after_completion_days = None template.after_completion_days = None
template.after_completion_unit = None
template.rrule = requested_rrule.upper() template.rrule = requested_rrule.upper()
if payload.title is not None: if payload.title is not None:
task.title = payload.title task.title = payload.title
@@ -551,7 +533,6 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
"ends_at": template.ends_at, "ends_at": template.ends_at,
"trigger_mode": template.trigger_mode, "trigger_mode": template.trigger_mode,
"after_completion_days": template.after_completion_days, "after_completion_days": template.after_completion_days,
"after_completion_unit": template.after_completion_unit,
"last_completed_at": template.last_completed_at, "last_completed_at": template.last_completed_at,
} }
@@ -1377,7 +1358,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memo
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders], "folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists], "lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "completed_at", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks], "tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "completed_at", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "after_completion_unit", "last_completed_at"]) for x in recurrences], "recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits], "habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns], "countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
"memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos], "memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos],
@@ -1589,17 +1570,11 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
rrule = raw.get("rrule") rrule = raw.get("rrule")
if trigger_mode not in {"scheduled", "after_completion"}: if trigger_mode not in {"scheduled", "after_completion"}:
raise HTTPException(422, "无效的重复触发模式") raise HTTPException(422, "无效的重复触发模式")
unit = raw.get("after_completion_unit")
if unit is not None and unit not in {"days", "months"}:
raise HTTPException(422, "无效的完成后重复单位")
if trigger_mode == "after_completion": if trigger_mode == "after_completion":
if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650 or rrule is not None: if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650 or rrule is not None:
raise HTTPException(422, "无效的完成后重复备份") raise HTTPException(422, "无效的完成后重复备份")
unit = unit or "days" elif not isinstance(rrule, str):
else: raise HTTPException(422, "定期重复缺少 RRULE")
if not isinstance(rrule, str) or days is not None or unit is not None:
raise HTTPException(422, "定期重复备份包含无效字段")
unit = None
db.add(RecurrenceTemplate( db.add(RecurrenceTemplate(
user_id=user.id, user_id=user.id,
task_id=task_id, task_id=task_id,
@@ -1608,7 +1583,6 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None, ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
trigger_mode=trigger_mode, trigger_mode=trigger_mode,
after_completion_days=days, after_completion_days=days,
after_completion_unit=unit,
last_completed_at=datetime.fromisoformat(raw["last_completed_at"]) last_completed_at=datetime.fromisoformat(raw["last_completed_at"])
if raw.get("last_completed_at") else None, if raw.get("last_completed_at") else None,
)) ))
+3 -13
View File
@@ -2,7 +2,6 @@ from datetime import UTC, datetime, time, timedelta
from uuid import UUID from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from dateutil.relativedelta import relativedelta
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,15 +25,10 @@ def _user_zone(user: User) -> ZoneInfo:
raise HTTPException(422, "用户时区无效") from exc raise HTTPException(422, "用户时区无效") from exc
def _after_completion_due( def _after_completion_due(task: Task, completed_at: datetime, days: int, user: User) -> datetime:
task: Task, completed_at: datetime, value: int, unit: str | None, user: User
) -> datetime:
zone = _user_zone(user) zone = _user_zone(user)
completed_local = completed_at.astimezone(zone) completed_local = completed_at.astimezone(zone)
if unit == "months": target_date = completed_local.date() + timedelta(days=days)
target_date = completed_local.date() + relativedelta(months=value)
else:
target_date = completed_local.date() + timedelta(days=value)
if task.due_has_time: if task.due_has_time:
due_local = task.due_at.astimezone(zone) due_local = task.due_at.astimezone(zone)
wall_time = due_local.timetz().replace(tzinfo=None) wall_time = due_local.timetz().replace(tzinfo=None)
@@ -73,11 +67,7 @@ async def apply_task_changes(
if recurrence.trigger_mode == "after_completion": if recurrence.trigger_mode == "after_completion":
completed_at = utcnow() completed_at = utcnow()
next_due = _after_completion_due( next_due = _after_completion_due(
task, task, completed_at, recurrence.after_completion_days, user
completed_at,
recurrence.after_completion_days,
recurrence.after_completion_unit,
user,
) )
changes["completed"] = False changes["completed"] = False
changes["due_at"] = next_due changes["due_at"] = next_due
+5 -16
View File
@@ -125,7 +125,6 @@ class TaskCreate(BaseModel):
rrule: str | None = Field(default=None, min_length=5, max_length=1000) rrule: str | None = Field(default=None, min_length=5, max_length=1000)
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$") trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
after_completion_days: int | None = Field(default=None, ge=1, le=3650) after_completion_days: int | None = Field(default=None, ge=1, le=3650)
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
@field_validator("title") @field_validator("title")
@classmethod @classmethod
@@ -137,12 +136,7 @@ class TaskCreate(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def validate_recurrence(self): def validate_recurrence(self):
has_recurrence = ( has_recurrence = self.rrule is not None or self.trigger_mode is not None or self.after_completion_days is not None
self.rrule is not None
or self.trigger_mode is not None
or self.after_completion_days is not None
or self.after_completion_unit is not None
)
if has_recurrence and self.due_at is None: if has_recurrence and self.due_at is None:
raise ValueError("recurrence requires due_at") raise ValueError("recurrence requires due_at")
if has_recurrence and self.parent_id is not None: if has_recurrence and self.parent_id is not None:
@@ -150,15 +144,10 @@ class TaskCreate(BaseModel):
if self.trigger_mode == "after_completion": if self.trigger_mode == "after_completion":
if self.after_completion_days is None or self.rrule is not None: if self.after_completion_days is None or self.rrule is not None:
raise ValueError("after_completion requires days and no rrule") raise ValueError("after_completion requires days and no rrule")
elif self.trigger_mode == "scheduled": elif self.trigger_mode == "scheduled" and self.rrule is None:
if ( raise ValueError("scheduled recurrence requires rrule")
self.rrule is None elif self.trigger_mode is None and self.after_completion_days is not None:
or self.after_completion_days is not None raise ValueError("after_completion_days requires after_completion mode")
or self.after_completion_unit is not None
):
raise ValueError("scheduled recurrence requires rrule and no completion interval")
elif self.after_completion_days is not None or self.after_completion_unit is not None:
raise ValueError("completion interval requires after_completion mode")
return self return self
+1 -44
View File
@@ -160,7 +160,7 @@ test('all bottom destinations expose one active page and desktop layout stays un
const navigation = page.getByRole('navigation', { name: '主要导航' }) const navigation = page.getByRole('navigation', { name: '主要导航' })
const mobile = (await page.viewportSize())!.width <= 930 const mobile = (await page.viewportSize())!.width <= 930
if (mobile) { if (mobile) {
for (const label of ['今天', '习惯', '倒数日', '备忘录', '日历订阅']) { for (const label of ['今天', '习惯', '倒数日', '备忘录', '日历']) {
await bottomTab(page, label).click() await bottomTab(page, label).click()
await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1) await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1)
await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page') await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page')
@@ -200,49 +200,6 @@ test('all bottom destinations expose one active page and desktop layout stays un
expect(desktopBottomGap).toBe(84) expect(desktopBottomGap).toBe(84)
}) })
test('calendar week focus keeps seven usable day controls without page overflow', async ({ page }) => {
const calendarSources = ['天气','法定节假日','老黄历','节日节气','星座','影视上新','股市指数','街舞赛事','F1'].map((name, index) => ({ id:`calendar-source-${index}`, name, url:`https://example.com/${index}.ics`, color:'#f15a29', enabled:true, refreshed_at:null, last_error:null, stale:false }))
await page.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: calendarSources }))
await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [], sources: [] } }))
await page.goto('/')
const mobile = (await page.viewportSize())!.width <= 930
if (mobile) await bottomTab(page, '日历订阅').click()
else await page.locator('.primary-nav').getByRole('button', { name: '日历订阅', exact: true }).click()
const strip = page.getByRole('group', { name: '选择日期' })
const filters = page.locator('.calendar-filters')
await expect(strip).toBeVisible()
await expect(filters).toBeVisible()
await expect(strip.locator('.calendar-week-day')).toHaveCount(7)
const metrics = await strip.evaluate(element => {
const filters = document.querySelector<HTMLElement>('.calendar-filters')!
const calendar = document.querySelector<HTMLElement>('.calendar-view')!
const main = calendar.closest('main') as HTMLElement
return {
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
bodyOverflow: document.body.scrollWidth - document.body.clientWidth,
mainOverflow: main.scrollWidth - main.clientWidth,
calendarOverflow: calendar.scrollWidth - calendar.clientWidth,
filters: { clientWidth: filters.clientWidth, scrollWidth: filters.scrollWidth, overflowX: getComputedStyle(filters).overflowX },
stripOverflow: element.scrollWidth > element.clientWidth,
buttons: [...element.querySelectorAll<HTMLElement>('.calendar-week-day')].map(button => ({ width: button.getBoundingClientRect().width, height: button.getBoundingClientRect().height })),
}
})
expect(metrics.documentOverflow).toBe(0)
expect(metrics.bodyOverflow).toBe(0)
expect(metrics.mainOverflow).toBe(0)
expect(metrics.calendarOverflow).toBe(0)
if ((await page.viewportSize())!.width <= 930) expect(metrics.filters.clientWidth).toBeLessThan(metrics.filters.scrollWidth)
else expect(metrics.filters.clientWidth).toBeGreaterThanOrEqual(metrics.filters.scrollWidth)
expect(metrics.filters.overflowX).toBe('auto')
expect(metrics.buttons.every(button => button.width >= 44 && button.height >= 44)).toBeTruthy()
const selectedColors = await strip.locator('.calendar-week-day.is-selected').evaluate(element => {
const style = getComputedStyle(element)
return { background: style.backgroundColor, color: style.color }
})
expect(selectedColors).toEqual({ background: 'rgb(241, 90, 41)', color: 'rgb(255, 255, 255)' })
if ((await page.viewportSize())!.width <= 390) expect(metrics.stripOverflow).toBeTruthy()
})
test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => { test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => {
await page.goto('/') await page.goto('/')
await openSettings(page) await openSettings(page)
@@ -1,63 +0,0 @@
import type { APIRequestContext, Page } from '@playwright/test'
import { expect, test } from './fixtures'
async function csrf(request: APIRequestContext) {
const state = await request.storageState()
return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? ''
}
async function createTask(request: APIRequestContext, baseURL: string, listId: string, title: string) {
const response = await request.post('/api/v1/tasks', {
data: { title, list_id: listId },
headers: { 'x-csrf-token': await csrf(request), origin: baseURL },
})
expect(response.ok(), await response.text()).toBeTruthy()
}
async function openInbox(page: Page) {
const inbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true })
if (await page.evaluate(() => window.innerWidth <= 930)) {
await page.locator('main .topbar > button').first().click()
}
await inbox.click()
}
test('task pagination stays below the list and returns to the list start after navigation', async ({ page, request, baseURL }) => {
const bootstrapResponse = await request.get('/api/v1/bootstrap')
expect(bootstrapResponse.ok()).toBeTruthy()
const inbox = (await bootstrapResponse.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
expect(inbox).toBeTruthy()
for (let index = 1; index <= 51; index += 1) {
await createTask(request, baseURL!, inbox.id, `分页验收任务 ${String(index).padStart(2, '0')}`)
}
await page.goto('/')
await openInbox(page)
const taskList = page.locator('.task-list')
const pager = page.locator('.pager')
await expect(taskList.locator('.task-row')).toHaveCount(50)
await expect(pager).toBeVisible()
await expect(pager).toContainText('1 / 2')
const firstPageGeometry = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>('.task-list')!.getBoundingClientRect()
const pagerBox = document.querySelector<HTMLElement>('.pager')!.getBoundingClientRect()
return { listBottom: list.bottom, pagerTop: pagerBox.top }
})
expect(firstPageGeometry.pagerTop).toBeGreaterThanOrEqual(firstPageGeometry.listBottom)
await pager.getByRole('button', { name: '下一页' }).click()
await expect(pager).toContainText('2 / 2')
await expect(taskList.locator('.task-row')).toHaveCount(1)
const secondPageGeometry = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>('.task-list')!.getBoundingClientRect()
const pagerBox = document.querySelector<HTMLElement>('.pager')!.getBoundingClientRect()
return { listTop: list.top, listBottom: list.bottom, pagerTop: pagerBox.top, viewportHeight: innerHeight }
})
expect(secondPageGeometry.listTop).toBeGreaterThanOrEqual(-1)
expect(secondPageGeometry.listTop).toBeLessThan(secondPageGeometry.viewportHeight / 2)
expect(secondPageGeometry.pagerTop).toBeGreaterThanOrEqual(secondPageGeometry.listBottom)
})
+22 -41
View File
@@ -5,7 +5,7 @@ import {
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote,
Settings, Trash2, X, Repeat2, StickyNote, Settings, Trash2, X, Repeat2, StickyNote,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils' import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
@@ -32,7 +32,7 @@ type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean } type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] } type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
type RepeatOption = TaskRepeatOption type RepeatOption = TaskRepeatOption
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null; after_completion_unit: AfterCompletionUnit | null } type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings' type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
const initialized = ref<boolean | null>(null) const initialized = ref<boolean | null>(null)
@@ -56,7 +56,6 @@ let purgeListTrigger: HTMLElement | null = null
const tasks = ref<Task[]>([]) const tasks = ref<Task[]>([])
const overdueTasks = ref<Task[]>([]) const overdueTasks = ref<Task[]>([])
const trash = ref<Task[]>([]) const trash = ref<Task[]>([])
const taskListElement = ref<HTMLElement | null>(null)
const NAVIGATION_STORAGE_KEY = 'dodo.navigation' const NAVIGATION_STORAGE_KEY = 'dodo.navigation'
const restoredNavigation = readStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY) const restoredNavigation = readStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY)
const activeList = ref(restoredNavigation.listId) const activeList = ref(restoredNavigation.listId)
@@ -159,11 +158,9 @@ const composePriority = ref(0)
const composeDescription = ref('') const composeDescription = ref('')
const composeRepeat = ref<RepeatOption>('none') const composeRepeat = ref<RepeatOption>('none')
const composeAfterCompletionDays = ref('1') const composeAfterCompletionDays = ref('1')
const composeAfterCompletionUnit = ref<AfterCompletionUnit>('days')
const composeRepeatError = ref('') const composeRepeatError = ref('')
const selectedTaskRepeat = ref<RepeatOption>('none') const selectedTaskRepeat = ref<RepeatOption>('none')
const selectedAfterCompletionDays = ref('1') const selectedAfterCompletionDays = ref('1')
const selectedAfterCompletionUnit = ref<AfterCompletionUnit>('days')
const selectedRepeatError = ref('') const selectedRepeatError = ref('')
const selectedTaskRecurrence = ref<Recurrence | null>(null) const selectedTaskRecurrence = ref<Recurrence | null>(null)
const recurrenceLoading = ref(false) const recurrenceLoading = ref(false)
@@ -208,7 +205,6 @@ function openTaskCompose() {
composeDescription.value = '' composeDescription.value = ''
composeRepeat.value = 'none' composeRepeat.value = 'none'
composeAfterCompletionDays.value = '1' composeAfterCompletionDays.value = '1'
composeAfterCompletionUnit.value = 'days'
composeRepeatError.value = '' composeRepeatError.value = ''
composeRepeatConfig.value = defaultRepeatConfig() composeRepeatConfig.value = defaultRepeatConfig()
composeCalendarOpen.value = false composeCalendarOpen.value = false
@@ -247,13 +243,13 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
else if (activeView.value === 'memos') void memoPanel.value?.createMemo() else if (activeView.value === 'memos') void memoPanel.value?.createMemo()
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose() else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
} }
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, afterCompletionUnit: AfterCompletionUnit, recurrence: Recurrence | null) { async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, recurrence: Recurrence | null) {
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间') if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
if (value === 'none') { if (value === 'none') {
if (recurrence) await api(`/recurrences/${recurrence.id}`, { method: 'DELETE' }) if (recurrence) await api(`/recurrences/${recurrence.id}`, { method: 'DELETE' })
return null return null
} }
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config }) const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })
if (recurrence) { if (recurrence) {
return await api(`/recurrences/${recurrence.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence return await api(`/recurrences/${recurrence.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence
} }
@@ -267,7 +263,6 @@ async function loadTaskRecurrence(task: Task) {
selectedTaskRecurrence.value = null selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none' selectedTaskRepeat.value = 'none'
selectedAfterCompletionDays.value = '1' selectedAfterCompletionDays.value = '1'
selectedAfterCompletionUnit.value = 'days'
selectedRepeatError.value = '' selectedRepeatError.value = ''
try { try {
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
@@ -276,7 +271,6 @@ async function loadTaskRecurrence(task: Task) {
const parsed = parseTaskRecurrence(recurrence) const parsed = parseTaskRecurrence(recurrence)
selectedTaskRepeat.value = parsed.option selectedTaskRepeat.value = parsed.option
selectedAfterCompletionDays.value = String(parsed.afterCompletionDays) selectedAfterCompletionDays.value = String(parsed.afterCompletionDays)
selectedAfterCompletionUnit.value = parsed.afterCompletionUnit
selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig() selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
} catch (reason) { } catch (reason) {
if (selectionIsCurrent()) fail(reason) if (selectionIsCurrent()) fail(reason)
@@ -299,7 +293,7 @@ async function submitTaskCompose() {
const targetListId = composeListId.value const targetListId = composeListId.value
creatingTask.value = true creatingTask.value = true
try { try {
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, afterCompletionUnit: composeAfterCompletionUnit.value, repeatConfig: composeRepeatConfig.value }) const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, repeatConfig: composeRepeatConfig.value })
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : '' const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间') if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间')
await taskMutationReconciler.run( await taskMutationReconciler.run(
@@ -396,7 +390,7 @@ const activeName = computed(() => {
if (activeView.value === 'habits') return '习惯' if (activeView.value === 'habits') return '习惯'
if (activeView.value === 'countdowns') return '倒数日' if (activeView.value === 'countdowns') return '倒数日'
if (activeView.value === 'memos') return '备忘录' if (activeView.value === 'memos') return '备忘录'
if (activeView.value === 'calendar') return '日历订阅' if (activeView.value === 'calendar') return '日历'
if (activeView.value === 'settings') return '设置与数据' if (activeView.value === 'settings') return '设置与数据'
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱' return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
}) })
@@ -725,7 +719,7 @@ async function loadTrashPage() {
async function loadTrash() { async function loadTrash() {
loading.value = true loading.value = true
error.value = '' error.value = ''
const committed = await runLatestRequest('trash', loadTrashPage, { return await runLatestRequest('trash', loadTrashPage, {
success: (data) => { success: (data) => {
trash.value = data.items ?? [] trash.value = data.items ?? []
totalTasks.value = data.total ?? trash.value.length totalTasks.value = data.total ?? trash.value.length
@@ -733,11 +727,6 @@ async function loadTrash() {
error: fail, error: fail,
finally: () => { loading.value = false }, finally: () => { loading.value = false },
}) })
if (committed && page.value > totalPages.value) {
page.value = totalPages.value
return await loadTrash()
}
return committed
} }
async function switchView(view: View, listId?: string) { async function switchView(view: View, listId?: string) {
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return
@@ -1104,7 +1093,6 @@ async function saveSelectedTaskChanges() {
const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none' const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'
const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig
const afterCompletionDays = selectedAfterCompletionDays.value const afterCompletionDays = selectedAfterCompletionDays.value
const afterCompletionUnit = selectedAfterCompletionUnit.value
const recurrence = selectedTaskRecurrence.value const recurrence = selectedTaskRecurrence.value
selectedRepeatError.value = '' selectedRepeatError.value = ''
try { try {
@@ -1114,7 +1102,7 @@ async function saveSelectedTaskChanges() {
selectedTaskRecurrence.value = null selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none' selectedTaskRepeat.value = 'none'
} else { } else {
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence) const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
selectedTaskRecurrence.value = updatedRecurrence selectedTaskRecurrence.value = updatedRecurrence
} }
@@ -1288,7 +1276,8 @@ async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskLi
} }
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) { async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
if (kind === 'lists') { if (kind === 'lists') {
if (!(await confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复'))) return const answer = await askText(`归档清单「${item.name}」?`, '', '', '归档')
if (answer === null) return
try { try {
const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id
await api(`/${kind}/${item.id}`, { method: 'DELETE' }) await api(`/${kind}/${item.id}`, { method: 'DELETE' })
@@ -1541,23 +1530,15 @@ function moveListWithinScope(item: TaskList, direction: 'up' | 'down') {
void persistListMove(item, item.folder_id, move.targetId, move.placement) void persistListMove(item, item.folder_id, move.targetId, move.placement)
closeSidebarAction() closeSidebarAction()
} }
async function scrollToTaskPageStart() { function previousPage() {
await nextTick()
taskListElement.value?.scrollIntoView({ block: 'start' })
}
async function previousPage() {
if (page.value <= 1 || loading.value) return if (page.value <= 1 || loading.value) return
page.value -= 1 page.value -= 1
if (activeView.value === 'trash') await loadTrash() activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
else if (isTaskView(activeView.value)) await loadAll()
await scrollToTaskPageStart()
} }
async function nextPage() { function nextPage() {
if (page.value >= totalPages.value || loading.value) return if (page.value >= totalPages.value || loading.value) return
page.value += 1 page.value += 1
if (activeView.value === 'trash') await loadTrash() activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
else if (isTaskView(activeView.value)) await loadAll()
await scrollToTaskPageStart()
} }
function reconcileDesktopPaneWidths() { function reconcileDesktopPaneWidths() {
@@ -1634,7 +1615,7 @@ onUnmounted(() => {
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button> <button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button> <button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
<button :class="{ active: activeView==='memos' }" @click="switchView('memos')"><StickyNote />备忘录</button> <button :class="{ active: activeView==='memos' }" @click="switchView('memos')"><StickyNote />备忘录</button>
<button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarDays />日历订阅</button> <button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarDays />日历</button>
</nav> </nav>
<div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div> <div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
<div class="folders"> <div class="folders">
@@ -1732,8 +1713,10 @@ onUnmounted(() => {
<button id="today-tasks-heading" class="today-section-toggle today-section-anchor" type="button" :aria-expanded="!todaySectionCollapse.tasks" aria-controls="today-tasks" @click="toggleTodaySection('tasks')"><span class="today-section-title">今天</span><span class="today-section-summary">{{totalTasks}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.tasks ? '' : '⌄' }}</span></button> <button id="today-tasks-heading" class="today-section-toggle today-section-anchor" type="button" :aria-expanded="!todaySectionCollapse.tasks" aria-controls="today-tasks" @click="toggleTodaySection('tasks')"><span class="today-section-title">今天</span><span class="today-section-summary">{{totalTasks}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.tasks ? '' : '⌄' }}</span></button>
</template> </template>
<div v-if="activeView==='tasks' || activeView==='upcoming'" id="task-list-heading" class="list-section-heading"><span id="task-list-title" class="list-section-title">任务</span><span class="list-section-count">{{ totalTasks }}</span><button v-if="activeView==='tasks' && taskReorderAvailable" class="list-section-action" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></div> <div v-if="activeView==='tasks' || activeView==='upcoming'" id="task-list-heading" class="list-section-heading"><span id="task-list-title" class="list-section-title">任务</span><span class="list-section-count">{{ totalTasks }}</span><button v-if="activeView==='tasks' && taskReorderAvailable" class="list-section-action" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></div>
<div v-if="activeView==='trash'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></div> <div v-if="activeView==='trash'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></div>
<section :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list plain-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined" ref="taskListElement"> <div v-if="activeView==='tasks' && totalPages > 1" class="list-page-meta"><span> {{ page }} / {{ totalPages }} · {{ totalTasks }} </span></div>
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
<section :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list plain-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined">
<template v-for="node in taskTree" :key="node.task.id"> <template v-for="node in taskTree" :key="node.task.id">
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'task-row--trash':activeView==='trash','just-completed': justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)"> <article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'task-row--trash':activeView==='trash','just-completed': justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
<button v-if="taskReorderMode" class="drag-handle task-drag-handle" :disabled="totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button> <button v-if="taskReorderMode" class="drag-handle task-drag-handle" :disabled="totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
@@ -1745,8 +1728,6 @@ onUnmounted(() => {
<div v-if="activeView==='today' && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div> <div v-if="activeView==='today' && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div>
<div v-else-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span></div> <div v-else-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span></div>
</section> </section>
<div v-if="activeView==='tasks' && totalPages > 1" class="list-page-meta" aria-live="polite"><span> {{ page }} / {{ totalPages }} · {{ totalTasks }} </span></div>
<div v-if="totalPages > 1 && (activeView!=='today' || !todaySectionCollapse.tasks)" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span aria-live="polite">{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
<div v-if="activeView==='today'" class="today-habits-section today-section-anchor"> <div v-if="activeView==='today'" class="today-habits-section today-section-anchor">
<button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title">习惯</span><span class="today-section-summary">{{todayHabitTotal}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.habits ? '' : '⌄' }}</span></button> <button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title">习惯</span><span class="today-section-summary">{{todayHabitTotal}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.habits ? '' : '⌄' }}</span></button>
<div v-show="!todaySectionCollapse.habits" id="today-habits" role="region" aria-labelledby="today-habits-heading"> <div v-show="!todaySectionCollapse.habits" id="today-habits" role="region" aria-labelledby="today-habits-heading">
@@ -1768,7 +1749,7 @@ onUnmounted(() => {
<div class="task-detail-field"><span class="task-detail-field-label">时间</span><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add task-detail-time-control" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip task-detail-time-control"><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label><span v-else class="task-detail-time-empty" aria-hidden="true"></span></div> <div class="task-detail-field"><span class="task-detail-field-label">时间</span><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add task-detail-time-control" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip task-detail-time-control"><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label><span v-else class="task-detail-time-empty" aria-hidden="true"></span></div>
</div> </div>
<label class="task-detail-field"><span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间才能开启重复</small></label> <label class="task-detail-field"><span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间才能开启重复</small></label>
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="selectedAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months"></option></select>重复</div><small>每次完成后将截止日期按所选间隔顺延月末会自动取目标月最后一天</small></section> <section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 重复</div><small>每次完成后将截止时间顺延对应天数首版永不结束</small></section>
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section> <section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small> <small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
</section> </section>
@@ -1788,7 +1769,7 @@ onUnmounted(() => {
<footer class="detail-actions"><button class="danger-text detail-trash" type="button" :disabled="taskDetailBusy" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button><button class="primary detail-save" type="submit" :disabled="taskDetailBusy">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button></footer> <footer class="detail-actions"><button class="danger-text detail-trash" type="button" :disabled="taskDetailBusy" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button><button class="primary detail-save" type="submit" :disabled="taskDetailBusy">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button></footer>
</AppSheet> </AppSheet>
<nav class="bottom" :inert="memoBackgroundInert ? true : undefined" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='memos'}" :aria-current="activeView==='memos' ? 'page' : undefined" @click="switchView('memos')"><StickyNote/><span>备忘录</span></button><button :class="{active:activeView==='calendar'}" :aria-current="activeView==='calendar' ? 'page' : undefined" @click="switchView('calendar')"><CalendarDays/><span>日历订阅</span></button></nav> <nav class="bottom" :inert="memoBackgroundInert ? true : undefined" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='memos'}" :aria-current="activeView==='memos' ? 'page' : undefined" @click="switchView('memos')"><StickyNote/><span>备忘录</span></button><button :class="{active:activeView==='calendar'}" :aria-current="activeView==='calendar' ? 'page' : undefined" @click="switchView('calendar')"><CalendarDays/><span>日历</span></button></nav>
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" /> <FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
<AppSheet :open="taskComposeOpen" variant="create" panel-class="task-compose-sheet" title-id="task-compose-title" initial-focus=".task-compose-input" :style="taskComposeStyle" @close="closeTaskCompose" @submit.prevent="submitTaskCompose"> <AppSheet :open="taskComposeOpen" variant="create" panel-class="task-compose-sheet" title-id="task-compose-title" initial-focus=".task-compose-input" :style="taskComposeStyle" @close="closeTaskCompose" @submit.prevent="submitTaskCompose">
<header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header> <header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
@@ -1807,7 +1788,7 @@ onUnmounted(() => {
<label v-else-if="composeDueAt" class="task-compose-time-chip"><span>时间</span><input ref="composeTimePicker" v-model="composeTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="composeHasTime=false"><X/></button></label> <label v-else-if="composeDueAt" class="task-compose-time-chip"><span>时间</span><input ref="composeTimePicker" v-model="composeTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="composeHasTime=false"><X/></button></label>
</div> </div>
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">请先设置截止时间才能开启重复</small></label> <label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">请先设置截止时间才能开启重复</small></label>
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="composeAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months"></option></select>重复</div><small>每次完成后将截止日期按所选间隔顺延月末会自动取目标月最后一天</small></section> <section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 重复</div><small>每次完成后将截止时间顺延对应天数首版永不结束</small></section>
<small v-if="composeRepeatError" role="alert" class="field-error">{{composeRepeatError}}</small> <small v-if="composeRepeatError" role="alert" class="field-error">{{composeRepeatError}}</small>
<section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section> <section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section>
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label> <label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
-1
View File
@@ -5,5 +5,4 @@ describe('calendar shell integration',()=>{
it('puts calendar beside the other first-level tools on desktop and mobile',()=>{const nav=app.slice(app.indexOf('<nav class="primary-nav">'),app.indexOf('</nav>',app.indexOf('<nav class="primary-nav">')));expect(nav.indexOf("switchView('habits')")).toBeLessThan(nav.indexOf("switchView('countdowns')"));expect(nav.indexOf("switchView('countdowns')")).toBeLessThan(nav.indexOf("switchView('memos')"));expect(nav.indexOf("switchView('memos')")).toBeLessThan(nav.indexOf("switchView('calendar')"));const bottom=app.slice(app.indexOf('<nav class="bottom"'),app.indexOf('</nav>',app.indexOf('<nav class="bottom"')));for(const view of ['today','habits','countdowns','memos','calendar'])expect(bottom).toContain(`switchView('${view}')`);expect(bottom).not.toContain("switchView('settings')")}) it('puts calendar beside the other first-level tools on desktop and mobile',()=>{const nav=app.slice(app.indexOf('<nav class="primary-nav">'),app.indexOf('</nav>',app.indexOf('<nav class="primary-nav">')));expect(nav.indexOf("switchView('habits')")).toBeLessThan(nav.indexOf("switchView('countdowns')"));expect(nav.indexOf("switchView('countdowns')")).toBeLessThan(nav.indexOf("switchView('memos')"));expect(nav.indexOf("switchView('memos')")).toBeLessThan(nav.indexOf("switchView('calendar')"));const bottom=app.slice(app.indexOf('<nav class="bottom"'),app.indexOf('</nav>',app.indexOf('<nav class="bottom"')));for(const view of ['today','habits','countdowns','memos','calendar'])expect(bottom).toContain(`switchView('${view}')`);expect(bottom).not.toContain("switchView('settings')")})
it('persists calendar navigation and mounts the panel',()=>{expect(utils).toContain("'calendar'");expect(app).toContain("import CalendarPanel from './CalendarPanel.vue'");expect(app).toContain("activeView==='calendar'")}) it('persists calendar navigation and mounts the panel',()=>{expect(utils).toContain("'calendar'");expect(app).toContain("import CalendarPanel from './CalendarPanel.vue'");expect(app).toContain("activeView==='calendar'")})
it('keeps five 44px mobile targets across required breakpoints',()=>{const shellCss=readFileSync('src/style.css','utf8');expect(shellCss).toContain('grid-template-columns:repeat(5,minmax(0,1fr))');expect(css).toContain('min-height:44px');expect(css).toContain('@media(max-width:720px)');expect(css).toContain('@media(min-width:931px)');expect(css).toContain('@media(min-width:1440px)')}) it('keeps five 44px mobile targets across required breakpoints',()=>{const shellCss=readFileSync('src/style.css','utf8');expect(shellCss).toContain('grid-template-columns:repeat(5,minmax(0,1fr))');expect(css).toContain('min-height:44px');expect(css).toContain('@media(max-width:720px)');expect(css).toContain('@media(min-width:931px)');expect(css).toContain('@media(min-width:1440px)')})
it('uses the shell page title once and contains the horizontal source scroller',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(panel).not.toContain('<h1>日历订阅</h1>');expect(css).toContain('.calendar-view{min-width:0;');expect(css).toContain('.calendar-filters{min-width:0;max-width:100%;')})
}) })
+13 -73
View File
@@ -4,57 +4,16 @@ import CalendarPanel from './CalendarPanel.vue'
const cleanups: Array<() => void> = [] const cleanups: Array<() => void> = []
const subscriptions = [{ id:'s1', name:'工作', url:'https://example.com/work.ics', color:'#f15a29', enabled:true, refreshed_at:'2026-09-20T08:00:00Z', last_error:null, stale:false }] const subscriptions = [{ id:'s1', name:'工作', url:'https://example.com/work.ics', color:'#f15a29', enabled:true, refreshed_at:'2026-09-20T08:00:00Z', last_error:null, stale:false }]
const events = [{ id:'e1', title:'发布会', starts_at:'2026-09-22T02:00:00Z', ends_at:'2026-09-22T03:00:00Z', all_day:false, description:'产品发布', location:'会议室', source_id:'s1', source_name:'工作', color:'#f15a29' }] const events = [{ id:'e1', title:'发布会', starts_at:'2026-09-22T02:00:00Z', ends_at:'2026-09-22T03:00:00Z', all_day:false, description:'产品发布', location:'会议室', source_name:'工作', color:'#f15a29' }]
const json = (value:unknown, status=200) => new Response(JSON.stringify(value), { status, headers:{'content-type':'application/json'} }) const json = (value:unknown, status=200) => new Response(JSON.stringify(value), { status, headers:{'content-type':'application/json'} })
async function flush(){ await Promise.resolve(); await new Promise(r=>vi.isFakeTimers()?vi.advanceTimersByTimeAsync(0).then(()=>r(undefined)):setTimeout(r,0)); await nextTick() } async function flush(){ await Promise.resolve(); await new Promise(r=>setTimeout(r,0)); await nextTick() }
async function mount(fetchMock:ReturnType<typeof vi.fn>){ vi.stubGlobal('fetch',fetchMock); const host=document.createElement('div');document.body.append(host);const notices:string[]=[];const app=createApp(()=>h(CalendarPanel,{onNotice:(v:string)=>notices.push(v)}));app.mount(host);cleanups.push(()=>{app.unmount();host.remove()});await flush();return {host,notices} } async function mount(fetchMock:ReturnType<typeof vi.fn>){ vi.stubGlobal('fetch',fetchMock); const host=document.createElement('div');document.body.append(host);const notices:string[]=[];const app=createApp(()=>h(CalendarPanel,{onNotice:(v:string)=>notices.push(v)}));app.mount(host);cleanups.push(()=>{app.unmount();host.remove()});await flush();return {host,notices} }
afterEach(()=>{cleanups.splice(0).forEach(fn=>fn());vi.useRealTimers();vi.unstubAllGlobals();vi.restoreAllMocks()}) afterEach(()=>{cleanups.splice(0).forEach(fn=>fn());vi.unstubAllGlobals();vi.restoreAllMocks()})
describe('CalendarPanel',()=>{ describe('CalendarPanel',()=>{
it('focuses the current week and shows only the selected day events',async()=>{ it('loads subscriptions and the visible month then filters and opens event detail',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
const weekEvents=[
{...events[0],id:'today',title:'今天日程',starts_at:new Date(2026,8,20,9).toISOString(),ends_at:new Date(2026,8,20,10).toISOString()},
{...events[0],id:'monday',title:'周一日程',starts_at:new Date(2026,8,14,9).toISOString(),ends_at:new Date(2026,8,14,10).toISOString()},
]
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:weekEvents,sources:[]}:subscriptions)))
const {host}=await mount(fetchMock)
expect(host.querySelectorAll('.calendar-week-day')).toHaveLength(7)
expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('20')
expect(host.textContent).toContain('今天日程');expect(host.textContent).not.toContain('周一日程')
host.querySelectorAll<HTMLButtonElement>('.calendar-week-day')[0].click();await nextTick()
expect(host.textContent).toContain('周一日程');expect(host.textContent).not.toContain('今天日程')
vi.useRealTimers()
})
it('moves one week at a time and can return to today',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions)))
const {host}=await mount(fetchMock)
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('21')
host.querySelector<HTMLButtonElement>('[aria-label="回到今天"]')!.click();await flush()
expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('20')
vi.useRealTimers()
})
it('navigates to the exact next Monday across month and year boundaries',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,11,31,10))
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions)))
const {host}=await mount(fetchMock)
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.getAttribute('data-day')).toBe('2027-01-04')
expect(host.textContent).toContain('2027年1月')
const eventCalls=fetchMock.mock.calls.filter(([url])=>String(url).includes('calendar-events'))
const latest=new URL(String(eventCalls.at(-1)?.[0]),'http://localhost').searchParams
expect(latest.get('start')).toBe(new Date(2027,0,4).toISOString())
expect(latest.get('end')).toBe(new Date(2027,0,11).toISOString())
vi.useRealTimers()
})
it('loads subscriptions and the visible week then filters and opens event detail',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
const fetchMock=vi.fn((url:string)=>url.includes('calendar-events')?Promise.resolve(json({events,sources:[{id:'s1',name:'工作',stale:false}]})):Promise.resolve(json(subscriptions))) const fetchMock=vi.fn((url:string)=>url.includes('calendar-events')?Promise.resolve(json({events,sources:[{id:'s1',name:'工作',stale:false}]})):Promise.resolve(json(subscriptions)))
const {host}=await mount(fetchMock) const {host}=await mount(fetchMock)
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
host.querySelector<HTMLButtonElement>('[data-day="2026-09-22"]')!.click();await nextTick()
const eventsUrl=String(fetchMock.mock.calls.find(([url])=>String(url).includes('calendar-events'))?.[0]) const eventsUrl=String(fetchMock.mock.calls.find(([url])=>String(url).includes('calendar-events'))?.[0])
const params=new URL(eventsUrl,'http://localhost').searchParams const params=new URL(eventsUrl,'http://localhost').searchParams
expect(params.get('start')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) expect(params.get('start')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
@@ -64,49 +23,30 @@ describe('CalendarPanel',()=>{
expect(document.querySelector('.calendar-event-detail')?.textContent).toContain('产品发布') expect(document.querySelector('.calendar-event-detail')?.textContent).toContain('产品发布')
host.querySelector<HTMLInputElement>('input[aria-label="筛选工作"]')!.click();await nextTick() host.querySelector<HTMLInputElement>('input[aria-label="筛选工作"]')!.click();await nextTick()
expect(host.querySelector('[data-event-id="e1"]')).toBeNull() expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
vi.useRealTimers()
})
it('filters duplicate source names by source id',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
const duplicateSubscriptions=[subscriptions[0],{...subscriptions[0],id:'s2',url:'https://example.com/personal.ics',color:'#334455'}]
const duplicateEvents=[events[0],{...events[0],id:'e2',title:'私人日程',source_id:'s2',color:'#334455'}]
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:duplicateEvents,sources:[]}:duplicateSubscriptions)))
const {host}=await mount(fetchMock)
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
host.querySelector<HTMLButtonElement>('[data-day="2026-09-22"]')!.click();await nextTick()
host.querySelectorAll<HTMLInputElement>('input[aria-label="筛选工作"]')[0].click();await nextTick()
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
expect(host.querySelector('[data-event-id="e2"]')).not.toBeNull()
vi.useRealTimers()
}) })
it('groups UTC events by the browser-local calendar day',async()=>{ it('groups UTC events by the browser-local calendar day',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
const boundary=[{...events[0],id:'boundary',starts_at:'2026-09-21T23:30:00Z',ends_at:'2026-09-22T00:30:00Z'}] const boundary=[{...events[0],id:'boundary',starts_at:'2026-09-21T23:30:00Z',ends_at:'2026-09-22T00:30:00Z'}]
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:boundary,sources:[]}:subscriptions))) const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:boundary,sources:[]}:subscriptions)))
const {host}=await mount(fetchMock) const {host}=await mount(fetchMock)
const boundaryDay=new Date(boundary[0].starts_at)
if(!host.querySelector(`[data-day="${boundaryDay.getFullYear()}-${String(boundaryDay.getMonth()+1).padStart(2,'0')}-${String(boundaryDay.getDate()).padStart(2,'0')}"]`)){host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()}
host.querySelector<HTMLButtonElement>(`[data-day="${boundaryDay.getFullYear()}-${String(boundaryDay.getMonth()+1).padStart(2,'0')}-${String(boundaryDay.getDate()).padStart(2,'0')}"]`)!.click();await nextTick()
const expected=new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'short'}).format(new Date(boundary[0].starts_at)) const expected=new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'short'}).format(new Date(boundary[0].starts_at))
expect(host.querySelector('.calendar-agenda h2')?.textContent).toContain(expected) expect(host.querySelector('.calendar-agenda h2')?.textContent).toBe(expected)
vi.useRealTimers()
}) })
it('keeps the newest week response when requests finish out of order',async()=>{ it('keeps the newest month response when requests finish out of order',async()=>{
const pending:Array<{url:string;resolve:(response:Response)=>void}>=[] const pending:Array<{url:string;resolve:(response:Response)=>void}>=[]
const fetchMock=vi.fn((url:string)=>String(url).includes('calendar-events')?new Promise<Response>(resolve=>pending.push({url:String(url),resolve})):Promise.resolve(json(subscriptions))) const fetchMock=vi.fn((url:string)=>String(url).includes('calendar-events')?new Promise<Response>(resolve=>pending.push({url:String(url),resolve})):Promise.resolve(json(subscriptions)))
const {host}=await mount(fetchMock) const {host}=await mount(fetchMock)
expect(pending).toHaveLength(1) expect(pending).toHaveLength(1)
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await nextTick() host.querySelector<HTMLButtonElement>('[aria-label="下个月"]')!.click();await nextTick()
expect(pending).toHaveLength(2) expect(pending).toHaveLength(2)
pending[1].resolve(json({events:[{...events[0],id:'new',title:'新一周',starts_at:'2026-09-21T02:00:00Z'}],sources:[]}));await flush() pending[1].resolve(json({events:[{...events[0],id:'new',title:'新月份'}],sources:[]}));await flush()
pending[0].resolve(json({events:[{...events[0],id:'old',title:'旧一周'}],sources:[]}));await flush() pending[0].resolve(json({events:[{...events[0],id:'old',title:'旧月份'}],sources:[]}));await flush()
expect(host.textContent).toContain('新一周') expect(host.textContent).toContain('新月份')
expect(host.textContent).not.toContain('旧一周') expect(host.textContent).not.toContain('旧月份')
}) })
it('supports week navigation and today',async()=>{ it('supports month navigation and today',async()=>{
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions))) const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions)))
const {host}=await mount(fetchMock);const before=fetchMock.mock.calls.length const {host}=await mount(fetchMock);const before=fetchMock.mock.calls.length
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush() host.querySelector<HTMLButtonElement>('[aria-label="下个月"]')!.click();await flush()
host.querySelector<HTMLButtonElement>('[aria-label="回到今天"]')!.click();await flush() host.querySelector<HTMLButtonElement>('[aria-label="回到今天"]')!.click();await flush()
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(before+2) expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(before+2)
}) })
+13 -22
View File
@@ -7,34 +7,27 @@ import AppSheet from './components/AppSheet.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue' import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
type Subscription = { id:string; name:string; url:string; color:string; enabled:boolean; refreshed_at:string|null; last_error:string|null; stale:boolean } type Subscription = { id:string; name:string; url:string; color:string; enabled:boolean; refreshed_at:string|null; last_error:string|null; stale:boolean }
type CalendarEvent = { id:string; title:string; starts_at:string; ends_at:string; all_day:boolean; source_id:string; source_name:string; color:string; description?:string|null; location?:string|null } type CalendarEvent = { id:string; title:string; starts_at:string; ends_at:string; all_day:boolean; source_name:string; color:string; description?:string|null; location?:string|null }
type EventResponse = { events:CalendarEvent[]; sources:Array<{id:string;name:string;stale:boolean}> } type EventResponse = { events:CalendarEvent[]; sources:Array<{id:string;name:string;stale:boolean}> }
type Form = { name:string; url:string; color:string; enabled:boolean } type Form = { name:string; url:string; color:string; enabled:boolean }
const emit=defineEmits<{notice:[message:string]}>() const emit=defineEmits<{notice:[message:string]}>()
const subscriptions=ref<Subscription[]>([]),events=ref<CalendarEvent[]>([]),loading=ref(false),error=ref('') const subscriptions=ref<Subscription[]>([]),events=ref<CalendarEvent[]>([]),loading=ref(false),error=ref('')
let eventsRequestGeneration=0 let eventsRequestGeneration=0
const selectedDay=ref(new Date(new Date().getFullYear(),new Date().getMonth(),new Date().getDate())),hiddenSources=ref(new Set<string>()) const month=ref(new Date(new Date().getFullYear(),new Date().getMonth(),1)),hiddenSources=ref(new Set<string>())
const selected=ref<CalendarEvent|null>(null),manageOpen=ref(false),formOpen=ref(false),editing=ref<Subscription|null>(null),busyId=ref('') const selected=ref<CalendarEvent|null>(null),manageOpen=ref(false),formOpen=ref(false),editing=ref<Subscription|null>(null),busyId=ref('')
const form=ref<Form>({name:'',url:'',color:'#f15a29',enabled:true}) const form=ref<Form>({name:'',url:'',color:'#f15a29',enabled:true})
const appDialog=ref<{show:(options:AppDialogOptions)=>Promise<boolean|string|null>}|null>(null) const appDialog=ref<{show:(options:AppDialogOptions)=>Promise<boolean|string|null>}|null>(null)
const request=async(path:string,options:RequestInit={})=>{const headers:Record<string,string>={...(options.headers as Record<string,string>||{})};if(options.body)headers['Content-Type']='application/json';Object.assign(headers,csrfHeader(options.method));const response=await fetch('/api/v1'+path,{credentials:'include',...options,headers});if(!response.ok){const body=await response.json().catch(()=>({}));throw new Error(formatApiErrorDetail((body as {detail?:unknown}).detail??body))}return response.status===204?null:response.json()} const request=async(path:string,options:RequestInit={})=>{const headers:Record<string,string>={...(options.headers as Record<string,string>||{})};if(options.body)headers['Content-Type']='application/json';Object.assign(headers,csrfHeader(options.method));const response=await fetch('/api/v1'+path,{credentials:'include',...options,headers});if(!response.ok){const body=await response.json().catch(()=>({}));throw new Error(formatApiErrorDetail((body as {detail?:unknown}).detail??body))}return response.status===204?null:response.json()}
const key=(date:Date)=>`${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}` const key=(date:Date)=>`${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`
const startOfWeek=(value:Date)=>{const date=new Date(value.getFullYear(),value.getMonth(),value.getDate());date.setDate(date.getDate()-((date.getDay()+6)%7));return date} const range=computed(()=>{const start=new Date(month.value.getFullYear(),month.value.getMonth(),1);const end=new Date(month.value.getFullYear(),month.value.getMonth()+1,1);return{start:start.toISOString(),end:end.toISOString()}})
const weekStart=computed(()=>startOfWeek(selectedDay.value)) const monthLabel=computed(()=>new Intl.DateTimeFormat('zh-CN',{year:'numeric',month:'long'}).format(month.value))
const weekDays=computed(()=>Array.from({length:7},(_,index)=>{const date=new Date(weekStart.value);date.setDate(date.getDate()+index);return date}))
const range=computed(()=>{const start=weekStart.value;const end=new Date(start);end.setDate(end.getDate()+7);return{start:start.toISOString(),end:end.toISOString()}})
const weekLabel=computed(()=>{const start=weekDays.value[0],end=weekDays.value[6];if(start.getFullYear()!==end.getFullYear())return `${start.getFullYear()}${start.getMonth()+1}${start.getDate()}日 - ${end.getFullYear()}${end.getMonth()+1}${end.getDate()}`;return start.getMonth()===end.getMonth()?`${start.getFullYear()}${start.getMonth()+1}`:`${start.getFullYear()}${start.getMonth()+1}${start.getDate()}日 - ${end.getMonth()+1}${end.getDate()}`})
const eventStart=(event:CalendarEvent)=>event.starts_at const eventStart=(event:CalendarEvent)=>event.starts_at
const eventEnd=(event:CalendarEvent)=>event.ends_at const eventEnd=(event:CalendarEvent)=>event.ends_at
const eventKey=(event:CalendarEvent)=>event.id const eventKey=(event:CalendarEvent)=>event.id
const filteredEvents=computed(()=>events.value.filter(event=>!hiddenSources.value.has(event.source_id)).sort((a,b)=>eventStart(a).localeCompare(eventStart(b)))) const sourceId=(event:CalendarEvent)=>subscriptions.value.find(item=>item.name===event.source_name)?.id??''
const visibleEvents=computed(()=>events.value.filter(event=>!hiddenSources.value.has(sourceId(event))).sort((a,b)=>eventStart(a).localeCompare(eventStart(b))))
const localDayKey=(value:string)=>{const date=new Date(value);return Number.isNaN(date.getTime())?value.slice(0,10):key(date)} const localDayKey=(value:string)=>{const date=new Date(value);return Number.isNaN(date.getTime())?value.slice(0,10):key(date)}
const selectedDayKey=computed(()=>key(selectedDay.value)) const groupedEvents=computed(()=>{const groups=new Map<string,CalendarEvent[]>();for(const event of visibleEvents.value){const day=localDayKey(eventStart(event));groups.set(day,[...(groups.get(day)??[]),event])}return [...groups].map(([day,items])=>({day,items}))})
const visibleEvents=computed(()=>filteredEvents.value.filter(event=>localDayKey(eventStart(event))===selectedDayKey.value))
const selectedDayLabel=computed(()=>displayDay(selectedDayKey.value))
const dayEventCount=(date:Date)=>filteredEvents.value.filter(event=>localDayKey(eventStart(event))===key(date)).length
const isToday=(date:Date)=>key(date)===key(new Date())
const weekDayLabel=(date:Date)=>new Intl.DateTimeFormat('zh-CN',{weekday:'short'}).format(date).replace('周','')
const eventTitle=(event:CalendarEvent)=>event.title||'未命名事件' const eventTitle=(event:CalendarEvent)=>event.title||'未命名事件'
const eventSource=(event:CalendarEvent)=>event.source_name||'日历' const eventSource=(event:CalendarEvent)=>event.source_name||'日历'
const eventColor=(event:CalendarEvent)=>event.color||'#f15a29' const eventColor=(event:CalendarEvent)=>event.color||'#f15a29'
@@ -43,9 +36,8 @@ function displayTime(event:CalendarEvent){if(event.all_day)return'全天';const
async function loadSubscriptions(){subscriptions.value=await request('/calendar-subscriptions') as Subscription[]} async function loadSubscriptions(){subscriptions.value=await request('/calendar-subscriptions') as Subscription[]}
async function loadEvents(){const generation=++eventsRequestGeneration;const requestedRange=range.value;const data=await request(`/calendar-events?start=${encodeURIComponent(requestedRange.start)}&end=${encodeURIComponent(requestedRange.end)}`) as EventResponse;if(generation===eventsRequestGeneration)events.value=data.events} async function loadEvents(){const generation=++eventsRequestGeneration;const requestedRange=range.value;const data=await request(`/calendar-events?start=${encodeURIComponent(requestedRange.start)}&end=${encodeURIComponent(requestedRange.end)}`) as EventResponse;if(generation===eventsRequestGeneration)events.value=data.events}
async function load(){loading.value=true;error.value='';try{await Promise.all([loadSubscriptions(),loadEvents()])}catch(reason){error.value=reason instanceof Error?reason.message:'日历载入失败'}finally{loading.value=false}} async function load(){loading.value=true;error.value='';try{await Promise.all([loadSubscriptions(),loadEvents()])}catch(reason){error.value=reason instanceof Error?reason.message:'日历载入失败'}finally{loading.value=false}}
async function moveWeek(offset:number){const next=new Date(weekStart.value);next.setDate(next.getDate()+offset*7);selectedDay.value=next;await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')} async function moveMonth(offset:number){month.value=new Date(month.value.getFullYear(),month.value.getMonth()+offset,1);await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')}
async function today(){const now=new Date();selectedDay.value=new Date(now.getFullYear(),now.getMonth(),now.getDate());await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')} async function today(){const now=new Date();month.value=new Date(now.getFullYear(),now.getMonth(),1);await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')}
function selectDay(date:Date){selectedDay.value=new Date(date.getFullYear(),date.getMonth(),date.getDate())}
function toggleFilter(id:string){const next=new Set(hiddenSources.value);next.has(id)?next.delete(id):next.add(id);hiddenSources.value=next} function toggleFilter(id:string){const next=new Set(hiddenSources.value);next.has(id)?next.delete(id):next.add(id);hiddenSources.value=next}
function openCreate(){editing.value=null;form.value={name:'',url:'',color:'#f15a29',enabled:true};formOpen.value=true} function openCreate(){editing.value=null;form.value={name:'',url:'',color:'#f15a29',enabled:true};formOpen.value=true}
function openEdit(item:Subscription){editing.value=item;form.value={name:item.name,url:item.url,color:item.color||'#f15a29',enabled:item.enabled};formOpen.value=true} function openEdit(item:Subscription){editing.value=item;form.value={name:item.name,url:item.url,color:item.color||'#f15a29',enabled:item.enabled};formOpen.value=true}
@@ -59,13 +51,12 @@ onMounted(()=>void load())
<template> <template>
<section class="calendar-view" :class="{loading}"> <section class="calendar-view" :class="{loading}">
<header class="calendar-heading"><p>{{filteredEvents.length}} 个日程 · {{subscriptions.length}} 个来源</p><button class="soft-button calendar-manage" aria-label="管理日历源" @click="manageOpen=true"><Settings2/>日历源</button></header> <header class="calendar-heading"><div><h1>日历</h1><p>{{visibleEvents.length}} 个日程 · {{subscriptions.length}} 个来源</p></div><button class="soft-button calendar-manage" aria-label="管理日历源" @click="manageOpen=true"><Settings2/>日历源</button></header>
<p v-if="error" class="inline-error" role="alert">{{error}}</p> <p v-if="error" class="inline-error" role="alert">{{error}}</p>
<div class="calendar-toolbar"><button aria-label="一周" @click="moveWeek(-1)"><ChevronLeft/></button><button class="calendar-today" aria-label="回到今天" @click="today">今天</button><strong>{{weekLabel}}</strong><button aria-label="一周" @click="moveWeek(1)"><ChevronRight/></button></div> <div class="calendar-toolbar"><button aria-label="个月" @click="moveMonth(-1)"><ChevronLeft/></button><button class="calendar-today" aria-label="回到今天" @click="today">今天</button><strong>{{monthLabel}}</strong><button aria-label="个月" @click="moveMonth(1)"><ChevronRight/></button></div>
<div class="calendar-week-strip" role="group" aria-label="选择日期"><button v-for="day in weekDays" :key="key(day)" class="calendar-week-day" :class="{'is-selected':key(day)===selectedDayKey,'is-today':isToday(day)}" :data-day="key(day)" :aria-label="`${displayDay(key(day))}${dayEventCount(day)?`${dayEventCount(day)}个日程`:',无日程'}`" :aria-pressed="key(day)===selectedDayKey" @click="selectDay(day)"><small>{{weekDayLabel(day)}}</small><b>{{day.getDate()}}</b><i v-if="dayEventCount(day)" aria-hidden="true">{{dayEventCount(day)}}</i></button></div>
<div v-if="subscriptions.length" class="calendar-filters" aria-label="筛选日历源"><label v-for="source in subscriptions" :key="source.id"><input type="checkbox" :aria-label="`筛选${source.name}`" :checked="!hiddenSources.has(source.id)" @change="toggleFilter(source.id)"><i :style="{background:source.color}"/>{{source.name}}</label></div> <div v-if="subscriptions.length" class="calendar-filters" aria-label="筛选日历源"><label v-for="source in subscriptions" :key="source.id"><input type="checkbox" :aria-label="`筛选${source.name}`" :checked="!hiddenSources.has(source.id)" @change="toggleFilter(source.id)"><i :style="{background:source.color}"/>{{source.name}}</label></div>
<div v-if="visibleEvents.length" class="calendar-agenda"><section><h2>{{selectedDayLabel}} · {{visibleEvents.length}} 个日程</h2><button v-for="event in visibleEvents" :key="eventKey(event)" :data-event-id="event.id" class="calendar-event-row" @click="selected=event"><i :style="{background:eventColor(event)}"/><time>{{displayTime(event)}}</time><span><b>{{eventTitle(event)}}</b><small>{{eventSource(event)}}<template v-if="event.location"> · {{event.location}}</template></small></span><ChevronRight/></button></section></div> <div v-if="groupedEvents.length" class="calendar-agenda"><section v-for="group in groupedEvents" :key="group.day"><h2>{{displayDay(group.day)}}</h2><button v-for="event in group.items" :key="eventKey(event)" :data-event-id="event.id" class="calendar-event-row" @click="selected=event"><i :style="{background:eventColor(event)}"/><time>{{displayTime(event)}}</time><span><b>{{eventTitle(event)}}</b><small>{{eventSource(event)}}<template v-if="event.location"> · {{event.location}}</template></small></span><ChevronRight/></button></section></div>
<div v-else-if="!loading" class="calendar-empty"><CalendarDays/><b>这一天还没有日程</b><span>{{subscriptions.length?'可以选择本周其他日期或检查来源筛选':'先添加一个 iCal 日历订阅'}}</span><button v-if="!subscriptions.length" class="primary-small" @click="manageOpen=true;openCreate()">添加日历源</button></div> <div v-else-if="!loading" class="calendar-empty"><CalendarDays/><b>这个月还没有日程</b><span>{{subscriptions.length?'可以切换月份或检查来源筛选':'先添加一个 iCal 日历订阅'}}</span><button v-if="!subscriptions.length" class="primary-small" @click="manageOpen=true;openCreate()">添加日历源</button></div>
<AppSheet :open="Boolean(selected)" variant="detail" panel-class="calendar-event-detail" title-id="calendar-event-title" initial-focus="button[aria-label='关闭日程详情']" @close="selected=null"><template v-if="selected"><header class="app-sheet__header"><h3 id="calendar-event-title">{{eventTitle(selected)}}</h3><button aria-label="关闭日程详情" @click="selected=null"><X/></button></header><div class="app-sheet__body"><dl><div><dt>时间</dt><dd>{{displayDay(localDayKey(eventStart(selected)))}} {{displayTime(selected)}}<template v-if="eventEnd(selected) && !selected.all_day"> {{displayTime({...selected,starts_at:eventEnd(selected)})}}</template></dd></div><div><dt>来源</dt><dd><i :style="{background:eventColor(selected)}"/>{{eventSource(selected)}}</dd></div><div v-if="selected.location"><dt>地点</dt><dd>{{selected.location}}</dd></div></dl><section v-if="selected.description"><h4>备注</h4><p>{{selected.description}}</p></section></div></template></AppSheet> <AppSheet :open="Boolean(selected)" variant="detail" panel-class="calendar-event-detail" title-id="calendar-event-title" initial-focus="button[aria-label='关闭日程详情']" @close="selected=null"><template v-if="selected"><header class="app-sheet__header"><h3 id="calendar-event-title">{{eventTitle(selected)}}</h3><button aria-label="关闭日程详情" @click="selected=null"><X/></button></header><div class="app-sheet__body"><dl><div><dt>时间</dt><dd>{{displayDay(localDayKey(eventStart(selected)))}} {{displayTime(selected)}}<template v-if="eventEnd(selected) && !selected.all_day"> {{displayTime({...selected,starts_at:eventEnd(selected)})}}</template></dd></div><div><dt>来源</dt><dd><i :style="{background:eventColor(selected)}"/>{{eventSource(selected)}}</dd></div><div v-if="selected.location"><dt>地点</dt><dd>{{selected.location}}</dd></div></dl><section v-if="selected.description"><h4>备注</h4><p>{{selected.description}}</p></section></div></template></AppSheet>
<AppSheet :open="manageOpen" variant="detail" panel-class="calendar-sources-sheet" title-id="calendar-sources-title" initial-focus="button[aria-label='关闭日历源']" @close="manageOpen=false"><header class="app-sheet__header"><h3 id="calendar-sources-title">日历源</h3><button aria-label="关闭日历源" @click="manageOpen=false"><X/></button></header><div class="app-sheet__body"><button class="primary-small calendar-source-add" aria-label="添加日历订阅" @click="openCreate"><Plus/>添加订阅</button><div class="calendar-source-list"><article v-for="source in subscriptions" :key="source.id"><div class="calendar-source-copy"><b><i :style="{background:source.color}"/>{{source.name}}</b><small>{{source.url}}</small><small v-if="source.last_error" class="calendar-source-error" role="alert">{{source.last_error}}</small></div><label class="calendar-source-toggle"><input type="checkbox" :aria-label="`启用${source.name}`" :checked="source.enabled" :disabled="Boolean(busyId)" @change="toggleEnabled(source)"><span>启用</span></label><button :aria-label="`刷新${source.name}`" :disabled="Boolean(busyId)" @click="refresh(source)"><RefreshCw/></button><button :aria-label="`编辑${source.name}`" :disabled="Boolean(busyId)" @click="openEdit(source)"><Pencil/></button><button class="danger-text" :aria-label="`删除${source.name}`" :disabled="Boolean(busyId)" @click="remove(source)"><Trash2/></button></article></div></div></AppSheet> <AppSheet :open="manageOpen" variant="detail" panel-class="calendar-sources-sheet" title-id="calendar-sources-title" initial-focus="button[aria-label='关闭日历源']" @close="manageOpen=false"><header class="app-sheet__header"><h3 id="calendar-sources-title">日历源</h3><button aria-label="关闭日历源" @click="manageOpen=false"><X/></button></header><div class="app-sheet__body"><button class="primary-small calendar-source-add" aria-label="添加日历订阅" @click="openCreate"><Plus/>添加订阅</button><div class="calendar-source-list"><article v-for="source in subscriptions" :key="source.id"><div class="calendar-source-copy"><b><i :style="{background:source.color}"/>{{source.name}}</b><small>{{source.url}}</small><small v-if="source.last_error" class="calendar-source-error" role="alert">{{source.last_error}}</small></div><label class="calendar-source-toggle"><input type="checkbox" :aria-label="`启用${source.name}`" :checked="source.enabled" :disabled="Boolean(busyId)" @change="toggleEnabled(source)"><span>启用</span></label><button :aria-label="`刷新${source.name}`" :disabled="Boolean(busyId)" @click="refresh(source)"><RefreshCw/></button><button :aria-label="`编辑${source.name}`" :disabled="Boolean(busyId)" @click="openEdit(source)"><Pencil/></button><button class="danger-text" :aria-label="`删除${source.name}`" :disabled="Boolean(busyId)" @click="remove(source)"><Trash2/></button></article></div></div></AppSheet>
<AppSheet :open="formOpen" variant="create" panel-class="calendar-subscription-form" title-id="calendar-form-title" initial-focus="input[aria-label='订阅名称']" :busy="busyId==='form'" @close="formOpen=false" @submit.prevent="save"><header class="app-sheet__header"><h3 id="calendar-form-title">{{editing?'编辑订阅':'添加订阅'}}</h3><button type="button" aria-label="关闭订阅表单" @click="formOpen=false"><X/></button></header><div class="app-sheet__body"><label>名称<input v-model="form.name" aria-label="订阅名称" maxlength="120" required placeholder="例如:工作"></label><label>iCal 地址<input v-model="form.url" aria-label="订阅地址" type="url" required placeholder="https://example.com/calendar.ics"></label><label>颜色<input v-model="form.color" aria-label="订阅颜色" type="color"></label><label class="calendar-form-toggle"><input v-model="form.enabled" type="checkbox">启用此订阅</label></div><footer class="app-sheet__footer"><button type="button" class="secondary" @click="formOpen=false">取消</button><button type="submit" class="primary-small" :disabled="Boolean(busyId)||!form.name.trim()||!form.url.trim()">保存</button></footer></AppSheet> <AppSheet :open="formOpen" variant="create" panel-class="calendar-subscription-form" title-id="calendar-form-title" initial-focus="input[aria-label='订阅名称']" :busy="busyId==='form'" @close="formOpen=false" @submit.prevent="save"><header class="app-sheet__header"><h3 id="calendar-form-title">{{editing?'编辑订阅':'添加订阅'}}</h3><button type="button" aria-label="关闭订阅表单" @click="formOpen=false"><X/></button></header><div class="app-sheet__body"><label>名称<input v-model="form.name" aria-label="订阅名称" maxlength="120" required placeholder="例如:工作"></label><label>iCal 地址<input v-model="form.url" aria-label="订阅地址" type="url" required placeholder="https://example.com/calendar.ics"></label><label>颜色<input v-model="form.color" aria-label="订阅颜色" type="color"></label><label class="calendar-form-toggle"><input v-model="form.enabled" type="checkbox">启用此订阅</label></div><footer class="app-sheet__footer"><button type="button" class="secondary" @click="formOpen=false">取消</button><button type="submit" class="primary-small" :disabled="Boolean(busyId)||!form.name.trim()||!form.url.trim()">保存</button></footer></AppSheet>
File diff suppressed because one or more lines are too long
+6 -8
View File
@@ -75,14 +75,12 @@ describe('task utilities', () => {
expect(() => buildTaskRrule({ frequency: 'daily', interval: 0, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('重复间隔至少为 1') expect(() => buildTaskRrule({ frequency: 'daily', interval: 0, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('重复间隔至少为 1')
}) })
it('builds and parses completion-trigger intervals in days or months', () => { it('builds a distinct completion-trigger payload and parses it without RRULE', () => {
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7, after_completion_unit: 'days' }) expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7 })
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '2', afterCompletionUnit: 'months' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 2, after_completion_unit: 'months' }) expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14 })
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14, afterCompletionUnit: 'days' }) expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数天数')
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 3, after_completion_unit: 'months' })).toEqual({ option: 'after_completion', afterCompletionDays: 3, afterCompletionUnit: 'months' }) expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数天数')
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数') expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数天数')
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数')
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数')
}) })
it('builds scheduled recurrence payloads separately from completion triggers', () => { it('builds scheduled recurrence payloads separately from completion triggers', () => {
+7 -8
View File
@@ -172,16 +172,15 @@ export function parseTaskRrule(rrule = ''): TaskRepeatConfig {
} }
export type TaskRepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'after_completion' | 'custom' export type TaskRepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'after_completion' | 'custom'
export type AfterCompletionUnit = 'days' | 'months' export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null }
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null; after_completion_unit?: AfterCompletionUnit | null }
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; afterCompletionUnit?: AfterCompletionUnit; repeatConfig?: TaskRepeatConfig }) { export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; repeatConfig?: TaskRepeatConfig }) {
if (option === 'none') return {} if (option === 'none') return {}
if (option === 'after_completion') { if (option === 'after_completion') {
const raw = String(values.afterCompletionDays).trim() const raw = String(values.afterCompletionDays).trim()
const days = Number(raw) const days = Number(raw)
if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数') if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数天数')
return { trigger_mode: 'after_completion' as const, after_completion_days: days, after_completion_unit: values.afterCompletionUnit ?? 'days' } return { trigger_mode: 'after_completion' as const, after_completion_days: days }
} }
const rrule = option === 'custom' const rrule = option === 'custom'
? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' }) ? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' })
@@ -190,13 +189,13 @@ export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { a
} }
export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) { export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) {
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit } if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1 }
if (recurrence.trigger_mode === 'after_completion') { if (recurrence.trigger_mode === 'after_completion') {
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1, afterCompletionUnit: recurrence.after_completion_unit ?? 'days' as AfterCompletionUnit } return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1 }
} }
const parsed = parseTaskRrule(recurrence.rrule ?? '') const parsed = parseTaskRrule(recurrence.rrule ?? '')
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never' const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit } return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1 }
} }
export function defaultTaskDueAt(now = new Date()) { export function defaultTaskDueAt(now = new Date()) {
+1 -1
View File
@@ -134,7 +134,7 @@ main.list-main>.mvp-view>.habit-archive-section{border-top:1px solid #e8e0d5}
.list-section-heading{width:min(100%,630px);min-height:44px;margin:0 auto;display:flex;align-items:center;border-bottom:1px solid #e8e0d5} .list-section-heading{width:min(100%,630px);min-height:44px;margin:0 auto;display:flex;align-items:center;border-bottom:1px solid #e8e0d5}
.list-section-title{font-size:13px;font-weight:700}.list-section-count{margin-left:7px;font-size:12px;font-weight:400;color:var(--muted)} .list-section-title{font-size:13px;font-weight:700}.list-section-count{margin-left:7px;font-size:12px;font-weight:400;color:var(--muted)}
.list-section-action{min-height:44px;margin-left:auto;padding:0;border:0;background:transparent;color:var(--accent);font-size:12px;font-weight:650} .list-section-action{min-height:44px;margin-left:auto;padding:0;border:0;background:transparent;color:var(--accent);font-size:12px;font-weight:650}
.list-page-meta{min-height:36px;margin-top:10px;display:flex;align-items:center;justify-content:flex-end;gap:10px;color:var(--muted);font-size:12px} .list-page-meta{min-height:36px;display:flex;align-items:center;justify-content:flex-end;gap:10px;color:var(--muted);font-size:12px}
@media(min-width:1440px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,900px)}} @media(min-width:1440px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,900px)}}
@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}} @media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}}
@media(max-width:720px){main.list-main{padding-left:29px;padding-right:29px}main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}.list-page-title{font-size:24px}} @media(max-width:720px){main.list-main{padding-left:29px;padding-right:29px}main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}.list-page-title{font-size:24px}}
+5 -14
View File
@@ -122,7 +122,7 @@ describe('mobile navigation styles', () => {
expect(app).not.toContain('aria-controls="mobile-more-menu"') expect(app).not.toContain('aria-controls="mobile-more-menu"')
expect(app).not.toContain('<Ellipsis/><span>更多</span>') expect(app).not.toContain('<Ellipsis/><span>更多</span>')
expect(app).toContain("<StickyNote/><span>备忘录</span>") expect(app).toContain("<StickyNote/><span>备忘录</span>")
expect(app).toContain("<CalendarDays/><span>日历订阅</span>") expect(app).toContain("<CalendarDays/><span>日历</span>")
expect(app).not.toContain("@click=\"switchView('settings')\"><Settings/><span>设置</span>") expect(app).not.toContain("@click=\"switchView('settings')\"><Settings/><span>设置</span>")
}) })
@@ -849,10 +849,7 @@ describe('task and habit row decoration', () => {
const mutationBlock = app.slice(app.indexOf('async function mutateTrashTask'), app.indexOf('async function restoreTask')) const mutationBlock = app.slice(app.indexOf('async function mutateTrashTask'), app.indexOf('async function restoreTask'))
const restoreBlock = app.slice(app.indexOf('async function restoreTask'), app.indexOf('async function purgeTask')) const restoreBlock = app.slice(app.indexOf('async function restoreTask'), app.indexOf('async function purgeTask'))
const purgeBlock = app.slice(app.indexOf('async function purgeTask'), app.indexOf('async function addSubtask')) const purgeBlock = app.slice(app.indexOf('async function purgeTask'), app.indexOf('async function addSubtask'))
expect(loadBlock).toContain("const committed = await runLatestRequest('trash'") expect(loadBlock).toContain('return await runLatestRequest')
expect(loadBlock).toContain('if (committed && page.value > totalPages.value)')
expect(loadBlock).toContain('page.value = totalPages.value')
expect(loadBlock).toContain('return await loadTrash()')
expect(mutationBlock).toContain('await taskMutationReconciler.run(') expect(mutationBlock).toContain('await taskMutationReconciler.run(')
expect(mutationBlock).toContain('{ affectsTrash: true, affectsTaskView }') expect(mutationBlock).toContain('{ affectsTrash: true, affectsTaskView }')
expect(mutationBlock).not.toContain('performTrashMutation(') expect(mutationBlock).not.toContain('performTrashMutation(')
@@ -1373,13 +1370,10 @@ describe('unified floating add interaction', () => {
expect(app).toContain('<span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat"') expect(app).toContain('<span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat"')
expect(app).toContain('<option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option>') expect(app).toContain('<option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option>')
expect(app).toContain('完成后 <input v-model="composeAfterCompletionDays"') expect(app).toContain('完成后 <input v-model="composeAfterCompletionDays"')
expect(app).toContain('v-model="composeAfterCompletionUnit"')
expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"') expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"')
expect(app).toContain('v-model="selectedAfterCompletionUnit"') expect(app).toContain('每次完成后,将截止时间顺延对应天数;首版永不结束')
expect(app).toContain('<option value="days">天</option><option value="months">月</option>')
expect(app).toContain('月末会自动取目标月最后一天')
expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value') expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value')
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config })') expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })')
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)") expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
const createBlock = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()')) const createBlock = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()'))
expect(createBlock).toContain("api('/tasks',") expect(createBlock).toContain("api('/tasks',")
@@ -1394,7 +1388,7 @@ describe('unified floating add interaction', () => {
expect(saveBlock).toContain('if (!taskSaved.due_at) {') expect(saveBlock).toContain('if (!taskSaved.due_at) {')
expect(saveBlock).toContain('selectedTaskRecurrence.value = null') expect(saveBlock).toContain('selectedTaskRecurrence.value = null')
expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'") expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'")
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence)') expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)')
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {')) const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
expect(dueRemovalBlock).not.toContain('saveRepeat(') expect(dueRemovalBlock).not.toContain('saveRepeat(')
expect(saveBlock).toContain("selectedRepeatError.value = ''") expect(saveBlock).toContain("selectedRepeatError.value = ''")
@@ -1573,9 +1567,6 @@ describe('sidebar information hierarchy', () => {
expect(app).toContain('class="sidebar-action-danger"') expect(app).toContain('class="sidebar-action-danger"')
expect(app).toContain("sidebarAction.kind==='folders'?'文件夹':'清单'") expect(app).toContain("sidebarAction.kind==='folders'?'文件夹':'清单'")
expect(app).toContain("sidebarAction.kind==='folders' ? `删除后,其中 ${sidebarActionFolderListCount} 个清单会移到“我的清单”` : '任务会保留,可从“已归档”恢复'") expect(app).toContain("sidebarAction.kind==='folders' ? `删除后,其中 ${sidebarActionFolderListCount} 个清单会移到“我的清单”` : '任务会保留,可从“已归档”恢复'")
const deleteEntityBlock = app.slice(app.indexOf('async function deleteEntity'), app.indexOf('async function loadArchivedLists'))
expect(deleteEntityBlock).toContain("confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复')")
expect(deleteEntityBlock).not.toContain("askText(`归档清单")
expect(css).toContain('.sidebar-action-sheet{width:min(320px,calc(100vw - 24px));') expect(css).toContain('.sidebar-action-sheet{width:min(320px,calc(100vw - 24px));')
expect(css).toContain('.sidebar-action-group{display:grid;gap:2px;') expect(css).toContain('.sidebar-action-group{display:grid;gap:2px;')
expect(css).toContain('.sidebar-action-danger{border-top:1px solid') expect(css).toContain('.sidebar-action-danger{border-top:1px solid')
-8
View File
@@ -81,14 +81,6 @@ describe('approved five-detail polish', () => {
expect(app).toContain("v-if=\"activeView==='tasks' && taskReorderAvailable\"") expect(app).toContain("v-if=\"activeView==='tasks' && taskReorderAvailable\"")
expect(app).toContain('<span class="list-section-count">{{ totalTasks }}</span>') expect(app).toContain('<span class="list-section-count">{{ totalTasks }}</span>')
expect(app).toContain("v-if=\"activeView==='tasks' && totalPages > 1\" class=\"list-page-meta\"") expect(app).toContain("v-if=\"activeView==='tasks' && totalPages > 1\" class=\"list-page-meta\"")
const taskListEnd = app.indexOf('</section>', app.indexOf('class=\"task-list plain-list\"'))
expect(app.indexOf('class=\"list-page-meta\"')).toBeGreaterThan(taskListEnd)
expect(app.indexOf('class=\"pager\"')).toBeGreaterThan(taskListEnd)
expect(app).toContain('ref="taskListElement"')
expect(app).toContain("totalPages > 1 && (activeView!=='today' || !todaySectionCollapse.tasks)")
expect(app).toContain("taskListElement.value?.scrollIntoView({ block: 'start' })")
expect(app).toContain('class="list-page-meta" aria-live="polite"')
expect(app).toContain('<span aria-live="polite">{{page}} / {{totalPages}}</span>')
expect(app).toContain("if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) }") expect(app).toContain("if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) }")
expect(app).toContain("new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)") expect(app).toContain("new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)")
expect(app).not.toContain('class="list-search-clear"') expect(app).not.toContain('class="list-search-clear"')
@@ -1,29 +0,0 @@
"""add after_completion unit
Revision ID: 0021_after_completion_unit
Revises: 0020_calendar_subscriptions
"""
import sqlalchemy as sa
from alembic import op
revision = "0021_after_completion_unit"
down_revision = "0020_calendar_subscriptions"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("recurrence_templates") as batch_op:
batch_op.add_column(
sa.Column("after_completion_unit", sa.String(length=8), nullable=True)
)
op.execute(
"UPDATE recurrence_templates SET after_completion_unit = 'days' "
"WHERE trigger_mode = 'after_completion'"
)
def downgrade() -> None:
with op.batch_alter_table("recurrence_templates") as batch_op:
batch_op.drop_column("after_completion_unit")
@@ -1,52 +0,0 @@
"""restore calendar subscriptions after the reverted release
Revision ID: 0020_calendar_subscriptions
Revises: 0019_backup_imports
The original revision reached production before the feature was reverted. Existing
databases may therefore already contain the table while fresh databases do not.
Keep the revision id and make the schema operation idempotent for both cases.
"""
import sqlalchemy as sa
from alembic import op
revision = "0020_calendar_subscriptions"
down_revision = "0019_backup_imports"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if "calendar_subscriptions" in sa.inspect(bind).get_table_names():
return
op.create_table(
"calendar_subscriptions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("color", sa.String(length=32), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("ics_cache", sa.Text(), nullable=True),
sa.Column("etag", sa.String(length=512), nullable=True),
sa.Column("last_modified", sa.String(length=512), nullable=True),
sa.Column("refreshed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_calendar_subscriptions_user_id", "calendar_subscriptions", ["user_id"]
)
def downgrade() -> None:
bind = op.get_bind()
if "calendar_subscriptions" not in sa.inspect(bind).get_table_names():
return
op.drop_index("ix_calendar_subscriptions_user_id", table_name="calendar_subscriptions")
op.drop_table("calendar_subscriptions")
-2
View File
@@ -16,8 +16,6 @@ dependencies = [
"structlog>=25,<26", "structlog>=25,<26",
"lunar-python>=1.2,<2", "lunar-python>=1.2,<2",
"httpx>=0.28,<1", "httpx>=0.28,<1",
"icalendar>=6,<7",
"python-dateutil>=2.9,<3",
] ]
[dependency-groups] [dependency-groups]
+2 -29
View File
@@ -48,7 +48,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
due_has_time=True, due_has_time=True,
) )
gap_due = recurrence_service._after_completion_due( gap_due = recurrence_service._after_completion_due(
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, "days", user gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, user
) )
assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap
@@ -60,7 +60,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
due_has_time=True, due_has_time=True,
) )
fold_due = recurrence_service._after_completion_due( fold_due = recurrence_service._after_completion_due(
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, "days", user fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, user
) )
assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0 assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0
@@ -89,30 +89,6 @@ def test_user_timezone_is_the_calendar_contract_for_after_completion(client, mon
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC) assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC)
def test_month_interval_uses_calendar_month_clamping(client, monkeypatch):
inbox = boot(client)
task = create_after_completion_task(
client,
inbox,
due_at="2026-01-31T01:30:00Z",
after_completion_days=1,
after_completion_unit="months",
).json()
monkeypatch.setattr(
"backend.recurrence_service.utcnow",
lambda: datetime(2026, 1, 30, 16, 30, tzinfo=UTC), # 2026-01-31 00:30 Asia/Shanghai
)
completed = client.patch(
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
)
assert completed.status_code == 200
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 2, 28, 1, 30, tzinfo=UTC)
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
assert recurrence["after_completion_unit"] == "months"
def test_atomic_task_create_and_read_after_completion_recurrence(client): def test_atomic_task_create_and_read_after_completion_recurrence(client):
inbox = boot(client) inbox = boot(client)
@@ -129,7 +105,6 @@ def test_atomic_task_create_and_read_after_completion_recurrence(client):
"ends_at": None, "ends_at": None,
"trigger_mode": "after_completion", "trigger_mode": "after_completion",
"after_completion_days": 2, "after_completion_days": 2,
"after_completion_unit": "days",
"last_completed_at": None, "last_completed_at": None,
} }
@@ -147,8 +122,6 @@ def test_after_completion_configuration_validation(client):
{"title": "零天", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 0}, {"title": "零天", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 0},
{"title": "太长", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 3651}, {"title": "太长", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 3651},
{"title": "子任务", "list_id": inbox["id"], "parent_id": child["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 1}, {"title": "子任务", "list_id": inbox["id"], "parent_id": child["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 1},
{"title": "只有单位", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "after_completion_unit": "months"},
{"title": "定期带月份", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "scheduled", "rrule": "FREQ=DAILY", "after_completion_unit": "months"},
] ]
for payload in invalid_payloads: for payload in invalid_payloads:
assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422} assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422}
-30
View File
@@ -200,36 +200,6 @@ def test_merge_same_backup_is_idempotent_via_import_ledger(client):
assert asyncio.run(counts()) == (1, 1) assert asyncio.run(counts()) == (1, 1)
def test_legacy_backup_without_completion_unit_is_idempotent(client):
inbox = boot(client)
client.post(
"/api/v1/tasks",
json={
"title": "旧版完成后重复",
"list_id": inbox["id"],
"due_at": "2026-03-08T01:30:00Z",
"trigger_mode": "after_completion",
"after_completion_days": 2,
},
)
content = client.get("/api/v1/backup/export.zip").content
recurrences = _archive_rows(content, "recurrences")
for recurrence in recurrences:
recurrence.pop("after_completion_unit", None)
legacy_content = _replace_entities(content, {"recurrences": recurrences})
first_token = _preflight(client, legacy_content).json()["preflight_token"]
assert client.post(
"/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}
).status_code == 200
second_token = _preflight(client, legacy_content).json()["preflight_token"]
second = client.post(
"/api/v1/backup/restore", json={"preflight_token": second_token, "mode": "merge"}
)
assert second.status_code == 200
assert second.json()["already_imported"] is True
def test_invalid_zip_variants_are_rejected_before_any_write(client): def test_invalid_zip_variants_are_rejected_before_any_write(client):
inbox = boot(client) inbox = boot(client)
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"]) before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
-60
View File
@@ -1,60 +0,0 @@
import asyncio
import json
import zipfile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from backend.backup.archive import parse_archive_path
from backend.backup.service import export_v2, validate_archive
from backend.db import get_engine
from backend.models import CalendarSubscription, User
def initialized(client):
response = client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
assert response.status_code == 201
return client
def test_backup_exports_calendar_subscriptions_and_accepts_old_archive(client, tmp_path):
client = initialized(client)
async def prepare_and_export():
factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with factory() as db:
user = await db.scalar(select(User))
db.add(CalendarSubscription(
user_id=user.id,
name="Work",
url="https://example.com/work.ics",
color="#123abc",
ics_cache="BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n",
))
await db.commit()
path = await export_v2(db, user)
await db.commit()
return path
path = asyncio.run(prepare_and_export())
try:
parsed = parse_archive_path(path)
assert parsed.entities["calendar_subscriptions"][0]["name"] == "Work"
validate_archive(parsed)
old_path = tmp_path / "old.zip"
with zipfile.ZipFile(path) as source, zipfile.ZipFile(old_path, "w") as target:
manifest = json.loads(source.read("manifest.json"))
manifest["entities"].pop("calendar_subscriptions")
manifest["checksums"].pop("data/calendar_subscriptions.json")
for name in source.namelist():
if name not in {"manifest.json", "data/calendar_subscriptions.json"}:
target.writestr(name, source.read(name))
from backend.backup.archive import canonical_json
target.writestr("manifest.json", canonical_json(manifest))
validate_archive(parse_archive_path(old_path))
finally:
path.unlink(missing_ok=True)
-236
View File
@@ -1,236 +0,0 @@
import socket
from datetime import UTC, datetime
import pytest
from fastapi import HTTPException
from backend.calendar import (
FetchResult,
parse_ics_events,
validate_calendar_url,
)
ICS = b"""BEGIN:VCALENDAR\r
VERSION:2.0\r
BEGIN:VEVENT\r
UID:one\r
DTSTART:20260920T090000Z\r
DTEND:20260920T100000Z\r
SUMMARY:Meeting\r
DESCRIPTION:Body line one\\nBody line two\r
LOCATION:Meeting room\r
END:VEVENT\r
END:VCALENDAR\r
"""
RECURRING_ICS = b"""BEGIN:VCALENDAR\r
VERSION:2.0\r
BEGIN:VEVENT\r
UID:daily\r
DTSTART;TZID=Asia/Shanghai:20260920T090000\r
DTEND;TZID=Asia/Shanghai:20260920T100000\r
RRULE:FREQ=DAILY;COUNT=3\r
EXDATE;TZID=Asia/Shanghai:20260921T090000\r
SUMMARY:Daily\r
END:VEVENT\r
BEGIN:VEVENT\r
UID:daily\r
RECURRENCE-ID;TZID=Asia/Shanghai:20260922T090000\r
DTSTART;TZID=Asia/Shanghai:20260922T110000\r
DTEND;TZID=Asia/Shanghai:20260922T120000\r
SUMMARY:Moved\r
END:VEVENT\r
END:VCALENDAR\r
"""
INHERITED_DURATION_ICS = RECURRING_ICS.replace(
b"DTEND;TZID=Asia/Shanghai:20260922T120000\r\n", b""
).replace(
b"DTEND;TZID=Asia/Shanghai:20260920T100000\r\n",
b"DTEND;TZID=Asia/Shanghai:20260920T103000\r\n",
)
def initialized(client, username="owner"):
response = client.post(
"/api/v1/setup/initialize",
json={"username": username, "password": "correct horse battery staple"},
)
assert response.status_code == 201
return client
def test_parser_restored_with_recurrence_exdates_overrides_and_timezone():
events = parse_ics_events(
RECURRING_ICS,
"Work",
"#123456",
datetime(2026, 9, 19, tzinfo=UTC),
datetime(2026, 9, 24, tzinfo=UTC),
"Asia/Shanghai",
)
assert [(event["title"], event["starts_at"].isoformat()) for event in events] == [
("Daily", "2026-09-20T01:00:00+00:00"),
("Moved", "2026-09-22T03:00:00+00:00"),
]
def test_recurrence_override_inherits_master_duration_and_source_id():
events = parse_ics_events(
INHERITED_DURATION_ICS,
"Work",
"#123456",
datetime(2026, 9, 19, tzinfo=UTC),
datetime(2026, 9, 24, tzinfo=UTC),
"Asia/Shanghai",
source_id="source-1",
)
moved = next(event for event in events if event["title"] == "Moved")
assert (moved["ends_at"] - moved["starts_at"]).total_seconds() == 90 * 60
assert moved["source_id"] == "source-1"
def test_parser_limits_recurrence_expansion():
endless = ICS.replace(b"UID:one", b"UID:one\r\nRRULE:FREQ=SECONDLY")
with pytest.raises(ValueError, match="recurrence limit"):
parse_ics_events(
endless,
"x",
"#000000",
datetime(2026, 9, 20, tzinfo=UTC),
datetime(2026, 9, 21, tzinfo=UTC),
"UTC",
recurrence_limit=10,
)
def test_url_validation_rejects_fragments_userinfo_and_mixed_dns(monkeypatch):
with pytest.raises(HTTPException):
validate_calendar_url("https://example.com/a.ics#secret")
with pytest.raises(HTTPException):
validate_calendar_url("https://[email protected]/a.ics")
monkeypatch.setattr(socket, "getaddrinfo", lambda *args, **kwargs: [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)),
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 443)),
])
with pytest.raises(HTTPException, match="public"):
validate_calendar_url("https://example.com/a.ics")
def test_subscription_crud_refresh_events_and_stale_cache(client, monkeypatch):
client = initialized(client)
monkeypatch.setattr(
"backend.calendar.validate_calendar_url",
lambda url: (url, "93.184.216.34", 443),
)
calls = []
def fetch(url, *, etag=None, last_modified=None):
calls.append((url, etag, last_modified))
if len(calls) == 1:
return FetchResult(ICS, '"v1"', "Sun, 20 Sep 2026 00:00:00 GMT", False)
raise HTTPException(502, "upstream down")
monkeypatch.setattr("backend.calendar.fetch_calendar", fetch)
created = client.post(
"/api/v1/calendar-subscriptions",
json={"name": " Work ", "url": "https://example.com/work.ics", "color": "#123abc"},
)
assert created.status_code == 201
body = created.json()
assert body["name"] == "Work"
assert body["enabled"] is True
assert body["stale"] is False
assert calls == [("https://example.com/work.ics", None, None)]
listed = client.get("/api/v1/calendar-subscriptions")
assert [item["id"] for item in listed.json()] == [body["id"]]
events = client.get(
"/api/v1/calendar-events",
params={"start": "2026-09-20T00:00:00Z", "end": "2026-09-21T00:00:00Z"},
)
assert events.status_code == 200
assert events.json()["events"][0]["title"] == "Meeting"
assert events.json()["events"][0]["description"] == "Body line one\nBody line two"
assert events.json()["events"][0]["location"] == "Meeting room"
assert events.json()["sources"][0]["stale"] is False
refreshed = client.post(f"/api/v1/calendar-subscriptions/{body['id']}/refresh")
assert refreshed.status_code == 200
assert refreshed.json()["stale"] is True
assert refreshed.json()["last_error"] == "upstream down"
assert calls[1][1:] == ('"v1"', "Sun, 20 Sep 2026 00:00:00 GMT")
patched = client.patch(
f"/api/v1/calendar-subscriptions/{body['id']}",
json={"name": "Personal", "enabled": False, "color": "#abcdef"},
)
assert patched.status_code == 200
assert patched.json()["name"] == "Personal"
assert patched.json()["enabled"] is False
assert client.delete(f"/api/v1/calendar-subscriptions/{body['id']}").status_code == 204
def test_parser_bounds_large_event_text_fields():
oversized = ICS.replace(
b"DESCRIPTION:Body line one\\nBody line two",
b"DESCRIPTION:" + b"x" * 3_000,
).replace(
b"LOCATION:Meeting room",
b"LOCATION:" + b"y" * 1_000,
)
event = parse_ics_events(
oversized,
"Work",
"#123456",
datetime(2026, 9, 20, tzinfo=UTC),
datetime(2026, 9, 21, tzinfo=UTC),
"UTC",
)[0]
assert event["description"] == "x" * 2_000
assert event["location"] == "y" * 500
def test_events_validate_window_and_disabled_sources_are_skipped(client, monkeypatch):
client = initialized(client)
monkeypatch.setattr(
"backend.calendar.validate_calendar_url",
lambda url: (url, "93.184.216.34", 443),
)
monkeypatch.setattr(
"backend.calendar.fetch_calendar",
lambda *args, **kwargs: FetchResult(ICS, None, None, False),
)
sub = client.post(
"/api/v1/calendar-subscriptions",
json={"name": "x", "url": "https://example.com/x.ics", "enabled": False},
).json()
assert sub["enabled"] is False
response = client.get(
"/api/v1/calendar-events",
params={"start": "2026-09-21T00:00:00Z", "end": "2026-09-20T00:00:00Z"},
)
assert response.status_code == 422
valid = client.get(
"/api/v1/calendar-events",
params={"start": "2026-09-20T00:00:00Z", "end": "2026-09-21T00:00:00Z"},
)
assert valid.json() == {"events": [], "sources": []}
def test_subscription_ownership_is_strict(client, monkeypatch):
client = initialized(client)
monkeypatch.setattr(
"backend.calendar.validate_calendar_url",
lambda url: (url, "93.184.216.34", 443),
)
monkeypatch.setattr(
"backend.calendar.fetch_calendar",
lambda *args, **kwargs: FetchResult(ICS, None, None, False),
)
sub = client.post(
"/api/v1/calendar-subscriptions",
json={"name": "private", "url": "https://example.com/private.ics"},
).json()
client.post("/api/v1/auth/logout")
assert client.patch(f"/api/v1/calendar-subscriptions/{sub['id']}", json={"name": "x"}).status_code == 401
assert client.delete(f"/api/v1/calendar-subscriptions/{sub['id']}").status_code == 401
Generated
-47
View File
@@ -277,10 +277,8 @@ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "icalendar" },
{ name = "lunar-python" }, { name = "lunar-python" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-dateutil" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlalchemy", extra = ["asyncio"] },
{ name = "structlog" }, { name = "structlog" },
@@ -303,10 +301,8 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.30,<1" }, { name = "asyncpg", specifier = ">=0.30,<1" },
{ name = "fastapi", specifier = ">=0.116,<1" }, { name = "fastapi", specifier = ">=0.116,<1" },
{ name = "httpx", specifier = ">=0.28,<1" }, { name = "httpx", specifier = ">=0.28,<1" },
{ name = "icalendar", specifier = ">=6,<7" },
{ name = "lunar-python", specifier = ">=1.2,<2" }, { name = "lunar-python", specifier = ">=1.2,<2" },
{ name = "pydantic-settings", specifier = ">=2.10,<3" }, { name = "pydantic-settings", specifier = ">=2.10,<3" },
{ name = "python-dateutil", specifier = ">=2.9,<3" },
{ name = "python-multipart", specifier = ">=0.0.20,<1" }, { name = "python-multipart", specifier = ">=0.0.20,<1" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" },
{ name = "structlog", specifier = ">=25,<26" }, { name = "structlog", specifier = ">=25,<26" },
@@ -478,19 +474,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.19" version = "3.19"
@@ -759,18 +742,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 }, { 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]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.3" version = "1.2.3"
@@ -860,15 +831,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 }, { 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]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.52" version = "2.0.52"
@@ -952,15 +914,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 }, { 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.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494 },
]
[[package]] [[package]]
name = "uuid-utils" name = "uuid-utils"
version = "0.17.0" version = "0.17.0"