Author SHA1 Message Date
bboysoul 12e4753aeb feat: redesign calendar detail for reading
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 4m12s
2026-09-21 17:33:02 +08:00
bboysoul 9b8f55cf46 fix: stabilize calendar detail lifecycle
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 5m34s
2026-09-21 16:31:00 +08:00
bboysoul 8125174263 fix: remount calendar detail across breakpoints
ci / gitleaks (push) Successful in 1m24s
ci / docker (push) Successful in 4m15s
2026-09-21 16:13:02 +08:00
bboysoul f437eded84 fix: dock calendar event detail on desktop
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m35s
2026-09-21 15:55:12 +08:00
bboysoul a68d6ecc5e fix: pace failed calendar refresh retries
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m11s
2026-09-21 15:38:52 +08:00
bboysoul 97c1bd4e87 feat: auto-refresh calendar subscriptions
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s
2026-09-21 15:36:09 +08:00
bboysoul a970a03f5d style: refine archived habits section
ci / gitleaks (push) Successful in 1m6s
ci / docker (push) Successful in 4m24s
2026-09-21 11:32:49 +08:00
bboysoul 158fd7532c [verified] center compact list editor
ci / gitleaks (push) Successful in 50s
ci / docker (push) Successful in 4m8s
2026-09-21 11:19:05 +08:00
bboysoul d925a043dc [verified] redesign compact list editor
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m59s
2026-09-21 10:59:41 +08:00
bboysoul 7f8b435164 [verified] move trash restore into action menu
ci / gitleaks (push) Successful in 1m9s
ci / docker (push) Successful in 5m49s
2026-09-21 10:22:46 +08:00
bboysoul 927e15cf17 [verified] harden trash actions and mobile details
ci / gitleaks (push) Successful in 26s
ci / docker (push) Successful in 5m5s
2026-09-21 09:37:55 +08:00
bboysoul 8a4d1de9be [verified] keep trash row rhythm consistent
ci / gitleaks (push) Successful in 49s
ci / docker (push) Successful in 4m24s
2026-09-21 09:08:33 +08:00
bboysoul 740eae8a59 [verified] redesign trash with deadline groups
ci / gitleaks (push) Successful in 11s
ci / docker (push) Successful in 8m0s
2026-09-21 09:00:07 +08:00
bboysoul c7bb9742f1 fix: refine task pagination controls
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 6m18s
2026-09-21 06:30:52 +08:00
bboysoul cbf307507c feat: redesign task pagination controls
ci / gitleaks (push) Successful in 10s
ci / docker (push) Successful in 7m22s
2026-09-21 06:10:54 +08:00
bboysoul 34691b6fe8 fix: move task pagination below list
ci / gitleaks (push) Successful in 36s
ci / docker (push) Successful in 5m10s
2026-09-20 23:18:04 +08:00
bboysoul 5e338fa90f fix: restore list archiving
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 5m30s
2026-09-20 22:48:12 +08:00
bboysoul 99da524070 fix: show subscribed calendar event details
ci / gitleaks (push) Successful in 1m23s
ci / docker (push) Successful in 4m14s
2026-09-20 22:16:13 +08:00
bboysoul 3a73473c4d [verified] fix calendar subscription layout
ci / gitleaks (push) Successful in 38s
ci / docker (push) Successful in 7m20s
2026-09-20 21:39:01 +08:00
bboysoul 3155155f53 [verified] feat: support monthly completion recurrence
ci / gitleaks (push) Successful in 23s
ci / docker (push) Successful in 7m16s
2026-09-20 21:04:31 +08:00
bboysoul e6d720b85c Revert "fix: align calendar sheets with detail pane"
ci / gitleaks (push) Successful in 58s
ci / docker (push) Successful in 5m35s
This reverts commit 6e3e09e8b6.
2026-09-20 18:12:12 +08:00
bboysoul 6e3e09e8b6 fix: align calendar sheets with detail pane
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 4m48s
2026-09-20 17:55:06 +08:00
bboysoul bb59dc9346 fix: restore selected calendar day contrast
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 6m36s
2026-09-20 17:35:44 +08:00
bboysoul 7140102aeb feat: redesign calendar around weekly focus
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m5s
2026-09-20 17:23:02 +08:00
bboysoul f3ad1eec03 feat: add iCal calendar subscriptions
ci / gitleaks (push) Successful in 56s
ci / docker (push) Successful in 5m55s
2026-09-20 16:37:43 +08:00
bboysoul af52fe0cad fix: add tombstone migration for reverted calendar subscriptions
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 5m34s
2026-09-20 14:11:06 +08:00
bboysoul 2715b74f2e Revert "[verified] feat: add external calendar subscriptions"
ci / gitleaks (push) Successful in 9s
ci / docker (push) Failing after 11m21s
This reverts commit 583b6a8a9d.
2026-09-20 13:57:35 +08:00
bboysoul 6528cbbb1e Revert "feat: add calendar frontend"
This reverts commit af83a68fe1.
2026-09-20 13:57:35 +08:00
bboysoul af83a68fe1 feat: add calendar frontend
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 4m41s
2026-09-20 13:21:47 +08:00
bboysoul 583b6a8a9d [verified] feat: add external calendar subscriptions 2026-09-20 12:29:21 +08:00
41 changed files with 2427 additions and 275 deletions
+17 -1
View File
@@ -152,6 +152,18 @@ def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES
content_names = set(names) - {"manifest.json"}
if set(checksums) != content_names:
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]] = {}
files: dict[str, StagedBlob] = {}
for index, name in enumerate(sorted(content_names)):
@@ -170,7 +182,11 @@ def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES
if not isinstance(checksums[name], str) or actual_digest != checksums[name]:
raise backup_error("backup_checksum_mismatch", "备份校验和不匹配")
if set(declared_entities) != set(entities):
raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
required_declared = set(entities) - optional_entities
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()):
raise backup_error("backup_manifest_mismatch", "manifest 实体数量不一致")
except HTTPException:
+20 -1
View File
@@ -19,6 +19,7 @@ from backend.models import (
BackupImport,
BackupImportEntity,
BackupPreflight,
CalendarSubscription,
Countdown,
Folder,
Habit,
@@ -65,6 +66,7 @@ ENTITY_MODELS = {
"habit_pauses": HabitPause,
"countdowns": Countdown,
"memos": Memo,
"calendar_subscriptions": CalendarSubscription,
"attachments": Attachment,
}
RELATIONS = {
@@ -352,6 +354,9 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
"rrule": row.get("rrule"),
"trigger_mode": row.get("trigger_mode", "scheduled"),
"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"])
ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None
@@ -392,6 +397,9 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
def validate_archive(parsed: ParsedArchive) -> None:
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)
if unknown:
raise backup_error("backup_entity_unknown", "备份包含未知实体")
@@ -518,6 +526,8 @@ def _coerce(model, raw: dict, mapping: dict[str, dict[str, UUID]], user_id: UUID
elif isinstance(effective_type, Date) and isinstance(value, str):
value = date.fromisoformat(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
@@ -611,7 +621,16 @@ async def restore_v2(
await db.execute(delete(HabitPause).where(
HabitPause.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id))
))
for model in (Attachment, Memo, Countdown, Task, Habit, TaskList, Folder):
for model in (
Attachment,
CalendarSubscription,
Memo,
Countdown,
Task,
Habit,
TaskList,
Folder,
):
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(BackupImport).where(BackupImport.user_id == user.id))
+254
View File
@@ -0,0 +1,254 @@
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"]))
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from fastapi import HTTPException
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from . import calendar as calendar_service
from .models import CalendarSubscription
logger = logging.getLogger(__name__)
DEFAULT_REFRESH_INTERVAL = timedelta(minutes=15)
DEFAULT_POLL_SECONDS = 60
async def refresh_subscription_cache(
db: AsyncSession,
row: CalendarSubscription,
*,
fail_without_cache: bool = True,
attempted_at: datetime | None = None,
) -> bool:
attempt_time = attempted_at or datetime.now(UTC)
requested_url = row.url
requested_version = getattr(row, "updated_at", None)
try:
result = await asyncio.to_thread(
calendar_service.fetch_calendar,
requested_url,
etag=row.etag,
last_modified=row.last_modified,
)
await db.refresh(row)
if row.url != requested_url or getattr(row, "updated_at", None) != requested_version:
return False
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 = attempt_time
row.updated_at = attempt_time
row.last_error = None
await db.commit()
return True
except Exception as exc:
error = exc.detail if isinstance(exc, HTTPException) else str(exc)
if not row.ics_cache and fail_without_cache:
await db.rollback()
raise HTTPException(502, error) from exc
row.last_error = error
row.updated_at = attempt_time
await db.commit()
return False
async def refresh_due_subscriptions(
db: AsyncSession,
*,
now: datetime | None = None,
refresh_interval: timedelta = DEFAULT_REFRESH_INTERVAL,
) -> int:
current = now or datetime.now(UTC)
cutoff = current - refresh_interval
rows = (await db.scalars(select(CalendarSubscription).where(
CalendarSubscription.enabled.is_(True),
or_(
CalendarSubscription.updated_at.is_(None),
CalendarSubscription.updated_at <= cutoff,
),
).order_by(CalendarSubscription.refreshed_at, CalendarSubscription.created_at))).all()
for row in rows:
try:
await refresh_subscription_cache(
db, row, fail_without_cache=False, attempted_at=current
)
except Exception:
await db.rollback()
logger.exception("Unexpected calendar refresh failure", extra={"subscription_id": str(row.id)})
return len(rows)
async def calendar_refresh_loop(
session_factory: Callable[[], AsyncSession],
stop_event: asyncio.Event,
*,
refresh_interval: timedelta = DEFAULT_REFRESH_INTERVAL,
poll_seconds: int = DEFAULT_POLL_SECONDS,
) -> None:
while not stop_event.is_set():
try:
async with session_factory() as db:
await refresh_due_subscriptions(db, refresh_interval=refresh_interval)
except Exception:
logger.exception("Calendar background refresh cycle failed")
try:
await asyncio.wait_for(stop_event.wait(), timeout=poll_seconds)
except TimeoutError:
pass
+194
View File
@@ -0,0 +1,194 @@
from datetime import 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 .calendar_refresh import refresh_subscription_cache
from .db import get_db
from .models import CalendarSubscription, User
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
@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_subscription_cache(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_subscription_cache(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_subscription_cache(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}
+7 -3
View File
@@ -22,6 +22,12 @@ def get_engine():
return _engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
get_engine()
assert _session_factory is not None
return _session_factory
def reset_engine() -> None:
global _engine, _session_factory
_engine = None
@@ -29,9 +35,7 @@ def reset_engine() -> None:
async def get_db() -> AsyncIterator[AsyncSession]:
get_engine()
assert _session_factory is not None
async with _session_factory() as session:
async with get_session_factory()() as session:
yield session
+44 -19
View File
@@ -1,3 +1,4 @@
import asyncio
import base64
import json
import logging
@@ -30,7 +31,9 @@ from .auth import (
verify_password,
)
from .backup import router as backup_router
from .db import create_schema, get_db
from .calendar_refresh import calendar_refresh_loop
from .calendar_router import router as calendar_router
from .db import create_schema, get_db, get_session_factory
from .models import (
AppState,
Attachment,
@@ -79,7 +82,19 @@ async def lifespan(app: FastAPI):
if get_settings().auto_create_schema:
await create_schema()
yield
stop_calendar_refresh = asyncio.Event()
calendar_refresh_task = asyncio.create_task(
calendar_refresh_loop(get_session_factory(), stop_calendar_refresh)
)
try:
yield
finally:
stop_calendar_refresh.set()
calendar_refresh_task.cancel()
try:
await calendar_refresh_task
except asyncio.CancelledError:
pass
app = FastAPI(
@@ -120,6 +135,7 @@ async def openapi(_: User = Depends(current_user)):
app.include_router(mvp_router)
app.include_router(backup_router)
app.include_router(calendar_router)
logger = logging.getLogger(__name__)
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
@@ -997,7 +1013,7 @@ async def create_task(
from .mvp import parse_rrule
if payload.rrule:
parse_rrule(payload.rrule)
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days"})
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days", "after_completion_unit"})
parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None)
max_position = await db.scalar(select(func.max(Task.position)).where(
Task.user_id == user.id,
@@ -1017,6 +1033,9 @@ async def create_task(
starts_at=task.due_at,
trigger_mode=payload.trigger_mode or "scheduled",
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)
@@ -1086,22 +1105,22 @@ async def list_tasks(
return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit)
async def _task_details(db: AsyncSession, tasks: list[Task]) -> list[TaskDetailOut]:
async def _task_details(
db: AsyncSession,
tasks: list[Task],
*,
include_deleted_subtasks: bool = False,
) -> list[TaskDetailOut]:
if not tasks:
return []
allowed_scopes = {(task.id, task.user_id, task.list_id) for task in tasks}
subtasks = list(
(
await db.scalars(
select(Task)
.where(
tuple_(Task.parent_id, Task.user_id, Task.list_id).in_(allowed_scopes),
Task.deleted_at.is_(None),
)
.order_by(*_task_ordering())
)
).all()
subtask_query = select(Task).where(
tuple_(Task.parent_id, Task.user_id, Task.list_id).in_(allowed_scopes)
)
subtask_query = subtask_query.where(
Task.deleted_at.is_not(None) if include_deleted_subtasks else Task.deleted_at.is_(None)
)
subtasks = list((await db.scalars(subtask_query.order_by(*_task_ordering()))).all())
subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list)
for subtask in subtasks:
if (subtask.parent_id, subtask.user_id, subtask.list_id) in allowed_scopes:
@@ -1252,11 +1271,17 @@ async def list_trash(
Task.user_id == user.id, Task.deleted_at.is_not(None), Task.parent_id.is_(None)
)
total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
ordering = (Task.created_at, Task.id)
grouping_rank = case(
(Task.due_at < utcnow(), 0),
(Task.due_at.is_not(None), 1),
else_=2,
)
if page is not None:
size = page_size or limit
items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all())
return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size)
page_ordering = (grouping_rank, Task.due_at, Task.created_at, Task.id)
items = list((await db.scalars(query.order_by(*page_ordering).offset((page - 1) * size).limit(size))).all())
return TaskPage(items=await _task_details(db, items, include_deleted_subtasks=True), total=total, page=page, page_size=size)
ordering = (Task.created_at, Task.id)
if cursor:
created_at, task_id = _decode_trash_cursor(cursor)
query = query.where(
@@ -1266,7 +1291,7 @@ async def list_trash(
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = _encode_trash_cursor(items[-1].created_at, items[-1].id) if has_more else None
return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit)
return TaskPage(items=await _task_details(db, items, include_deleted_subtasks=True), next_cursor=next_cursor, total=total, page=1, page_size=limit)
@app.post("/api/v1/tasks/{task_id}/restore", response_model=TaskDetailOut)
+18
View File
@@ -154,6 +154,7 @@ class RecurrenceTemplate(Base):
ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
trigger_mode: Mapped[str] = mapped_column(String(32), default="scheduled")
after_completion_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
after_completion_unit: Mapped[str | None] = mapped_column(String(8), nullable=True)
last_completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
@@ -301,6 +302,23 @@ class BackupImportEntity(Base):
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):
__tablename__ = "audit_logs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
+30 -4
View File
@@ -251,17 +251,22 @@ class RecurrenceCreate(BaseModel):
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
trigger_mode: str = Field(default="scheduled", pattern="^(scheduled|after_completion)$")
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
@model_validator(mode="after")
def validate_mode(self):
if self.trigger_mode == "scheduled" and (
self.rrule is None or self.after_completion_days is not None
self.rrule is None
or self.after_completion_days is not None
or self.after_completion_unit is not None
):
raise ValueError("scheduled recurrence requires rrule and no completion interval")
if self.trigger_mode == "after_completion" and (
self.after_completion_days is None or self.rrule is not None
):
raise ValueError("after_completion requires days and no rrule")
if self.trigger_mode == "after_completion" and self.after_completion_unit is None:
self.after_completion_unit = "days"
return self
@@ -271,6 +276,7 @@ class RecurrenceChange(BaseModel):
rrule: str | None = None
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
class OccurrenceComplete(BaseModel):
@@ -430,6 +436,7 @@ async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user),
"ends_at": row.ends_at,
"trigger_mode": row.trigger_mode,
"after_completion_days": row.after_completion_days,
"after_completion_unit": row.after_completion_unit,
"last_completed_at": row.last_completed_at,
}
@@ -452,6 +459,9 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
starts_at=task.due_at,
trigger_mode=payload.trigger_mode,
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)
await db.commit(); await db.refresh(row)
@@ -463,6 +473,7 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
"ends_at": row.ends_at,
"trigger_mode": row.trigger_mode,
"after_completion_days": row.after_completion_days,
"after_completion_unit": row.after_completion_unit,
"last_completed_at": row.last_completed_at,
}
@@ -504,12 +515,18 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
if "after_completion_days" in payload.model_fields_set
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
if requested_mode == "after_completion":
if requested_days is None:
raise HTTPException(422, "完成后重复需要间隔天数")
template.trigger_mode = requested_mode
template.after_completion_days = requested_days
template.after_completion_unit = requested_unit or "days"
template.rrule = None
else:
if requested_rrule is None:
@@ -517,6 +534,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
parse_rrule(requested_rrule)
template.trigger_mode = requested_mode
template.after_completion_days = None
template.after_completion_unit = None
template.rrule = requested_rrule.upper()
if payload.title is not None:
task.title = payload.title
@@ -533,6 +551,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
"ends_at": template.ends_at,
"trigger_mode": template.trigger_mode,
"after_completion_days": template.after_completion_days,
"after_completion_unit": template.after_completion_unit,
"last_completed_at": template.last_completed_at,
}
@@ -1358,7 +1377,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memo
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
"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],
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "after_completion_unit", "last_completed_at"]) for x in recurrences],
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
"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],
@@ -1570,11 +1589,17 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
rrule = raw.get("rrule")
if trigger_mode not in {"scheduled", "after_completion"}:
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 not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650 or rrule is not None:
raise HTTPException(422, "无效的完成后重复备份")
elif not isinstance(rrule, str):
raise HTTPException(422, "定期重复缺少 RRULE")
unit = unit or "days"
else:
if not isinstance(rrule, str) or days is not None or unit is not None:
raise HTTPException(422, "定期重复备份包含无效字段")
unit = None
db.add(RecurrenceTemplate(
user_id=user.id,
task_id=task_id,
@@ -1583,6 +1608,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
trigger_mode=trigger_mode,
after_completion_days=days,
after_completion_unit=unit,
last_completed_at=datetime.fromisoformat(raw["last_completed_at"])
if raw.get("last_completed_at") else None,
))
+13 -3
View File
@@ -2,6 +2,7 @@ from datetime import UTC, datetime, time, timedelta
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from dateutil.relativedelta import relativedelta
from fastapi import HTTPException
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -25,10 +26,15 @@ def _user_zone(user: User) -> ZoneInfo:
raise HTTPException(422, "用户时区无效") from exc
def _after_completion_due(task: Task, completed_at: datetime, days: int, user: User) -> datetime:
def _after_completion_due(
task: Task, completed_at: datetime, value: int, unit: str | None, user: User
) -> datetime:
zone = _user_zone(user)
completed_local = completed_at.astimezone(zone)
target_date = completed_local.date() + timedelta(days=days)
if unit == "months":
target_date = completed_local.date() + relativedelta(months=value)
else:
target_date = completed_local.date() + timedelta(days=value)
if task.due_has_time:
due_local = task.due_at.astimezone(zone)
wall_time = due_local.timetz().replace(tzinfo=None)
@@ -67,7 +73,11 @@ async def apply_task_changes(
if recurrence.trigger_mode == "after_completion":
completed_at = utcnow()
next_due = _after_completion_due(
task, completed_at, recurrence.after_completion_days, user
task,
completed_at,
recurrence.after_completion_days,
recurrence.after_completion_unit,
user,
)
changes["completed"] = False
changes["due_at"] = next_due
+16 -5
View File
@@ -125,6 +125,7 @@ class TaskCreate(BaseModel):
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
@field_validator("title")
@classmethod
@@ -136,7 +137,12 @@ class TaskCreate(BaseModel):
@model_validator(mode="after")
def validate_recurrence(self):
has_recurrence = self.rrule is not None or self.trigger_mode is not None or self.after_completion_days is not None
has_recurrence = (
self.rrule is not None
or self.trigger_mode is not None
or self.after_completion_days is not None
or self.after_completion_unit is not None
)
if has_recurrence and self.due_at is None:
raise ValueError("recurrence requires due_at")
if has_recurrence and self.parent_id is not None:
@@ -144,10 +150,15 @@ class TaskCreate(BaseModel):
if self.trigger_mode == "after_completion":
if self.after_completion_days is None or self.rrule is not None:
raise ValueError("after_completion requires days and no rrule")
elif self.trigger_mode == "scheduled" and self.rrule is None:
raise ValueError("scheduled recurrence requires rrule")
elif self.trigger_mode is None and self.after_completion_days is not None:
raise ValueError("after_completion_days requires after_completion mode")
elif self.trigger_mode == "scheduled":
if (
self.rrule is None
or self.after_completion_days is not None
or self.after_completion_unit is not None
):
raise ValueError("scheduled recurrence requires rrule and no completion interval")
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
+6 -1
View File
@@ -8,6 +8,11 @@ function bottomTab(page: Page, name: string) {
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
}
async function openSidebarView(page: Page, name: string) {
await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click()
await page.locator('.sidebar').getByRole('button', { name, exact: true }).click()
}
async function csrf(request: APIRequestContext, baseURL: string) {
const state = await request.storageState()
return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value
@@ -50,7 +55,7 @@ test('complete ZIP backup preflights and replace-restores task, habit history, c
expect(countdownResponse.ok()).toBeTruthy()
await page.goto('/')
await bottomTab(page, '设置').click()
await openSidebarView(page, '设置')
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: '导出 ZIP' }).click()
const download = await downloadPromise
@@ -108,7 +108,19 @@ test('habit detail paper flow is responsive, ordered, scrollable, and preserves
await dialog.getByRole('button', { name: '关闭习惯详情' }).click()
const archiveToggle = page.locator('.habit-archive-toggle')
const activeList = page.locator('.habit-list.plain-list').last()
const [activeListBox, archiveToggleBox] = await Promise.all([activeList.boundingBox(), archiveToggle.boundingBox()])
expect(activeListBox).not.toBeNull()
expect(archiveToggleBox).not.toBeNull()
expect(archiveToggleBox!.y - (activeListBox!.y + activeListBox!.height)).toBeGreaterThanOrEqual(24)
await expect(archiveToggle).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
await expect(archiveToggle).toHaveCSS('border-top-width', '0px')
await archiveToggle.click()
const archivedPanel = page.locator('#archived-habits-panel')
const [toggleOpenBox, archivePanelBox] = await Promise.all([archiveToggle.boundingBox(), archivedPanel.boundingBox()])
expect(toggleOpenBox).not.toBeNull()
expect(archivePanelBox).not.toBeNull()
expect(archivePanelBox!.y - (toggleOpenBox!.y + toggleOpenBox!.height)).toBeGreaterThanOrEqual(10)
const archivedOpener = page.getByRole('button', { name: new RegExp(archivedName) })
await archivedOpener.click()
dialog = page.getByRole('dialog', { name: archivedName })
+58 -12
View File
@@ -6,11 +6,6 @@ function bottomTab(page: Page, name: string) {
}
async function openSettings(page: Page) {
const mobileTab = bottomTab(page, '设置')
if (await mobileTab.isVisible()) {
await mobileTab.click()
return
}
const desktopSettings = page.getByRole('navigation', { name: '管理' }).getByRole('button', { name: '设置', exact: true })
const box = await desktopSettings.boundingBox()
if (box && box.x + box.width > 0 && box.y + box.height > 0 && box.x < (await page.viewportSize())!.width) await desktopSettings.click()
@@ -159,15 +154,21 @@ test('bottom navigation keeps its safe-area gap after dragging', async ({ page }
})
test('all bottom destinations expose one active page and desktop layout stays unchanged', async ({ page }) => {
await page.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: [] }))
await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [], sources: [] } }))
await page.goto('/')
const navigation = page.getByRole('navigation', { name: '主要导航' })
for (const label of ['今天', '习惯', '倒数日', '设置']) {
await bottomTab(page, label).click()
await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1)
await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page')
const mobile = (await page.viewportSize())!.width <= 930
if (mobile) {
for (const label of ['今天', '习惯', '倒数日', '备忘录', '日历订阅']) {
await bottomTab(page, label).click()
await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1)
await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page')
}
await bottomTab(page, '今天').click()
} else {
await expect(navigation).toBeHidden()
}
await bottomTab(page, '今天').click()
await page.setViewportSize({ width: 1440, height: 900 })
const desktop = await page.locator('.shell').evaluate(element => {
const shell = getComputedStyle(element)
@@ -199,10 +200,55 @@ test('all bottom destinations expose one active page and desktop layout stays un
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 }) => {
await page.goto('/')
await openSettings(page)
if ((await page.viewportSize())!.width <= 720) await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page')
if ((await page.viewportSize())!.width <= 720) {
await expect(page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name: '设置', exact: true })).toHaveCount(0)
}
const groups = page.locator('.settings-group')
await expect(groups).toHaveCount(4)
const layout = await page.locator('.settings-sections').evaluate(element => {
@@ -0,0 +1,96 @@
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.locator('.pager-status')).toHaveText('1 / 2共 51 项')
await expect(page.locator('.list-page-meta')).toHaveCount(0)
const firstPageGeometry = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>('.task-list')!.getBoundingClientRect()
const pagerElement = document.querySelector<HTMLElement>('.pager')!
const pagerBox = pagerElement.getBoundingClientRect()
const previous = pagerElement.querySelector<HTMLButtonElement>('.pager-button--previous')!
const next = pagerElement.querySelector<HTMLButtonElement>('.pager-button--next')!
const status = pagerElement.querySelector<HTMLElement>('.pager-status')!
const previousBox = previous.getBoundingClientRect()
const nextBox = next.getBoundingClientRect()
const statusBox = status.getBoundingClientRect()
const pagerCenter = pagerBox.left + pagerBox.width / 2
return {
listBottom: list.bottom,
pagerTop: pagerBox.top,
previousWidth: previousBox.width,
nextWidth: nextBox.width,
previousHeight: previousBox.height,
nextHeight: nextBox.height,
statusCenterOffset: Math.abs(statusBox.left + statusBox.width / 2 - pagerCenter),
rootScrollWidth: document.documentElement.scrollWidth,
viewportWidth: innerWidth,
}
})
expect(firstPageGeometry.pagerTop).toBeGreaterThanOrEqual(firstPageGeometry.listBottom)
expect(Math.abs(firstPageGeometry.previousWidth - firstPageGeometry.nextWidth)).toBeLessThanOrEqual(1)
expect(firstPageGeometry.previousHeight).toBeGreaterThanOrEqual(44)
expect(firstPageGeometry.nextHeight).toBeGreaterThanOrEqual(44)
expect(firstPageGeometry.statusCenterOffset).toBeLessThanOrEqual(1)
expect(firstPageGeometry.rootScrollWidth).toBeLessThanOrEqual(firstPageGeometry.viewportWidth)
const nextButton = pager.getByRole('button', { name: '下一页' })
await page.locator('body').click({ position: { x: 1, y: 1 } })
for (let index = 0; index < 300; index += 1) {
await page.keyboard.press('Tab')
if (await nextButton.evaluate(element => element === document.activeElement)) break
}
await expect(nextButton).toBeFocused()
const focusOutline = await nextButton.evaluate(element => getComputedStyle(element).outlineStyle)
expect(focusOutline).not.toBe('none')
await nextButton.click()
await expect(pager.locator('.pager-status')).toHaveText('2 / 2共 51 项')
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)
})
+105
View File
@@ -0,0 +1,105 @@
import type { APIRequestContext, Page } from '@playwright/test'
import { allowExpectedError, 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 mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters<APIRequestContext['fetch']>[1]) {
return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } })
}
async function openTrash(page: Page) {
if ((await page.viewportSize())!.width <= 930) {
await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click()
}
await page.locator('.sidebar').getByRole('button', { name: '回收站', exact: true }).click()
}
test('Trash uses compact deadline groups without exposing child-level actions', async ({ page, request, baseURL }) => {
const suffix = `${test.info().project.name}-${Date.now()}`
const bootstrap = await request.get('/api/v1/bootstrap')
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
expect(inbox).toBeTruthy()
const createDeleted = async (title: string, dueAt?: string) => {
const created = await mutate(request, baseURL!, '/api/v1/tasks', {
method: 'POST', data: { title, list_id: inbox.id, ...(dueAt ? { due_at: dueAt, due_has_time: false } : {}) },
})
expect(created.ok(), await created.text()).toBeTruthy()
const task = await created.json() as { id: string }
expect((await mutate(request, baseURL!, `/api/v1/tasks/${task.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
return task
}
const overdueTitle = `回收站过期-${suffix}`
const futureTitle = `回收站未来-${suffix}`
const undatedTitle = `回收站无日期-${suffix}`
await createDeleted(overdueTitle, '2026-01-02T15:59:00.000Z')
await createDeleted(futureTitle, '2099-12-30T15:59:00.000Z')
await createDeleted(undatedTitle)
await page.goto('/')
await openTrash(page)
await expect(page.locator('.trash-page-context')).toContainText('删除的任务保留在这里,可整组恢复或永久删除。')
for (const group of ['已过期', '未来截止', '无截止日期']) {
await expect(page.getByRole('heading', { name: group, exact: true })).toBeVisible()
}
for (const title of [overdueTitle, futureTitle, undatedTitle]) {
const row = page.locator('.task-row--trash').filter({ hasText: title })
await expect(row).toHaveCount(1)
await expect(row.getByRole('button', { name: '恢复' })).toHaveCount(0)
await expect(row.getByRole('button', { name: '打开任务操作' })).toBeVisible()
expect(await row.locator('.task-check').count()).toBe(0)
}
const overdueRow = page.locator('.task-row--trash').filter({ hasText: overdueTitle })
await expect(overdueRow.locator('.task-tail')).toBeVisible()
const actionTrigger = overdueRow.getByRole('button', { name: '打开任务操作' })
await actionTrigger.focus()
await actionTrigger.press('Enter')
const menu = page.getByRole('menu', { name: '回收站任务操作' })
await expect(menu).toBeVisible()
await expect(menu.getByRole('menuitem', { name: '恢复', exact: true })).toBeFocused()
await menu.getByRole('menuitem', { name: '恢复', exact: true }).press('Tab')
await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused()
await menu.getByRole('menuitem', { name: '永久删除' }).press('Shift+Tab')
await expect(menu.getByRole('menuitem', { name: '恢复', exact: true })).toBeFocused()
await menu.getByRole('menuitem', { name: '恢复', exact: true }).press('ArrowUp')
await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused()
await menu.getByRole('menuitem', { name: '永久删除' }).press('Escape')
await expect(menu).toBeHidden()
await expect(actionTrigger).toBeFocused()
await actionTrigger.click()
await page.getByRole('menuitem', { name: '永久删除' }).click()
const dialog = page.getByRole('dialog')
await expect(dialog).toContainText(`输入任务名称“${overdueTitle}”确认`)
await dialog.getByRole('button', { name: '永久删除' }).click()
await expect(dialog.getByRole('alert')).toContainText('任务名称不匹配')
await dialog.getByRole('button', { name: '取消' }).click()
await expect(actionTrigger).toBeFocused()
const geometry = await page.evaluate(() => ({
viewport: innerWidth,
document: document.documentElement.scrollWidth,
controls: [...document.querySelectorAll<HTMLElement>('.trash-list button')].map(button => {
const rect = button.getBoundingClientRect()
return { width: rect.width, height: rect.height }
}),
}))
expect(geometry.document).toBe(geometry.viewport)
for (const control of geometry.controls) {
expect(control.width).toBeGreaterThanOrEqual(44)
expect(control.height).toBeGreaterThanOrEqual(44)
}
await actionTrigger.click()
await page.getByRole('menuitem', { name: '永久删除' }).click()
await dialog.getByLabel(`输入任务名称“${overdueTitle}”确认`).fill(overdueTitle)
allowExpectedError(page, `requestfailed: DELETE ${baseURL}/api/v1/trash/`)
await dialog.getByRole('button', { name: '永久删除' }).click()
await expect(overdueRow).toHaveCount(0)
await expect(page.getByRole('heading', { name: '回收站', exact: true })).toBeFocused()
})
+11 -6
View File
@@ -101,23 +101,28 @@ test('task rows use the body for detail and Trash keeps distinct actions', async
await page.getByRole('button', { name: '关闭详情' }).click()
await openSidebarView(page, '回收站')
await expect(page.locator('.trash-page-context')).toContainText('删除的任务保留在这里,可整组恢复或永久删除。')
await expect(page.getByRole('heading', { name: '无截止日期', exact: true })).toBeVisible()
const restoreRow = await taskRow(page, restoreTitle)
const purgeRow = await taskRow(page, purgeTitle)
for (const deletedRow of [restoreRow, purgeRow]) {
await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible()
await expect(deletedRow.getByRole('button', { name: '永久删除' })).toBeVisible()
await expect(deletedRow.getByRole('button', { name: '恢复' })).toHaveCount(0)
await expect(deletedRow.getByRole('button', { name: '打开任务操作' })).toBeVisible()
await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('role')
await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('tabindex')
expect(await deletedRow.locator('.task-detail-trigger, .task-check').count()).toBe(0)
}
await restoreRow.getByRole('button', { name: '恢复' }).click()
await restoreRow.getByRole('button', { name: '打开任务操作' }).click()
await page.getByRole('menuitem', { name: '恢复', exact: true }).click()
await expect(restoreRow).toHaveCount(0)
await purgeRow.getByRole('button', { name: '永久删除' }).click()
await purgeRow.getByRole('button', { name: '打开任务操作' }).click()
await page.getByRole('menuitem', { name: '永久删除' }).click()
const purgeDialog = page.getByRole('dialog', { name: `永久删除“${purgeTitle}”?` })
await expect(purgeDialog).toBeVisible()
// The UI can abort the completed 204 request while the confirmation overlay closes.
allowExpectedError(page, `requestfailed: DELETE ${baseURL}/api/v1/trash/`)
await purgeDialog.getByRole('button', { name: '确认', exact: true }).click()
await purgeDialog.getByLabel(`输入任务名称“${purgeTitle}”确认`).fill(purgeTitle)
await purgeDialog.getByRole('button', { name: '永久删除', exact: true }).click()
await expect(purgeRow).toHaveCount(0)
await page.reload()
await expect(page.locator('.task-row').filter({ hasText: restoreTitle })).toHaveCount(0)
@@ -130,7 +135,7 @@ test('task rows use the body for detail and Trash keeps distinct actions', async
test('Settings removes intro/empty danger and places mode-specific restore risk copy correctly', async ({ page }, testInfo) => {
await page.goto('/')
await bottomTab(page, '设置').click()
await openSidebarView(page, '设置')
expect(await page.locator('.view-intro').count()).toBe(0)
await expect(page.locator('.settings-group')).toHaveCount(4)
expect(await page.locator('.settings-danger').count()).toBe(0)
+258 -126
View File
@@ -1,21 +1,22 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Code, Folder,
ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronLeft, ChevronRight, Code, Folder,
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote,
Settings, Trash2, X, Repeat2, StickyNote,
} from 'lucide-vue-next'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, groupTrashTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
import { captureListDragPointer, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
import { deriveMemoShellState } from './lib/app-shell-state'
import { clampDesktopPaneWidth, getDesktopPaneMax, readDesktopPaneWidth, writeDesktopPaneWidth, type DesktopPane } from './lib/desktop-shell-resize'
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue'
import MemoPanel from './MemoPanel.vue'
import CalendarPanel from './CalendarPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.vue'
@@ -31,8 +32,8 @@ type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
type RepeatOption = TaskRepeatOption
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null; after_completion_unit: AfterCompletionUnit | null }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
const initialized = ref<boolean | null>(null)
const authReady = ref(false)
@@ -55,6 +56,11 @@ let purgeListTrigger: HTMLElement | null = null
const tasks = ref<Task[]>([])
const overdueTasks = ref<Task[]>([])
const trash = ref<Task[]>([])
const taskListElement = ref<HTMLElement | null>(null)
const trashAction = ref<Task | null>(null)
const trashMenu = ref<HTMLElement | null>(null)
const trashPageTitle = ref<HTMLElement | null>(null)
let trashActionTrigger: HTMLElement | null = null
const NAVIGATION_STORAGE_KEY = 'dodo.navigation'
const restoredNavigation = readStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY)
const activeList = ref(restoredNavigation.listId)
@@ -63,7 +69,11 @@ const selectedTask = ref<Task | null>(null)
const taskSelectionGeneration = ref(0)
const sidebarCreateOpen = ref(false)
const sidebarAction = ref<{ kind: 'folders' | 'lists'; item: FolderItem | TaskList } | null>(null)
const listMoveMenuOpen = ref(false)
const listEditorTarget = ref<TaskList | null>(null)
const listEditorName = ref('')
const listEditorFolderId = ref('')
const listEditorError = ref('')
const listEditorBusy = ref(false)
const sidebarActionFolderListCount = computed(() => {
const action = sidebarAction.value
if (!action || action.kind !== 'folders') return 0
@@ -88,9 +98,9 @@ const desktopViewportWidth = ref(window.innerWidth)
const sidebarWidth = ref(readDesktopPaneWidth(window.localStorage, SIDEBAR_WIDTH_STORAGE_KEY, 236))
const detailWidth = ref(readDesktopPaneWidth(window.localStorage, DETAIL_WIDTH_STORAGE_KEY, 350))
const resizingPane = ref<DesktopPane | null>(null)
const sidebarMaxWidth = computed(() => getDesktopPaneMax('sidebar', desktopViewportWidth.value, selectedTask.value || habitDetailOpen.value ? detailWidth.value : 0))
const sidebarMaxWidth = computed(() => getDesktopPaneMax('sidebar', desktopViewportWidth.value, selectedTask.value || habitDetailOpen.value || calendarDetailOpen.value ? detailWidth.value : 0))
const detailMaxWidth = computed(() => getDesktopPaneMax('detail', desktopViewportWidth.value, sidebarCollapsed.value ? 0 : sidebarWidth.value))
const detailSeparatorLabel = computed(() => habitDetailOpen.value && !selectedTask.value ? '调整习惯详情宽度' : '调整任务详情宽度')
const detailSeparatorLabel = computed(() => calendarDetailOpen.value ? '调整日程详情宽度' : habitDetailOpen.value && !selectedTask.value ? '调整习惯详情宽度' : '调整任务详情宽度')
const shellStyle = computed(() => ({ '--sidebar-width': `${sidebarWidth.value}px`, '--detail-width': `${detailWidth.value}px` }))
let paneResizePointerId: number | null = null
let paneResizeCaptureTarget: HTMLElement | null = null
@@ -157,9 +167,11 @@ const composePriority = ref(0)
const composeDescription = ref('')
const composeRepeat = ref<RepeatOption>('none')
const composeAfterCompletionDays = ref('1')
const composeAfterCompletionUnit = ref<AfterCompletionUnit>('days')
const composeRepeatError = ref('')
const selectedTaskRepeat = ref<RepeatOption>('none')
const selectedAfterCompletionDays = ref('1')
const selectedAfterCompletionUnit = ref<AfterCompletionUnit>('days')
const selectedRepeatError = ref('')
const selectedTaskRecurrence = ref<Recurrence | null>(null)
const recurrenceLoading = ref(false)
@@ -182,6 +194,7 @@ const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
const memoTrash = ref(false)
const memoDetailOpen = ref(false)
const habitDetailOpen = ref(false)
const calendarDetailOpen = ref(false)
const compactLayout = ref(desktopViewportWidth.value <= 930)
const memoShellState = computed(() => deriveMemoShellState({ view: activeView.value, detailOpen: memoDetailOpen.value, compact: compactLayout.value, trash: memoTrash.value }))
const memoBackgroundInert = computed(() => memoShellState.value.backgroundInert)
@@ -204,6 +217,7 @@ function openTaskCompose() {
composeDescription.value = ''
composeRepeat.value = 'none'
composeAfterCompletionDays.value = '1'
composeAfterCompletionUnit.value = 'days'
composeRepeatError.value = ''
composeRepeatConfig.value = defaultRepeatConfig()
composeCalendarOpen.value = false
@@ -242,13 +256,13 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
else if (activeView.value === 'memos') void memoPanel.value?.createMemo()
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
}
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, recurrence: Recurrence | null) {
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, afterCompletionUnit: AfterCompletionUnit, recurrence: Recurrence | null) {
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
if (value === 'none') {
if (recurrence) await api(`/recurrences/${recurrence.id}`, { method: 'DELETE' })
return null
}
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config })
if (recurrence) {
return await api(`/recurrences/${recurrence.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence
}
@@ -262,6 +276,7 @@ async function loadTaskRecurrence(task: Task) {
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
selectedAfterCompletionDays.value = '1'
selectedAfterCompletionUnit.value = 'days'
selectedRepeatError.value = ''
try {
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
@@ -270,6 +285,7 @@ async function loadTaskRecurrence(task: Task) {
const parsed = parseTaskRecurrence(recurrence)
selectedTaskRepeat.value = parsed.option
selectedAfterCompletionDays.value = String(parsed.afterCompletionDays)
selectedAfterCompletionUnit.value = parsed.afterCompletionUnit
selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
} catch (reason) {
if (selectionIsCurrent()) fail(reason)
@@ -292,7 +308,7 @@ async function submitTaskCompose() {
const targetListId = composeListId.value
creatingTask.value = true
try {
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, repeatConfig: composeRepeatConfig.value })
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, afterCompletionUnit: composeAfterCompletionUnit.value, repeatConfig: composeRepeatConfig.value })
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间')
await taskMutationReconciler.run(
@@ -350,11 +366,11 @@ function movePaneResize(event: PointerEvent) {
if (!resizingPane.value || event.pointerId !== paneResizePointerId) return
if (event.buttons === 0) { stopPaneResize(); return }
const nextWidth = resizingPane.value === 'sidebar' ? event.clientX : window.innerWidth - event.clientX
if (resizingPane.value === 'sidebar') sidebarWidth.value = clampDesktopPaneWidth('sidebar', nextWidth, window.innerWidth, selectedTask.value || habitDetailOpen.value ? detailWidth.value : 0)
if (resizingPane.value === 'sidebar') sidebarWidth.value = clampDesktopPaneWidth('sidebar', nextWidth, window.innerWidth, selectedTask.value || habitDetailOpen.value || calendarDetailOpen.value ? detailWidth.value : 0)
else detailWidth.value = clampDesktopPaneWidth('detail', nextWidth, window.innerWidth, sidebarCollapsed.value ? 0 : sidebarWidth.value)
}
function startPaneResize(pane: DesktopPane, event: PointerEvent) {
if (event.button !== 0 || compactLayout.value || (pane === 'detail' && !selectedTask.value && !habitDetailOpen.value)) return
if (event.button !== 0 || compactLayout.value || (pane === 'detail' && !selectedTask.value && !habitDetailOpen.value && !calendarDetailOpen.value)) return
event.preventDefault()
paneResizeCaptureTarget = event.currentTarget as HTMLElement
paneResizeCaptureTarget.setPointerCapture?.(event.pointerId)
@@ -365,7 +381,7 @@ function resizePaneWithKeyboard(pane: DesktopPane, event: KeyboardEvent) {
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return
event.preventDefault()
const direction = event.key === 'ArrowRight' ? 1 : -1
if (pane === 'sidebar') sidebarWidth.value = clampDesktopPaneWidth('sidebar', sidebarWidth.value + direction * 12, window.innerWidth, selectedTask.value || habitDetailOpen.value ? detailWidth.value : 0)
if (pane === 'sidebar') sidebarWidth.value = clampDesktopPaneWidth('sidebar', sidebarWidth.value + direction * 12, window.innerWidth, selectedTask.value || habitDetailOpen.value || calendarDetailOpen.value ? detailWidth.value : 0)
else detailWidth.value = clampDesktopPaneWidth('detail', detailWidth.value - direction * 12, window.innerWidth, sidebarCollapsed.value ? 0 : sidebarWidth.value)
writeDesktopPaneWidth(window.localStorage, pane === 'sidebar' ? SIDEBAR_WIDTH_STORAGE_KEY : DETAIL_WIDTH_STORAGE_KEY, pane === 'sidebar' ? sidebarWidth.value : detailWidth.value)
}
@@ -389,6 +405,7 @@ const activeName = computed(() => {
if (activeView.value === 'habits') return '习惯'
if (activeView.value === 'countdowns') return '倒数日'
if (activeView.value === 'memos') return '备忘录'
if (activeView.value === 'calendar') return '日历订阅'
if (activeView.value === 'settings') return '设置与数据'
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
})
@@ -409,7 +426,7 @@ const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value :
const visibleTasks = computed(() => {
const now = new Date()
let result = sourceTasks.value
if (['habits','settings','countdowns','memos'].includes(activeView.value)) return []
if (['habits','settings','countdowns','memos','calendar'].includes(activeView.value)) return []
if (activeView.value === 'today') result = result.filter((task) => {
const dueToday = task.due_at && new Date(task.due_at).toDateString() === now.toDateString()
const completedToday = task.completed_at && new Date(task.completed_at).toDateString() === now.toDateString()
@@ -420,6 +437,7 @@ const visibleTasks = computed(() => {
})
const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? [])
const taskTree = computed(() => groupTaskTree(visibleTasks.value))
const trashGroups = computed(() => groupTrashTaskTree(visibleTasks.value))
const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value))
watch(composeDueAt, (value) => {
if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' }
@@ -717,7 +735,7 @@ async function loadTrashPage() {
async function loadTrash() {
loading.value = true
error.value = ''
return await runLatestRequest('trash', loadTrashPage, {
const committed = await runLatestRequest('trash', loadTrashPage, {
success: (data) => {
trash.value = data.items ?? []
totalTasks.value = data.total ?? trash.value.length
@@ -725,6 +743,11 @@ async function loadTrash() {
error: fail,
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) {
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return
@@ -748,7 +771,7 @@ async function switchView(view: View, listId?: string) {
if (listId) activeList.value = listId
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
page.value = 1
habitComposer.value?.closeHabitDetail(true); selectedTask.value = null; habitDetailOpen.value = false; taskSelectionGeneration.value += 1; mobileSidebar.value = false; mobileDetail.value = false; taskComposeGeneration.value += 1; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
habitComposer.value?.closeHabitDetail(true); selectedTask.value = null; habitDetailOpen.value = false; calendarDetailOpen.value = false; taskSelectionGeneration.value += 1; mobileSidebar.value = false; mobileDetail.value = false; taskComposeGeneration.value += 1; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
if (view !== 'memos') memoDetailOpen.value = false
if (view === 'trash') await loadTrash()
else if (view === 'today') await loadTodayView()
@@ -1091,6 +1114,7 @@ async function saveSelectedTaskChanges() {
const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'
const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig
const afterCompletionDays = selectedAfterCompletionDays.value
const afterCompletionUnit = selectedAfterCompletionUnit.value
const recurrence = selectedTaskRecurrence.value
selectedRepeatError.value = ''
try {
@@ -1100,7 +1124,7 @@ async function saveSelectedTaskChanges() {
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
} else {
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence)
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
selectedTaskRecurrence.value = updatedRecurrence
}
@@ -1148,8 +1172,57 @@ async function mutateTrashTask(task: Task, mutation: () => Promise<unknown>, suc
async function restoreTask(task: Task) {
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复', true)
}
function openTrashAction(task: Task, event?: Event) {
trashActionTrigger = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null
trashAction.value = task
nextTick(() => trashMenu.value?.querySelector<HTMLElement>('[role=menuitem]')?.focus())
}
function moveTrashMenuFocus(step: number) {
const items = [...(trashMenu.value?.querySelectorAll<HTMLElement>('[role=menuitem]') ?? [])]
if (!items.length) return
const activeIndex = items.indexOf(document.activeElement as HTMLElement)
items[(activeIndex + step + items.length) % items.length]?.focus()
}
function focusTrashActionTrigger(trigger: HTMLElement | null) {
nextTick(() => {
if (trigger?.isConnected) trigger.focus()
else trashPageTitle.value?.focus()
})
}
function closeTrashAction(restoreFocus = true) {
const trigger = trashActionTrigger
trashAction.value = null
trashActionTrigger = null
if (restoreFocus) focusTrashActionTrigger(trigger)
}
async function requestRestoreTask() {
const task = trashAction.value
const trigger = trashActionTrigger
closeTrashAction(false)
if (task) await restoreTask(task)
focusTrashActionTrigger(trigger)
}
async function requestPurgeTask() {
const task = trashAction.value
const trigger = trashActionTrigger
closeTrashAction(false)
if (task) await purgeTask(task)
focusTrashActionTrigger(trigger)
}
async function purgeTask(task: Task) {
if (!(await confirmAction(`永久删除“${task.title}”?`, '这个操作不能撤销。', true))) return
const childCount = task.subtasks?.length ?? 0
const impact = childCount
? `此任务及其 ${childCount} 个子任务将被永久删除,不能撤销。`
: '此任务将被永久删除,不能撤销。'
const entered = await appDialog.value?.show({
title: `永久删除“${task.title}”?`,
description: impact,
label: `输入任务名称“${task.title}”确认`,
confirmText: '永久删除',
danger: true,
validate: (value) => value.trim() === task.title ? null : '任务名称不匹配',
})
if (typeof entered !== 'string') return
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除', false)
}
async function addSubtask() {
@@ -1268,32 +1341,14 @@ async function createList(folderId: string | null = null) {
const name = (await askText('新建清单', '清单名称', '', '创建'))?.trim(); if (!name) return
try { const item = await api('/lists', { method: 'POST', body: JSON.stringify({ name, folder_id: folderId }) }); lists.value.push(item); await switchView('tasks', item.id); toast('清单已创建') } catch (reason) { fail(reason) }
}
async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
const name = (await askText('重命名', kind === 'lists' ? '清单名称' : '文件夹名称', item.name, '保存'))?.trim(); if (!name || name === item.name) return
try { const updated = await api(`/${kind}/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) }); Object.assign(item, updated); toast('已重命名') } catch (reason) { fail(reason) }
async function renameFolder(item: FolderItem) {
const name = (await askText('重命名', '文件夹名称', item.name, '保存'))?.trim(); if (!name || name === item.name) return
try { const updated = await api(`/folders/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) }); Object.assign(item, updated); toast('已重命名') } catch (reason) { fail(reason) }
}
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
if (kind === 'lists') {
const answer = await askText(`归档清单「${item.name}」?`, '', '', '归档')
if (answer === null) return
try {
const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id
await api(`/${kind}/${item.id}`, { method: 'DELETE' })
await loadArchivedLists()
await refreshAll()
if (wasCurrentList) {
selectedTask.value = null
mobileDetail.value = false
const inboxId = lists.value.find((list) => list.is_inbox)?.id || ''
await switchView('tasks', inboxId)
}
toast('清单已归档')
} catch (reason) { fail(reason) }
return
}
async function deleteFolder(item: FolderItem) {
const answer = await askText(`删除文件夹「${item.name}」?`, '', '', '删除')
if (answer === null) return
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await refreshAll(); toast('已删除') } catch (reason) { fail(reason) }
try { await api(`/folders/${item.id}`, { method: 'DELETE' }); await refreshAll(); toast('已删除') } catch (reason) { fail(reason) }
}
async function loadArchivedLists() {
try { archivedLists.value = await api('/lists?archived=true') } catch { archivedLists.value = [] }
@@ -1389,6 +1444,15 @@ async function confirmPurgeList() {
function toggleSidebarCreate() { sidebarCreateOpen.value = !sidebarCreateOpen.value; sidebarAction.value = null }
function openSidebarAction(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
closeArchivedListAction(false)
if (kind === 'lists') {
sidebarAction.value = null
listEditorTarget.value = item as TaskList
listEditorName.value = item.name
listEditorFolderId.value = (item as TaskList).folder_id ?? ''
listEditorError.value = ''
sidebarCreateOpen.value = false
return
}
sidebarAction.value = sidebarAction.value?.item.id === item.id ? null : { kind, item }
sidebarCreateOpen.value = false
}
@@ -1396,7 +1460,76 @@ function runSidebarCreate(kind: 'folder' | 'list') {
sidebarCreateOpen.value = false
kind === 'folder' ? void createFolder() : void createList(null)
}
function closeSidebarAction() { sidebarAction.value = null; listMoveMenuOpen.value = false }
function closeSidebarAction() { sidebarAction.value = null }
function closeListEditor() {
if (listEditorBusy.value) return
listEditorTarget.value = null
listEditorError.value = ''
}
async function saveListEditor() {
const item = listEditorTarget.value
if (!item || listEditorBusy.value) return
const normalized = normalizeRequiredName(listEditorName.value)
if (normalized.error) { listEditorError.value = normalized.error; return }
const name = normalized.value
const folderId = listEditorFolderId.value || null
listEditorBusy.value = true
listEditorError.value = ''
let nameSaved = false
try {
if (name !== item.name) {
const updated = await api(`/lists/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) })
Object.assign(item, updated)
nameSaved = true
}
if (folderId !== item.folder_id) {
const previous = lists.value
const result = moveListToScope(previous, item.id, folderId)
lists.value = result.items
try {
await api(`/lists/${item.id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })
} catch (reason) {
lists.value = previous
throw reason
}
}
listEditorTarget.value = null
toast('清单已更新')
} catch (reason) {
if (nameSaved) {
await refreshAll()
const current = lists.value.find((list) => list.id === item.id)
if (current) {
listEditorTarget.value = current
listEditorName.value = current.name
listEditorFolderId.value = current.folder_id ?? ''
}
listEditorError.value = '名称已保存,但移动文件夹失败,请重试。'
} else listEditorError.value = reason instanceof Error ? reason.message : '保存失败'
} finally { listEditorBusy.value = false }
}
async function archiveListFromEditor() {
const item = listEditorTarget.value
if (!item || listEditorBusy.value) return
if (!(await confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复'))) return
listEditorBusy.value = true
try {
const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id
await api(`/lists/${item.id}`, { method: 'DELETE' })
listEditorTarget.value = null
await loadArchivedLists()
await refreshAll()
if (wasCurrentList) {
selectedTask.value = null
mobileDetail.value = false
const inboxId = lists.value.find((list) => list.is_inbox)?.id || ''
await switchView('tasks', inboxId)
}
toast('清单已归档')
} catch (reason) {
listEditorError.value = reason instanceof Error ? reason.message : '归档失败'
} finally { listEditorBusy.value = false }
}
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
function beginListDrag(list: TaskList, pointer: ListDragPointer) {
@@ -1510,44 +1643,35 @@ function cancelListDrag() {
listDropFolderId.value = undefined
listReorderTarget.value = ''
}
function openListMoveMenu() { listMoveMenuOpen.value = true }
function closeListMoveMenu() { listMoveMenuOpen.value = false }
function moveListFromMenu(folderId: string | null) {
const item = sidebarAction.value?.kind === 'lists' ? sidebarAction.value.item as TaskList : null
if (!item) return
listMoveMenuOpen.value = false
void persistListMove(item, folderId)
closeSidebarAction()
async function scrollToTaskPageStart() {
await nextTick()
taskListElement.value?.scrollIntoView({ block: 'start' })
}
function canMoveListWithinScope(item: TaskList, direction: 'up' | 'down') {
return getAdjacentListMove(lists.value, item.id, direction) !== null
}
function moveListWithinScope(item: TaskList, direction: 'up' | 'down') {
const move = getAdjacentListMove(lists.value, item.id, direction)
if (!move) return
void persistListMove(item, item.folder_id, move.targetId, move.placement)
closeSidebarAction()
}
function previousPage() {
async function previousPage() {
if (page.value <= 1 || loading.value) return
page.value -= 1
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
if (activeView.value === 'trash') await loadTrash()
else if (isTaskView(activeView.value)) await loadAll()
await scrollToTaskPageStart()
}
function nextPage() {
async function nextPage() {
if (page.value >= totalPages.value || loading.value) return
page.value += 1
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
if (activeView.value === 'trash') await loadTrash()
else if (isTaskView(activeView.value)) await loadAll()
await scrollToTaskPageStart()
}
function reconcileDesktopPaneWidths() {
if (compactLayout.value) return
if (selectedTask.value || habitDetailOpen.value) {
if (selectedTask.value || habitDetailOpen.value || calendarDetailOpen.value) {
detailWidth.value = clampDesktopPaneWidth('detail', detailWidth.value, desktopViewportWidth.value, sidebarCollapsed.value ? 0 : sidebarWidth.value)
}
sidebarWidth.value = clampDesktopPaneWidth('sidebar', sidebarWidth.value, desktopViewportWidth.value, selectedTask.value || habitDetailOpen.value ? detailWidth.value : 0)
sidebarWidth.value = clampDesktopPaneWidth('sidebar', sidebarWidth.value, desktopViewportWidth.value, selectedTask.value || habitDetailOpen.value || calendarDetailOpen.value ? detailWidth.value : 0)
}
watch([selectedTask, habitDetailOpen, sidebarCollapsed], reconcileDesktopPaneWidths)
watch([selectedTask, habitDetailOpen, calendarDetailOpen, sidebarCollapsed], reconcileDesktopPaneWidths)
function handlePaneResizeBlur() { stopPaneResize() }
@@ -1602,7 +1726,7 @@ onUnmounted(() => {
<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>
</form>
</div>
<div v-else class="shell" :class="{ 'today-active': activeView==='today', 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask) || habitDetailOpen, 'memo-detail-open': activeView==='memos' && memoDetailOpen, 'mobile-sidebar-open': mobileSidebar, 'pane-resizing': Boolean(resizingPane) }" :style="shellStyle">
<div v-else class="shell" :class="{ 'today-active': activeView==='today', 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask) || habitDetailOpen || calendarDetailOpen, 'memo-detail-open': activeView==='memos' && memoDetailOpen, 'mobile-sidebar-open': mobileSidebar, 'pane-resizing': Boolean(resizingPane) }" :style="shellStyle">
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
<aside class="sidebar" :inert="memoBackgroundInert ? true : undefined" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()">
<div class="brand-row"><div class="brand small brand-lockup"><img class="brand-logo" src="/dodo-logo.svg" alt=""><span class="brand-wordmark">dodo</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
@@ -1613,14 +1737,15 @@ onUnmounted(() => {
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</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==='calendar' }" @click="switchView('calendar')"><CalendarDays />日历订阅</button>
</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="folders">
<div v-for="folder in folders" :key="folder.id" class="folder-block" :data-folder-id="folder.id">
<div class="folder-row" :class="{'list-drop-target':listDrag&&listDropFolderId===folder.id&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><button :title="folder.name" :aria-label="folder.name" @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/><span>{{folder.name}}</span></button><span class="row-actions"><button aria-label="打开文件夹操作" :aria-expanded="sidebarAction?.item.id===folder.id" @click="openSidebarAction('folders',folder)"><Ellipsis/></button></span></div>
<div v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main draggable-list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
<div v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main draggable-list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="listEditorTarget?.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
</div>
<div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main draggable-list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
<div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main draggable-list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="listEditorTarget?.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
<div class="archived-lists">
<button ref="archivedListsToggle" class="archived-lists-toggle" :class="{ empty: archivedLists.length === 0 }" :aria-expanded="archivedLists.length > 0 && archivedListsExpanded" aria-controls="archived-task-lists" :disabled="archivedLists.length === 0" @click="toggleArchivedLists"><ChevronRight :class="{ expanded: archivedListsExpanded }"/><span>已归档 {{ archivedLists.length }}</span></button>
<div id="archived-task-lists" v-show="archivedListsExpanded" class="archived-list-items">
@@ -1634,42 +1759,33 @@ onUnmounted(() => {
</nav>
<AppSheet :open="Boolean(sidebarAction)" variant="actions" panel-class="sidebar-action-sheet" :label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined" initial-focus=".app-sheet__header button" @close="closeSidebarAction">
<template v-if="sidebarAction">
<template v-if="!listMoveMenuOpen">
<header class="app-sheet__header sidebar-action-header">
<div><span class="sidebar-action-kind">{{sidebarAction.kind==='folders'?'文件夹':'清单'}}</span><b>{{sidebarAction.item.name}}</b></div>
<button class="icon" :aria-label="`关闭${sidebarAction.kind==='folders'?'文件夹':'清单'}操作`" @click="closeSidebarAction"><X/></button>
</header>
<div class="app-sheet__body sidebar-action-body">
<section class="sidebar-action-group" aria-label="常用操作">
<span class="sidebar-action-group-title">常用操作</span>
<button @click="renameEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><Pencil/><span>重命名</span></button>
<button v-if="sidebarAction.kind==='folders'" @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/><span>新建清单</span></button>
</section>
<section v-if="sidebarAction.kind==='lists'" class="sidebar-action-group" aria-label="整理清单">
<span class="sidebar-action-group-title">整理清单</span>
<button aria-label="上移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'up')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'up')"><ChevronDown class="sidebar-action-up"/><span>上移</span></button>
<button aria-label="下移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'down')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'down')"><ChevronDown/><span>下移</span></button>
<button aria-label="移动到文件夹" aria-haspopup="menu" :aria-expanded="listMoveMenuOpen" @click="openListMoveMenu"><Folder/><span>{{(sidebarAction.item as TaskList).folder_id?'更改所在文件夹':'移动到文件夹'}}</span><ChevronRight class="sidebar-action-chevron"/></button>
</section>
<section class="sidebar-action-danger">
<span>{{sidebarAction.kind==='folders' ? `删除后,其中 ${sidebarActionFolderListCount} 个清单会移到“我的清单”` : '任务会保留,可从“已归档”恢复'}}</span>
<button class="danger" @click="deleteEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><component :is="sidebarAction.kind==='folders' ? Trash2 : ArchiveRestore"/><span>{{sidebarAction.kind==='folders'?'删除文件夹':'归档清单'}}</span></button>
</section>
</div>
</template>
<template v-if="listMoveMenuOpen">
<header class="app-sheet__header sidebar-action-move-view">
<button class="sidebar-action-move-back" aria-label="返回清单操作" @click="closeListMoveMenu"><ChevronRight/></button>
<div><span class="sidebar-action-kind">清单位置</span><b class="sidebar-action-move-title">选择目标位置</b></div>
<button class="icon" aria-label="关闭清单操作" @click="closeSidebarAction"><X/></button>
</header>
<div class="app-sheet__body sidebar-action-body">
<div class="list-move-menu" role="menu" aria-label="选择目标文件夹">
<button role="menuitem" :class="{'list-move-current':!(sidebarAction.item as TaskList).folder_id}" :disabled="!(sidebarAction.item as TaskList).folder_id" @click="moveListFromMenu(null)"><ListTodo/><span>我的清单</span><Check v-if="!(sidebarAction.item as TaskList).folder_id"/></button>
<button v-for="folder in folders" :key="folder.id" role="menuitem" :class="{'list-move-current':(sidebarAction.item as TaskList).folder_id===folder.id}" :disabled="(sidebarAction.item as TaskList).folder_id===folder.id" @click="moveListFromMenu(folder.id)"><Folder/><span>{{folder.name}}</span><Check v-if="(sidebarAction.item as TaskList).folder_id===folder.id"/></button>
</div>
</div>
</template>
<header class="app-sheet__header sidebar-action-header">
<div><span class="sidebar-action-kind">文件夹</span><b>{{sidebarAction.item.name}}</b></div>
<button class="icon" aria-label="关闭文件夹操作" @click="closeSidebarAction"><X/></button>
</header>
<div class="app-sheet__body sidebar-action-body">
<section class="sidebar-action-group" aria-label="常用操作">
<span class="sidebar-action-group-title">常用操作</span>
<button @click="renameFolder(sidebarAction.item as FolderItem);closeSidebarAction()"><Pencil/><span>重命名</span></button>
<button @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/><span>新建清单</span></button>
</section>
<section class="sidebar-action-danger">
<span>删除后其中 {{sidebarActionFolderListCount}} 个清单会移到我的清单</span>
<button class="danger" @click="deleteFolder(sidebarAction.item as FolderItem);closeSidebarAction()"><Trash2/><span>删除文件夹</span></button>
</section>
</div>
</template>
</AppSheet>
<AppSheet :open="Boolean(listEditorTarget)" variant="actions" panel-class="list-editor-sheet" title-id="list-editor-title" initial-focus=".list-editor-name" :busy="listEditorBusy" @close="closeListEditor" @submit.prevent="saveListEditor">
<template v-if="listEditorTarget">
<header class="app-sheet__header list-editor-header"><div><h2 id="list-editor-title">编辑清单</h2></div><button class="icon" type="button" :disabled="listEditorBusy" aria-label="关闭编辑清单" @click="closeListEditor"><X/></button></header>
<div class="app-sheet__body list-editor-form">
<label>清单名称<input v-model="listEditorName" class="list-editor-name" autocomplete="off" maxlength="80" :aria-invalid="Boolean(listEditorError)" @input="listEditorError=''"/><small>名称最多 80 个字符</small></label>
<label>所在文件夹<select v-model="listEditorFolderId"><option value="">不放入文件夹</option><option v-for="folder in folders" :key="folder.id" :value="folder.id">{{folder.name}}</option></select></label>
<p v-if="listEditorError" class="list-editor-error" role="alert">{{listEditorError}}</p>
<section class="list-editor-danger"><div><b>归档清单</b><small>任务会保留可从已归档恢复</small></div><button type="button" class="danger-button" :disabled="listEditorBusy" @click="archiveListFromEditor"><ArchiveRestore/>归档</button></section>
</div>
<footer class="app-sheet__footer list-editor-footer"><button type="button" class="secondary" :disabled="listEditorBusy" @click="closeListEditor">取消</button><button class="primary-small" :disabled="listEditorBusy || !listEditorName.trim()">{{listEditorBusy?'正在保存…':'保存更改'}}</button></footer>
</template>
</AppSheet>
</aside>
@@ -1678,12 +1794,13 @@ onUnmounted(() => {
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}">
<header class="topbar" :class="{'settings-topbar':activeView==='settings'}" :inert="memoBackgroundInert ? true : undefined">
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
<div v-if="!['today','tasks','upcoming','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
<div v-if="!['today','tasks','upcoming','trash','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
</header>
<template v-if="['habits','settings'].includes(activeView)">
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" :compact-layout="compactLayout" @update:show-completed="showCompleted=$event" @detail="handleHabitDetail" @changed="refreshAll" @notice="toast" @logout="completeLogout" />
</template>
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @detail="memoDetailOpen=$event" @notice="toast" />
<CalendarPanel v-else-if="activeView==='calendar'" :compact-layout="compactLayout" @detail="calendarDetailOpen=$event" @notice="toast" />
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else>
<section v-if="activeView==='today'" class="today-context" aria-label="今日概览">
@@ -1708,22 +1825,36 @@ onUnmounted(() => {
</section>
<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>
<section v-if="activeView==='trash'" class="trash-page-context">
<div><h1 ref="trashPageTitle" class="trash-page-title" tabindex="-1">回收站</h1><p class="trash-page-summary">删除的任务保留在这里可整组恢复或永久删除</p></div>
<span class="trash-page-count"> {{totalTasks}} </span>
</section>
<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">{{ 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>
<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">
<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="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
<div class="task-main" :role="activeView==='trash' ? undefined : 'button'" :tabindex="activeView==='trash' ? undefined : 0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','','',''][node.task.priority]}}</span></div>
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span>
</article>
</template>
<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>
</section>
<section v-if="activeView==='trash' && visibleTasks.length" ref="taskListElement" class="trash-groups" aria-label="回收站任务分组">
<section v-for="group in trashGroups" :key="group.key" class="trash-group" :aria-labelledby="`trash-group-${group.key}`">
<header class="trash-group-heading"><h2 :id="`trash-group-${group.key}`">{{group.label}}</h2><span>{{group.nodes.length}}</span></header>
<div class="task-list plain-list trash-list">
<article v-for="node in group.nodes" :key="node.task.id" :data-task-id="node.task.id" class="task-row task-row--trash">
<div class="task-main"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/> {{node.subtasks.length}} 个子任务整组处理</span></span></div>
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span>
<span class="task-actions"><button class="icon ghost trash-more" aria-label="打开任务操作" aria-haspopup="menu" :aria-expanded="trashAction?.id===node.task.id" :title="`${node.task.title}操作`" @click.stop="openTrashAction(node.task,$event)"><Ellipsis/></button></span>
</article>
</div>
</section>
</section>
<section v-else :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">
<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,'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 class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
<div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(node.task)" @keydown.enter="selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','','',''][node.task.priority]}}</span></div>
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span>
</article>
</template>
<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>{{ activeView==='trash' ? '回收站是空的' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{activeView==='trash' ? '删除的任务会显示在这里' : hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span></div>
</section>
<nav v-if="totalPages > 1 && (activeView!=='today' || !todaySectionCollapse.tasks)" class="pager" aria-label="任务分页"><button class="pager-button pager-button--previous" :disabled="page<=1 || loading" aria-label="上一页" @click="previousPage"><ChevronLeft aria-hidden="true"/><span>上一页</span></button><span class="pager-status" aria-live="polite"><strong>{{page}} / {{totalPages}}</strong><span>共 {{ totalTasks }} 项</span></span><button class="pager-button pager-button--next" :disabled="page>=totalPages || loading" aria-label="下一页" @click="nextPage"><span>下一页</span><ChevronRight aria-hidden="true"/></button></nav>
<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>
<div v-show="!todaySectionCollapse.habits" id="today-habits" role="region" aria-labelledby="today-habits-heading">
@@ -1732,7 +1863,7 @@ onUnmounted(() => {
</div>
</template>
</main>
<div v-if="selectedTask || habitDetailOpen" class="pane-resizer pane-resizer--detail" role="separator" :aria-label="detailSeparatorLabel" aria-orientation="vertical" aria-valuemin="300" :aria-valuemax="detailMaxWidth" :aria-valuenow="detailWidth" tabindex="0" @pointerdown="startPaneResize('detail',$event)" @lostpointercapture="handlePaneLostPointerCapture" @keydown="resizePaneWithKeyboard('detail',$event)" />
<div v-if="selectedTask || habitDetailOpen || calendarDetailOpen" class="pane-resizer pane-resizer--detail" role="separator" :aria-label="detailSeparatorLabel" aria-orientation="vertical" aria-valuemin="300" :aria-valuemax="detailMaxWidth" :aria-valuenow="detailWidth" tabindex="0" @pointerdown="startPaneResize('detail',$event)" @lostpointercapture="handlePaneLostPointerCapture" @keydown="resizePaneWithKeyboard('detail',$event)" />
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" :close-on-scrim="compactLayout" :busy="taskDetailBusy" @close="closeTaskDetail" @submit.prevent="saveSelectedTaskChanges">
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" type="button" :disabled="taskDetailBusy" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
@@ -1745,7 +1876,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>
<label class="task-detail-field"><span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间才能开启重复</small></label>
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 重复</div><small>每次完成后将截止时间顺延对应天数首版永不结束</small></section>
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="selectedAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months"></option></select>重复</div><small>每次完成后将截止日期按所选间隔顺延月末会自动取目标月最后一天</small></section>
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
</section>
@@ -1765,7 +1896,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>
</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==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><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" />
<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>
@@ -1784,7 +1915,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>
</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>
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 重复</div><small>每次完成后将截止时间顺延对应天数首版永不结束</small></section>
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="composeAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months"></option></select>重复</div><small>每次完成后将截止日期按所选间隔顺延月末会自动取目标月最后一天</small></section>
<small v-if="composeRepeatError" role="alert" class="field-error">{{composeRepeatError}}</small>
<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>
@@ -1794,6 +1925,7 @@ onUnmounted(() => {
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
<Teleport to="body">
<span v-if="trashAction" class="trash-action-mask" @click.self="closeTrashAction()"><span ref="trashMenu" class="trash-action-menu" role="menu" aria-label="回收站任务操作" @keydown.esc.stop="closeTrashAction()" @keydown.tab.prevent="moveTrashMenuFocus($event.shiftKey?-1:1)" @keydown.down.prevent="moveTrashMenuFocus(1)" @keydown.up.prevent="moveTrashMenuFocus(-1)"><span class="trash-action-title" :title="trashAction.title">{{trashAction.title}}</span><button role="menuitem" @click="requestRestoreTask"><ArchiveRestore/>恢复</button><button role="menuitem" class="danger-text" @click="requestPurgeTask"><Trash2/>永久删除</button></span></span>
<span v-if="archivedListAction" class="archived-action-mask" @click.self="closeArchivedListAction()"><span ref="archivedMenu" class="archived-row-actions" :style="archivedMenuStyle" role="menu"><button role="menuitem" @click="restoreList(archivedListAction)"><ArchiveRestore/>恢复清单</button><button role="menuitem" class="danger-text" @click="openPurgeList(archivedListAction)"><Trash2/>永久删除清单</button></span></span>
</Teleport>
<AppSheet :open="Boolean(purgeListTarget)" variant="actions" panel-class="purge-list-dialog" title-id="purge-list-title" description-id="purge-list-description" initial-focus=".secondary" :busy="purgeListSubmitting" @close="closePurgeList">
+11
View File
@@ -0,0 +1,11 @@
import { readFileSync } from 'node:fs'
import { describe,expect,it } from 'vitest'
const app=readFileSync('src/App.vue','utf8');const utils=readFileSync('src/lib/mvp-utils.ts','utf8');const css=readFileSync('src/calendar.css','utf8')
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('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('places desktop event detail in the shell right pane while retaining the mobile sheet',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(app).toContain("'detail-open': Boolean(selectedTask) || habitDetailOpen || calendarDetailOpen");expect(app).toContain(':compact-layout="compactLayout" @detail="calendarDetailOpen=$event"');expect(app).toContain("selectedTask || habitDetailOpen || calendarDetailOpen");expect(app).toContain('watch([selectedTask, habitDetailOpen, calendarDetailOpen, sidebarCollapsed], reconcileDesktopPaneWidths)');expect(panel).toContain('defineProps<{ compactLayout: boolean }>()');expect(panel).toContain(':modal="compactLayout"');expect(panel).toContain('inline-target=".shell"');expect(panel).toContain("watch(selected,event=>emit('detail',Boolean(event)))");expect(panel).toContain("watch(()=>props.compactLayout,()=>{selected.value=null},{flush:'sync'})");expect(panel).not.toContain("onBeforeUnmount(()=>emit('detail',false))");expect(css).toContain('@media(min-width:931px){.calendar-view{padding-top:4px}.calendar-event-detail')})
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%;')})
it('uses the approved minimal reading layout for event details',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(panel).toContain('class="calendar-event-detail__meta"');expect(panel).toContain('class="calendar-event-detail__content"');expect(panel).not.toContain('<dt>时间</dt>');expect(css).toContain('.calendar-event-detail__meta{');expect(css).toContain('.calendar-event-detail__content{')})
})
+181
View File
@@ -0,0 +1,181 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick } from 'vue'
import CalendarPanel from './CalendarPanel.vue'
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 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 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 mount(fetchMock:ReturnType<typeof vi.fn>,compactLayout=true){ vi.stubGlobal('fetch',fetchMock); const shell=document.createElement('div');shell.className='shell';const host=document.createElement('div');shell.append(host);document.body.append(shell);const notices:string[]=[];const errors:unknown[]=[];const app=createApp(()=>h(CalendarPanel,{compactLayout,onNotice:(v:string)=>notices.push(v)}));app.config.errorHandler=error=>errors.push(error);app.mount(host);cleanups.push(()=>{app.unmount();shell.remove()});await flush();return {host,shell,notices,errors} }
afterEach(()=>{cleanups.splice(0).forEach(fn=>fn());vi.useRealTimers();vi.unstubAllGlobals();vi.restoreAllMocks()})
describe('CalendarPanel',()=>{
it('focuses the current week and shows only the selected day events',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 {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 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('end')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
expect(host.textContent).toContain('发布会');expect(host.textContent).toContain('工作')
host.querySelector<HTMLButtonElement>('[data-event-id="e1"]')!.click();await nextTick()
expect(document.querySelector('.calendar-event-detail')?.textContent).toContain('产品发布')
expect(document.querySelector('.calendar-event-detail__meta')?.textContent).toContain('9月22日周二')
expect(document.querySelector('.calendar-event-detail__meta')?.textContent).toContain('工作')
expect(document.querySelector('.calendar-event-detail__content')?.textContent).toContain('产品发布')
expect(document.querySelector('.calendar-event-detail dl')).toBeNull()
host.querySelector<HTMLInputElement>('input[aria-label="筛选工作"]')!.click();await nextTick()
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
vi.useRealTimers()
})
it('opens and closes desktop event detail without teleport patch errors',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,22,10))
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events,sources:[]}:subscriptions)))
const {host,shell,errors}=await mount(fetchMock,false)
host.querySelector<HTMLButtonElement>('[data-event-id="e1"]')!.click();await nextTick()
expect(shell.querySelector('.calendar-event-detail')).not.toBeNull()
shell.querySelector<HTMLButtonElement>('[aria-label="关闭日程详情"]')!.click();await nextTick()
expect(shell.querySelector('.calendar-event-detail')).toBeNull()
expect(errors).toEqual([])
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()=>{
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 fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:boundary,sources:[]}:subscriptions)))
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))
expect(host.querySelector('.calendar-agenda h2')?.textContent).toContain(expected)
vi.useRealTimers()
})
it('keeps the newest week response when requests finish out of order',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
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 {host}=await mount(fetchMock)
expect(pending).toHaveLength(1)
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await nextTick()
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[0].resolve(json({events:[{...events[0],id:'old',title:'旧一周'}],sources:[]}));await flush()
expect(host.textContent).toContain('新一周')
expect(host.textContent).not.toContain('旧一周')
vi.useRealTimers()
})
it('supports week navigation and today',async()=>{
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
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
host.querySelector<HTMLButtonElement>('[aria-label="回到今天"]')!.click();await flush()
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(before+2)
})
it('closes the subscription form without submitting it',async()=>{
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 nextTick()
document.querySelector<HTMLButtonElement>('[aria-label="添加日历订阅"]')!.click();await nextTick()
const name=document.querySelector<HTMLInputElement>('input[aria-label="订阅名称"]')!,url=document.querySelector<HTMLInputElement>('input[aria-label="订阅地址"]')!
name.value='私人';name.dispatchEvent(new Event('input'));url.value='https://example.com/a.ics';url.dispatchEvent(new Event('input'));await nextTick()
document.querySelector<HTMLButtonElement>('[aria-label="关闭订阅表单"]')!.click();await flush()
const calls=fetchMock.mock.calls as unknown as Array<[string,RequestInit?]>
expect(calls.some(([,options])=>options?.method==='POST')).toBe(false)
})
it('creates, edits, toggles, refreshes and deletes a source with confirmation',async()=>{
const calls:Array<[string,RequestInit|undefined]>=[]
const fetchMock=vi.fn((url:string,options?:RequestInit)=>{calls.push([url,options]);if(options?.method==='DELETE')return Promise.resolve(new Response(null,{status:204}));if(options?.method)return Promise.resolve(json(subscriptions[0]));return Promise.resolve(json(url.includes('calendar-events')?{events,sources:[{id:'s1',name:'工作',stale:false}]}:subscriptions))})
const {host}=await mount(fetchMock)
host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
document.querySelector<HTMLButtonElement>('[aria-label="添加日历订阅"]')!.click();await nextTick()
const name=document.querySelector<HTMLInputElement>('input[aria-label="订阅名称"]')!,url=document.querySelector<HTMLInputElement>('input[aria-label="订阅地址"]')!;name.value='私人';name.dispatchEvent(new Event('input'));url.value='https://example.com/a.ics';url.dispatchEvent(new Event('input'));await nextTick();document.querySelector<HTMLButtonElement>('.calendar-subscription-form button[type="submit"]')!.click();await flush()
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions')&&o?.method==='POST')).toBe(true)
host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
document.querySelector<HTMLButtonElement>('[aria-label="编辑工作"]')!.click();await nextTick()
const editedName=document.querySelector<HTMLInputElement>('input[aria-label="订阅名称"]')!;editedName.value='工作日历';editedName.dispatchEvent(new Event('input'));await nextTick();document.querySelector<HTMLButtonElement>('.calendar-subscription-form button[type="submit"]')!.click();await flush()
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1')&&o?.method==='PATCH'&&String(o.body).includes('工作日历'))).toBe(true)
host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
document.querySelector<HTMLButtonElement>('[aria-label="刷新工作"]')!.click();await flush()
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1/refresh')&&o?.method==='POST')).toBe(true)
document.querySelector<HTMLInputElement>('[aria-label="启用工作"]')!.click();await flush()
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1')&&o?.method==='PATCH')).toBe(true)
document.querySelector<HTMLButtonElement>('[aria-label="删除工作"]')!.click();await nextTick()
document.querySelector<HTMLButtonElement>('.app-dialog .danger-button')!.click();await flush()
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1')&&o?.method==='DELETE')).toBe(true)
})
it('clears an earlier action error after a successful retry',async()=>{
let failRefresh=true
const fetchMock=vi.fn((url:string,options?:RequestInit)=>{
if(options?.method==='POST'&&String(url).endsWith('/refresh')&&failRefresh){failRefresh=false;return Promise.resolve(json({detail:'上游不可用'},502))}
if(options?.method)return Promise.resolve(json(subscriptions[0]))
return Promise.resolve(json(String(url).includes('calendar-events')?{events:[],sources:[]}:subscriptions))
})
const {host}=await mount(fetchMock);host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
document.querySelector<HTMLButtonElement>('[aria-label="刷新工作"]')!.click();await flush()
expect(host.querySelector('.inline-error')?.textContent).toContain('上游不可用')
document.querySelector<HTMLButtonElement>('[aria-label="刷新工作"]')!.click();await flush()
expect(host.querySelector('.inline-error')).toBeNull()
})
it('shows source-specific refresh errors',async()=>{
const failed=[{...subscriptions[0],last_error:'订阅地址无法访问'}]
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:failed)))
const {host}=await mount(fetchMock);host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
expect(document.querySelector('[role="alert"]')?.textContent).toContain('订阅地址无法访问')
})
})
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-vue-next'
import { csrfHeader } from './lib/csrf'
import { formatApiErrorDetail } from './lib/mvp-utils'
import AppSheet from './components/AppSheet.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 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 EventResponse = { events:CalendarEvent[]; sources:Array<{id:string;name:string;stale:boolean}> }
type Form = { name:string; url:string; color:string; enabled:boolean }
const props=defineProps<{ compactLayout: boolean }>()
const emit=defineEmits<{notice:[message:string];detail:[open:boolean]}>()
const subscriptions=ref<Subscription[]>([]),events=ref<CalendarEvent[]>([]),loading=ref(false),error=ref('')
let eventsRequestGeneration=0
const selectedDay=ref(new Date(new Date().getFullYear(),new Date().getMonth(),new Date().getDate())),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 form=ref<Form>({name:'',url:'',color:'#f15a29',enabled:true})
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 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 weekStart=computed(()=>startOfWeek(selectedDay.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 eventEnd=(event:CalendarEvent)=>event.ends_at
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 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 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 eventSource=(event:CalendarEvent)=>event.source_name||'日历'
const eventColor=(event:CalendarEvent)=>event.color||'#f15a29'
function displayDay(day:string){const [y,m,d]=day.split('-').map(Number);return new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'short'}).format(new Date(y,m-1,d))}
function displayTime(event:CalendarEvent){if(event.all_day)return'全天';const date=new Date(eventStart(event));return Number.isNaN(date.getTime())?'时间待定':new Intl.DateTimeFormat('zh-CN',{hour:'2-digit',minute:'2-digit'}).format(date)}
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 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 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:'事件载入失败')}
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 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}
async function save(){if(busyId.value||!form.value.name.trim()||!form.value.url.trim())return;busyId.value='form';error.value='';try{await request(editing.value?`/calendar-subscriptions/${editing.value.id}`:'/calendar-subscriptions',{method:editing.value?'PATCH':'POST',body:JSON.stringify({...form.value,name:form.value.name.trim(),url:form.value.url.trim()})});formOpen.value=false;await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',editing.value?'日历订阅已更新':'日历订阅已添加')}catch(reason){error.value=reason instanceof Error?reason.message:'保存失败'}finally{busyId.value=''}}
async function toggleEnabled(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'PATCH',body:JSON.stringify({enabled:!item.enabled})});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',item.enabled?'日历订阅已停用':'日历订阅已启用')}catch(reason){error.value=reason instanceof Error?reason.message:'更新失败'}finally{busyId.value=''}}
async function refresh(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}/refresh`,{method:'POST'});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',`${item.name}已刷新`)}catch(reason){error.value=reason instanceof Error?reason.message:'刷新失败'}finally{busyId.value=''}}
async function remove(item:Subscription){if(busyId.value)return;if(await appDialog.value?.show({title:`删除“${item.name}”?`,description:'该来源的事件也会从日历中移除。',danger:true,confirmText:'删除'})!==true)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'DELETE'});hiddenSources.value.delete(item.id);await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice','日历订阅已删除')}catch(reason){error.value=reason instanceof Error?reason.message:'删除失败'}finally{busyId.value=''}}
watch(manageOpen,open=>{if(!open)formOpen.value=false})
watch(selected,event=>emit('detail',Boolean(event)))
watch(()=>props.compactLayout,()=>{selected.value=null},{flush:'sync'})
onMounted(()=>void load())
</script>
<template>
<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>
<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-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="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-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)" :modal="compactLayout" inline-target=".shell" variant="detail" panel-class="calendar-event-detail" title-id="calendar-event-title" initial-focus="button[aria-label='关闭日程详情']" :close-on-scrim="compactLayout" @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"><div class="calendar-event-detail__meta"><span>{{displayDay(localDayKey(eventStart(selected)))}}</span><span>{{displayTime(selected)}}<template v-if="eventEnd(selected) && !selected.all_day"> - {{displayTime({...selected,starts_at:eventEnd(selected)})}}</template></span><span><i :style="{background:eventColor(selected)}"/>{{eventSource(selected)}}</span><span v-if="selected.location">{{selected.location}}</span></div><section v-if="selected.description" class="calendar-event-detail__content"><p>{{selected.description}}</p></section><p v-else class="calendar-event-detail__empty">没有备注</p></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="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>
<AppDialog ref="appDialog"/>
</section>
</template>
+3 -3
View File
@@ -7,13 +7,13 @@ const panel = readFileSync('src/MemoPanel.vue', 'utf8')
const editor = readFileSync('src/components/MemoEditor.vue', 'utf8')
describe('memo shell integration', () => {
it('places Memos immediately after Countdowns in desktop navigation and keeps mobile tabs unchanged', () => {
it('places Memos immediately after Countdowns in desktop navigation and exposes it as a direct mobile destination', () => {
const nav = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">')))
expect(nav.indexOf("switchView('memos')")).toBeGreaterThan(nav.indexOf("switchView('countdowns')"))
expect(nav.match(/switchView\('memos'\)/g)).toHaveLength(1)
const bottom = app.slice(app.indexOf('<nav class="bottom"'), app.indexOf('</nav>', app.indexOf('<nav class="bottom"')))
expect(bottom).not.toContain("switchView('memos')")
expect(bottom.match(/aria-current=/g)).toHaveLength(4)
expect(bottom).toContain("switchView('memos')")
expect(bottom.match(/aria-current=/g)).toHaveLength(5)
})
it('routes the shared cat FAB to a local memo draft, hides it in trash, and defers POST until save', () => {
+1 -1
View File
@@ -90,7 +90,7 @@ describe('Today environment integration', () => {
expect(filter).toBeGreaterThan(remaining)
expect(overdue).toBeGreaterThan(filter)
expect(main.match(/>今天<\/h1>/g)).toHaveLength(1)
expect(main).toContain("<div v-if=\"!['today','tasks','upcoming','habits','settings'].includes(activeView)\" class=\"topbar-title\"><h1")
expect(main).toContain("<div v-if=\"!['today','tasks','upcoming','trash','habits','settings'].includes(activeView)\" class=\"topbar-title\"><h1")
expect(main).not.toContain('class="topbar-actions"')
expect(main).not.toContain('class="topbar-filter"')
expect(main).not.toContain('aria-label="刷新当前页面"')
File diff suppressed because one or more lines are too long
+2
View File
@@ -67,6 +67,8 @@ describe('MVP view utilities', () => {
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'tasks', listId: 'list-2' })
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'memos', 'list-2')
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'memos', listId: 'list-2' })
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'calendar', 'list-2')
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'calendar', listId: 'list-2' })
storage.set('dodo.navigation', JSON.stringify({ view: 'invalid', listId: 'list-2' }))
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
})
+2 -2
View File
@@ -1,7 +1,7 @@
type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'>
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
type StoredNavigation = { view: NavigationView; listId: string }
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'settings'])
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'calendar', 'settings'])
export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation {
try {
+34 -7
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, groupTaskTree, isSameTaskSortTier, mergeReorderedSubset, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, groupTaskTree, groupTrashTaskTree, isSameTaskSortTier, mergeReorderedSubset, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
type TaskFixture = {
id: string
@@ -7,6 +7,7 @@ type TaskFixture = {
description?: string
parent_id?: string | null
completed?: boolean
due_at?: string | null
list_name?: string
subtasks?: TaskFixture[]
}
@@ -28,6 +29,30 @@ describe('task utilities', () => {
expect(groupTaskTree([parent])).toEqual([{ task: parent, subtasks: [child] }])
})
it('groups trash parents by overdue, upcoming, and no deadline while keeping parent-child units intact', () => {
const now = new Date('2026-09-21T08:00:00+08:00')
const child: TaskFixture = { id: 'child', title: 'Child', parent_id: 'overdue' }
const rows: TaskFixture[] = [
{ id: 'none', title: 'No deadline', parent_id: null },
{ id: 'future', title: 'Future', parent_id: null, due_at: '2026-10-20T15:59:00.000Z' },
{ id: 'overdue', title: 'Overdue', parent_id: null, due_at: '2026-09-07T01:11:00.000Z' },
child,
]
expect(groupTrashTaskTree(rows, now)).toEqual([
{ key: 'overdue', label: '已过期', nodes: [{ task: rows[2], subtasks: [child] }] },
{ key: 'upcoming', label: '未来截止', nodes: [{ task: rows[1], subtasks: [] }] },
{ key: 'undated', label: '无截止日期', nodes: [{ task: rows[0], subtasks: [] }] },
])
})
it('treats invalid trash deadlines as undated instead of overdue', () => {
const task: TaskFixture = { id: 'invalid', title: 'Invalid', parent_id: null, due_at: 'not-a-date' }
expect(groupTrashTaskTree([task], new Date('2026-09-21T08:00:00+08:00'))).toEqual([
{ key: 'undated', label: '无截止日期', nodes: [{ task, subtasks: [] }] },
])
})
it('classifies due edits for Today membership', () => {
const start = new Date('2026-09-10T00:00:00+08:00')
const end = new Date('2026-09-11T00:00:00+08:00')
@@ -75,12 +100,14 @@ describe('task utilities', () => {
expect(() => buildTaskRrule({ frequency: 'daily', interval: 0, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('重复间隔至少为 1')
})
it('builds a distinct completion-trigger payload and parses it without RRULE', () => {
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7 })
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14 })
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数天数')
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数天数')
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数天数')
it('builds and parses completion-trigger intervals in days or months', () => {
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7, after_completion_unit: 'days' })
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, afterCompletionUnit: 'days' })
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: '1.5' })).toThrow('请输入 1 到 3650 的整数')
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数')
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数')
})
it('builds scheduled recurrence payloads separately from completion triggers', () => {
+29 -7
View File
@@ -85,6 +85,27 @@ export function groupTaskTree<T extends MinimalTask>(tasks: T[]) {
}))
}
export type TrashTaskGroup<T extends MinimalTask> = {
key: 'overdue' | 'upcoming' | 'undated'
label: '已过期' | '未来截止' | '无截止日期'
nodes: Array<{ task: T; subtasks: T[] }>
}
export function groupTrashTaskTree<T extends MinimalTask>(tasks: T[], now = new Date()): TrashTaskGroup<T>[] {
const nodes = groupTaskTree(tasks)
const groups: TrashTaskGroup<T>[] = [
{ key: 'overdue', label: '已过期', nodes: [] },
{ key: 'upcoming', label: '未来截止', nodes: [] },
{ key: 'undated', label: '无截止日期', nodes: [] },
]
for (const node of nodes) {
const due = node.task.due_at ? Date.parse(node.task.due_at) : Number.NaN
const key = Number.isFinite(due) ? (due < now.valueOf() ? 'overdue' : 'upcoming') : 'undated'
groups.find((group) => group.key === key)!.nodes.push(node)
}
return groups.filter((group) => group.nodes.length)
}
export function classifyTaskForToday(
task: Pick<MinimalTask, 'completed' | 'due_at'>,
start: Date,
@@ -172,15 +193,16 @@ export function parseTaskRrule(rrule = ''): TaskRepeatConfig {
}
export type TaskRepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'after_completion' | 'custom'
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null }
export type AfterCompletionUnit = 'days' | 'months'
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null; after_completion_unit?: AfterCompletionUnit | null }
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; repeatConfig?: TaskRepeatConfig }) {
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; afterCompletionUnit?: AfterCompletionUnit; repeatConfig?: TaskRepeatConfig }) {
if (option === 'none') return {}
if (option === 'after_completion') {
const raw = String(values.afterCompletionDays).trim()
const days = Number(raw)
if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数天数')
return { trigger_mode: 'after_completion' as const, after_completion_days: days }
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' }
}
const rrule = option === 'custom'
? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' })
@@ -189,13 +211,13 @@ export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { a
}
export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) {
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1 }
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
if (recurrence.trigger_mode === 'after_completion') {
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1 }
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1, afterCompletionUnit: recurrence.after_completion_unit ?? 'days' as AfterCompletionUnit }
}
const parsed = parseTaskRrule(recurrence.rrule ?? '')
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1 }
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
}
export function defaultTaskDueAt(now = new Date()) {
+1
View File
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
import './memo.css'
import './calendar.css'
createApp(App).mount('#app')
if ('serviceWorker' in navigator && import.meta.env.PROD) {
+20 -8
View File
File diff suppressed because one or more lines are too long
+87 -55
View File
@@ -14,7 +14,7 @@ const appSheet = readFileSync('src/components/AppSheet.vue', 'utf8')
describe('unified task due display', () => {
it('uses the shared display for overdue and ordinary parent task rows', () => {
expect(app).toContain("import TaskDueDisplay from './components/TaskDueDisplay.vue'")
expect(app.match(/<TaskDueDisplay/g)).toHaveLength(2)
expect(app.match(/<TaskDueDisplay/g)).toHaveLength(3)
expect(app).toContain(':due-at="node.task.due_at"')
expect(app).not.toContain(':due-at="subtask.due_at"')
expect(app).not.toContain('formatDue(')
@@ -31,10 +31,10 @@ describe('unified task due display', () => {
})
it('places every visible-list due display in a right tail before stable actions', () => {
expect(app.match(/<span v-if="[^\"]+\.due_at" class="task-tail"><TaskDueDisplay/g)).toHaveLength(2)
expect(app.match(/<span v-if="[^\"]+\.due_at" class="task-tail"><TaskDueDisplay/g)).toHaveLength(3)
expect(app).not.toContain('class="meta"><TaskDueDisplay')
expect(app).toContain('</div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay')
expect(app).toContain('</span><span v-if="activeView===\'trash\'" class="task-actions">')
expect(app).toContain('<span class="task-actions"><button class="icon ghost trash-more"')
expect(app).not.toContain('restoreTask(subtask)')
expect(app).not.toContain('purgeTask(subtask)')
expect(app).not.toContain('task-detail-trigger')
@@ -118,16 +118,16 @@ describe('approved cream solid button system', () => {
})
describe('mobile navigation styles', () => {
it('renames the bottom More tab to a direct Settings tab', () => {
it('uses five direct mobile destinations and keeps Settings in the sidebar', () => {
expect(app).not.toContain('aria-controls="mobile-more-menu"')
expect(app).not.toContain('<Ellipsis/><span>更多</span>')
expect(app).toContain("<Settings/><span>设置</span>")
expect(app).toContain("@click=\"switchView('settings')\"")
expect(app).toContain('<span>设置</span>')
expect(app).toContain("<StickyNote/><span>备忘录</span>")
expect(app).toContain("<CalendarDays/><span>日历订阅</span>")
expect(app).not.toContain("@click=\"switchView('settings')\"><Settings/><span>设置</span>")
})
it('marks only exact mobile destinations active and exposes aria-current only there', () => {
for (const view of ['today', 'habits', 'countdowns', 'settings']) {
for (const view of ['today', 'habits', 'countdowns', 'memos', 'calendar']) {
expect(app).toContain(`:class="{active:activeView==='${view}'}" :aria-current="activeView==='${view}' ? 'page' : undefined"`)
}
expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'")
@@ -160,7 +160,7 @@ describe('approved Settings 01 paper ledger', () => {
it('moves Settings identity into the body and suppresses the duplicate shell title', () => {
expect(app).not.toContain("'settings-main':activeView==='settings'")
expect(app).toContain(":class=\"{'settings-topbar':activeView==='settings'}\"")
expect(app).toContain("v-if=\"!['today','tasks','upcoming','habits','settings'].includes(activeView)\" class=\"topbar-title\"")
expect(app).toContain("v-if=\"!['today','tasks','upcoming','trash','habits','settings'].includes(activeView)\" class=\"topbar-title\"")
expect(app).not.toContain('v-if="activeView===\'settings\'" class="icon topbar-refresh settings-refresh"')
expect(app).not.toContain('aria-label="刷新当前页面"')
expect(mvpPanel).toContain('<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>')
@@ -317,22 +317,23 @@ describe('solid cream material system', () => {
describe('approved UI detail direction', () => {
it('opens ordinary and overdue task details from the task body while preserving Trash actions', () => {
const ordinaryStart = app.indexOf('<section :id="activeView===\'today\' ? \'today-tasks\' : undefined"')
const ordinaryStart = app.indexOf('<section v-else :id="activeView===\'today\' ? \'today-tasks\' : undefined"')
const ordinaryRows = app.slice(ordinaryStart, app.indexOf('</section>', ordinaryStart))
expect(ordinaryRows).toContain("selectTaskUnlessSwiped(node.task)")
expect(ordinaryRows).not.toContain('task-detail-trigger')
expect(ordinaryRows).not.toContain('selectTask(subtask)')
expect(ordinaryRows).not.toContain('aria-label="删除任务"')
expect(ordinaryRows).toContain('v-if="activeView===\'trash\'" class="task-actions"')
expect(ordinaryRows).toContain('restoreTask(node.task)')
expect(ordinaryRows).toContain('purgeTask(node.task)')
const trashRows = app.slice(app.indexOf('class="trash-groups"'), ordinaryStart)
expect(trashRows).toContain('class="task-actions"')
expect(trashRows).not.toContain('restoreTask(node.task)')
expect(trashRows).toContain('openTrashAction(node.task,$event)')
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('id="today-tasks-heading"'))
expect(overdue).toContain('selectTaskUnlessSwiped(node.task)')
expect(overdue).not.toContain('aria-label="打开任务详情"')
})
it('keeps the due tail as the final parent-row control and opens details from the task body', () => {
expect(app).toContain('@click="activeView===\'trash\'?undefined:selectTaskUnlessSwiped(node.task)"')
expect(app).toContain('@click="selectTaskUnlessSwiped(node.task)"')
expect(app).toContain('<span v-if="node.task.due_at" class="task-tail">')
expect(app).not.toContain('task-detail-trigger')
expect(css).not.toContain('.task-detail-trigger')
@@ -798,7 +799,7 @@ describe('task and habit row decoration', () => {
it('shows task drag handles only in an explicit available reorder mode', () => {
expect(app).toContain('const taskReorderMode = ref(false)')
expect(app).toContain("const taskReorderAvailable = computed(() => activeView.value === 'tasks' && totalPages.value === 1 && taskTree.value.length > 1)")
expect(app).toContain('class="soft-button reorder-mode-toggle task-reorder-toggle"')
expect(app).toContain('class="list-section-action"')
expect(app).toContain("{{ taskReorderMode ? '完成' : '调整顺序' }}")
expect(app).toContain('v-if="taskReorderMode" class="drag-handle task-drag-handle"')
expect(app).toContain('if (!taskReorderAvailable.value) taskReorderMode.value = false')
@@ -835,13 +836,46 @@ describe('task and habit row decoration', () => {
})
it('exposes complete titles on every ellipsized visible task title node', () => {
expect(app.match(/<strong :title="node\.task\.title">\{\{node\.task\.title\}\}<\/strong>/g)).toHaveLength(2)
expect(app.match(/<strong :title="node\.task\.title">\{\{node\.task\.title\}\}<\/strong>/g)).toHaveLength(3)
expect(app).not.toContain('<strong :title="subtask.title">{{subtask.title}}</strong>')
expect(app).not.toContain('<strong>{{node.task.title}}</strong>')
})
it('keeps the Trash restore action at least 44px tall', () => {
expect(css).toMatch(/\.restore\{[^}]*min-height:44px/)
it('keeps Trash row actions inside the ellipsis menu', () => {
const trashRows = app.slice(app.indexOf('class="trash-groups"'), app.indexOf('<section v-else'))
const trashMenu = app.slice(app.indexOf('class="trash-action-menu"'), app.indexOf('class="archived-action-mask"'))
expect(trashRows).not.toContain('class="restore"')
expect(trashRows).not.toContain('restoreTask(node.task)')
expect(trashMenu).toContain('@click="requestRestoreTask"')
expect(trashMenu).toContain('<ArchiveRestore/>恢复')
expect(trashMenu).toContain('@click="requestPurgeTask"')
})
it('keeps Trash deadlines visible on mobile before the action area', () => {
expect(css).not.toContain('.trash-list .task-tail{display:none}')
expect(css).toContain('.trash-list>.task-row{height:68px;min-height:68px;max-height:68px;grid-template-columns:minmax(0,1fr) minmax(38px,auto) auto;gap:6px}')
})
it('uses a real Trash action menu and typed permanent-delete confirmation', () => {
expect(app).toContain('const trashAction = ref<Task | null>(null)')
expect(app).toContain('const trashMenu = ref<HTMLElement | null>(null)')
expect(app).toContain('const trashPageTitle = ref<HTMLElement | null>(null)')
expect(app).toContain('aria-haspopup="menu"')
expect(app).toContain('@click.stop="openTrashAction(node.task,$event)"')
expect(app).toContain('ref="trashMenu" class="trash-action-menu" role="menu" aria-label="回收站任务操作"')
expect(app).toContain('@keydown.esc.stop="closeTrashAction()"')
expect(app).toContain('@keydown.tab.prevent="moveTrashMenuFocus($event.shiftKey?-1:1)"')
expect(app).toContain('@keydown.down.prevent="moveTrashMenuFocus(1)"')
expect(app).toContain('@keydown.up.prevent="moveTrashMenuFocus(-1)"')
expect(app).toContain("nextTick(() => trashMenu.value?.querySelector<HTMLElement>('[role=menuitem]')?.focus())")
expect(app).toContain('if (trigger?.isConnected) trigger.focus()')
expect(app).toContain('await purgeTask(task)')
expect(app).toContain('focusTrashActionTrigger(trigger)')
expect(app).toContain('else trashPageTitle.value?.focus()')
expect(app).toContain('@click="requestPurgeTask"')
expect(app).toContain("label: `输入任务名称“${task.title}”确认`")
expect(app).toContain("validate: (value) => value.trim() === task.title ? null : '任务名称不匹配'")
expect(app).not.toContain('class="trash-safety-note"')
})
it('reconciles only top-level Trash mutations and distinguishes refresh failure', () => {
@@ -849,7 +883,10 @@ describe('task and habit row decoration', () => {
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 purgeBlock = app.slice(app.indexOf('async function purgeTask'), app.indexOf('async function addSubtask'))
expect(loadBlock).toContain('return await runLatestRequest')
expect(loadBlock).toContain("const committed = await runLatestRequest('trash'")
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('{ affectsTrash: true, affectsTaskView }')
expect(mutationBlock).not.toContain('performTrashMutation(')
@@ -898,7 +935,7 @@ describe('task and habit row decoration', () => {
expect(ordinaryRows).not.toContain('v-for="subtask in node.subtasks"')
expect(app).not.toContain('collapsedTaskIds')
expect(app).not.toContain('toggleTaskChildren')
expect(app.match(/<span v-if="node\.subtasks\.length" class="meta">/g)).toHaveLength(2)
expect(app.match(/<span v-if="node\.subtasks\.length" class="meta">/g)).toHaveLength(3)
expect(app).toContain('v-for="subtask in selectedTaskSubtasks"')
expect(app).toContain('class="subtask-detail"')
})
@@ -1132,7 +1169,7 @@ describe('task and habit row decoration', () => {
expect(mvpPanel).toContain("emit('summary', value)")
expect(app).toContain('taskComposeTitle')
expect(app).toContain('添加今天任务')
expect(app).toContain('v-if="activeView===\'trash\' || totalPages > 1 || totalTasks > 0"')
expect(app).toContain('<span class="trash-page-count">共 {{totalTasks}} 项</span>')
expect(mvpPanel).toContain('class="empty-panel today-empty-panel"')
expect(mvpPanel).toContain('添加习惯')
expect(css).toContain('.today-context{')
@@ -1370,10 +1407,13 @@ describe('unified floating add interaction', () => {
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('完成后 <input v-model="composeAfterCompletionDays"')
expect(app).toContain('v-model="composeAfterCompletionUnit"')
expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"')
expect(app).toContain('每次完成后,将截止时间顺延对应天数;首版永不结束')
expect(app).toContain('v-model="selectedAfterCompletionUnit"')
expect(app).toContain('<option value="days">天</option><option value="months">月</option>')
expect(app).toContain('月末会自动取目标月最后一天')
expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value')
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })')
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config })')
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
const createBlock = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()'))
expect(createBlock).toContain("api('/tasks',")
@@ -1388,7 +1428,7 @@ describe('unified floating add interaction', () => {
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
expect(saveBlock).toContain('selectedTaskRecurrence.value = null')
expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'")
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)')
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence)')
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
expect(dueRemovalBlock).not.toContain('saveRepeat(')
expect(saveBlock).toContain("selectedRepeatError.value = ''")
@@ -1556,36 +1596,32 @@ describe('sidebar information hierarchy', () => {
expect(css).toContain('width:3px;')
})
it('groups folder and list editing actions into a clear compact hierarchy', () => {
it('uses the selected compact editor for list name, folder, and archive actions', () => {
expect(app).toContain('aria-label="打开文件夹操作"')
expect(app).toContain('aria-label="打开清单操作"')
expect(app).toContain('panel-class="sidebar-action-sheet"')
expect(app).toContain(':label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined"')
expect(app).toContain('class="sidebar-action-kind"')
expect(app).toContain('class="sidebar-action-group"')
expect(app).toContain('class="sidebar-action-group-title"')
expect(app).toContain('class="sidebar-action-danger"')
expect(app).toContain("sidebarAction.kind==='folders'?'文件夹':'清单'")
expect(app).toContain("sidebarAction.kind==='folders' ? `删除后,其中 ${sidebarActionFolderListCount} 个清单会移到“我的清单”` : '任务会保留,可从“已归档”恢复'")
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-danger{border-top:1px solid')
expect(app).toContain('panel-class="list-editor-sheet"')
expect(app).toContain('id="list-editor-title"')
expect(app).toContain('v-model="listEditorName"')
expect(app).toContain('v-model="listEditorFolderId"')
expect(app).toContain('>所在文件夹<')
expect(app).toContain("{{listEditorBusy?'正在保存…':'保存更改'}}")
expect(app).toContain('任务会保留,可从“已归档”恢复')
expect(app).not.toContain('class="list-editor-position"')
expect(app).not.toContain('aria-label="上移清单"')
expect(app).not.toContain('aria-label="下移清单"')
const archiveListBlock = app.slice(app.indexOf('async function archiveListFromEditor'), app.indexOf('function toggleFolder'))
expect(archiveListBlock).toContain("confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复')")
expect(archiveListBlock).not.toContain("askText(`归档清单")
expect(css).toContain('.list-editor-sheet{width:min(460px,calc(100vw - 24px));')
expect(css).toContain('@media(min-width:931px){.app-sheet-mask:has(.list-editor-sheet){place-items:center;padding:24px}.app-sheet-mask:has(.list-editor-sheet) .list-editor-sheet{margin:0}}')
expect(css).toContain('.list-editor-form{display:grid;gap:17px;padding:18px 20px}')
expect(css).toContain('.list-editor-danger{border-top:1px solid var(--border-cream);')
expect(app).toContain('@keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()"')
expect(app).toContain('sidebarCreateOpen.value = false; sidebarAction.value = null')
expect(app).not.toContain('aria-label="重命名文件夹" @click="renameEntity')
expect(app).not.toContain('aria-label="重命名清单" @click="renameEntity')
})
it('uses a dedicated second step for choosing a list destination', () => {
expect(app).toContain('<template v-if="listMoveMenuOpen">')
expect(app).toContain('aria-label="返回清单操作"')
expect(app).toContain('class="sidebar-action-move-title"')
expect(app).toContain('选择目标位置')
expect(app).toContain('role="menu" aria-label="选择目标文件夹"')
expect(app).toContain("'list-move-current':")
expect(css).toContain('.sidebar-action-move-back{min-height:44px;')
expect(css).toContain('.list-move-menu{display:grid;gap:2px;padding:0}')
})
})
describe('quiet index sidebar parity', () => {
@@ -1684,7 +1720,7 @@ describe('sidebar layout', () => {
expect(app).not.toContain('list.is_inbox" class="list-drag-handle"')
})
it('uses the handle-only touch contract and exposes same-scope move controls', () => {
it('uses the handle-only touch contract while keeping drag organization available', () => {
expect(app).not.toContain('@pointerdown="startListLongPress(list, $event)"')
expect(app).not.toContain('function startListLongPress')
expect(app).not.toContain('listLongPressTimer')
@@ -1699,14 +1735,10 @@ describe('sidebar layout', () => {
expect(app).toContain('@pointerup.stop="finishListDrag(list,$event)"')
expect(app).toContain('@pointercancel.stop="cancelListDrag"')
expect(app).toContain('listHandlePending = undefined')
expect(app).toContain('aria-label="上移清单"')
expect(app).toContain('aria-label="下移清单"')
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'up')")
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'down')")
expect(app).toContain('aria-label="移动到文件夹"')
expect(app).toContain('role="menu"')
expect(app).toContain('role="menuitem"')
expect(app).toContain('移出文件夹')
expect(app).not.toContain('aria-label="上移清单"')
expect(app).not.toContain('aria-label="下移清单"')
expect(app).not.toContain('class="list-editor-position"')
expect(app).toContain('v-model="listEditorFolderId"')
})
it('shows drag lift, folder highlighting, and insertion targets', () => {
+28 -9
View File
@@ -71,7 +71,7 @@ describe('approved five-detail polish', () => {
expect(css).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.countdown-focus\{[^}]*height:140px/)
})
it('uses approved Today-sourced page headers for task lists and Upcoming without changing Trash IA', () => {
it('uses approved Today-sourced page headers and the grouped Trash layout', () => {
expect(app).toContain('v-if="activeView===\'tasks\' || activeView===\'upcoming\'" class="list-page-context"')
expect(app).toContain('<h1 class="list-page-title" :title="activeName">{{ activeName }}</h1>')
expect(app).toContain('<p class="list-page-summary">{{ taskOpenTotal === null ? \'待完成统计暂不可用\' : `还有 ${taskOpenTotal} 项待完成` }}</p>')
@@ -80,7 +80,23 @@ describe('approved five-detail polish', () => {
expect(app).toContain('<span id="task-list-title" class="list-section-title">任务</span>')
expect(app).toContain("v-if=\"activeView==='tasks' && taskReorderAvailable\"")
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).not.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=\"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).not.toContain('class="list-page-meta" aria-live="polite"')
expect(app).toContain('<nav v-if="totalPages > 1 && (activeView!==\'today\' || !todaySectionCollapse.tasks)" class="pager" aria-label="任务分页">')
expect(app).toContain('class="pager-button pager-button--previous"')
expect(app).toContain('<ChevronLeft aria-hidden="true"/><span>上一页</span>')
expect(app).toContain('<span class="pager-status" aria-live="polite"><strong>{{page}} / {{totalPages}}</strong><span>共 {{ totalTasks }} 项</span></span>')
expect(app).toContain('class="pager-button pager-button--next"')
expect(app).toContain('<span>下一页</span><ChevronRight aria-hidden="true"/>')
expect(css).toContain('.pager{min-height:56px;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:8px;margin:8px 0 14px;border-top:1px solid #e8e0d5;background:transparent}')
expect(css).toContain('.pager-button{min-width:0;min-height:44px;padding:0 8px;display:flex;align-items:center;justify-content:center;gap:6px;border:0;border-radius:9px;background:transparent;color:#b7421e;font-size:13px;font-weight:650;box-shadow:none;transition:transform .14s ease,background-color .14s ease,color .14s ease}')
expect(css).toContain('.pager-status{min-width:72px;display:grid;justify-items:center;gap:1px;color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums}')
expect(css).toContain('@media(max-width:390px){.pager-button{padding:0 4px}.pager{grid-template-columns:minmax(0,1fr) 72px minmax(0,1fr);gap:2px}}')
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).not.toContain('class="list-search-clear"')
@@ -89,7 +105,8 @@ describe('approved five-detail polish', () => {
expect(app).toContain(":aria-labelledby=\"activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined\"")
const taskTopbar = app.slice(app.indexOf('<header class="topbar"'), app.indexOf('</header>'))
expect(taskTopbar).not.toContain('CompletedFilterPill v-if="activeView!==\'today\'"')
expect(app).toContain('v-if="activeView===\'trash\'" class="list-toolbar"')
expect(app).toContain('v-if="activeView===\'trash\'" class="trash-page-context"')
expect(app).toContain('class="trash-groups"')
expect(css).toContain('.list-page-context{width:min(100%,630px);margin:0 auto 0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:0 16px}')
expect(css).toContain('.list-page-title{margin:0;font-size:34px;line-height:1.15;font-weight:700;letter-spacing:-.035em}')
expect(css).toContain('.list-page-summary{margin:8px 0 18px;color:var(--muted);font-size:13px}')
@@ -122,16 +139,18 @@ describe('approved five-detail polish', () => {
expect(css).toContain('.habit-section-heading{margin-top:0}')
expect(css).toContain('main.list-main>.mvp-view{gap:0}')
expect(css).toContain('main.list-main>.mvp-view>.habit-section-heading{width:100%}')
expect(css).toContain('main.list-main>.mvp-view>.habit-archive-section{border-top:1px solid #e8e0d5}')
expect(css).toContain('main.list-main>.mvp-view>.habit-archive-section{margin-top:24px;padding-top:12px;border-top:1px solid #e8e0d5}')
expect(css).toContain('.habit-archive-section{display:grid;gap:10px}')
expect(css).toContain('.habit-archive-toggle{width:100%;min-height:44px;display:grid;grid-template-columns:28px minmax(0,1fr) 28px;align-items:center;gap:8px;padding:0 4px;border:0;background:transparent;box-shadow:none')
expect(css).toContain('.archived-habits{margin-top:2px;')
})
it('uses one 58px plain-list contract for active task and habit rows', () => {
expect(app).toContain("class=\"task-list plain-list\"")
expect(app).toContain("'task-row--trash':activeView==='trash'")
expect(app).toContain(':role="activeView===\'trash\' ? undefined : \'button\'"')
expect(app).toContain(':tabindex="activeView===\'trash\' ? undefined : 0"')
expect(app).toContain('@click="activeView===\'trash\'?undefined:selectTaskUnlessSwiped(node.task)"')
expect(app).toContain('v-if="activeView!==\'trash\'" class="task-check"')
expect(app).toContain('class="task-row task-row--trash"')
expect(app).toContain('class="task-main" role="button" tabindex="0"')
expect(app).toContain('@click="selectTaskUnlessSwiped(node.task)"')
expect(app).toContain('class="task-check" :aria-label="node.task.completed')
expect(habits).toContain('class=\"habit-list plain-list\"')
expect(habits).toContain('class=\"habit-row habit-row--full swipeable\"')
expect(css).toContain('.plain-list{display:grid;gap:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible}')
@@ -0,0 +1,29 @@
"""add after_completion unit
Revision ID: 0021_after_completion_unit
Revises: 0020_calendar_subscriptions
"""
import sqlalchemy as sa
from alembic import op
revision = "0021_after_completion_unit"
down_revision = "0020_calendar_subscriptions"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("recurrence_templates") as batch_op:
batch_op.add_column(
sa.Column("after_completion_unit", sa.String(length=8), nullable=True)
)
op.execute(
"UPDATE recurrence_templates SET after_completion_unit = 'days' "
"WHERE trigger_mode = 'after_completion'"
)
def downgrade() -> None:
with op.batch_alter_table("recurrence_templates") as batch_op:
batch_op.drop_column("after_completion_unit")
@@ -0,0 +1,52 @@
"""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,6 +16,8 @@ dependencies = [
"structlog>=25,<26",
"lunar-python>=1.2,<2",
"httpx>=0.28,<1",
"icalendar>=6,<7",
"python-dateutil>=2.9,<3",
]
[dependency-groups]
+29 -2
View File
@@ -48,7 +48,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
due_has_time=True,
)
gap_due = recurrence_service._after_completion_due(
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, user
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, "days", user
)
assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap
@@ -60,7 +60,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
due_has_time=True,
)
fold_due = recurrence_service._after_completion_due(
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, user
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, "days", user
)
assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0
@@ -89,6 +89,30 @@ def test_user_timezone_is_the_calendar_contract_for_after_completion(client, mon
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC)
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):
inbox = boot(client)
@@ -105,6 +129,7 @@ def test_atomic_task_create_and_read_after_completion_recurrence(client):
"ends_at": None,
"trigger_mode": "after_completion",
"after_completion_days": 2,
"after_completion_unit": "days",
"last_completed_at": None,
}
@@ -122,6 +147,8 @@ def test_after_completion_configuration_validation(client):
{"title": "零天", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 0},
{"title": "太长", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 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"], "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:
assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422}
+20
View File
@@ -400,6 +400,7 @@ def test_task_subtasks_and_recycle_bin(client):
assert client.delete(f"/api/v1/tasks/{parent['id']}").status_code == 204
trash = client.get("/api/v1/trash").json()["items"]
assert len(trash) == 1
assert trash[0]["subtasks"][0]["title"] == "比较价格"
assert client.post(f"/api/v1/tasks/{parent['id']}/restore").status_code == 200
@@ -566,6 +567,25 @@ def test_trash_cursor_paginates_more_than_fifty_items(client):
assert second.json()["next_cursor"] is None
def test_trash_page_orders_deadline_groups_globally(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
rows = [
("无日期", None),
("未来", "2099-12-30T15:59:00Z"),
("过期", "2020-01-02T15:59:00Z"),
]
for title, due_at in rows:
payload = {"title": title, "list_id": inbox["id"]}
if due_at:
payload.update({"due_at": due_at, "due_has_time": False})
task = client.post("/api/v1/tasks", json=payload).json()
assert client.delete(f"/api/v1/tasks/{task['id']}").status_code == 204
page = client.get("/api/v1/trash", params={"page": 1, "page_size": 50}).json()
assert [item["title"] for item in page["items"]] == ["过期", "未来", "无日期"]
def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
+30
View File
@@ -200,6 +200,36 @@ def test_merge_same_backup_is_idempotent_via_import_ledger(client):
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):
inbox = boot(client)
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
+60
View File
@@ -0,0 +1,60 @@
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)
+408
View File
@@ -0,0 +1,408 @@
import asyncio
import socket
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from backend.calendar import (
FetchResult,
parse_ics_events,
validate_calendar_url,
)
from backend.calendar_refresh import refresh_due_subscriptions, refresh_subscription_cache
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
def test_due_enabled_subscriptions_refresh_automatically(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)
return FetchResult(ICS, None, None, False)
monkeypatch.setattr("backend.calendar.fetch_calendar", fetch)
client.post(
"/api/v1/calendar-subscriptions",
json={"name": "enabled", "url": "https://example.com/enabled.ics"},
)
client.post(
"/api/v1/calendar-subscriptions",
json={"name": "disabled", "url": "https://example.com/disabled.ics", "enabled": False},
)
async def refresh():
from sqlalchemy.ext.asyncio import async_sessionmaker
from backend.db import get_engine
factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with factory() as db:
return await refresh_due_subscriptions(
db,
now=datetime.now(UTC) + timedelta(minutes=16),
refresh_interval=timedelta(minutes=15),
)
assert asyncio.run(refresh()) == 1
assert calls == [
"https://example.com/enabled.ics",
"https://example.com/disabled.ics",
"https://example.com/enabled.ics",
]
def test_failed_cached_refresh_preserves_last_success_timestamp(monkeypatch):
original_refresh = datetime(2026, 9, 20, tzinfo=UTC)
row = SimpleNamespace(
id="source-1",
url="https://example.com/work.ics",
name="work",
color="#123456",
ics_cache=ICS.decode(),
etag=None,
last_modified=None,
refreshed_at=original_refresh,
last_error=None,
)
class FakeDb:
async def refresh(self, _row):
pass
async def commit(self):
pass
async def rollback(self):
pass
def fail(*args, **kwargs):
raise HTTPException(502, "upstream down")
monkeypatch.setattr("backend.calendar.fetch_calendar", fail)
assert asyncio.run(refresh_subscription_cache(FakeDb(), row)) is False
assert row.refreshed_at == original_refresh
assert row.last_error == "upstream down"
def test_refresh_discards_response_when_url_changes_in_flight(monkeypatch):
row = SimpleNamespace(
id="source-1",
url="https://example.com/old.ics",
name="work",
color="#123456",
ics_cache="old cache",
etag=None,
last_modified=None,
refreshed_at=datetime(2026, 9, 20, tzinfo=UTC),
last_error=None,
)
class FakeDb:
committed = False
async def refresh(self, target):
target.url = "https://example.com/new.ics"
target.ics_cache = None
target.refreshed_at = None
async def commit(self):
self.committed = True
async def rollback(self):
pass
monkeypatch.setattr(
"backend.calendar.fetch_calendar",
lambda *args, **kwargs: FetchResult(ICS, None, None, False),
)
db = FakeDb()
assert asyncio.run(refresh_subscription_cache(db, row)) is False
assert row.url == "https://example.com/new.ics"
assert row.ics_cache is None
assert db.committed is False
def test_failed_source_waits_until_next_interval_and_does_not_abort_batch(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),
)
first = client.post(
"/api/v1/calendar-subscriptions",
json={"name": "first", "url": "https://example.com/first.ics"},
).json()
client.post(
"/api/v1/calendar-subscriptions",
json={"name": "second", "url": "https://example.com/second.ics"},
)
attempts = []
def fetch(url, **kwargs):
attempts.append(url)
if url.endswith("first.ics"):
raise HTTPException(502, "upstream down")
return FetchResult(ICS, None, None, False)
monkeypatch.setattr("backend.calendar.fetch_calendar", fetch)
async def refresh_twice():
from sqlalchemy.ext.asyncio import async_sessionmaker
from backend.db import get_engine
factory = async_sessionmaker(get_engine(), expire_on_commit=False)
now = datetime.now(UTC) + timedelta(minutes=16)
async with factory() as db:
first_count = await refresh_due_subscriptions(
db, now=now, refresh_interval=timedelta(minutes=15)
)
async with factory() as db:
second_count = await refresh_due_subscriptions(
db, now=now + timedelta(minutes=1), refresh_interval=timedelta(minutes=15)
)
return first_count, second_count
assert asyncio.run(refresh_twice()) == (2, 0)
assert attempts == [
"https://example.com/first.ics",
"https://example.com/second.ics",
]
listed = client.get("/api/v1/calendar-subscriptions").json()
failed = next(item for item in listed if item["id"] == first["id"])
assert failed["stale"] is True
Generated
+47
View File
@@ -277,8 +277,10 @@ dependencies = [
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "icalendar" },
{ name = "lunar-python" },
{ name = "pydantic-settings" },
{ name = "python-dateutil" },
{ name = "python-multipart" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "structlog" },
@@ -301,8 +303,10 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.30,<1" },
{ name = "fastapi", specifier = ">=0.116,<1" },
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "icalendar", specifier = ">=6,<7" },
{ name = "lunar-python", specifier = ">=1.2,<2" },
{ name = "pydantic-settings", specifier = ">=2.10,<3" },
{ name = "python-dateutil", specifier = ">=2.9,<3" },
{ name = "python-multipart", specifier = ">=0.0.20,<1" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" },
{ name = "structlog", specifier = ">=25,<26" },
@@ -474,6 +478,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "icalendar"
version = "6.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/70/458092b3e7c15783423fe64d07e63ea3311a597e723be6a1060513e3db93/icalendar-6.3.2.tar.gz", hash = "sha256:e0c10ecbfcebe958d33af7d491f6e6b7580d11d475f2eeb29532d0424f9110a1", size = 178422 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/ee/2ff96bb5bd88fe03ab90aedf5180f96dc0f3ae4648ca264b473055bcaaff/icalendar-6.3.2-py3-none-any.whl", hash = "sha256:d400e9c9bb8c025e5a3c77c236941bb690494be52528a0b43cc7e8b7c9505064", size = 242403 },
]
[[package]]
name = "idna"
version = "3.19"
@@ -742,6 +759,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "python-dotenv"
version = "1.2.3"
@@ -831,6 +860,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850 },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "sqlalchemy"
version = "2.0.52"
@@ -914,6 +952,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750 },
]
[[package]]
name = "tzdata"
version = "2026.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]]
name = "uuid-utils"
version = "0.17.0"