Compare commits
36
Commits
2715b74f2e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c9618252b | ||
|
|
a64b7b8262 | ||
|
|
2239cd4754 | ||
|
|
136acbc59c | ||
|
|
28a97d9f5a | ||
|
|
64f25a39c0 | ||
|
|
629e054823 | ||
|
|
058f353965 | ||
|
|
b3b0e07c42 | ||
|
|
a535f69cae | ||
|
|
12e4753aeb | ||
|
|
9b8f55cf46 | ||
|
|
8125174263 | ||
|
|
f437eded84 | ||
|
|
a68d6ecc5e | ||
|
|
97c1bd4e87 | ||
|
|
a970a03f5d | ||
|
|
158fd7532c | ||
|
|
d925a043dc | ||
|
|
7f8b435164 | ||
|
|
927e15cf17 | ||
|
|
8a4d1de9be | ||
|
|
740eae8a59 | ||
|
|
c7bb9742f1 | ||
|
|
cbf307507c | ||
|
|
34691b6fe8 | ||
|
|
5e338fa90f | ||
|
|
99da524070 | ||
|
|
3a73473c4d | ||
|
|
3155155f53 | ||
|
|
e6d720b85c | ||
|
|
6e3e09e8b6 | ||
|
|
bb59dc9346 | ||
|
|
7140102aeb | ||
|
|
f3ad1eec03 | ||
|
|
af52fe0cad |
@@ -9,6 +9,7 @@
|
|||||||
- RFC 5545 计划重复与“完成后重复”;乐观锁避免并发覆盖
|
- RFC 5545 计划重复与“完成后重复”;乐观锁避免并发覆盖
|
||||||
- 今日页按逾期任务、今日任务、今日习惯分组,并提供进度与环境信息
|
- 今日页按逾期任务、今日任务、今日习惯分组,并提供进度与环境信息
|
||||||
- 完成型/数值型习惯、日/周/月/间隔计划、暂停、历史与归档
|
- 完成型/数值型习惯、日/周/月/间隔计划、暂停、历史与归档
|
||||||
|
- 本地专注计时器:25 分钟专注、5 分钟休息、暂停/继续/重置与当日完成次数
|
||||||
- 倒数日、纪念日、生日及公历/农历重复
|
- 倒数日、纪念日、生日及公历/农历重复
|
||||||
- Markdown 备忘录及软删除/恢复
|
- Markdown 备忘录及软删除/恢复
|
||||||
- 任务附件、登录设备管理与审计日志
|
- 任务附件、登录设备管理与审计日志
|
||||||
|
|||||||
@@ -152,6 +152,18 @@ def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES
|
|||||||
content_names = set(names) - {"manifest.json"}
|
content_names = set(names) - {"manifest.json"}
|
||||||
if set(checksums) != content_names:
|
if set(checksums) != content_names:
|
||||||
raise backup_error("backup_manifest_mismatch", "manifest 与 ZIP 条目不一致")
|
raise backup_error("backup_manifest_mismatch", "manifest 与 ZIP 条目不一致")
|
||||||
|
# Calendar subscriptions are an additive backup-v2 entity. Accept archives
|
||||||
|
# produced by older helpers that checksum the new file but omit its count.
|
||||||
|
optional_entities = {"calendar_subscriptions"}
|
||||||
|
undeclared_optional = {
|
||||||
|
f"data/{name}.json" for name in optional_entities - set(declared_entities)
|
||||||
|
}
|
||||||
|
if set(declared_entities) != {
|
||||||
|
name[5:-5]
|
||||||
|
for name in content_names - undeclared_optional
|
||||||
|
if name.startswith("data/") and name.endswith(".json")
|
||||||
|
}:
|
||||||
|
raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
|
||||||
entities: dict[str, list[dict]] = {}
|
entities: dict[str, list[dict]] = {}
|
||||||
files: dict[str, StagedBlob] = {}
|
files: dict[str, StagedBlob] = {}
|
||||||
for index, name in enumerate(sorted(content_names)):
|
for index, name in enumerate(sorted(content_names)):
|
||||||
@@ -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]:
|
if not isinstance(checksums[name], str) or actual_digest != checksums[name]:
|
||||||
raise backup_error("backup_checksum_mismatch", "备份校验和不匹配")
|
raise backup_error("backup_checksum_mismatch", "备份校验和不匹配")
|
||||||
if set(declared_entities) != set(entities):
|
if set(declared_entities) != set(entities):
|
||||||
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()):
|
if any(type(count) is not int or count < 0 or count != len(entities[name]) for name, count in declared_entities.items()):
|
||||||
raise backup_error("backup_manifest_mismatch", "manifest 实体数量不一致")
|
raise backup_error("backup_manifest_mismatch", "manifest 实体数量不一致")
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from backend.models import (
|
|||||||
BackupImport,
|
BackupImport,
|
||||||
BackupImportEntity,
|
BackupImportEntity,
|
||||||
BackupPreflight,
|
BackupPreflight,
|
||||||
|
CalendarSubscription,
|
||||||
Countdown,
|
Countdown,
|
||||||
Folder,
|
Folder,
|
||||||
Habit,
|
Habit,
|
||||||
@@ -65,6 +66,7 @@ ENTITY_MODELS = {
|
|||||||
"habit_pauses": HabitPause,
|
"habit_pauses": HabitPause,
|
||||||
"countdowns": Countdown,
|
"countdowns": Countdown,
|
||||||
"memos": Memo,
|
"memos": Memo,
|
||||||
|
"calendar_subscriptions": CalendarSubscription,
|
||||||
"attachments": Attachment,
|
"attachments": Attachment,
|
||||||
}
|
}
|
||||||
RELATIONS = {
|
RELATIONS = {
|
||||||
@@ -352,6 +354,9 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
|
|||||||
"rrule": row.get("rrule"),
|
"rrule": row.get("rrule"),
|
||||||
"trigger_mode": row.get("trigger_mode", "scheduled"),
|
"trigger_mode": row.get("trigger_mode", "scheduled"),
|
||||||
"after_completion_days": row.get("after_completion_days"),
|
"after_completion_days": row.get("after_completion_days"),
|
||||||
|
"after_completion_unit": row.get("after_completion_unit")
|
||||||
|
if row.get("trigger_mode", "scheduled") == "after_completion"
|
||||||
|
else None,
|
||||||
})
|
})
|
||||||
starts_at = datetime.fromisoformat(row["starts_at"])
|
starts_at = datetime.fromisoformat(row["starts_at"])
|
||||||
ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None
|
ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None
|
||||||
@@ -392,6 +397,9 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
|
|||||||
|
|
||||||
def validate_archive(parsed: ParsedArchive) -> None:
|
def validate_archive(parsed: ParsedArchive) -> None:
|
||||||
unknown = set(parsed.entities) - set(ENTITY_MODELS)
|
unknown = set(parsed.entities) - set(ENTITY_MODELS)
|
||||||
|
# Calendar subscriptions were introduced after backup v2. Treat their
|
||||||
|
# absence as an empty collection so archives from older Dodo releases remain restorable.
|
||||||
|
parsed.entities.setdefault("calendar_subscriptions", [])
|
||||||
missing = set(ENTITY_MODELS) - set(parsed.entities)
|
missing = set(ENTITY_MODELS) - set(parsed.entities)
|
||||||
if unknown:
|
if unknown:
|
||||||
raise backup_error("backup_entity_unknown", "备份包含未知实体")
|
raise backup_error("backup_entity_unknown", "备份包含未知实体")
|
||||||
@@ -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):
|
elif isinstance(effective_type, Date) and isinstance(value, str):
|
||||||
value = date.fromisoformat(value)
|
value = date.fromisoformat(value)
|
||||||
values[name] = value
|
values[name] = value
|
||||||
|
if model is RecurrenceTemplate and "after_completion_unit" not in raw:
|
||||||
|
values["after_completion_unit"] = "days" if raw.get("trigger_mode") == "after_completion" else None
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
@@ -611,7 +621,16 @@ async def restore_v2(
|
|||||||
await db.execute(delete(HabitPause).where(
|
await db.execute(delete(HabitPause).where(
|
||||||
HabitPause.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id))
|
HabitPause.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id))
|
||||||
))
|
))
|
||||||
for model in (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(model).where(model.user_id == user.id))
|
||||||
await db.execute(delete(BackupImportEntity).where(BackupImportEntity.user_id == user.id))
|
await db.execute(delete(BackupImportEntity).where(BackupImportEntity.user_id == user.id))
|
||||||
await db.execute(delete(BackupImport).where(BackupImport.user_id == user.id))
|
await db.execute(delete(BackupImport).where(BackupImport.user_id == user.id))
|
||||||
|
|||||||
@@ -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"]))
|
||||||
@@ -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
|
||||||
@@ -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
@@ -22,6 +22,12 @@ def get_engine():
|
|||||||
return _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:
|
def reset_engine() -> None:
|
||||||
global _engine, _session_factory
|
global _engine, _session_factory
|
||||||
_engine = None
|
_engine = None
|
||||||
@@ -29,9 +35,7 @@ def reset_engine() -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncIterator[AsyncSession]:
|
async def get_db() -> AsyncIterator[AsyncSession]:
|
||||||
get_engine()
|
async with get_session_factory()() as session:
|
||||||
assert _session_factory is not None
|
|
||||||
async with _session_factory() as session:
|
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+44
-19
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -30,7 +31,9 @@ from .auth import (
|
|||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from .backup import router as backup_router
|
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 (
|
from .models import (
|
||||||
AppState,
|
AppState,
|
||||||
Attachment,
|
Attachment,
|
||||||
@@ -79,7 +82,19 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
if get_settings().auto_create_schema:
|
if get_settings().auto_create_schema:
|
||||||
await 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(
|
app = FastAPI(
|
||||||
@@ -120,6 +135,7 @@ async def openapi(_: User = Depends(current_user)):
|
|||||||
|
|
||||||
app.include_router(mvp_router)
|
app.include_router(mvp_router)
|
||||||
app.include_router(backup_router)
|
app.include_router(backup_router)
|
||||||
|
app.include_router(calendar_router)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
@@ -997,7 +1013,7 @@ async def create_task(
|
|||||||
from .mvp import parse_rrule
|
from .mvp import parse_rrule
|
||||||
if payload.rrule:
|
if payload.rrule:
|
||||||
parse_rrule(payload.rrule)
|
parse_rrule(payload.rrule)
|
||||||
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days"})
|
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days", "after_completion_unit"})
|
||||||
parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None)
|
parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None)
|
||||||
max_position = await db.scalar(select(func.max(Task.position)).where(
|
max_position = await db.scalar(select(func.max(Task.position)).where(
|
||||||
Task.user_id == user.id,
|
Task.user_id == user.id,
|
||||||
@@ -1017,6 +1033,9 @@ async def create_task(
|
|||||||
starts_at=task.due_at,
|
starts_at=task.due_at,
|
||||||
trigger_mode=payload.trigger_mode or "scheduled",
|
trigger_mode=payload.trigger_mode or "scheduled",
|
||||||
after_completion_days=payload.after_completion_days,
|
after_completion_days=payload.after_completion_days,
|
||||||
|
after_completion_unit=(payload.after_completion_unit or "days")
|
||||||
|
if payload.trigger_mode == "after_completion"
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
audit(db, user.id, "create", "task", task.id)
|
audit(db, user.id, "create", "task", task.id)
|
||||||
@@ -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)
|
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:
|
if not tasks:
|
||||||
return []
|
return []
|
||||||
allowed_scopes = {(task.id, task.user_id, task.list_id) for task in tasks}
|
allowed_scopes = {(task.id, task.user_id, task.list_id) for task in tasks}
|
||||||
subtasks = list(
|
subtask_query = select(Task).where(
|
||||||
(
|
tuple_(Task.parent_id, Task.user_id, Task.list_id).in_(allowed_scopes)
|
||||||
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 = 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)
|
subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list)
|
||||||
for subtask in subtasks:
|
for subtask in subtasks:
|
||||||
if (subtask.parent_id, subtask.user_id, subtask.list_id) in allowed_scopes:
|
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)
|
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
|
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:
|
if page is not None:
|
||||||
size = page_size or limit
|
size = page_size or limit
|
||||||
items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all())
|
page_ordering = (grouping_rank, Task.due_at, Task.created_at, Task.id)
|
||||||
return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size)
|
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:
|
if cursor:
|
||||||
created_at, task_id = _decode_trash_cursor(cursor)
|
created_at, task_id = _decode_trash_cursor(cursor)
|
||||||
query = query.where(
|
query = query.where(
|
||||||
@@ -1266,7 +1291,7 @@ async def list_trash(
|
|||||||
has_more = len(rows) > limit
|
has_more = len(rows) > limit
|
||||||
items = rows[:limit]
|
items = rows[:limit]
|
||||||
next_cursor = _encode_trash_cursor(items[-1].created_at, items[-1].id) if has_more else None
|
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)
|
@app.post("/api/v1/tasks/{task_id}/restore", response_model=TaskDetailOut)
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class RecurrenceTemplate(Base):
|
|||||||
ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||||
trigger_mode: Mapped[str] = mapped_column(String(32), default="scheduled")
|
trigger_mode: Mapped[str] = mapped_column(String(32), default="scheduled")
|
||||||
after_completion_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
after_completion_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
after_completion_unit: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||||
last_completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
last_completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||||
|
|
||||||
@@ -301,6 +302,23 @@ class BackupImportEntity(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSubscription(Base):
|
||||||
|
__tablename__ = "calendar_subscriptions"
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(120))
|
||||||
|
url: Mapped[str] = mapped_column(Text)
|
||||||
|
color: Mapped[str] = mapped_column(String(32), default="#f15a29")
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
ics_cache: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
etag: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
last_modified: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
refreshed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
__tablename__ = "audit_logs"
|
__tablename__ = "audit_logs"
|
||||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||||
|
|||||||
+30
-4
@@ -251,17 +251,22 @@ class RecurrenceCreate(BaseModel):
|
|||||||
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
||||||
trigger_mode: str = Field(default="scheduled", pattern="^(scheduled|after_completion)$")
|
trigger_mode: str = Field(default="scheduled", pattern="^(scheduled|after_completion)$")
|
||||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||||
|
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_mode(self):
|
def validate_mode(self):
|
||||||
if self.trigger_mode == "scheduled" and (
|
if self.trigger_mode == "scheduled" and (
|
||||||
self.rrule is None or self.after_completion_days is not None
|
self.rrule is None
|
||||||
|
or self.after_completion_days is not None
|
||||||
|
or self.after_completion_unit is not None
|
||||||
):
|
):
|
||||||
raise ValueError("scheduled recurrence requires rrule and no completion interval")
|
raise ValueError("scheduled recurrence requires rrule and no completion interval")
|
||||||
if self.trigger_mode == "after_completion" and (
|
if self.trigger_mode == "after_completion" and (
|
||||||
self.after_completion_days is None or self.rrule is not None
|
self.after_completion_days is None or self.rrule is not None
|
||||||
):
|
):
|
||||||
raise ValueError("after_completion requires days and no rrule")
|
raise ValueError("after_completion requires days and no rrule")
|
||||||
|
if self.trigger_mode == "after_completion" and self.after_completion_unit is None:
|
||||||
|
self.after_completion_unit = "days"
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -271,6 +276,7 @@ class RecurrenceChange(BaseModel):
|
|||||||
rrule: str | None = None
|
rrule: str | None = None
|
||||||
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
||||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||||
|
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
|
||||||
|
|
||||||
|
|
||||||
class OccurrenceComplete(BaseModel):
|
class OccurrenceComplete(BaseModel):
|
||||||
@@ -430,6 +436,7 @@ async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user),
|
|||||||
"ends_at": row.ends_at,
|
"ends_at": row.ends_at,
|
||||||
"trigger_mode": row.trigger_mode,
|
"trigger_mode": row.trigger_mode,
|
||||||
"after_completion_days": row.after_completion_days,
|
"after_completion_days": row.after_completion_days,
|
||||||
|
"after_completion_unit": row.after_completion_unit,
|
||||||
"last_completed_at": row.last_completed_at,
|
"last_completed_at": row.last_completed_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +459,9 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
|
|||||||
starts_at=task.due_at,
|
starts_at=task.due_at,
|
||||||
trigger_mode=payload.trigger_mode,
|
trigger_mode=payload.trigger_mode,
|
||||||
after_completion_days=payload.after_completion_days,
|
after_completion_days=payload.after_completion_days,
|
||||||
|
after_completion_unit=(payload.after_completion_unit or "days")
|
||||||
|
if payload.trigger_mode == "after_completion"
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
await db.commit(); await db.refresh(row)
|
await db.commit(); await db.refresh(row)
|
||||||
@@ -463,6 +473,7 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
|
|||||||
"ends_at": row.ends_at,
|
"ends_at": row.ends_at,
|
||||||
"trigger_mode": row.trigger_mode,
|
"trigger_mode": row.trigger_mode,
|
||||||
"after_completion_days": row.after_completion_days,
|
"after_completion_days": row.after_completion_days,
|
||||||
|
"after_completion_unit": row.after_completion_unit,
|
||||||
"last_completed_at": row.last_completed_at,
|
"last_completed_at": row.last_completed_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,12 +515,18 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
|||||||
if "after_completion_days" in payload.model_fields_set
|
if "after_completion_days" in payload.model_fields_set
|
||||||
else template.after_completion_days
|
else template.after_completion_days
|
||||||
)
|
)
|
||||||
|
requested_unit = (
|
||||||
|
payload.after_completion_unit
|
||||||
|
if "after_completion_unit" in payload.model_fields_set
|
||||||
|
else template.after_completion_unit
|
||||||
|
)
|
||||||
requested_rrule = payload.rrule if payload.rrule is not None else template.rrule
|
requested_rrule = payload.rrule if payload.rrule is not None else template.rrule
|
||||||
if requested_mode == "after_completion":
|
if requested_mode == "after_completion":
|
||||||
if requested_days is None:
|
if requested_days is None:
|
||||||
raise HTTPException(422, "完成后重复需要间隔天数")
|
raise HTTPException(422, "完成后重复需要间隔天数")
|
||||||
template.trigger_mode = requested_mode
|
template.trigger_mode = requested_mode
|
||||||
template.after_completion_days = requested_days
|
template.after_completion_days = requested_days
|
||||||
|
template.after_completion_unit = requested_unit or "days"
|
||||||
template.rrule = None
|
template.rrule = None
|
||||||
else:
|
else:
|
||||||
if requested_rrule is None:
|
if requested_rrule is None:
|
||||||
@@ -517,6 +534,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
|||||||
parse_rrule(requested_rrule)
|
parse_rrule(requested_rrule)
|
||||||
template.trigger_mode = requested_mode
|
template.trigger_mode = requested_mode
|
||||||
template.after_completion_days = None
|
template.after_completion_days = None
|
||||||
|
template.after_completion_unit = None
|
||||||
template.rrule = requested_rrule.upper()
|
template.rrule = requested_rrule.upper()
|
||||||
if payload.title is not None:
|
if payload.title is not None:
|
||||||
task.title = payload.title
|
task.title = payload.title
|
||||||
@@ -533,6 +551,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
|||||||
"ends_at": template.ends_at,
|
"ends_at": template.ends_at,
|
||||||
"trigger_mode": template.trigger_mode,
|
"trigger_mode": template.trigger_mode,
|
||||||
"after_completion_days": template.after_completion_days,
|
"after_completion_days": template.after_completion_days,
|
||||||
|
"after_completion_unit": template.after_completion_unit,
|
||||||
"last_completed_at": template.last_completed_at,
|
"last_completed_at": template.last_completed_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1358,7 +1377,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memo
|
|||||||
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
|
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
|
||||||
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
|
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
|
||||||
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "completed_at", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
|
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "completed_at", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
|
||||||
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
|
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "after_completion_unit", "last_completed_at"]) for x in recurrences],
|
||||||
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
|
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
|
||||||
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
|
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
|
||||||
"memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos],
|
"memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos],
|
||||||
@@ -1570,11 +1589,17 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
|||||||
rrule = raw.get("rrule")
|
rrule = raw.get("rrule")
|
||||||
if trigger_mode not in {"scheduled", "after_completion"}:
|
if trigger_mode not in {"scheduled", "after_completion"}:
|
||||||
raise HTTPException(422, "无效的重复触发模式")
|
raise HTTPException(422, "无效的重复触发模式")
|
||||||
|
unit = raw.get("after_completion_unit")
|
||||||
|
if unit is not None and unit not in {"days", "months"}:
|
||||||
|
raise HTTPException(422, "无效的完成后重复单位")
|
||||||
if trigger_mode == "after_completion":
|
if trigger_mode == "after_completion":
|
||||||
if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650 or rrule is not None:
|
if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650 or rrule is not None:
|
||||||
raise HTTPException(422, "无效的完成后重复备份")
|
raise HTTPException(422, "无效的完成后重复备份")
|
||||||
elif not isinstance(rrule, str):
|
unit = unit or "days"
|
||||||
raise HTTPException(422, "定期重复缺少 RRULE")
|
else:
|
||||||
|
if not isinstance(rrule, str) or days is not None or unit is not None:
|
||||||
|
raise HTTPException(422, "定期重复备份包含无效字段")
|
||||||
|
unit = None
|
||||||
db.add(RecurrenceTemplate(
|
db.add(RecurrenceTemplate(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
@@ -1583,6 +1608,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
|||||||
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
|
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
|
||||||
trigger_mode=trigger_mode,
|
trigger_mode=trigger_mode,
|
||||||
after_completion_days=days,
|
after_completion_days=days,
|
||||||
|
after_completion_unit=unit,
|
||||||
last_completed_at=datetime.fromisoformat(raw["last_completed_at"])
|
last_completed_at=datetime.fromisoformat(raw["last_completed_at"])
|
||||||
if raw.get("last_completed_at") else None,
|
if raw.get("last_completed_at") else None,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from datetime import UTC, datetime, time, timedelta
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -25,10 +26,15 @@ def _user_zone(user: User) -> ZoneInfo:
|
|||||||
raise HTTPException(422, "用户时区无效") from exc
|
raise HTTPException(422, "用户时区无效") from exc
|
||||||
|
|
||||||
|
|
||||||
def _after_completion_due(task: Task, completed_at: datetime, days: int, user: User) -> datetime:
|
def _after_completion_due(
|
||||||
|
task: Task, completed_at: datetime, value: int, unit: str | None, user: User
|
||||||
|
) -> datetime:
|
||||||
zone = _user_zone(user)
|
zone = _user_zone(user)
|
||||||
completed_local = completed_at.astimezone(zone)
|
completed_local = completed_at.astimezone(zone)
|
||||||
target_date = completed_local.date() + timedelta(days=days)
|
if unit == "months":
|
||||||
|
target_date = completed_local.date() + relativedelta(months=value)
|
||||||
|
else:
|
||||||
|
target_date = completed_local.date() + timedelta(days=value)
|
||||||
if task.due_has_time:
|
if task.due_has_time:
|
||||||
due_local = task.due_at.astimezone(zone)
|
due_local = task.due_at.astimezone(zone)
|
||||||
wall_time = due_local.timetz().replace(tzinfo=None)
|
wall_time = due_local.timetz().replace(tzinfo=None)
|
||||||
@@ -67,7 +73,11 @@ async def apply_task_changes(
|
|||||||
if recurrence.trigger_mode == "after_completion":
|
if recurrence.trigger_mode == "after_completion":
|
||||||
completed_at = utcnow()
|
completed_at = utcnow()
|
||||||
next_due = _after_completion_due(
|
next_due = _after_completion_due(
|
||||||
task, completed_at, recurrence.after_completion_days, user
|
task,
|
||||||
|
completed_at,
|
||||||
|
recurrence.after_completion_days,
|
||||||
|
recurrence.after_completion_unit,
|
||||||
|
user,
|
||||||
)
|
)
|
||||||
changes["completed"] = False
|
changes["completed"] = False
|
||||||
changes["due_at"] = next_due
|
changes["due_at"] = next_due
|
||||||
|
|||||||
+16
-5
@@ -125,6 +125,7 @@ class TaskCreate(BaseModel):
|
|||||||
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
||||||
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
||||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||||
|
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
|
||||||
|
|
||||||
@field_validator("title")
|
@field_validator("title")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -136,7 +137,12 @@ class TaskCreate(BaseModel):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_recurrence(self):
|
def validate_recurrence(self):
|
||||||
has_recurrence = self.rrule is not None or self.trigger_mode is not None or self.after_completion_days is not None
|
has_recurrence = (
|
||||||
|
self.rrule is not None
|
||||||
|
or self.trigger_mode is not None
|
||||||
|
or self.after_completion_days is not None
|
||||||
|
or self.after_completion_unit is not None
|
||||||
|
)
|
||||||
if has_recurrence and self.due_at is None:
|
if has_recurrence and self.due_at is None:
|
||||||
raise ValueError("recurrence requires due_at")
|
raise ValueError("recurrence requires due_at")
|
||||||
if has_recurrence and self.parent_id is not None:
|
if has_recurrence and self.parent_id is not None:
|
||||||
@@ -144,10 +150,15 @@ class TaskCreate(BaseModel):
|
|||||||
if self.trigger_mode == "after_completion":
|
if self.trigger_mode == "after_completion":
|
||||||
if self.after_completion_days is None or self.rrule is not None:
|
if self.after_completion_days is None or self.rrule is not None:
|
||||||
raise ValueError("after_completion requires days and no rrule")
|
raise ValueError("after_completion requires days and no rrule")
|
||||||
elif self.trigger_mode == "scheduled" and self.rrule is None:
|
elif self.trigger_mode == "scheduled":
|
||||||
raise ValueError("scheduled recurrence requires rrule")
|
if (
|
||||||
elif self.trigger_mode is None and self.after_completion_days is not None:
|
self.rrule is None
|
||||||
raise ValueError("after_completion_days requires after_completion mode")
|
or self.after_completion_days is not None
|
||||||
|
or self.after_completion_unit is not None
|
||||||
|
):
|
||||||
|
raise ValueError("scheduled recurrence requires rrule and no completion interval")
|
||||||
|
elif self.after_completion_days is not None or self.after_completion_unit is not None:
|
||||||
|
raise ValueError("completion interval requires after_completion mode")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 定位与当前范围
|
## 定位与当前范围
|
||||||
|
|
||||||
dodo 是纯自托管、面向个人长期使用的任务与生活管理 PWA。当前包含任务/子任务、文件夹与清单、今日视图、重复任务、习惯、倒数日、Markdown 备忘录、附件、会话管理、审计和数据备份;不提供番茄钟、自然语言建任务或外部通知渠道。
|
dodo 是纯自托管、面向个人长期使用的任务与生活管理 PWA。当前包含任务/子任务、文件夹与清单、今日视图、重复任务、习惯、本地专注计时器、倒数日、Markdown 备忘录、附件、会话管理、审计和数据备份;不提供自然语言建任务或外部通知渠道。
|
||||||
|
|
||||||
## 技术与数据
|
## 技术与数据
|
||||||
|
|
||||||
@@ -19,11 +19,13 @@ dodo 是纯自托管、面向个人长期使用的任务与生活管理 PWA。
|
|||||||
- 习惯支持完成型/数值型、日/周/月/间隔计划、暂停、历史和归档
|
- 习惯支持完成型/数值型、日/周/月/间隔计划、暂停、历史和归档
|
||||||
- 倒数日支持公历/农历、生日/纪念日、重复、置顶与归档
|
- 倒数日支持公历/农历、生日/纪念日、重复、置顶与归档
|
||||||
- 备忘录使用 Markdown,支持软删除、恢复及归档后永久删除
|
- 备忘录使用 Markdown,支持软删除、恢复及归档后永久删除
|
||||||
|
- 专注计时器只使用前端 `localStorage`,不关联任务、不保存历史,也不跨设备同步;自然结束才计入当日完成次数
|
||||||
|
|
||||||
## UI 决策
|
## UI 决策
|
||||||
|
|
||||||
- 桌面保留左导航/内容/可选详情三栏;移动端使用底部导航
|
- 桌面保留左导航/内容/可选详情三栏;移动端使用底部导航
|
||||||
- 手机底栏固定为“今天、习惯、倒数日、设置”,精确匹配当前页面;不使用“更多”中转
|
- 手机底栏固定为“今天、习惯、倒数日、设置”,精确匹配当前页面;不使用“更多”中转
|
||||||
|
- 专注入口位于桌面侧栏的“习惯”之后;手机从汉堡菜单进入,不占用固定五项底栏
|
||||||
- 新建入口使用同一个普通圆形 Plus FAB,禁止装饰性光环或吉祥物
|
- 新建入口使用同一个普通圆形 Plus FAB,禁止装饰性光环或吉祥物
|
||||||
- 设置页使用连续分组:数据、账户与安全、登录设备、活动、危险操作
|
- 设置页使用连续分组:数据、账户与安全、登录设备、活动、危险操作
|
||||||
- 任务、习惯、倒数日、备忘录、操作菜单和确认框统一走 `AppSheet` / `AppDialog` 覆盖层栈;共享背景 inert、焦点陷阱、Escape、忙碌态和嵌套焦点恢复
|
- 任务、习惯、倒数日、备忘录、操作菜单和确认框统一走 `AppSheet` / `AppDialog` 覆盖层栈;共享背景 inert、焦点陷阱、Escape、忙碌态和嵌套焦点恢复
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ function bottomTab(page: Page, name: string) {
|
|||||||
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
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) {
|
async function csrf(request: APIRequestContext, baseURL: string) {
|
||||||
const state = await request.storageState()
|
const state = await request.storageState()
|
||||||
return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value
|
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()
|
expect(countdownResponse.ok()).toBeTruthy()
|
||||||
|
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await bottomTab(page, '设置').click()
|
await openSidebarView(page, '设置')
|
||||||
const downloadPromise = page.waitForEvent('download')
|
const downloadPromise = page.waitForEvent('download')
|
||||||
await page.getByRole('button', { name: '导出 ZIP' }).click()
|
await page.getByRole('button', { name: '导出 ZIP' }).click()
|
||||||
const download = await downloadPromise
|
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()
|
await dialog.getByRole('button', { name: '关闭习惯详情' }).click()
|
||||||
|
|
||||||
const archiveToggle = page.locator('.habit-archive-toggle')
|
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()
|
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) })
|
const archivedOpener = page.getByRole('button', { name: new RegExp(archivedName) })
|
||||||
await archivedOpener.click()
|
await archivedOpener.click()
|
||||||
dialog = page.getByRole('dialog', { name: archivedName })
|
dialog = page.getByRole('dialog', { name: archivedName })
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ function bottomTab(page: Page, name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openSettings(page: Page) {
|
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 desktopSettings = page.getByRole('navigation', { name: '管理' }).getByRole('button', { name: '设置', exact: true })
|
||||||
const box = await desktopSettings.boundingBox()
|
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()
|
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 }) => {
|
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('/')
|
await page.goto('/')
|
||||||
const navigation = page.getByRole('navigation', { name: '主要导航' })
|
const navigation = page.getByRole('navigation', { name: '主要导航' })
|
||||||
for (const label of ['今天', '习惯', '倒数日', '设置']) {
|
const mobile = (await page.viewportSize())!.width <= 930
|
||||||
await bottomTab(page, label).click()
|
if (mobile) {
|
||||||
await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1)
|
for (const label of ['今天', '习惯', '倒数日', '备忘录', '日历订阅']) {
|
||||||
await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page')
|
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 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
const desktop = await page.locator('.shell').evaluate(element => {
|
const desktop = await page.locator('.shell').evaluate(element => {
|
||||||
const shell = getComputedStyle(element)
|
const shell = getComputedStyle(element)
|
||||||
@@ -199,10 +200,53 @@ test('all bottom destinations expose one active page and desktop layout stays un
|
|||||||
expect(desktopBottomGap).toBe(84)
|
expect(desktopBottomGap).toBe(84)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('calendar week focus keeps seven usable day controls without page overflow', async ({ page }) => {
|
||||||
|
const calendarSources = ['天气','法定节假日','老黄历','节日节气','星座','影视上新','股市指数','街舞赛事','F1'].map((name, index) => ({ id:`calendar-source-${index}`, name, url:`https://example.com/${index}.ics`, color:'#f15a29', enabled:true, refreshed_at:null, last_error:null, stale:false }))
|
||||||
|
await page.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: calendarSources }))
|
||||||
|
await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [], sources: [] } }))
|
||||||
|
await page.goto('/')
|
||||||
|
const mobile = (await page.viewportSize())!.width <= 930
|
||||||
|
if (mobile) await bottomTab(page, '日历订阅').click()
|
||||||
|
else await page.locator('.primary-nav').getByRole('button', { name: '日历订阅', exact: true }).click()
|
||||||
|
const strip = page.getByRole('group', { name: '选择日期' })
|
||||||
|
const filters = page.locator('.calendar-filters')
|
||||||
|
await expect(strip).toBeVisible()
|
||||||
|
await expect(filters).toBeVisible()
|
||||||
|
await expect(strip.locator('.calendar-week-day')).toHaveCount(7)
|
||||||
|
const metrics = await strip.evaluate(element => {
|
||||||
|
const filters = document.querySelector<HTMLElement>('.calendar-filters')!
|
||||||
|
const calendar = document.querySelector<HTMLElement>('.calendar-view')!
|
||||||
|
const main = calendar.closest('main') as HTMLElement
|
||||||
|
return {
|
||||||
|
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||||
|
bodyOverflow: document.body.scrollWidth - document.body.clientWidth,
|
||||||
|
mainOverflow: main.scrollWidth - main.clientWidth,
|
||||||
|
calendarOverflow: calendar.scrollWidth - calendar.clientWidth,
|
||||||
|
filters: { clientWidth: filters.clientWidth, scrollWidth: filters.scrollWidth, overflowX: getComputedStyle(filters).overflowX },
|
||||||
|
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)' })
|
||||||
|
})
|
||||||
|
|
||||||
test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => {
|
test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await openSettings(page)
|
await openSettings(page)
|
||||||
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')
|
const groups = page.locator('.settings-group')
|
||||||
await expect(groups).toHaveCount(4)
|
await expect(groups).toHaveCount(4)
|
||||||
const layout = await page.locator('.settings-sections').evaluate(element => {
|
const layout = await page.locator('.settings-sections').evaluate(element => {
|
||||||
@@ -227,8 +271,8 @@ test('settings match the approved paper-ledger geometry and action hierarchy', a
|
|||||||
expect(layout.bodyOverflow).toBe(0)
|
expect(layout.bodyOverflow).toBe(0)
|
||||||
const isMobileContract = (await page.viewportSize())!.width <= 720
|
const isMobileContract = (await page.viewportSize())!.width <= 720
|
||||||
if (isMobileContract) {
|
if (isMobileContract) {
|
||||||
expect(layout.leftPadding).toBeCloseTo(29, 0)
|
expect(layout.leftPadding).toBeCloseTo(28, 0)
|
||||||
expect(layout.rightPadding).toBeCloseTo(29, 0)
|
expect(layout.rightPadding).toBeCloseTo(28, 0)
|
||||||
expect(layout.headingSize).toBe('24px')
|
expect(layout.headingSize).toBe('24px')
|
||||||
} else {
|
} else {
|
||||||
const pageWidth = await page.locator('.settings-sections').evaluate(element => element.getBoundingClientRect().width)
|
const pageWidth = await page.locator('.settings-sections').evaluate(element => element.getBoundingClientRect().width)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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 createList(request: APIRequestContext, baseURL: string, name: string) {
|
||||||
|
const response = await request.post('/api/v1/lists', {
|
||||||
|
data: { name },
|
||||||
|
headers: { 'x-csrf-token': await csrf(request), origin: baseURL },
|
||||||
|
})
|
||||||
|
expect(response.ok(), await response.text()).toBeTruthy()
|
||||||
|
return response.json() as Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openList(page: Page, name: string) {
|
||||||
|
const list = page.locator('.sidebar').getByRole('button', { name, exact: true })
|
||||||
|
if (await page.evaluate(() => window.innerWidth <= 930)) {
|
||||||
|
await page.locator('main .topbar > button').first().click()
|
||||||
|
}
|
||||||
|
await list.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('task pagination stays below the list and returns to the list start after navigation', async ({ page, request, baseURL }) => {
|
||||||
|
const nonce = crypto.randomUUID()
|
||||||
|
const listName = `分页验收清单 ${nonce}`
|
||||||
|
const list = await createList(request, baseURL!, listName)
|
||||||
|
|
||||||
|
for (let index = 1; index <= 51; index += 1) {
|
||||||
|
await createTask(request, baseURL!, list.id, `分页验收任务 ${nonce} ${String(index).padStart(2, '0')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await openList(page, listName)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -60,7 +60,7 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
}
|
}
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
media720: width <= 720,
|
media720: width <= 720,
|
||||||
contentWidth: width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : width === 721 ? 630 : width - 58,
|
contentWidth: width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : width === 721 ? 630 : width - 56,
|
||||||
environmentHeight: width >= 721 ? 55 : width === 375 ? 79 : 81,
|
environmentHeight: width >= 721 ? 55 : width === 375 ? 79 : 81,
|
||||||
})
|
})
|
||||||
await expect(page.locator('.today-heading')).toHaveCount(1)
|
await expect(page.locator('.today-heading')).toHaveCount(1)
|
||||||
@@ -270,9 +270,9 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
expect(metrics.directions.weather).toBe('column')
|
expect(metrics.directions.weather).toBe('column')
|
||||||
expect(metrics.directions.gold).toBe('column')
|
expect(metrics.directions.gold).toBe('column')
|
||||||
if (width <= 720) {
|
if (width <= 720) {
|
||||||
expect(metrics.content.width).toBeCloseTo(width - 58, 0)
|
expect(metrics.content.width).toBeCloseTo(width - 56, 0)
|
||||||
expect(metrics.content.left).toBeCloseTo(29, 0)
|
expect(metrics.content.left).toBeCloseTo(28, 0)
|
||||||
expect(metrics.styles.mainPaddingLeft).toBe('29px')
|
expect(metrics.styles.mainPaddingLeft).toBe('28px')
|
||||||
expect(metrics.styles.titleFontSize).toBe('24px')
|
expect(metrics.styles.titleFontSize).toBe('24px')
|
||||||
expect(metrics.environment.height).toBeCloseTo(width === 375 ? 79 : 81, 0)
|
expect(metrics.environment.height).toBeCloseTo(width === 375 ? 79 : 81, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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 page.getByRole('button', { name: '关闭详情' }).click()
|
||||||
|
|
||||||
await openSidebarView(page, '回收站')
|
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 restoreRow = await taskRow(page, restoreTitle)
|
||||||
const purgeRow = await taskRow(page, purgeTitle)
|
const purgeRow = await taskRow(page, purgeTitle)
|
||||||
for (const deletedRow of [restoreRow, purgeRow]) {
|
for (const deletedRow of [restoreRow, purgeRow]) {
|
||||||
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.getByRole('button', { name: '打开任务操作' })).toBeVisible()
|
||||||
await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('role')
|
await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('role')
|
||||||
await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('tabindex')
|
await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('tabindex')
|
||||||
expect(await deletedRow.locator('.task-detail-trigger, .task-check').count()).toBe(0)
|
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 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}”?` })
|
const purgeDialog = page.getByRole('dialog', { name: `永久删除“${purgeTitle}”?` })
|
||||||
await expect(purgeDialog).toBeVisible()
|
await expect(purgeDialog).toBeVisible()
|
||||||
// The UI can abort the completed 204 request while the confirmation overlay closes.
|
// The UI can abort the completed 204 request while the confirmation overlay closes.
|
||||||
allowExpectedError(page, `requestfailed: DELETE ${baseURL}/api/v1/trash/`)
|
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 expect(purgeRow).toHaveCount(0)
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(page.locator('.task-row').filter({ hasText: restoreTitle })).toHaveCount(0)
|
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) => {
|
test('Settings removes intro/empty danger and places mode-specific restore risk copy correctly', async ({ page }, testInfo) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await bottomTab(page, '设置').click()
|
await openSidebarView(page, '设置')
|
||||||
expect(await page.locator('.view-intro').count()).toBe(0)
|
expect(await page.locator('.view-intro').count()).toBe(0)
|
||||||
await expect(page.locator('.settings-group')).toHaveCount(4)
|
await expect(page.locator('.settings-group')).toHaveCount(4)
|
||||||
expect(await page.locator('.settings-danger').count()).toBe(0)
|
expect(await page.locator('.settings-danger').count()).toBe(0)
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ test('Upcoming shares the task-list hierarchy while keeping its date scope and n
|
|||||||
expect(geometry.section.left).toBeCloseTo(geometry.list.left, 0)
|
expect(geometry.section.left).toBeCloseTo(geometry.list.left, 0)
|
||||||
expect(geometry.header.width).toBeCloseTo(geometry.list.width, 0)
|
expect(geometry.header.width).toBeCloseTo(geometry.list.width, 0)
|
||||||
if (geometry.viewportWidth <= 720) {
|
if (geometry.viewportWidth <= 720) {
|
||||||
expect(geometry.header.left).toBeCloseTo(29, 0)
|
expect(geometry.header.left).toBeCloseTo(28, 0)
|
||||||
expect(geometry.list.right).toBeCloseTo(geometry.viewportWidth - 29, 0)
|
expect(geometry.list.right).toBeCloseTo(geometry.viewportWidth - 28, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fab = page.getByRole('button', { name: '添加任务' })
|
const fab = page.getByRole('button', { name: '添加任务' })
|
||||||
|
|||||||
@@ -14,27 +14,34 @@ async function createTask(request: APIRequestContext, baseURL: string, title: st
|
|||||||
expect(response.ok(), await response.text()).toBeTruthy()
|
expect(response.ok(), await response.text()).toBeTruthy()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openInbox(page: Page) {
|
async function createList(request: APIRequestContext, baseURL: string, name: string) {
|
||||||
|
const response = await request.post('/api/v1/lists', {
|
||||||
|
data: { name },
|
||||||
|
headers: { 'x-csrf-token': await csrf(request), origin: baseURL },
|
||||||
|
})
|
||||||
|
expect(response.ok(), await response.text()).toBeTruthy()
|
||||||
|
return response.json() as Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openList(page: Page, name: string) {
|
||||||
if ((await page.viewportSize())!.width <= 930) {
|
if ((await page.viewportSize())!.width <= 930) {
|
||||||
await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click()
|
await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click()
|
||||||
}
|
}
|
||||||
await page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }).click()
|
await page.locator('.sidebar').getByRole('button', { name, exact: true }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
test('inbox and task composer match approved visuals', async ({ page, request, baseURL }, testInfo) => {
|
test('inbox and task composer match approved visuals', async ({ page, request, baseURL }, testInfo) => {
|
||||||
test.skip(testInfo.project.name !== 'mobile-390', 'approved visual baselines are maintained for mobile-390 only')
|
test.skip(testInfo.project.name !== 'mobile-390', 'approved visual baselines are maintained for mobile-390 only')
|
||||||
await page.clock.install({ time: new Date('2026-09-19T05:00:00.000Z') })
|
await page.clock.install({ time: new Date('2026-09-19T05:00:00.000Z') })
|
||||||
|
|
||||||
const bootstrap = await request.get('/api/v1/bootstrap')
|
const listName = '视觉回归清单'
|
||||||
expect(bootstrap.ok()).toBeTruthy()
|
const list = await createList(request, baseURL!, listName)
|
||||||
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
|
||||||
expect(inbox).toBeTruthy()
|
|
||||||
|
|
||||||
const seededTitles = ['整理本周工作记录', '检查 Dodo 备份', '周末买水果和牛奶'] as const
|
const seededTitles = ['整理本周工作记录', '检查 Dodo 备份', '周末买水果和牛奶'] as const
|
||||||
for (const title of seededTitles) await createTask(request, baseURL!, title, inbox.id)
|
for (const title of seededTitles) await createTask(request, baseURL!, title, list.id)
|
||||||
|
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await openInbox(page)
|
await openList(page, listName)
|
||||||
const seededRows = page.locator('.task-row').filter({ hasText: /整理本周工作记录|检查 Dodo 备份|周末买水果和牛奶/ })
|
const seededRows = page.locator('.task-row').filter({ hasText: /整理本周工作记录|检查 Dodo 备份|周末买水果和牛奶/ })
|
||||||
await expect(seededRows).toHaveCount(3)
|
await expect(seededRows).toHaveCount(3)
|
||||||
await expect(page).toHaveScreenshot('inbox.png', { fullPage: true })
|
await expect(page).toHaveScreenshot('inbox.png', { fullPage: true })
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 43 KiB |
+288
-141
@@ -1,21 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import {
|
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,
|
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote,
|
||||||
Settings, Trash2, X, Repeat2, StickyNote,
|
Settings, Trash2, X, Repeat2, StickyNote, TimerReset,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, 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 { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||||
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 { deriveMemoShellState } from './lib/app-shell-state'
|
||||||
import { clampDesktopPaneWidth, getDesktopPaneMax, readDesktopPaneWidth, writeDesktopPaneWidth, type DesktopPane } from './lib/desktop-shell-resize'
|
import { clampDesktopPaneWidth, getDesktopPaneMax, readDesktopPaneWidth, writeDesktopPaneWidth, type DesktopPane } from './lib/desktop-shell-resize'
|
||||||
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
||||||
import MvpPanel from './MvpPanel.vue'
|
import MvpPanel from './MvpPanel.vue'
|
||||||
import CountdownPanel from './CountdownPanel.vue'
|
import CountdownPanel from './CountdownPanel.vue'
|
||||||
import MemoPanel from './MemoPanel.vue'
|
import MemoPanel from './MemoPanel.vue'
|
||||||
|
import CalendarPanel from './CalendarPanel.vue'
|
||||||
|
import PomodoroPanel from './PomodoroPanel.vue'
|
||||||
import FloatingAddButton from './components/FloatingAddButton.vue'
|
import FloatingAddButton from './components/FloatingAddButton.vue'
|
||||||
import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
||||||
import CalendarPicker from './components/CalendarPicker.vue'
|
import CalendarPicker from './components/CalendarPicker.vue'
|
||||||
@@ -26,13 +28,14 @@ import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
|||||||
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
||||||
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
|
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
|
||||||
import { loadTaskOpenTotal } from './lib/task-open-total'
|
import { loadTaskOpenTotal } from './lib/task-open-total'
|
||||||
|
import { failureHint } from './failureHint'
|
||||||
|
|
||||||
type FolderItem = { id: string; name: string }
|
type FolderItem = { id: string; name: string }
|
||||||
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
||||||
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
|
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
|
||||||
type RepeatOption = TaskRepeatOption
|
type RepeatOption = TaskRepeatOption
|
||||||
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
|
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null; after_completion_unit: AfterCompletionUnit | null }
|
||||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
|
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'focus' | 'countdowns' | 'memos' | 'calendar' | 'settings'
|
||||||
|
|
||||||
const initialized = ref<boolean | null>(null)
|
const initialized = ref<boolean | null>(null)
|
||||||
const authReady = ref(false)
|
const authReady = ref(false)
|
||||||
@@ -42,6 +45,7 @@ const password = ref('')
|
|||||||
const folders = ref<FolderItem[]>([])
|
const folders = ref<FolderItem[]>([])
|
||||||
const lists = ref<TaskList[]>([])
|
const lists = ref<TaskList[]>([])
|
||||||
const archivedLists = ref<TaskList[]>([])
|
const archivedLists = ref<TaskList[]>([])
|
||||||
|
const archivedListsLoaded = ref(false)
|
||||||
const archivedListsExpanded = ref(false)
|
const archivedListsExpanded = ref(false)
|
||||||
const archivedListAction = ref<TaskList | null>(null)
|
const archivedListAction = ref<TaskList | null>(null)
|
||||||
const archivedListsToggle = ref<HTMLButtonElement | null>(null)
|
const archivedListsToggle = ref<HTMLButtonElement | null>(null)
|
||||||
@@ -55,6 +59,11 @@ let purgeListTrigger: HTMLElement | null = null
|
|||||||
const tasks = ref<Task[]>([])
|
const tasks = ref<Task[]>([])
|
||||||
const overdueTasks = ref<Task[]>([])
|
const overdueTasks = ref<Task[]>([])
|
||||||
const trash = ref<Task[]>([])
|
const trash = ref<Task[]>([])
|
||||||
|
const taskListElement = ref<HTMLElement | null>(null)
|
||||||
|
const 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 NAVIGATION_STORAGE_KEY = 'dodo.navigation'
|
||||||
const restoredNavigation = readStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY)
|
const restoredNavigation = readStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY)
|
||||||
const activeList = ref(restoredNavigation.listId)
|
const activeList = ref(restoredNavigation.listId)
|
||||||
@@ -63,7 +72,11 @@ const selectedTask = ref<Task | null>(null)
|
|||||||
const taskSelectionGeneration = ref(0)
|
const taskSelectionGeneration = ref(0)
|
||||||
const sidebarCreateOpen = ref(false)
|
const sidebarCreateOpen = ref(false)
|
||||||
const sidebarAction = ref<{ kind: 'folders' | 'lists'; item: FolderItem | TaskList } | null>(null)
|
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 sidebarActionFolderListCount = computed(() => {
|
||||||
const action = sidebarAction.value
|
const action = sidebarAction.value
|
||||||
if (!action || action.kind !== 'folders') return 0
|
if (!action || action.kind !== 'folders') return 0
|
||||||
@@ -88,9 +101,9 @@ const desktopViewportWidth = ref(window.innerWidth)
|
|||||||
const sidebarWidth = ref(readDesktopPaneWidth(window.localStorage, SIDEBAR_WIDTH_STORAGE_KEY, 236))
|
const sidebarWidth = ref(readDesktopPaneWidth(window.localStorage, SIDEBAR_WIDTH_STORAGE_KEY, 236))
|
||||||
const detailWidth = ref(readDesktopPaneWidth(window.localStorage, DETAIL_WIDTH_STORAGE_KEY, 350))
|
const detailWidth = ref(readDesktopPaneWidth(window.localStorage, DETAIL_WIDTH_STORAGE_KEY, 350))
|
||||||
const resizingPane = ref<DesktopPane | null>(null)
|
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 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` }))
|
const shellStyle = computed(() => ({ '--sidebar-width': `${sidebarWidth.value}px`, '--detail-width': `${detailWidth.value}px` }))
|
||||||
let paneResizePointerId: number | null = null
|
let paneResizePointerId: number | null = null
|
||||||
let paneResizeCaptureTarget: HTMLElement | null = null
|
let paneResizeCaptureTarget: HTMLElement | null = null
|
||||||
@@ -100,6 +113,7 @@ const markdownPreview = ref(false)
|
|||||||
const taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
|
const taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
|
||||||
const SHOW_COMPLETED_STORAGE_KEY = 'dodo.show-completed'
|
const SHOW_COMPLETED_STORAGE_KEY = 'dodo.show-completed'
|
||||||
const showCompleted = ref(readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true))
|
const showCompleted = ref(readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true))
|
||||||
|
const completedFilterReveal = ref(false)
|
||||||
const TODAY_SECTION_COLLAPSE_KEY = 'dodo.today-section-collapse.v1'
|
const TODAY_SECTION_COLLAPSE_KEY = 'dodo.today-section-collapse.v1'
|
||||||
const todaySectionCollapse = ref<TodaySectionCollapse>(readTodaySectionCollapse(window.localStorage, TODAY_SECTION_COLLAPSE_KEY))
|
const todaySectionCollapse = ref<TodaySectionCollapse>(readTodaySectionCollapse(window.localStorage, TODAY_SECTION_COLLAPSE_KEY))
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
@@ -157,9 +171,11 @@ const composePriority = ref(0)
|
|||||||
const composeDescription = ref('')
|
const composeDescription = ref('')
|
||||||
const composeRepeat = ref<RepeatOption>('none')
|
const composeRepeat = ref<RepeatOption>('none')
|
||||||
const composeAfterCompletionDays = ref('1')
|
const composeAfterCompletionDays = ref('1')
|
||||||
|
const composeAfterCompletionUnit = ref<AfterCompletionUnit>('days')
|
||||||
const composeRepeatError = ref('')
|
const composeRepeatError = ref('')
|
||||||
const selectedTaskRepeat = ref<RepeatOption>('none')
|
const selectedTaskRepeat = ref<RepeatOption>('none')
|
||||||
const selectedAfterCompletionDays = ref('1')
|
const selectedAfterCompletionDays = ref('1')
|
||||||
|
const selectedAfterCompletionUnit = ref<AfterCompletionUnit>('days')
|
||||||
const selectedRepeatError = ref('')
|
const selectedRepeatError = ref('')
|
||||||
const selectedTaskRecurrence = ref<Recurrence | null>(null)
|
const selectedTaskRecurrence = ref<Recurrence | null>(null)
|
||||||
const recurrenceLoading = ref(false)
|
const recurrenceLoading = ref(false)
|
||||||
@@ -182,6 +198,7 @@ const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
|
|||||||
const memoTrash = ref(false)
|
const memoTrash = ref(false)
|
||||||
const memoDetailOpen = ref(false)
|
const memoDetailOpen = ref(false)
|
||||||
const habitDetailOpen = ref(false)
|
const habitDetailOpen = ref(false)
|
||||||
|
const calendarDetailOpen = ref(false)
|
||||||
const compactLayout = ref(desktopViewportWidth.value <= 930)
|
const compactLayout = ref(desktopViewportWidth.value <= 930)
|
||||||
const memoShellState = computed(() => deriveMemoShellState({ view: activeView.value, detailOpen: memoDetailOpen.value, compact: compactLayout.value, trash: memoTrash.value }))
|
const memoShellState = computed(() => deriveMemoShellState({ view: activeView.value, detailOpen: memoDetailOpen.value, compact: compactLayout.value, trash: memoTrash.value }))
|
||||||
const memoBackgroundInert = computed(() => memoShellState.value.backgroundInert)
|
const memoBackgroundInert = computed(() => memoShellState.value.backgroundInert)
|
||||||
@@ -204,6 +221,7 @@ function openTaskCompose() {
|
|||||||
composeDescription.value = ''
|
composeDescription.value = ''
|
||||||
composeRepeat.value = 'none'
|
composeRepeat.value = 'none'
|
||||||
composeAfterCompletionDays.value = '1'
|
composeAfterCompletionDays.value = '1'
|
||||||
|
composeAfterCompletionUnit.value = 'days'
|
||||||
composeRepeatError.value = ''
|
composeRepeatError.value = ''
|
||||||
composeRepeatConfig.value = defaultRepeatConfig()
|
composeRepeatConfig.value = defaultRepeatConfig()
|
||||||
composeCalendarOpen.value = false
|
composeCalendarOpen.value = false
|
||||||
@@ -242,13 +260,13 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
|
|||||||
else if (activeView.value === 'memos') void memoPanel.value?.createMemo()
|
else if (activeView.value === 'memos') void memoPanel.value?.createMemo()
|
||||||
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
|
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
|
||||||
}
|
}
|
||||||
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, recurrence: Recurrence | null) {
|
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, afterCompletionUnit: AfterCompletionUnit, recurrence: Recurrence | null) {
|
||||||
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
|
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
|
||||||
if (value === 'none') {
|
if (value === 'none') {
|
||||||
if (recurrence) await api(`/recurrences/${recurrence.id}`, { method: 'DELETE' })
|
if (recurrence) await api(`/recurrences/${recurrence.id}`, { method: 'DELETE' })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })
|
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config })
|
||||||
if (recurrence) {
|
if (recurrence) {
|
||||||
return await api(`/recurrences/${recurrence.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence
|
return await api(`/recurrences/${recurrence.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence
|
||||||
}
|
}
|
||||||
@@ -262,6 +280,7 @@ async function loadTaskRecurrence(task: Task) {
|
|||||||
selectedTaskRecurrence.value = null
|
selectedTaskRecurrence.value = null
|
||||||
selectedTaskRepeat.value = 'none'
|
selectedTaskRepeat.value = 'none'
|
||||||
selectedAfterCompletionDays.value = '1'
|
selectedAfterCompletionDays.value = '1'
|
||||||
|
selectedAfterCompletionUnit.value = 'days'
|
||||||
selectedRepeatError.value = ''
|
selectedRepeatError.value = ''
|
||||||
try {
|
try {
|
||||||
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
|
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
|
||||||
@@ -270,6 +289,7 @@ async function loadTaskRecurrence(task: Task) {
|
|||||||
const parsed = parseTaskRecurrence(recurrence)
|
const parsed = parseTaskRecurrence(recurrence)
|
||||||
selectedTaskRepeat.value = parsed.option
|
selectedTaskRepeat.value = parsed.option
|
||||||
selectedAfterCompletionDays.value = String(parsed.afterCompletionDays)
|
selectedAfterCompletionDays.value = String(parsed.afterCompletionDays)
|
||||||
|
selectedAfterCompletionUnit.value = parsed.afterCompletionUnit
|
||||||
selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
|
selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (selectionIsCurrent()) fail(reason)
|
if (selectionIsCurrent()) fail(reason)
|
||||||
@@ -292,7 +312,7 @@ async function submitTaskCompose() {
|
|||||||
const targetListId = composeListId.value
|
const targetListId = composeListId.value
|
||||||
creatingTask.value = true
|
creatingTask.value = true
|
||||||
try {
|
try {
|
||||||
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, repeatConfig: composeRepeatConfig.value })
|
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, afterCompletionUnit: composeAfterCompletionUnit.value, repeatConfig: composeRepeatConfig.value })
|
||||||
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
|
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
|
||||||
if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间')
|
if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间')
|
||||||
await taskMutationReconciler.run(
|
await taskMutationReconciler.run(
|
||||||
@@ -350,11 +370,11 @@ function movePaneResize(event: PointerEvent) {
|
|||||||
if (!resizingPane.value || event.pointerId !== paneResizePointerId) return
|
if (!resizingPane.value || event.pointerId !== paneResizePointerId) return
|
||||||
if (event.buttons === 0) { stopPaneResize(); return }
|
if (event.buttons === 0) { stopPaneResize(); return }
|
||||||
const nextWidth = resizingPane.value === 'sidebar' ? event.clientX : window.innerWidth - event.clientX
|
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)
|
else detailWidth.value = clampDesktopPaneWidth('detail', nextWidth, window.innerWidth, sidebarCollapsed.value ? 0 : sidebarWidth.value)
|
||||||
}
|
}
|
||||||
function startPaneResize(pane: DesktopPane, event: PointerEvent) {
|
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()
|
event.preventDefault()
|
||||||
paneResizeCaptureTarget = event.currentTarget as HTMLElement
|
paneResizeCaptureTarget = event.currentTarget as HTMLElement
|
||||||
paneResizeCaptureTarget.setPointerCapture?.(event.pointerId)
|
paneResizeCaptureTarget.setPointerCapture?.(event.pointerId)
|
||||||
@@ -365,7 +385,7 @@ function resizePaneWithKeyboard(pane: DesktopPane, event: KeyboardEvent) {
|
|||||||
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return
|
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const direction = event.key === 'ArrowRight' ? 1 : -1
|
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)
|
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)
|
writeDesktopPaneWidth(window.localStorage, pane === 'sidebar' ? SIDEBAR_WIDTH_STORAGE_KEY : DETAIL_WIDTH_STORAGE_KEY, pane === 'sidebar' ? sidebarWidth.value : detailWidth.value)
|
||||||
}
|
}
|
||||||
@@ -387,8 +407,10 @@ const activeName = computed(() => {
|
|||||||
if (activeView.value === 'today') return '今天'
|
if (activeView.value === 'today') return '今天'
|
||||||
if (activeView.value === 'upcoming') return '最近 7 天'
|
if (activeView.value === 'upcoming') return '最近 7 天'
|
||||||
if (activeView.value === 'habits') return '习惯'
|
if (activeView.value === 'habits') return '习惯'
|
||||||
|
if (activeView.value === 'focus') return '专注'
|
||||||
if (activeView.value === 'countdowns') return '倒数日'
|
if (activeView.value === 'countdowns') return '倒数日'
|
||||||
if (activeView.value === 'memos') return '备忘录'
|
if (activeView.value === 'memos') return '备忘录'
|
||||||
|
if (activeView.value === 'calendar') return '日历订阅'
|
||||||
if (activeView.value === 'settings') return '设置与数据'
|
if (activeView.value === 'settings') return '设置与数据'
|
||||||
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
||||||
})
|
})
|
||||||
@@ -409,7 +431,7 @@ const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value :
|
|||||||
const visibleTasks = computed(() => {
|
const visibleTasks = computed(() => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
let result = sourceTasks.value
|
let result = sourceTasks.value
|
||||||
if (['habits','settings','countdowns','memos'].includes(activeView.value)) return []
|
if (['habits','settings','focus','countdowns','memos','calendar'].includes(activeView.value)) return []
|
||||||
if (activeView.value === 'today') result = result.filter((task) => {
|
if (activeView.value === 'today') result = result.filter((task) => {
|
||||||
const dueToday = task.due_at && new Date(task.due_at).toDateString() === now.toDateString()
|
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()
|
const completedToday = task.completed_at && new Date(task.completed_at).toDateString() === now.toDateString()
|
||||||
@@ -420,6 +442,7 @@ const visibleTasks = computed(() => {
|
|||||||
})
|
})
|
||||||
const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? [])
|
const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? [])
|
||||||
const taskTree = computed(() => groupTaskTree(visibleTasks.value))
|
const taskTree = computed(() => groupTaskTree(visibleTasks.value))
|
||||||
|
const trashGroups = computed(() => groupTrashTaskTree(visibleTasks.value))
|
||||||
const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value))
|
const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value))
|
||||||
watch(composeDueAt, (value) => {
|
watch(composeDueAt, (value) => {
|
||||||
if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' }
|
if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' }
|
||||||
@@ -429,6 +452,10 @@ watch(taskReorderAvailable, () => {
|
|||||||
})
|
})
|
||||||
watch(showCompleted, (value) => {
|
watch(showCompleted, (value) => {
|
||||||
writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)
|
writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)
|
||||||
|
if (value) {
|
||||||
|
completedFilterReveal.value = true
|
||||||
|
window.setTimeout(() => { completedFilterReveal.value = false }, 500)
|
||||||
|
}
|
||||||
if (isTaskView(activeView.value)) { page.value = 1; loadAll() }
|
if (isTaskView(activeView.value)) { page.value = 1; loadAll() }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -445,7 +472,7 @@ async function api(path: string, options: RequestInit = {}) {
|
|||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = '请求失败'
|
let message = '请求失败,请检查网络后重试'
|
||||||
try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ }
|
try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ }
|
||||||
const requestError = new Error(message) as Error & { status: number }
|
const requestError = new Error(message) as Error & { status: number }
|
||||||
requestError.status = response.status
|
requestError.status = response.status
|
||||||
@@ -458,7 +485,7 @@ function toast(message: string) {
|
|||||||
notice.value = message
|
notice.value = message
|
||||||
window.setTimeout(() => { if (notice.value === message) notice.value = '' }, 2400)
|
window.setTimeout(() => { if (notice.value === message) notice.value = '' }, 2400)
|
||||||
}
|
}
|
||||||
function fail(reason: unknown) { error.value = reason instanceof Error ? reason.message : '请求失败' }
|
function fail(reason: unknown) { error.value = failureHint('请求失败', reason) }
|
||||||
|
|
||||||
async function syncBrowserTimezone(currentTimezone?: string) {
|
async function syncBrowserTimezone(currentTimezone?: string) {
|
||||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
@@ -717,7 +744,7 @@ async function loadTrashPage() {
|
|||||||
async function loadTrash() {
|
async function loadTrash() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
return await runLatestRequest('trash', loadTrashPage, {
|
const committed = await runLatestRequest('trash', loadTrashPage, {
|
||||||
success: (data) => {
|
success: (data) => {
|
||||||
trash.value = data.items ?? []
|
trash.value = data.items ?? []
|
||||||
totalTasks.value = data.total ?? trash.value.length
|
totalTasks.value = data.total ?? trash.value.length
|
||||||
@@ -725,6 +752,11 @@ async function loadTrash() {
|
|||||||
error: fail,
|
error: fail,
|
||||||
finally: () => { loading.value = false },
|
finally: () => { loading.value = false },
|
||||||
})
|
})
|
||||||
|
if (committed && page.value > totalPages.value) {
|
||||||
|
page.value = totalPages.value
|
||||||
|
return await loadTrash()
|
||||||
|
}
|
||||||
|
return committed
|
||||||
}
|
}
|
||||||
async function switchView(view: View, listId?: string) {
|
async function switchView(view: View, listId?: string) {
|
||||||
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return
|
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return
|
||||||
@@ -748,7 +780,7 @@ async function switchView(view: View, listId?: string) {
|
|||||||
if (listId) activeList.value = listId
|
if (listId) activeList.value = listId
|
||||||
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
|
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
|
||||||
page.value = 1
|
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 !== 'memos') memoDetailOpen.value = false
|
||||||
if (view === 'trash') await loadTrash()
|
if (view === 'trash') await loadTrash()
|
||||||
else if (view === 'today') await loadTodayView()
|
else if (view === 'today') await loadTodayView()
|
||||||
@@ -1091,6 +1123,7 @@ async function saveSelectedTaskChanges() {
|
|||||||
const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'
|
const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'
|
||||||
const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig
|
const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig
|
||||||
const afterCompletionDays = selectedAfterCompletionDays.value
|
const afterCompletionDays = selectedAfterCompletionDays.value
|
||||||
|
const afterCompletionUnit = selectedAfterCompletionUnit.value
|
||||||
const recurrence = selectedTaskRecurrence.value
|
const recurrence = selectedTaskRecurrence.value
|
||||||
selectedRepeatError.value = ''
|
selectedRepeatError.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -1100,14 +1133,14 @@ async function saveSelectedTaskChanges() {
|
|||||||
selectedTaskRecurrence.value = null
|
selectedTaskRecurrence.value = null
|
||||||
selectedTaskRepeat.value = 'none'
|
selectedTaskRepeat.value = 'none'
|
||||||
} else {
|
} else {
|
||||||
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)
|
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence)
|
||||||
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
|
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
|
||||||
selectedTaskRecurrence.value = updatedRecurrence
|
selectedTaskRecurrence.value = updatedRecurrence
|
||||||
}
|
}
|
||||||
toast('已保存')
|
toast('已保存')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
|
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
|
||||||
selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败'
|
selectedRepeatError.value = failureHint('保存失败', reason)
|
||||||
fail(reason)
|
fail(reason)
|
||||||
} finally {
|
} finally {
|
||||||
savingSelectedTask.value = false
|
savingSelectedTask.value = false
|
||||||
@@ -1148,8 +1181,57 @@ async function mutateTrashTask(task: Task, mutation: () => Promise<unknown>, suc
|
|||||||
async function restoreTask(task: Task) {
|
async function restoreTask(task: Task) {
|
||||||
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复', true)
|
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) {
|
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)
|
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除', false)
|
||||||
}
|
}
|
||||||
async function addSubtask() {
|
async function addSubtask() {
|
||||||
@@ -1268,35 +1350,18 @@ async function createList(folderId: string | null = null) {
|
|||||||
const name = (await askText('新建清单', '清单名称', '', '创建'))?.trim(); if (!name) return
|
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) }
|
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) {
|
async function renameFolder(item: FolderItem) {
|
||||||
const name = (await askText('重命名', kind === 'lists' ? '清单名称' : '文件夹名称', item.name, '保存'))?.trim(); if (!name || name === item.name) return
|
const name = (await askText('重命名', '文件夹名称', 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) }
|
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) {
|
async function deleteFolder(item: FolderItem) {
|
||||||
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
|
|
||||||
}
|
|
||||||
const answer = await askText(`删除文件夹「${item.name}」?`, '', '', '删除')
|
const answer = await askText(`删除文件夹「${item.name}」?`, '', '', '删除')
|
||||||
if (answer === null) return
|
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() {
|
async function loadArchivedLists() {
|
||||||
try { archivedLists.value = await api('/lists?archived=true') } catch { archivedLists.value = [] }
|
try { archivedLists.value = await api('/lists?archived=true') } catch { archivedLists.value = [] }
|
||||||
|
archivedListsLoaded.value = true
|
||||||
}
|
}
|
||||||
async function restoreList(item: TaskList) {
|
async function restoreList(item: TaskList) {
|
||||||
archivedListAction.value = null
|
archivedListAction.value = null
|
||||||
@@ -1381,7 +1446,7 @@ async function confirmPurgeList() {
|
|||||||
focusPurgeListTrigger()
|
focusPurgeListTrigger()
|
||||||
toast('清单已永久删除')
|
toast('清单已永久删除')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
purgeListError.value = reason instanceof Error ? reason.message : '永久删除失败'
|
purgeListError.value = failureHint('永久删除失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
purgeListSubmitting.value = false
|
purgeListSubmitting.value = false
|
||||||
}
|
}
|
||||||
@@ -1389,6 +1454,15 @@ async function confirmPurgeList() {
|
|||||||
function toggleSidebarCreate() { sidebarCreateOpen.value = !sidebarCreateOpen.value; sidebarAction.value = null }
|
function toggleSidebarCreate() { sidebarCreateOpen.value = !sidebarCreateOpen.value; sidebarAction.value = null }
|
||||||
function openSidebarAction(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
function openSidebarAction(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
||||||
closeArchivedListAction(false)
|
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 }
|
sidebarAction.value = sidebarAction.value?.item.id === item.id ? null : { kind, item }
|
||||||
sidebarCreateOpen.value = false
|
sidebarCreateOpen.value = false
|
||||||
}
|
}
|
||||||
@@ -1396,7 +1470,76 @@ function runSidebarCreate(kind: 'folder' | 'list') {
|
|||||||
sidebarCreateOpen.value = false
|
sidebarCreateOpen.value = false
|
||||||
kind === 'folder' ? void createFolder() : void createList(null)
|
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 = failureHint('归档失败', reason)
|
||||||
|
} 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 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) {
|
function beginListDrag(list: TaskList, pointer: ListDragPointer) {
|
||||||
@@ -1510,44 +1653,35 @@ function cancelListDrag() {
|
|||||||
listDropFolderId.value = undefined
|
listDropFolderId.value = undefined
|
||||||
listReorderTarget.value = ''
|
listReorderTarget.value = ''
|
||||||
}
|
}
|
||||||
function openListMoveMenu() { listMoveMenuOpen.value = true }
|
|
||||||
function closeListMoveMenu() { listMoveMenuOpen.value = false }
|
async function scrollToTaskPageStart() {
|
||||||
function moveListFromMenu(folderId: string | null) {
|
await nextTick()
|
||||||
const item = sidebarAction.value?.kind === 'lists' ? sidebarAction.value.item as TaskList : null
|
taskListElement.value?.scrollIntoView({ block: 'start' })
|
||||||
if (!item) return
|
|
||||||
listMoveMenuOpen.value = false
|
|
||||||
void persistListMove(item, folderId)
|
|
||||||
closeSidebarAction()
|
|
||||||
}
|
}
|
||||||
function canMoveListWithinScope(item: TaskList, direction: 'up' | 'down') {
|
async function previousPage() {
|
||||||
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() {
|
|
||||||
if (page.value <= 1 || loading.value) return
|
if (page.value <= 1 || loading.value) return
|
||||||
page.value -= 1
|
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
|
if (page.value >= totalPages.value || loading.value) return
|
||||||
page.value += 1
|
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() {
|
function reconcileDesktopPaneWidths() {
|
||||||
if (compactLayout.value) return
|
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)
|
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() }
|
function handlePaneResizeBlur() { stopPaneResize() }
|
||||||
|
|
||||||
@@ -1602,7 +1736,7 @@ onUnmounted(() => {
|
|||||||
<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>
|
<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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" />
|
<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()">
|
<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>
|
<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>
|
||||||
@@ -1611,18 +1745,20 @@ onUnmounted(() => {
|
|||||||
<button :class="{ active: activeView==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
|
<button :class="{ active: activeView==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
|
||||||
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 天</button>
|
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 天</button>
|
||||||
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
|
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
|
||||||
|
<button :class="{ active: activeView==='focus' }" @click="switchView('focus')"><TimerReset />专注</button>
|
||||||
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
|
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
|
||||||
<button :class="{ active: activeView==='memos' }" @click="switchView('memos')"><StickyNote />备忘录</button>
|
<button :class="{ active: activeView==='memos' }" @click="switchView('memos')"><StickyNote />备忘录</button>
|
||||||
|
<button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarDays />日历订阅</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
|
<div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
|
||||||
<div class="folders">
|
<div class="folders">
|
||||||
<div v-for="folder in folders" :key="folder.id" class="folder-block" :data-folder-id="folder.id">
|
<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 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>
|
||||||
<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">
|
<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>
|
<button v-if="archivedListsLoaded" 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">
|
<div id="archived-task-lists" v-show="archivedListsExpanded" class="archived-list-items">
|
||||||
<div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><span class="archived-row-label" :title="list.name">{{list.name}}</span><span class="archived-row-menu"><button class="archived-row-menu-trigger" :aria-label="`${list.name}操作`" aria-haspopup="menu" :aria-expanded="archivedListAction?.id===list.id" @click="toggleArchivedListAction(list,$event.currentTarget)"><Ellipsis/></button></span></div>
|
<div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><span class="archived-row-label" :title="list.name">{{list.name}}</span><span class="archived-row-menu"><button class="archived-row-menu-trigger" :aria-label="`${list.name}操作`" aria-haspopup="menu" :aria-expanded="archivedListAction?.id===list.id" @click="toggleArchivedListAction(list,$event.currentTarget)"><Ellipsis/></button></span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1634,42 +1770,33 @@ onUnmounted(() => {
|
|||||||
</nav>
|
</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">
|
<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="sidebarAction">
|
||||||
<template v-if="!listMoveMenuOpen">
|
<header class="app-sheet__header sidebar-action-header">
|
||||||
<header class="app-sheet__header sidebar-action-header">
|
<div><span class="sidebar-action-kind">文件夹</span><b>{{sidebarAction.item.name}}</b></div>
|
||||||
<div><span class="sidebar-action-kind">{{sidebarAction.kind==='folders'?'文件夹':'清单'}}</span><b>{{sidebarAction.item.name}}</b></div>
|
<button class="icon" aria-label="关闭文件夹操作" @click="closeSidebarAction"><X/></button>
|
||||||
<button class="icon" :aria-label="`关闭${sidebarAction.kind==='folders'?'文件夹':'清单'}操作`" @click="closeSidebarAction"><X/></button>
|
</header>
|
||||||
</header>
|
<div class="app-sheet__body sidebar-action-body">
|
||||||
<div class="app-sheet__body sidebar-action-body">
|
<section class="sidebar-action-group" aria-label="常用操作">
|
||||||
<section class="sidebar-action-group" aria-label="常用操作">
|
<span class="sidebar-action-group-title">常用操作</span>
|
||||||
<span class="sidebar-action-group-title">常用操作</span>
|
<button @click="renameFolder(sidebarAction.item as FolderItem);closeSidebarAction()"><Pencil/><span>重命名</span></button>
|
||||||
<button @click="renameEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><Pencil/><span>重命名</span></button>
|
<button @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/><span>新建清单</span></button>
|
||||||
<button v-if="sidebarAction.kind==='folders'" @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/><span>新建清单</span></button>
|
</section>
|
||||||
</section>
|
<section class="sidebar-action-danger">
|
||||||
<section v-if="sidebarAction.kind==='lists'" class="sidebar-action-group" aria-label="整理清单">
|
<span>删除后,其中 {{sidebarActionFolderListCount}} 个清单会移到“我的清单”</span>
|
||||||
<span class="sidebar-action-group-title">整理清单</span>
|
<button class="danger" @click="deleteFolder(sidebarAction.item as FolderItem);closeSidebarAction()"><Trash2/><span>删除文件夹</span></button>
|
||||||
<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>
|
</section>
|
||||||
<button aria-label="下移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'down')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'down')"><ChevronDown/><span>下移</span></button>
|
</div>
|
||||||
<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>
|
</template>
|
||||||
</section>
|
</AppSheet>
|
||||||
<section class="sidebar-action-danger">
|
<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">
|
||||||
<span>{{sidebarAction.kind==='folders' ? `删除后,其中 ${sidebarActionFolderListCount} 个清单会移到“我的清单”` : '任务会保留,可从“已归档”恢复'}}</span>
|
<template v-if="listEditorTarget">
|
||||||
<button class="danger" @click="deleteEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><component :is="sidebarAction.kind==='folders' ? Trash2 : ArchiveRestore"/><span>{{sidebarAction.kind==='folders'?'删除文件夹':'归档清单'}}</span></button>
|
<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>
|
||||||
</section>
|
<div class="app-sheet__body list-editor-form">
|
||||||
</div>
|
<label>清单名称<input v-model="listEditorName" class="list-editor-name" autocomplete="off" maxlength="80" :aria-invalid="Boolean(listEditorError)" @input="listEditorError=''"/><small>名称最多 80 个字符</small></label>
|
||||||
</template>
|
<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>
|
||||||
<template v-if="listMoveMenuOpen">
|
<p v-if="listEditorError" class="list-editor-error" role="alert">{{listEditorError}}</p>
|
||||||
<header class="app-sheet__header sidebar-action-move-view">
|
<section class="list-editor-danger"><div><b>归档清单</b><small>任务会保留,可从“已归档”恢复</small></div><button type="button" class="danger-button" :disabled="listEditorBusy" @click="archiveListFromEditor"><ArchiveRestore/>归档</button></section>
|
||||||
<button class="sidebar-action-move-back" aria-label="返回清单操作" @click="closeListMoveMenu"><ChevronRight/></button>
|
</div>
|
||||||
<div><span class="sidebar-action-kind">清单位置</span><b class="sidebar-action-move-title">选择目标位置</b></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>
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
</AppSheet>
|
</AppSheet>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -1678,12 +1805,14 @@ onUnmounted(() => {
|
|||||||
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}">
|
<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">
|
<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>
|
<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','focus'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
|
||||||
</header>
|
</header>
|
||||||
<template v-if="['habits','settings'].includes(activeView)">
|
<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" />
|
<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>
|
</template>
|
||||||
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @detail="memoDetailOpen=$event" @notice="toast" />
|
<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" />
|
||||||
|
<PomodoroPanel v-else-if="activeView==='focus'" />
|
||||||
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section v-if="activeView==='today'" class="today-context" aria-label="今日概览">
|
<section v-if="activeView==='today'" class="today-context" aria-label="今日概览">
|
||||||
@@ -1698,43 +1827,59 @@ onUnmounted(() => {
|
|||||||
<CompletedFilterPill v-model="showCompleted" class="list-inline-filter" />
|
<CompletedFilterPill v-model="showCompleted" class="list-inline-filter" />
|
||||||
</section>
|
</section>
|
||||||
<template v-if="activeView==='today'">
|
<template v-if="activeView==='today'">
|
||||||
<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section">
|
<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section" :class="{'is-collapsed': todaySectionCollapse.overdue}">
|
||||||
<button id="today-overdue-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.overdue" aria-controls="today-overdue" @click="toggleTodaySection('overdue')"><span class="today-section-title">逾期</span><span class="today-section-summary">{{overdueTaskTree.length}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.overdue ? '›' : '⌄' }}</span></button>
|
<button id="today-overdue-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.overdue" aria-controls="today-overdue" @click="toggleTodaySection('overdue')"><span class="today-section-title">逾期</span><span class="today-section-summary">{{overdueTaskTree.length}}</span><span class="today-section-chevron" :class="{open: !todaySectionCollapse.overdue}" aria-hidden="true">›</span></button>
|
||||||
<div v-show="!todaySectionCollapse.overdue" id="today-overdue" class="task-list plain-list overdue-list" role="region" aria-labelledby="today-overdue-heading">
|
<div id="today-overdue" class="task-list plain-list overdue-list" role="region" aria-labelledby="today-overdue-heading">
|
||||||
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
|
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
|
||||||
<article :data-task-id="node.task.id" class="task-row overdue-task swipeable" :class="{'just-completed':justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 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 class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></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></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>
|
<article :data-task-id="node.task.id" class="task-row overdue-task swipeable" :class="{'just-completed':justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 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 class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></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></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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<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" :class="{open: !todaySectionCollapse.tasks}" aria-hidden="true">›</span></button>
|
||||||
</template>
|
</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==='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>
|
<section v-if="activeView==='trash' && visibleTasks.length" ref="taskListElement" class="trash-groups" aria-label="回收站任务分组">
|
||||||
<div v-if="activeView==='tasks' && totalPages > 1" class="list-page-meta"><span>第 {{ page }} / {{ totalPages }} 页 · 共 {{ totalTasks }} 项</span></div>
|
<section v-for="group in trashGroups" :key="group.key" class="trash-group" :aria-labelledby="`trash-group-${group.key}`">
|
||||||
<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>
|
<header class="trash-group-heading"><h2 :id="`trash-group-${group.key}`">{{group.label}}</h2><span>{{group.nodes.length}}</span></header>
|
||||||
<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">
|
<div class="task-list plain-list trash-list">
|
||||||
<template v-for="node in taskTree" :key="node.task.id">
|
<article v-for="node in group.nodes" :key="node.task.id" :data-task-id="node.task.id" class="task-row task-row--trash">
|
||||||
<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)">
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
</article>
|
||||||
<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>
|
</div>
|
||||||
</article>
|
</section>
|
||||||
</template>
|
</section>
|
||||||
<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>
|
<section v-else :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list plain-list" :class="{loading, 'is-collapsed': todaySectionCollapse.tasks}" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined" ref="taskListElement">
|
||||||
<div v-else-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span></div>
|
<div class="today-collapse-inner plain-list">
|
||||||
</section>
|
<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,'filter-reveal':completedFilterReveal&&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>
|
||||||
|
</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">
|
<div v-if="activeView==='today'" class="today-habits-section today-section-anchor">
|
||||||
<button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title">习惯</span><span class="today-section-summary">{{todayHabitTotal}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.habits ? '›' : '⌄' }}</span></button>
|
<button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title">习惯</span><span class="today-section-summary">{{todayHabitTotal}}</span><span class="today-section-chevron" :class="{open: !todaySectionCollapse.habits}" aria-hidden="true">›</span></button>
|
||||||
<div v-show="!todaySectionCollapse.habits" id="today-habits" role="region" aria-labelledby="today-habits-heading">
|
<div id="today-habits" :class="{'is-collapsed': todaySectionCollapse.habits}" role="region" aria-labelledby="today-habits-heading">
|
||||||
<MvpPanel ref="habitComposer" view="today-habits" :show-completed="showCompleted" :compact-layout="compactLayout" @detail="handleHabitDetail" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
|
<MvpPanel ref="habitComposer" view="today-habits" :show-completed="showCompleted" :compact-layout="compactLayout" @detail="handleHabitDetail" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</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">
|
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" initial-focus=".detail-head button" :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>
|
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" type="button" :disabled="taskDetailBusy" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
||||||
<fieldset class="detail-form" :disabled="taskDetailBusy">
|
<fieldset class="detail-form" :disabled="taskDetailBusy">
|
||||||
<div class="detail-title"><button class="task-check detail-task-check" type="button" :aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`" :aria-pressed="selectedTask.completed" @click="toggle(selectedTask)"><span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题"/></div>
|
<div class="detail-title"><button class="task-check detail-task-check" type="button" :aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`" :aria-pressed="selectedTask.completed" @click="toggle(selectedTask)"><span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题"/></div>
|
||||||
@@ -1745,7 +1890,7 @@ onUnmounted(() => {
|
|||||||
<div class="task-detail-field"><span class="task-detail-field-label">时间</span><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add task-detail-time-control" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip task-detail-time-control"><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label><span v-else class="task-detail-time-empty" aria-hidden="true">—</span></div>
|
<div class="task-detail-field"><span class="task-detail-field-label">时间</span><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add task-detail-time-control" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip task-detail-time-control"><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label><span v-else class="task-detail-time-empty" aria-hidden="true">—</span></div>
|
||||||
</div>
|
</div>
|
||||||
<label class="task-detail-field"><span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
<label class="task-detail-field"><span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
||||||
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 天重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
|
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="selectedAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months">月</option></select>重复</div><small>每次完成后,将截止日期按所选间隔顺延;月末会自动取目标月最后一天</small></section>
|
||||||
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
|
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
|
||||||
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
|
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
|
||||||
</section>
|
</section>
|
||||||
@@ -1755,7 +1900,7 @@ onUnmounted(() => {
|
|||||||
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式">
|
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式">
|
||||||
<button type="button" aria-label="标题" title="标题" @click="formatTaskNote('heading')"><Heading2/></button><button type="button" aria-label="粗体" title="粗体" @click="formatTaskNote('bold')"><Bold/></button><button type="button" aria-label="斜体" title="斜体" @click="formatTaskNote('italic')"><Italic/></button><button type="button" aria-label="无序列表" title="无序列表" @click="formatTaskNote('bullet')"><List/></button><button type="button" aria-label="有序列表" title="有序列表" @click="formatTaskNote('ordered')"><ListOrdered/></button><button type="button" aria-label="待办" title="待办" @click="formatTaskNote('task')"><ListChecks/></button><button type="button" aria-label="链接" title="链接" @click="formatTaskNote('link')"><Link/></button><button type="button" aria-label="行内代码" title="行内代码" @click="formatTaskNote('code')"><Code/></button><button type="button" aria-label="代码块" title="代码块" class="markdown-codeblock" @click="formatTaskNote('codeblock')">{ }</button><button type="button" aria-label="引用" title="引用" @click="formatTaskNote('quote')"><Quote/></button>
|
<button type="button" aria-label="标题" title="标题" @click="formatTaskNote('heading')"><Heading2/></button><button type="button" aria-label="粗体" title="粗体" @click="formatTaskNote('bold')"><Bold/></button><button type="button" aria-label="斜体" title="斜体" @click="formatTaskNote('italic')"><Italic/></button><button type="button" aria-label="无序列表" title="无序列表" @click="formatTaskNote('bullet')"><List/></button><button type="button" aria-label="有序列表" title="有序列表" @click="formatTaskNote('ordered')"><ListOrdered/></button><button type="button" aria-label="待办" title="待办" @click="formatTaskNote('task')"><ListChecks/></button><button type="button" aria-label="链接" title="链接" @click="formatTaskNote('link')"><Link/></button><button type="button" aria-label="行内代码" title="行内代码" @click="formatTaskNote('code')"><Code/></button><button type="button" aria-label="代码块" title="代码块" class="markdown-codeblock" @click="formatTaskNote('codeblock')">{ }</button><button type="button" aria-label="引用" title="引用" @click="formatTaskNote('quote')"><Quote/></button>
|
||||||
</div>
|
</div>
|
||||||
<textarea ref="taskNoteEditor" v-model="selectedTask.description" rows="9" placeholder="写备注,选中文字后可用上方工具栏添加格式…" @keydown="handleTaskNoteShortcut"/>
|
<textarea ref="taskNoteEditor" v-model="selectedTask.description" rows="9" aria-label="任务备注" placeholder="写备注,选中文字后可用上方工具栏添加格式…" @keydown="handleTaskNoteShortcut"/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="markdown-preview" :class="{'markdown-preview-empty':!selectedTask.description.trim()}" v-html="selectedTask.description.trim() ? renderMarkdown(selectedTask.description) : '<p>暂无备注,切回编辑开始书写。</p>'"/>
|
<div v-else class="markdown-preview" :class="{'markdown-preview-empty':!selectedTask.description.trim()}" v-html="selectedTask.description.trim() ? renderMarkdown(selectedTask.description) : '<p>暂无备注,切回编辑开始书写。</p>'"/>
|
||||||
</section>
|
</section>
|
||||||
@@ -1765,7 +1910,7 @@ onUnmounted(() => {
|
|||||||
<footer class="detail-actions"><button class="danger-text detail-trash" type="button" :disabled="taskDetailBusy" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button><button class="primary detail-save" type="submit" :disabled="taskDetailBusy">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button></footer>
|
<footer class="detail-actions"><button class="danger-text detail-trash" type="button" :disabled="taskDetailBusy" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button><button class="primary detail-save" type="submit" :disabled="taskDetailBusy">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button></footer>
|
||||||
</AppSheet>
|
</AppSheet>
|
||||||
|
|
||||||
<nav class="bottom" :inert="memoBackgroundInert ? true : undefined" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='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" />
|
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
|
||||||
<AppSheet :open="taskComposeOpen" variant="create" panel-class="task-compose-sheet" title-id="task-compose-title" initial-focus=".task-compose-input" :style="taskComposeStyle" @close="closeTaskCompose" @submit.prevent="submitTaskCompose">
|
<AppSheet :open="taskComposeOpen" variant="create" panel-class="task-compose-sheet" title-id="task-compose-title" initial-focus=".task-compose-input" :style="taskComposeStyle" @close="closeTaskCompose" @submit.prevent="submitTaskCompose">
|
||||||
<header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
|
<header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
|
||||||
@@ -1784,16 +1929,18 @@ onUnmounted(() => {
|
|||||||
<label v-else-if="composeDueAt" class="task-compose-time-chip"><span>时间</span><input ref="composeTimePicker" v-model="composeTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="composeHasTime=false"><X/></button></label>
|
<label v-else-if="composeDueAt" class="task-compose-time-chip"><span>时间</span><input ref="composeTimePicker" v-model="composeTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="composeHasTime=false"><X/></button></label>
|
||||||
</div>
|
</div>
|
||||||
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
||||||
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 天重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
|
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="composeAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months">月</option></select>重复</div><small>每次完成后,将截止日期按所选间隔顺延;月末会自动取目标月最后一天</small></section>
|
||||||
<small v-if="composeRepeatError" role="alert" class="field-error">{{composeRepeatError}}</small>
|
<small v-if="composeRepeatError" role="alert" class="field-error">{{composeRepeatError}}</small>
|
||||||
<section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section>
|
<section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section>
|
||||||
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
||||||
</div>
|
</div>
|
||||||
<footer class="app-sheet__footer"><button type="button" class="secondary" :disabled="creatingTask" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="creatingTask || !composeTitle.trim() || !composeListId">{{ creatingTask ? '正在添加…' : '添加任务' }}</button></footer>
|
<footer class="app-sheet__footer"><button type="button" class="secondary" :disabled="creatingTask" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="creatingTask || !composeTitle.trim() || !composeListId">{{ creatingTask ? '正在添加…' : '添加任务' }}</button></footer>
|
||||||
</AppSheet>
|
</AppSheet>
|
||||||
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
<div class="sr-only" role="status" aria-live="polite">{{ notice }}</div>
|
||||||
|
<Transition name="toast"><div v-if="notice" class="toast" aria-hidden="true">{{notice}}</div></Transition>
|
||||||
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
||||||
<Teleport to="body">
|
<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>
|
<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>
|
</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">
|
<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">
|
||||||
|
|||||||
@@ -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:var(--space-4)}.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{')})
|
||||||
|
})
|
||||||
@@ -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('订阅地址无法访问')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUpdated, 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'
|
||||||
|
import { failureHint } from './failureHint'
|
||||||
|
|
||||||
|
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 filtersEl=ref<HTMLElement|null>(null),filtersScrollHint=ref(false)
|
||||||
|
const syncFiltersHint=()=>{const el=filtersEl.value;filtersScrollHint.value=!!el&&el.scrollLeft+el.clientWidth<el.scrollWidth-4}
|
||||||
|
onMounted(syncFiltersHint)
|
||||||
|
onUpdated(syncFiltersHint)
|
||||||
|
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=failureHint('日历载入失败', reason)}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=failureHint('事件载入失败', reason))}
|
||||||
|
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=failureHint('保存失败', reason)}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=failureHint('更新失败', reason)}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=failureHint('刷新失败', reason)}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=failureHint('删除失败', reason)}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" ref="filtersEl" class="calendar-filters" :class="{'has-scroll-hint':filtersScrollHint}" aria-label="筛选日历源" @scroll.passive="syncFiltersHint"><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>
|
||||||
@@ -5,6 +5,7 @@ import { csrfHeader } from './lib/csrf'
|
|||||||
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils'
|
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils'
|
||||||
import AppSheet from './components/AppSheet.vue'
|
import AppSheet from './components/AppSheet.vue'
|
||||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
|
import { failureHint } from './failureHint'
|
||||||
|
|
||||||
type Countdown = {
|
type Countdown = {
|
||||||
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
|
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
|
||||||
@@ -98,7 +99,7 @@ async function safe(detailId:string|null, work:(context:OperationContext)=>Promi
|
|||||||
await work(context)
|
await work(context)
|
||||||
} catch(reason) {
|
} catch(reason) {
|
||||||
if (!currentContext(context)) return
|
if (!currentContext(context)) return
|
||||||
error.value=reason instanceof Error ? reason.message : '请求失败'
|
error.value=failureHint('请求失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
if (activeOperation === context) activeOperation=null
|
if (activeOperation === context) activeOperation=null
|
||||||
if (currentContext(context)) busy.value=false
|
if (currentContext(context)) busy.value=false
|
||||||
@@ -123,7 +124,7 @@ async function load(force = false, manageBusy = true) {
|
|||||||
items.value=data.items
|
items.value=data.items
|
||||||
archived.value=data.archived
|
archived.value=data.archived
|
||||||
} catch(reason) {
|
} catch(reason) {
|
||||||
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败'
|
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : failureHint('请求失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false
|
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ const panel = readFileSync('src/MemoPanel.vue', 'utf8')
|
|||||||
const editor = readFileSync('src/components/MemoEditor.vue', 'utf8')
|
const editor = readFileSync('src/components/MemoEditor.vue', 'utf8')
|
||||||
|
|
||||||
describe('memo shell integration', () => {
|
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">')))
|
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.indexOf("switchView('memos')")).toBeGreaterThan(nav.indexOf("switchView('countdowns')"))
|
||||||
expect(nav.match(/switchView\('memos'\)/g)).toHaveLength(1)
|
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"')))
|
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).toContain("switchView('memos')")
|
||||||
expect(bottom.match(/aria-current=/g)).toHaveLength(4)
|
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', () => {
|
it('routes the shared cat FAB to a local memo draft, hides it in trash, and defers POST until save', () => {
|
||||||
@@ -64,7 +64,7 @@ describe('memo shell integration', () => {
|
|||||||
expect(app).toContain(':show="showFloatingAdd"')
|
expect(app).toContain(':show="showFloatingAdd"')
|
||||||
expect(css).toContain('.shell.memo-detail-open main{padding-right:372px}')
|
expect(css).toContain('.shell.memo-detail-open main{padding-right:372px}')
|
||||||
expect(css).toContain('.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden')
|
expect(css).toContain('.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden')
|
||||||
expect(css).toContain('@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}')
|
expect(css).toContain('@media(max-width:930px){.shell.memo-detail-open main{padding:var(--space-20) var(--space-16) 112px}')
|
||||||
expect(css).toContain('.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%')
|
expect(css).toContain('.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
|
|||||||
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
|
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
|
||||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
import AppSheet from './components/AppSheet.vue'
|
import AppSheet from './components/AppSheet.vue'
|
||||||
|
import { failureHint } from './failureHint'
|
||||||
|
|
||||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||||
const props = defineProps<{ request: RequestFn }>()
|
const props = defineProps<{ request: RequestFn }>()
|
||||||
@@ -55,7 +56,7 @@ async function load(reset = true) {
|
|||||||
committedGeneration = token
|
committedGeneration = token
|
||||||
}
|
}
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (token === generation && criteriaEqual(criteria, currentCriteria())) error.value = reason instanceof Error ? reason.message : '加载失败'
|
if (token === generation && criteriaEqual(criteria, currentCriteria())) error.value = failureHint('加载失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
if (token === generation && criteriaEqual(criteria, currentCriteria())) {
|
if (token === generation && criteriaEqual(criteria, currentCriteria())) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -76,7 +77,7 @@ async function loadMore() {
|
|||||||
items.value = [...items.value, ...data.items]
|
items.value = [...items.value, ...data.items]
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
page.value = nextPage
|
page.value = nextPage
|
||||||
} catch (reason) { if (token === generation) error.value = reason instanceof Error ? reason.message : '加载失败' }
|
} catch (reason) { if (token === generation) error.value = reason instanceof Error ? reason.message : failureHint('加载失败', reason) }
|
||||||
finally { if (token === generation) refreshing.value = false }
|
finally { if (token === generation) refreshing.value = false }
|
||||||
}
|
}
|
||||||
function closeDetail() {
|
function closeDetail() {
|
||||||
@@ -108,7 +109,7 @@ async function selectMemo(id: string, opener?: EventTarget | null) {
|
|||||||
const memo = await props.request(`/memos/${id}`) as MemoRecord
|
const memo = await props.request(`/memos/${id}`) as MemoRecord
|
||||||
if (token === detailGeneration) { selectedToken.value = token; selected.value = memo; emit('detail', true) }
|
if (token === detailGeneration) { selectedToken.value = token; selected.value = memo; emit('detail', true) }
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (token === detailGeneration) error.value = reason instanceof Error ? reason.message : '读取失败'
|
if (token === detailGeneration) error.value = failureHint('读取失败', reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function createMemo() {
|
async function createMemo() {
|
||||||
|
|||||||
+16
-15
@@ -10,6 +10,7 @@ import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldComm
|
|||||||
import AppSheet from './components/AppSheet.vue'
|
import AppSheet from './components/AppSheet.vue'
|
||||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
||||||
|
import { failureHint } from './failureHint'
|
||||||
|
|
||||||
type View = 'habits' | 'today-habits' | 'settings'
|
type View = 'habits' | 'today-habits' | 'settings'
|
||||||
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
|
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
|
||||||
@@ -158,7 +159,7 @@ async function request<T = unknown>(path: string, options: RequestInit = {}): Pr
|
|||||||
}
|
}
|
||||||
async function safe(work: () => Promise<void>) {
|
async function safe(work: () => Promise<void>) {
|
||||||
busy.value = true; error.value = ''
|
busy.value = true; error.value = ''
|
||||||
try { await work() } catch (e) { error.value = e instanceof Error ? e.message : '请求失败' } finally { busy.value = false }
|
try { await work() } catch (e) { error.value = failureHint('请求失败', e) } finally { busy.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function logFor(h: Habit, day: string) { return (h.cells ?? []).find((c) => c.day === day) }
|
function logFor(h: Habit, day: string) { return (h.cells ?? []).find((c) => c.day === day) }
|
||||||
@@ -203,7 +204,7 @@ async function finishHabitReorder(h: Habit, event: PointerEvent) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
habits.value = previous
|
habits.value = previous
|
||||||
writeHabitGridCache(dateKey(new Date()), previous)
|
writeHabitGridCache(dateKey(new Date()), previous)
|
||||||
error.value = e instanceof Error ? e.message : '请求失败'
|
error.value = e instanceof Error ? e.message : failureHint('请求失败', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function cancelHabitReorder() {
|
function cancelHabitReorder() {
|
||||||
@@ -309,7 +310,7 @@ async function mutateHabitValue(h: Habit, next: number | boolean, previous: numb
|
|||||||
if (!ownership.current()) return
|
if (!ownership.current()) return
|
||||||
setHabitCompletionExiting(h.id, false)
|
setHabitCompletionExiting(h.id, false)
|
||||||
setLocalHabitValue(h, rollback, day)
|
setLocalHabitValue(h, rollback, day)
|
||||||
error.value = reason instanceof Error ? reason.message : '请求失败'
|
error.value = reason instanceof Error ? reason.message : '请求失败,请检查网络后重试'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -424,7 +425,7 @@ async function saveHabit() {
|
|||||||
originalHabitForm.value = null
|
originalHabitForm.value = null
|
||||||
await loadHabits()
|
await loadHabits()
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
habitFormError.value = reason instanceof Error ? reason.message : '请求失败'
|
habitFormError.value = reason instanceof Error ? reason.message : failureHint('请求失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
@@ -520,7 +521,7 @@ async function loadHabitHistory(reset = false) {
|
|||||||
habitHistoryHasMore.value = !habit.start_date || nextTo >= habit.start_date
|
habitHistoryHasMore.value = !habit.start_date || nextTo >= habit.start_date
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (requestId !== habitHistoryRequest || selectedHabit.value?.id !== habit.id) return
|
if (requestId !== habitHistoryRequest || selectedHabit.value?.id !== habit.id) return
|
||||||
habitHistoryError.value = reason instanceof Error ? reason.message : '历史记录加载失败'
|
habitHistoryError.value = failureHint('历史记录加载失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === habitHistoryRequest && selectedHabit.value?.id === habit.id) {
|
if (requestId === habitHistoryRequest && selectedHabit.value?.id === habit.id) {
|
||||||
habitHistoryLoading.value = false
|
habitHistoryLoading.value = false
|
||||||
@@ -563,7 +564,7 @@ async function archiveHabit(h: Habit) {
|
|||||||
if (props.view === 'habits' && showArchivedHabits.value) await loadArchivedHabits()
|
if (props.view === 'habits' && showArchivedHabits.value) await loadArchivedHabits()
|
||||||
emit('notice', '习惯已归档')
|
emit('notice', '习惯已归档')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
error.value = reason instanceof Error ? reason.message : '请求失败'
|
error.value = reason instanceof Error ? reason.message : failureHint('请求失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
@@ -585,7 +586,7 @@ async function restoreHabit(h: Habit) {
|
|||||||
emit('notice', result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试')
|
emit('notice', result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试')
|
||||||
void nextTick(() => habitArchiveToggle.value?.focus())
|
void nextTick(() => habitArchiveToggle.value?.focus())
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
error.value = reason instanceof Error ? reason.message : '恢复失败'
|
error.value = failureHint('恢复失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
@@ -603,7 +604,7 @@ async function deleteHabit(h: Habit) {
|
|||||||
emit('notice', '习惯已永久删除')
|
emit('notice', '习惯已永久删除')
|
||||||
void nextTick(() => habitArchiveToggle.value?.focus())
|
void nextTick(() => habitArchiveToggle.value?.focus())
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
error.value = reason instanceof Error ? reason.message : '永久删除失败'
|
error.value = failureHint('永久删除失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
@@ -617,7 +618,7 @@ async function loadArchivedHabits() {
|
|||||||
archiveState.value = 'success'
|
archiveState.value = 'success'
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
archiveState.value = 'error'
|
archiveState.value = 'error'
|
||||||
archiveError.value = reason instanceof Error ? reason.message : '归档习惯加载失败'
|
archiveError.value = failureHint('归档习惯加载失败', reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function toggleArchivedHabits() {
|
async function toggleArchivedHabits() {
|
||||||
@@ -645,7 +646,7 @@ async function loadHabits() {
|
|||||||
writeHabitGridCache(week, habits.value)
|
writeHabitGridCache(week, habits.value)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '请求失败'
|
error.value = e instanceof Error ? e.message : failureHint('请求失败', e)
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
if (!cached) busy.value = false
|
if (!cached) busy.value = false
|
||||||
@@ -667,14 +668,14 @@ async function loadSettings() {
|
|||||||
sessionsState.value = 'success'
|
sessionsState.value = 'success'
|
||||||
} else {
|
} else {
|
||||||
sessionsState.value = 'error'
|
sessionsState.value = 'error'
|
||||||
sessionsError.value = sessionResult.reason instanceof Error ? sessionResult.reason.message : '登录设备加载失败'
|
sessionsError.value = sessionResult.reason instanceof Error ? sessionResult.reason.message : '登录设备加载失败,请检查网络后重试'
|
||||||
}
|
}
|
||||||
if (auditResult.status === 'fulfilled') {
|
if (auditResult.status === 'fulfilled') {
|
||||||
audit.value = mergePage<any>(auditResult.value).items
|
audit.value = mergePage<any>(auditResult.value).items
|
||||||
auditState.value = 'success'
|
auditState.value = 'success'
|
||||||
} else {
|
} else {
|
||||||
auditState.value = 'error'
|
auditState.value = 'error'
|
||||||
auditError.value = auditResult.reason instanceof Error ? auditResult.reason.message : '活动记录加载失败'
|
auditError.value = auditResult.reason instanceof Error ? auditResult.reason.message : '活动记录加载失败,请检查网络后重试'
|
||||||
}
|
}
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
@@ -695,7 +696,7 @@ function downloadBlob(blob: Blob, name: string) {
|
|||||||
async function exportData() {
|
async function exportData() {
|
||||||
backupBusy.value = true; backupError.value = ''
|
backupBusy.value = true; backupError.value = ''
|
||||||
try { downloadBlob(await downloadFullBackup(), 'dodo-backup-v2.zip') }
|
try { downloadBlob(await downloadFullBackup(), 'dodo-backup-v2.zip') }
|
||||||
catch (reason) { backupError.value = reason instanceof Error ? reason.message : '完整备份导出失败' }
|
catch (reason) { backupError.value = failureHint('完整备份导出失败', reason) }
|
||||||
finally { backupBusy.value = false }
|
finally { backupBusy.value = false }
|
||||||
}
|
}
|
||||||
function openRestoreFilePicker() {
|
function openRestoreFilePicker() {
|
||||||
@@ -732,7 +733,7 @@ async function runPreflight() {
|
|||||||
acceptedPreflightSnapshot = snapshot
|
acceptedPreflightSnapshot = snapshot
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (generation !== preflightGeneration || controller.signal.aborted) return
|
if (generation !== preflightGeneration || controller.signal.aborted) return
|
||||||
backupError.value = reason instanceof Error ? reason.message : '备份预检失败'
|
backupError.value = failureHint('备份预检失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
if (generation === preflightGeneration) {
|
if (generation === preflightGeneration) {
|
||||||
backupBusy.value = false
|
backupBusy.value = false
|
||||||
@@ -762,7 +763,7 @@ async function restore() {
|
|||||||
restoreFile.value = null; restorePreflight.value = null; acceptedPreflightSnapshot = null
|
restoreFile.value = null; restorePreflight.value = null; acceptedPreflightSnapshot = null
|
||||||
if (restoreInput.value) restoreInput.value.value = ''
|
if (restoreInput.value) restoreInput.value.value = ''
|
||||||
emit('changed'); emit('notice', '数据已恢复')
|
emit('changed'); emit('notice', '数据已恢复')
|
||||||
} catch (reason) { backupError.value = reason instanceof Error ? reason.message : '恢复失败' }
|
} catch (reason) { backupError.value = reason instanceof Error ? reason.message : failureHint('恢复失败', reason) }
|
||||||
finally { backupBusy.value = false }
|
finally { backupBusy.value = false }
|
||||||
}
|
}
|
||||||
async function logout() {
|
async function logout() {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
describe('pomodoro integration', () => {
|
||||||
|
const app = readFileSync('src/App.vue', 'utf8')
|
||||||
|
const css = readFileSync('src/style.css', 'utf8')
|
||||||
|
const panel = readFileSync('src/PomodoroPanel.vue', 'utf8')
|
||||||
|
|
||||||
|
it('adds focus to desktop navigation and keeps the five-item mobile navigation unchanged', () => {
|
||||||
|
const primary = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">')))
|
||||||
|
const bottom = app.slice(app.indexOf('<nav class="bottom"'), app.indexOf('</nav>', app.indexOf('<nav class="bottom"')))
|
||||||
|
|
||||||
|
expect(primary).toContain("switchView('focus')")
|
||||||
|
expect(primary.indexOf("switchView('focus')")).toBeGreaterThan(primary.indexOf("switchView('habits')"))
|
||||||
|
expect(bottom).not.toContain("switchView('focus')")
|
||||||
|
expect(bottom.match(/aria-current=/g)).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the focus panel without the shared FAB and includes accessible stage controls', () => {
|
||||||
|
expect(app).toContain("<PomodoroPanel v-else-if=\"activeView==='focus'\"")
|
||||||
|
expect(app).toContain("'focus'].includes(activeView)")
|
||||||
|
expect(css).toContain('.pomodoro-controls button{min-height:44px')
|
||||||
|
expect(css).toContain('@media(prefers-reduced-motion:reduce)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coordinates writes across tabs with unique writers and a localStorage conflict check', () => {
|
||||||
|
expect(panel).toContain('const WRITER_ID = crypto.randomUUID()')
|
||||||
|
expect(panel).toContain('const stored = loadPomodoroState(window.localStorage, STORAGE_KEY)')
|
||||||
|
expect(panel).toContain('shouldAcceptPomodoroState(stored, next) ? stored : next')
|
||||||
|
expect(panel).toContain('Date.now(), WRITER_ID')
|
||||||
|
expect(panel).toContain('savePomodoroState(window.localStorage, STORAGE_KEY, state.value)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers duration settings and keeps announcements honest about the running state', () => {
|
||||||
|
expect(panel).toContain("import AppSheet from './components/AppSheet.vue'")
|
||||||
|
expect(panel).toContain('@click="settingsOpen = true"')
|
||||||
|
expect(panel).toContain('已跳过休息,下一轮专注已开始')
|
||||||
|
expect(panel).toContain('专注完成,休息已开始')
|
||||||
|
expect(panel).toContain('休息完成,下一轮专注已开始')
|
||||||
|
expect(panel).toContain("v-if=\"state.status !== 'idle'\"")
|
||||||
|
expect(panel).toContain("setPomodoroDurations(state.value, minutes, state.value.breakMinutes")
|
||||||
|
expect(css).toContain('.pomodoro-settings__options button{min-height:44px')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { Coffee, Pause, Play, RotateCcw, Settings2, SkipForward, TimerReset, X } from 'lucide-vue-next'
|
||||||
|
import AppSheet from './components/AppSheet.vue'
|
||||||
|
import {
|
||||||
|
formatPomodoroTime,
|
||||||
|
loadPomodoroState,
|
||||||
|
pausePomodoro,
|
||||||
|
recalibratePomodoro,
|
||||||
|
resetPomodoro,
|
||||||
|
savePomodoroState,
|
||||||
|
setPomodoroDurations,
|
||||||
|
shouldAcceptPomodoroState,
|
||||||
|
skipBreak,
|
||||||
|
startBreak,
|
||||||
|
startNextFocus,
|
||||||
|
startPomodoro,
|
||||||
|
type PomodoroState,
|
||||||
|
} from './lib/pomodoro-timer'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'dodo.pomodoro.v1'
|
||||||
|
const WRITER_ID = crypto.randomUUID()
|
||||||
|
const state = ref(loadPomodoroState(window.localStorage, STORAGE_KEY))
|
||||||
|
const nowMs = ref(Date.now())
|
||||||
|
const announcement = ref('')
|
||||||
|
const settingsOpen = ref(false)
|
||||||
|
const focusOptions = [15, 20, 25, 30, 45, 50]
|
||||||
|
const breakOptions = [3, 5, 10, 15]
|
||||||
|
let intervalId: number | undefined
|
||||||
|
|
||||||
|
const remainingMs = computed(() => state.value.status === 'running' && state.value.endsAt !== null
|
||||||
|
? Math.max(0, state.value.endsAt - nowMs.value)
|
||||||
|
: state.value.remainingMs)
|
||||||
|
const clock = computed(() => formatPomodoroTime(remainingMs.value))
|
||||||
|
const totalSeconds = computed(() => Math.max(0, Math.ceil(remainingMs.value / 1000)))
|
||||||
|
const clockMin = computed(() => String(Math.floor(totalSeconds.value / 60)).padStart(2, '0'))
|
||||||
|
const clockSec = computed(() => String(totalSeconds.value % 60).padStart(2, '0'))
|
||||||
|
const phaseLabel = computed(() => state.value.phase === 'focus' ? '专注' : '休息')
|
||||||
|
const statusLabel = computed(() => ({ idle: '准备开始', running: '进行中', paused: '已暂停', done: '已完成' })[state.value.status])
|
||||||
|
const timerLocked = computed(() => state.value.status === 'running')
|
||||||
|
const eyebrowText = computed(() => state.value.todayFocusCount > 0
|
||||||
|
? `今天完成 ${state.value.todayFocusCount} 次 · ${state.value.todayFocusCount * state.value.focusMinutes} 分钟`
|
||||||
|
: '今天完成 0 次')
|
||||||
|
const progress = computed(() => {
|
||||||
|
const total = (state.value.phase === 'focus' ? state.value.focusMinutes : state.value.breakMinutes) * 60_000
|
||||||
|
return total ? Math.min(100, Math.max(0, ((total - remainingMs.value) / total) * 100)) : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
function commit(next: PomodoroState, message = '') {
|
||||||
|
const stored = loadPomodoroState(window.localStorage, STORAGE_KEY)
|
||||||
|
const resolved = shouldAcceptPomodoroState(stored, next) ? stored : next
|
||||||
|
const changed = resolved !== state.value
|
||||||
|
state.value = resolved
|
||||||
|
nowMs.value = Date.now()
|
||||||
|
if (changed) savePomodoroState(window.localStorage, STORAGE_KEY, resolved)
|
||||||
|
if (message) announcement.value = message
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const next = startPomodoro(state.value, Date.now(), WRITER_ID)
|
||||||
|
commit(next, `${phaseLabel.value}已开始`)
|
||||||
|
}
|
||||||
|
function pause() { commit(pausePomodoro(state.value, Date.now(), WRITER_ID), `${phaseLabel.value}已暂停`) }
|
||||||
|
function reset() { commit(resetPomodoro(state.value, Date.now(), WRITER_ID), `${phaseLabel.value}已重置`) }
|
||||||
|
function beginBreak() { commit(startBreak(state.value, Date.now(), WRITER_ID), '专注完成,休息已开始') }
|
||||||
|
function skip() { commit(skipBreak(state.value, Date.now(), WRITER_ID), '已跳过休息,下一轮专注已准备好') }
|
||||||
|
function nextFocus() { commit(startNextFocus(state.value, Date.now(), WRITER_ID), '休息完成,下一轮专注已开始') }
|
||||||
|
function skipRest() { commit(startNextFocus(state.value, Date.now(), WRITER_ID), '已跳过休息,下一轮专注已开始') }
|
||||||
|
function setFocusMinutes(minutes: number) { commit(setPomodoroDurations(state.value, minutes, state.value.breakMinutes, Date.now(), WRITER_ID), `专注时长已设为 ${minutes} 分钟`) }
|
||||||
|
function setBreakMinutes(minutes: number) { commit(setPomodoroDurations(state.value, state.value.focusMinutes, minutes, Date.now(), WRITER_ID), `休息时长已设为 ${minutes} 分钟`) }
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (document.visibilityState === 'hidden') return
|
||||||
|
const before = state.value
|
||||||
|
const next = recalibratePomodoro(before, Date.now(), WRITER_ID)
|
||||||
|
nowMs.value = Date.now()
|
||||||
|
if (next !== before) {
|
||||||
|
const completed = before.status === 'running' && next.status === 'done'
|
||||||
|
commit(next, completed ? `${phaseLabel.value}完成` : '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStorage(event: StorageEvent) {
|
||||||
|
if (event.key !== STORAGE_KEY || event.newValue === null) return
|
||||||
|
const incoming = loadPomodoroState({ getItem: () => event.newValue, setItem: () => undefined }, STORAGE_KEY)
|
||||||
|
if (shouldAcceptPomodoroState(incoming, state.value)) {
|
||||||
|
state.value = incoming
|
||||||
|
refresh()
|
||||||
|
} else if (shouldAcceptPomodoroState(state.value, incoming)) {
|
||||||
|
savePomodoroState(window.localStorage, STORAGE_KEY, state.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refresh()
|
||||||
|
intervalId = window.setInterval(refresh, 250)
|
||||||
|
document.addEventListener('visibilitychange', refresh)
|
||||||
|
window.addEventListener('focus', refresh)
|
||||||
|
window.addEventListener('pageshow', refresh)
|
||||||
|
window.addEventListener('storage', handleStorage)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (intervalId) window.clearInterval(intervalId)
|
||||||
|
document.removeEventListener('visibilitychange', refresh)
|
||||||
|
window.removeEventListener('focus', refresh)
|
||||||
|
window.removeEventListener('pageshow', refresh)
|
||||||
|
window.removeEventListener('storage', handleStorage)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="pomodoro-view" aria-labelledby="pomodoro-title">
|
||||||
|
<header class="pomodoro-heading">
|
||||||
|
<div><span class="pomodoro-eyebrow">{{ eyebrowText }}</span><h1 id="pomodoro-title">专注</h1></div>
|
||||||
|
<TimerReset aria-hidden="true" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pomodoro-stage" :class="`pomodoro-stage--${state.phase}`">
|
||||||
|
<div class="pomodoro-stage-meta" :key="`${state.phase}-${state.status}`" :aria-label="`${phaseLabel} · ${statusLabel}`"><span>{{ phaseLabel }}</span><span class="pomodoro-stage-sep" aria-hidden="true">·</span><small>{{ statusLabel }}</small></div>
|
||||||
|
<output class="pomodoro-clock" :aria-label="`${phaseLabel}剩余 ${clock}`"><span class="pomodoro-clock-min">{{ clockMin }}</span><span class="pomodoro-clock-sep">:</span><span class="pomodoro-clock-sec" :key="clockSec">{{ clockSec }}</span></output>
|
||||||
|
<div class="pomodoro-progress" role="progressbar" :aria-label="`${phaseLabel}进度`" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="Math.round(progress)"><span :style="{ width: `${progress}%` }" /></div>
|
||||||
|
<p class="pomodoro-note" :key="state.phase">{{ state.phase === 'focus' ? '留一点完整的时间,只做眼前这一件事。' : '离开屏幕,喝口水,再回来。' }}</p>
|
||||||
|
|
||||||
|
<div class="pomodoro-controls">
|
||||||
|
<button v-if="state.status === 'idle'" class="primary" type="button" @click="start"><Play />开始{{ phaseLabel }}</button>
|
||||||
|
<button v-else-if="state.status === 'running'" class="primary" type="button" @click="pause"><Pause />暂停</button>
|
||||||
|
<button v-else-if="state.status === 'paused'" class="primary" type="button" @click="start"><Play />继续</button>
|
||||||
|
<button v-if="state.status !== 'idle'" class="soft-button" type="button" @click="reset"><RotateCcw />重置</button>
|
||||||
|
<template v-if="state.phase === 'focus' && state.status === 'done'">
|
||||||
|
<button class="primary" type="button" @click="beginBreak"><Coffee />开始休息</button>
|
||||||
|
<button class="soft-button" type="button" @click="skipRest"><SkipForward />跳过休息</button>
|
||||||
|
</template>
|
||||||
|
<template v-if="state.phase === 'break'">
|
||||||
|
<button v-if="state.status !== 'done'" class="soft-button" type="button" @click="skip"><SkipForward />跳过休息</button>
|
||||||
|
<button v-else class="primary" type="button" @click="nextFocus"><Play />下一轮专注</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="pomodoro-summary" type="button" :aria-label="`计时设置:专注 ${state.focusMinutes} 分钟,休息 ${state.breakMinutes} 分钟`" @click="settingsOpen = true">专注 {{ state.focusMinutes }} 分钟 · 休息 {{ state.breakMinutes }} 分钟<Settings2 aria-hidden="true" /></button>
|
||||||
|
|
||||||
|
<AppSheet :open="settingsOpen" variant="actions" panel-class="pomodoro-settings-sheet" title-id="pomodoro-settings-title" initial-focus=".app-sheet__header button" @close="settingsOpen = false">
|
||||||
|
<header class="app-sheet__header"><div><h2 id="pomodoro-settings-title">计时设置</h2></div><button type="button" aria-label="关闭计时设置" @click="settingsOpen = false"><X aria-hidden="true" /></button></header>
|
||||||
|
<div class="app-sheet__body pomodoro-settings__body">
|
||||||
|
<div class="pomodoro-settings__group" role="group" aria-label="专注时长">
|
||||||
|
<span class="pomodoro-settings__label">专注时长</span>
|
||||||
|
<div class="pomodoro-settings__options">
|
||||||
|
<button v-for="m in focusOptions" :key="`focus-${m}`" type="button" :aria-pressed="state.focusMinutes === m" :disabled="timerLocked" @click="setFocusMinutes(m)">{{ m }} 分钟</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pomodoro-settings__group" role="group" aria-label="休息时长">
|
||||||
|
<span class="pomodoro-settings__label">休息时长</span>
|
||||||
|
<div class="pomodoro-settings__options">
|
||||||
|
<button v-for="m in breakOptions" :key="`break-${m}`" type="button" :aria-pressed="state.breakMinutes === m" :disabled="timerLocked" @click="setBreakMinutes(m)">{{ m }} 分钟</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="timerLocked" class="pomodoro-settings__hint">计时进行中,暂停后可调整</p>
|
||||||
|
</div>
|
||||||
|
</AppSheet>
|
||||||
|
<p class="sr-only" aria-live="polite">{{ announcement }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -65,10 +65,10 @@ describe('Today environment integration', () => {
|
|||||||
expect(css).toContain('main.today-main{padding-left:max(44px,calc((100% - 1080px)/2));padding-right:max(44px,calc((100% - 1080px)/2))}')
|
expect(css).toContain('main.today-main{padding-left:max(44px,calc((100% - 1080px)/2));padding-right:max(44px,calc((100% - 1080px)/2))}')
|
||||||
expect(css).toContain('.today-environment{grid-template-columns:repeat(3,minmax(0,1fr));')
|
expect(css).toContain('.today-environment{grid-template-columns:repeat(3,minmax(0,1fr));')
|
||||||
expect(css).toContain('@media(max-width:930px){main.today-main{padding-left:max(44px,calc((100% - 630px)/2));padding-right:max(44px,calc((100% - 630px)/2))}}')
|
expect(css).toContain('@media(max-width:930px){main.today-main{padding-left:max(44px,calc((100% - 630px)/2));padding-right:max(44px,calc((100% - 630px)/2))}}')
|
||||||
expect(css).toContain('@media(max-width:720.98px){main.today-main{padding-left:29px!important;padding-right:29px!important;padding-bottom:calc(78px + var(--safe-area-bottom))}')
|
expect(css).toContain('@media(max-width:720.98px){main.today-main{padding-left:var(--space-28)!important;padding-right:var(--space-28)!important;padding-bottom:calc(78px + var(--safe-area-bottom))}')
|
||||||
expect(css).toContain('.today-context{margin:0 0 14px;border-bottom:0}')
|
expect(css).toContain('.today-context{margin:0 0 var(--space-14);border-bottom:0}')
|
||||||
expect(css).toContain('.today-heading{display:grid;')
|
expect(css).toContain('.today-heading{display:grid;')
|
||||||
expect(css).toContain('.today-heading .today-remaining{margin:6px 0 0}')
|
expect(css).toContain('.today-heading .today-remaining{margin:var(--space-6) 0 0}')
|
||||||
expect(css).not.toContain('.today-board{')
|
expect(css).not.toContain('.today-board{')
|
||||||
expect(css).not.toContain('.today-board__progress{')
|
expect(css).not.toContain('.today-board__progress{')
|
||||||
expect(css).not.toContain('.today-track{')
|
expect(css).not.toContain('.today-track{')
|
||||||
@@ -90,7 +90,7 @@ describe('Today environment integration', () => {
|
|||||||
expect(filter).toBeGreaterThan(remaining)
|
expect(filter).toBeGreaterThan(remaining)
|
||||||
expect(overdue).toBeGreaterThan(filter)
|
expect(overdue).toBeGreaterThan(filter)
|
||||||
expect(main.match(/>今天<\/h1>/g)).toHaveLength(1)
|
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','focus'].includes(activeView)\" class=\"topbar-title\"><h1")
|
||||||
expect(main).not.toContain('class="topbar-actions"')
|
expect(main).not.toContain('class="topbar-actions"')
|
||||||
expect(main).not.toContain('class="topbar-filter"')
|
expect(main).not.toContain('class="topbar-filter"')
|
||||||
expect(main).not.toContain('aria-label="刷新当前页面"')
|
expect(main).not.toContain('aria-label="刷新当前页面"')
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ describe('Today independent collapsible sections', () => {
|
|||||||
expect(app.match(/class="today-section-toggle(?: today-section-anchor)?"/g)).toHaveLength(3)
|
expect(app.match(/class="today-section-toggle(?: today-section-anchor)?"/g)).toHaveLength(3)
|
||||||
expect(app).toContain('<span class="today-section-summary">{{totalTasks}}</span>')
|
expect(app).toContain('<span class="today-section-summary">{{totalTasks}}</span>')
|
||||||
expect(app).not.toContain('<span class="today-section-summary">{{taskTree.length}} 项</span>')
|
expect(app).not.toContain('<span class="today-section-summary">{{taskTree.length}} 项</span>')
|
||||||
expect(app).toContain("{{ todaySectionCollapse.overdue ? '›' : '⌄' }}")
|
expect(app).toContain('<span class="today-section-chevron" :class="{open: !todaySectionCollapse.overdue}" aria-hidden="true">›</span>')
|
||||||
expect(app).not.toContain('<ChevronRight v-if="todaySectionCollapse.overdue"')
|
expect(app).not.toContain('<ChevronRight v-if="todaySectionCollapse.overdue"')
|
||||||
expect(css).toMatch(/\.today-section-toggle\{[^}]*min-height:44px/)
|
expect(css).toMatch(/\.today-section-toggle\{[^}]*min-height:44px/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the habit panel mounted while collapsed and omits an empty overdue section', () => {
|
it('keeps the habit panel mounted while collapsed and omits an empty overdue section', () => {
|
||||||
expect(app).toContain('<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section">')
|
expect(app).toContain('<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section" :class="{\'is-collapsed\': todaySectionCollapse.overdue}">')
|
||||||
expect(app).toContain('<div v-show="!todaySectionCollapse.habits" id="today-habits"')
|
expect(app).toContain('<div id="today-habits" :class="{\'is-collapsed\': todaySectionCollapse.habits}" role="region"')
|
||||||
const habits = app.slice(app.indexOf('id="today-habits-heading"'), app.indexOf('</div>', app.indexOf('<MvpPanel ref="habitComposer" view="today-habits"')) + 6)
|
const habits = app.slice(app.indexOf('id="today-habits-heading"'), app.indexOf('</div>', app.indexOf('<MvpPanel ref="habitComposer" view="today-habits"')) + 6)
|
||||||
expect(habits).toContain('<MvpPanel ref="habitComposer" view="today-habits"')
|
expect(habits).toContain('<MvpPanel ref="habitComposer" view="today-habits"')
|
||||||
expect(habits).not.toContain('v-if="!todaySectionCollapse.habits"')
|
expect(habits).not.toContain('v-if="!todaySectionCollapse.habits"')
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function formatApiErrorDetail(detail: unknown): string {
|
|||||||
if (typeof value.message === 'string') return value.message
|
if (typeof value.message === 'string') return value.message
|
||||||
if (typeof value.code === 'string') return value.code
|
if (typeof value.code === 'string') return value.code
|
||||||
}
|
}
|
||||||
return '请求失败'
|
return '请求失败,请检查网络后重试'
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
import { createApp, h, nextTick, ref } from 'vue'
|
import { createApp, h, nextTick, ref } from 'vue'
|
||||||
import AppSheet from './AppSheet.vue'
|
import AppSheet from './AppSheet.vue'
|
||||||
import AppDialog from './AppDialog.vue'
|
import AppDialog from './AppDialog.vue'
|
||||||
@@ -273,4 +274,11 @@ describe('AppSheet', () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
expect(calls).toEqual(['two'])
|
expect(calls).toEqual(['two'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keys each render branch so modal/inline/bare switches remount instead of cross-patching', () => {
|
||||||
|
const source = readFileSync('src/components/AppSheet.vue', 'utf8')
|
||||||
|
expect(source).toContain(`:key="'app-sheet-modal'"`)
|
||||||
|
expect(source).toContain(`:key="'app-sheet-inline'"`)
|
||||||
|
expect(source).toContain(`:key="'app-sheet-bare'"`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ function focusables() {
|
|||||||
.filter(isVisibleFocusable)
|
.filter(isVisibleFocusable)
|
||||||
}
|
}
|
||||||
function keydown(event: KeyboardEvent) {
|
function keydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (props.busy || (props.modal && !isTopOverlay(overlayId))) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!props.modal || event.key !== 'Tab' || !isTopOverlay(overlayId)) return
|
if (!props.modal || event.key !== 'Tab' || !isTopOverlay(overlayId)) return
|
||||||
const controls = focusables()
|
const controls = focusables()
|
||||||
if (!controls.length) { event.preventDefault(); panel.value?.focus(); return }
|
if (!controls.length) { event.preventDefault(); panel.value?.focus(); return }
|
||||||
@@ -50,42 +57,55 @@ function keydown(event: KeyboardEvent) {
|
|||||||
function focusIntoPanel() {
|
function focusIntoPanel() {
|
||||||
if (!panel.value?.contains(document.activeElement)) {
|
if (!panel.value?.contains(document.activeElement)) {
|
||||||
const target = props.initialFocus ? panel.value?.querySelector<HTMLElement>(props.initialFocus) : null
|
const target = props.initialFocus ? panel.value?.querySelector<HTMLElement>(props.initialFocus) : null
|
||||||
;(target ?? focusables()[0] ?? panel.value)?.focus()
|
const usable = !!target && !target.matches(':disabled')
|
||||||
|
&& !target.closest('[hidden],[aria-hidden="true"],[inert]')
|
||||||
|
&& (() => { const s = window.getComputedStyle(target); return s.display !== 'none' && s.visibility !== 'hidden' })()
|
||||||
|
const el = usable ? target : (focusables()[0] ?? panel.value)
|
||||||
|
el?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function activate() {
|
async function activate() {
|
||||||
if (!props.open || !props.modal || overlayId) return
|
if (!props.open) return
|
||||||
overlayId = pushOverlay(requestClose, () => props.busy, focusIntoPanel)
|
if (!props.modal && overlayId) deactivate()
|
||||||
layerZIndex.value = overlayZIndex(overlayId)
|
if (props.modal) {
|
||||||
|
if (!overlayId) {
|
||||||
|
overlayId = pushOverlay(requestClose, () => props.busy, focusIntoPanel)
|
||||||
|
layerZIndex.value = overlayZIndex(overlayId)
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
if (props.open && overlayId) focusIntoPanel()
|
||||||
|
return
|
||||||
|
}
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (!props.open || !props.modal || !overlayId) return
|
if (props.open) focusIntoPanel()
|
||||||
focusIntoPanel()
|
|
||||||
}
|
}
|
||||||
function deactivate() {
|
function deactivate() {
|
||||||
if (overlayId) popOverlay(overlayId)
|
if (overlayId) popOverlay(overlayId)
|
||||||
overlayId = null
|
overlayId = null
|
||||||
}
|
}
|
||||||
watch([() => props.open, () => props.modal], ([open, modal]) => {
|
watch([() => props.open, () => props.modal], ([open]) => {
|
||||||
if (open && modal) void activate()
|
if (open) void activate()
|
||||||
else deactivate()
|
else deactivate()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
onBeforeUnmount(deactivate)
|
onBeforeUnmount(deactivate)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport v-if="modal" :to="overlayRoot()">
|
<Teleport v-if="modal" :key="'app-sheet-modal'" :to="overlayRoot()">
|
||||||
|
<Transition name="app-sheet">
|
||||||
<div v-if="open" class="app-overlay app-sheet-mask" :style="{ zIndex: layerZIndex }" :aria-busy="busy || undefined" @click="scrimClose">
|
<div v-if="open" class="app-overlay app-sheet-mask" :style="{ zIndex: layerZIndex }" :aria-busy="busy || undefined" @click="scrimClose">
|
||||||
<component :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" aria-modal="true" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" tabindex="-1" v-bind="$attrs" @keydown="keydown">
|
<component :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" aria-modal="true" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" tabindex="-1" v-bind="$attrs" @keydown="keydown">
|
||||||
<slot />
|
<slot />
|
||||||
</component>
|
</component>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
<Teleport v-else-if="inlineTarget" :to="inlineTarget">
|
<Teleport v-else-if="inlineTarget && open" :key="'app-sheet-inline'" :to="inlineTarget">
|
||||||
<component v-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" :aria-busy="busy || undefined" v-bind="$attrs">
|
<component v-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" :aria-busy="busy || undefined" tabindex="-1" v-bind="$attrs" @keydown="keydown">
|
||||||
<slot />
|
<slot />
|
||||||
</component>
|
</component>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
<component v-else-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" v-bind="$attrs">
|
<component v-else-if="open" :key="'app-sheet-bare'" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" tabindex="-1" v-bind="$attrs" @keydown="keydown">
|
||||||
<slot />
|
<slot />
|
||||||
</component>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ describe('add-task CalendarPicker integration', () => {
|
|||||||
|
|
||||||
it('uses a fixed desktop popover and opaque mobile bottom sheet above task compose', () => {
|
it('uses a fixed desktop popover and opaque mobile bottom sheet above task compose', () => {
|
||||||
expect(css).toContain('.calendar-picker-layer{position:fixed;z-index:90;inset:0;pointer-events:none}')
|
expect(css).toContain('.calendar-picker-layer{position:fixed;z-index:90;inset:0;pointer-events:none}')
|
||||||
expect(css).toContain('.calendar-picker{position:fixed;width:328px;background:#fffdf8;border:1px solid #ded6ca;')
|
expect(css).toContain('.calendar-picker{position:fixed;width:328px;background:var(--surface-raised);border:1px solid var(--calendar-picker-border);')
|
||||||
expect(css).toContain('.calendar-picker__grid button.selected{background:var(--accent);color:#fff}')
|
expect(css).toContain('.calendar-picker__grid button.selected{background:var(--accent);color:var(--white)}')
|
||||||
expect(css).toContain('@media(max-width:600px){.calendar-picker-layer{pointer-events:auto;background:rgba(45,38,31,.3);display:flex;align-items:flex-end}')
|
expect(css).toContain('@media(max-width:600px){.calendar-picker-layer{pointer-events:auto;background:rgba(45,38,31,.3);display:flex;align-items:flex-end}')
|
||||||
expect(css).toContain('height:min(520px,72dvh)')
|
expect(css).toContain('height:min(520px,72dvh)')
|
||||||
expect(css).not.toContain('.calendar-picker{backdrop-filter')
|
expect(css).not.toContain('.calendar-picker{backdrop-filter')
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const draft = ref('')
|
|||||||
const focusedDate = ref(new Date())
|
const focusedDate = ref(new Date())
|
||||||
const visibleMonth = ref(new Date())
|
const visibleMonth = ref(new Date())
|
||||||
const compact = ref(false)
|
const compact = ref(false)
|
||||||
|
const monthDir = ref(0)
|
||||||
const position = ref({ left: 12, top: 12 })
|
const position = ref({ left: 12, top: 12 })
|
||||||
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
|
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
|
||||||
const title = computed(() => new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(visibleMonth.value))
|
const title = computed(() => new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(visibleMonth.value))
|
||||||
@@ -22,6 +23,12 @@ function choose(value: string) {
|
|||||||
draft.value = value
|
draft.value = value
|
||||||
const date = parseLocalDate(value)!
|
const date = parseLocalDate(value)!
|
||||||
focusedDate.value = date
|
focusedDate.value = date
|
||||||
|
aimMonth(date)
|
||||||
|
}
|
||||||
|
function aimMonth(date: Date) {
|
||||||
|
const prev = visibleMonth.value
|
||||||
|
const delta = (date.getFullYear() - prev.getFullYear()) * 12 + (date.getMonth() - prev.getMonth())
|
||||||
|
if (delta !== 0) monthDir.value = delta > 0 ? 1 : -1
|
||||||
visibleMonth.value = new Date(date.getFullYear(), date.getMonth(), 1)
|
visibleMonth.value = new Date(date.getFullYear(), date.getMonth(), 1)
|
||||||
}
|
}
|
||||||
function finish() { emit('update:modelValue', draft.value); close() }
|
function finish() { emit('update:modelValue', draft.value); close() }
|
||||||
@@ -33,7 +40,7 @@ function changeMonth(amount: number) {
|
|||||||
}
|
}
|
||||||
function focusDay(date: Date) {
|
function focusDay(date: Date) {
|
||||||
focusedDate.value = date
|
focusedDate.value = date
|
||||||
visibleMonth.value = new Date(date.getFullYear(), date.getMonth(), 1)
|
aimMonth(date)
|
||||||
nextTick(() => dialog.value?.querySelector<HTMLElement>(`[data-date="${formatLocalDate(date)}"]`)?.focus())
|
nextTick(() => dialog.value?.querySelector<HTMLElement>(`[data-date="${formatLocalDate(date)}"]`)?.focus())
|
||||||
}
|
}
|
||||||
function onGridKey(event: KeyboardEvent) {
|
function onGridKey(event: KeyboardEvent) {
|
||||||
@@ -90,6 +97,7 @@ onBeforeUnmount(() => { document.removeEventListener('pointerdown', onOutside);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
|
<Transition name="calendar-picker">
|
||||||
<div v-if="open" class="calendar-picker-layer" :class="{compact}" @click.self="compact && close()">
|
<div v-if="open" class="calendar-picker-layer" :class="{compact}" @click.self="compact && close()">
|
||||||
<section ref="dialog" class="calendar-picker" :class="{compact}" :style="dialogStyle" role="dialog" aria-modal="true" aria-labelledby="calendar-picker-title" @keydown="onDialogKey">
|
<section ref="dialog" class="calendar-picker" :class="{compact}" :style="dialogStyle" role="dialog" aria-modal="true" aria-labelledby="calendar-picker-title" @keydown="onDialogKey">
|
||||||
<header class="calendar-picker__header">
|
<header class="calendar-picker__header">
|
||||||
@@ -99,7 +107,7 @@ onBeforeUnmount(() => { document.removeEventListener('pointerdown', onOutside);
|
|||||||
<button v-if="compact" class="calendar-picker__close" type="button" aria-label="关闭日期选择器" @click="close"><X/></button>
|
<button v-if="compact" class="calendar-picker__close" type="button" aria-label="关闭日期选择器" @click="close"><X/></button>
|
||||||
</header>
|
</header>
|
||||||
<div class="calendar-picker__weekdays" aria-hidden="true"><span v-for="day in weekdays" :key="day">{{ day }}</span></div>
|
<div class="calendar-picker__weekdays" aria-hidden="true"><span v-for="day in weekdays" :key="day">{{ day }}</span></div>
|
||||||
<div class="calendar-picker__grid" role="grid" aria-label="日期" @keydown="onGridKey">
|
<div class="calendar-picker__grid" :key="title" :class="monthDir > 0 ? 'shift-next' : monthDir < 0 ? 'shift-prev' : ''" role="grid" aria-label="日期" @keydown="onGridKey">
|
||||||
<button v-for="day in days" :key="day.value" type="button" role="gridcell" :data-date="day.value" :class="{outside:!day.inMonth,today:day.isToday,selected:day.isSelected}" :aria-label="day.value" :aria-selected="day.isSelected" :aria-current="day.isToday?'date':undefined" :tabindex="day.value===formatLocalDate(focusedDate)?0:-1" @focus="focusedDate=day.date" @click="choose(day.value)">{{ day.date.getDate() }}</button>
|
<button v-for="day in days" :key="day.value" type="button" role="gridcell" :data-date="day.value" :class="{outside:!day.inMonth,today:day.isToday,selected:day.isSelected}" :aria-label="day.value" :aria-selected="day.isSelected" :aria-current="day.isToday?'date':undefined" :tabindex="day.value===formatLocalDate(focusedDate)?0:-1" @focus="focusedDate=day.date" @click="choose(day.value)">{{ day.date.getDate() }}</button>
|
||||||
</div>
|
</div>
|
||||||
<footer class="calendar-picker__footer">
|
<footer class="calendar-picker__footer">
|
||||||
@@ -111,5 +119,6 @@ onBeforeUnmount(() => { document.removeEventListener('pointerdown', onOutside);
|
|||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { ArchiveRestore, Bold, Code, Heading2, Italic, Link, List, ListChecks, ListOrdered, Quote, Trash2, X } from 'lucide-vue-next'
|
import { ArchiveRestore, Bold, Code, Heading2, Italic, Link, List, ListChecks, ListOrdered, Quote, Trash2, X } from 'lucide-vue-next'
|
||||||
import { applyMarkdownFormat, renderMarkdown, type MarkdownFormat } from '../lib/task-utils'
|
import { applyMarkdownFormat, renderMarkdown, type MarkdownFormat } from '../lib/task-utils'
|
||||||
|
import { failureHint } from '../failureHint'
|
||||||
|
|
||||||
export type MemoRecord = { id: string; title: string; content: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
export type MemoRecord = { id: string; title: string; content: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
||||||
export type MemoDraft = { id: null; title: string; content: string; version: null; created_at: null; updated_at: null; deleted_at: null }
|
export type MemoDraft = { id: null; title: string; content: string; version: null; created_at: null; updated_at: null; deleted_at: null }
|
||||||
@@ -67,7 +68,7 @@ async function save() {
|
|||||||
if (!isCurrentSelection()) return
|
if (!isCurrentSelection()) return
|
||||||
const status = (reason as { status?: number }).status
|
const status = (reason as { status?: number }).status
|
||||||
conflict.value = memoId !== null && status === 409
|
conflict.value = memoId !== null && status === 409
|
||||||
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : memoId === null ? '创建失败' : '保存失败'
|
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : memoId === null ? failureHint('创建失败', reason) : failureHint('保存失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
emit('saveFinished', selectionToken)
|
emit('saveFinished', selectionToken)
|
||||||
if (isCurrentSelection()) saving.value = false
|
if (isCurrentSelection()) saving.value = false
|
||||||
@@ -84,7 +85,7 @@ async function reload() {
|
|||||||
loadDraft(fresh)
|
loadDraft(fresh)
|
||||||
emit('saved', fresh, selectionToken)
|
emit('saved', fresh, selectionToken)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (isCurrentSelection()) error.value = reason instanceof Error ? reason.message : '重新载入失败'
|
if (isCurrentSelection()) error.value = failureHint('重新载入失败', reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function remove() {
|
async function remove() {
|
||||||
@@ -101,7 +102,7 @@ async function remove() {
|
|||||||
await props.request(`/memos/${memoId}`, { method: 'DELETE' })
|
await props.request(`/memos/${memoId}`, { method: 'DELETE' })
|
||||||
emit('deleted', memoId, selectionToken)
|
emit('deleted', memoId, selectionToken)
|
||||||
if (isCurrentOperation()) emit('notice', '备忘录已移到回收站')
|
if (isCurrentOperation()) emit('notice', '备忘录已移到回收站')
|
||||||
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '删除失败' }
|
} catch (reason) { if (isCurrentOperation()) error.value = failureHint('删除失败', reason) }
|
||||||
finally {
|
finally {
|
||||||
emit('lifecycleFinished', selectionToken)
|
emit('lifecycleFinished', selectionToken)
|
||||||
if (isCurrentOperation()) lifecycleBusy.value = false
|
if (isCurrentOperation()) lifecycleBusy.value = false
|
||||||
@@ -120,7 +121,7 @@ async function restore() {
|
|||||||
const restored = await props.request(`/memos/${memoId}/restore`, { method: 'POST' }) as Memo
|
const restored = await props.request(`/memos/${memoId}/restore`, { method: 'POST' }) as Memo
|
||||||
emit('restored', restored, selectionToken)
|
emit('restored', restored, selectionToken)
|
||||||
if (isCurrentOperation()) emit('notice', '备忘录已恢复')
|
if (isCurrentOperation()) emit('notice', '备忘录已恢复')
|
||||||
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '恢复失败' }
|
} catch (reason) { if (isCurrentOperation()) error.value = failureHint('恢复失败', reason) }
|
||||||
finally {
|
finally {
|
||||||
emit('lifecycleFinished', selectionToken)
|
emit('lifecycleFinished', selectionToken)
|
||||||
if (isCurrentOperation()) lifecycleBusy.value = false
|
if (isCurrentOperation()) lifecycleBusy.value = false
|
||||||
@@ -140,7 +141,7 @@ async function purge() {
|
|||||||
await props.request(`/memos/${memoId}/purge`, { method: 'DELETE' })
|
await props.request(`/memos/${memoId}/purge`, { method: 'DELETE' })
|
||||||
emit('purged', memoId, selectionToken)
|
emit('purged', memoId, selectionToken)
|
||||||
if (isCurrentOperation()) emit('notice', '备忘录已永久删除')
|
if (isCurrentOperation()) emit('notice', '备忘录已永久删除')
|
||||||
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '永久删除失败' }
|
} catch (reason) { if (isCurrentOperation()) error.value = failureHint('永久删除失败', reason) }
|
||||||
finally {
|
finally {
|
||||||
emit('lifecycleFinished', selectionToken)
|
emit('lifecycleFinished', selectionToken)
|
||||||
if (isCurrentOperation()) lifecycleBusy.value = false
|
if (isCurrentOperation()) lifecycleBusy.value = false
|
||||||
|
|||||||
@@ -130,10 +130,10 @@ describe('TodayEnvironmentStrip', () => {
|
|||||||
it('uses the selected landing-page typography and full-height separators', () => {
|
it('uses the selected landing-page typography and full-height separators', () => {
|
||||||
const css = readFileSync(resolve(process.cwd(), 'src/style.css'), 'utf8')
|
const css = readFileSync(resolve(process.cwd(), 'src/style.css'), 'utf8')
|
||||||
expect(css).toContain('.today-environment{grid-column:1/-1;position:relative;min-width:0;display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr) minmax(0,1fr);align-items:stretch')
|
expect(css).toContain('.today-environment{grid-column:1/-1;position:relative;min-width:0;display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr) minmax(0,1fr);align-items:stretch')
|
||||||
expect(css).toContain('color:#302a24;font-size:23px;line-height:1.15;font-weight:760')
|
expect(css).toContain('color:var(--text-primary);font-size:23px;line-height:1.15;font-weight:800')
|
||||||
expect(css).toContain('margin-top:6px;color:#655b50;font-size:12px;font-weight:450')
|
expect(css).toContain('margin-top:var(--space-6);color:var(--text-secondary);font-size:12px;font-weight:400')
|
||||||
expect(css).toContain('.today-environment__eyeline{color:#8b8275;font-size:11px')
|
expect(css).toContain('.today-environment__eyeline{color:var(--text-secondary);font-size:11px')
|
||||||
expect(css).toContain('.today-environment__item+.today-environment__item{border-left:1px solid var(--line)}')
|
expect(css).toContain('.today-environment__item+.today-environment__item{border-left:1px solid var(--border-cream)}')
|
||||||
expect(css).not.toContain('.today-environment.has-attention:before')
|
expect(css).not.toContain('.today-environment.has-attention:before')
|
||||||
expect(css).not.toMatch(/\.today-environment[^}]*gradient|\.today-environment[^}]*box-shadow|\.today-environment[^}]*backdrop-filter/)
|
expect(css).not.toMatch(/\.today-environment[^}]*gradient|\.today-environment[^}]*box-shadow|\.today-environment[^}]*backdrop-filter/)
|
||||||
})
|
})
|
||||||
@@ -143,9 +143,9 @@ describe('TodayEnvironmentStrip', () => {
|
|||||||
expect(css).not.toContain('.today-track-head{')
|
expect(css).not.toContain('.today-track-head{')
|
||||||
expect(css).toContain('@media(max-width:720px){.today-environment{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:auto auto}')
|
expect(css).toContain('@media(max-width:720px){.today-environment{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:auto auto}')
|
||||||
expect(css).toContain('.today-environment__calendar{grid-column:1/-1;grid-row:1;display:flex;flex-direction:row;align-items:baseline;justify-content:space-between')
|
expect(css).toContain('.today-environment__calendar{grid-column:1/-1;grid-row:1;display:flex;flex-direction:row;align-items:baseline;justify-content:space-between')
|
||||||
expect(css).toContain('.today-environment__weather{grid-column:1;grid-row:2;border-top:1px solid var(--line);border-left:0!important}')
|
expect(css).toContain('.today-environment__weather{grid-column:1;grid-row:2;border-top:1px solid var(--border-cream);border-left:0!important}')
|
||||||
expect(css).toContain('.today-environment__gold{grid-column:2;grid-row:2;border-top:1px solid var(--line)}')
|
expect(css).toContain('.today-environment__gold{grid-column:2;grid-row:2;border-top:1px solid var(--border-cream)}')
|
||||||
expect(css).toContain('.today-environment__weather,.today-environment__gold{padding:13px 10px;display:flex;flex-direction:column;')
|
expect(css).toContain('.today-environment__weather,.today-environment__gold{padding:var(--space-12) var(--space-10);display:flex;flex-direction:column;')
|
||||||
expect(css).toContain('.today-environment__calendar-main--desktop,.today-environment__gold-date--desktop{display:none}')
|
expect(css).toContain('.today-environment__calendar-main--desktop,.today-environment__gold-date--desktop{display:none}')
|
||||||
expect(css).toContain('.today-environment__calendar-main--mobile,.today-environment__gold-date--mobile{display:inline}')
|
expect(css).toContain('.today-environment__calendar-main--mobile,.today-environment__gold-date--mobile{display:inline}')
|
||||||
const mobileBlock = css.slice(css.indexOf('@media(max-width:720px){.today-environment'), css.indexOf('/* Solid cream material system'))
|
const mobileBlock = css.slice(css.indexOf('@media(max-width:720px){.today-environment'), css.indexOf('/* Solid cream material system'))
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { failureHint } from './failureHint'
|
||||||
|
|
||||||
|
describe('failureHint', () => {
|
||||||
|
it('uses network hint without error', () => {
|
||||||
|
expect(failureHint('保存失败')).toBe('保存失败,请检查网络后重试')
|
||||||
|
})
|
||||||
|
it('classifies TypeError as network', () => {
|
||||||
|
const e = new TypeError('Failed to fetch')
|
||||||
|
expect(failureHint('请求失败', e)).toBe('请求失败,请检查网络后重试')
|
||||||
|
})
|
||||||
|
it('classifies 401 as auth', () => {
|
||||||
|
expect(failureHint('加载失败', { status: 401 })).toBe('加载失败,请重新登录后再试')
|
||||||
|
})
|
||||||
|
it('classifies 5xx as server', () => {
|
||||||
|
expect(failureHint('保存失败', { status: 502 })).toBe('保存失败,服务器出错,请稍后重试')
|
||||||
|
})
|
||||||
|
it('keeps Chinese server detail', () => {
|
||||||
|
expect(failureHint('保存失败', new Error('重复规则冲突'))).toBe('保存失败:重复规则冲突')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
type FailErr = unknown
|
||||||
|
|
||||||
|
/** 错误提示统一出口:动作短语 + 可操作后缀(网络/鉴权/校验/服务端四类) */
|
||||||
|
export function failureHint(phrase: string, err?: FailErr): string {
|
||||||
|
if (!err) return `${phrase},请检查网络后重试`
|
||||||
|
const e = err as { name?: string; message?: string; status?: number; response?: { status?: number } }
|
||||||
|
const name = String(e.name ?? "")
|
||||||
|
const msg = String(e.message ?? "")
|
||||||
|
const status = Number(e.status ?? e.response?.status ?? 0)
|
||||||
|
const blob = `${name} ${msg}`
|
||||||
|
if (status === 401 || status === 403 || /unauthorized|\b401\b|登录|token/i.test(blob)) return `${phrase},请重新登录后再试`
|
||||||
|
if (name === 'TypeError' || /failed to fetch|networkerror|network request failed|net::|fetch/i.test(blob)) return `${phrase},请检查网络后重试`
|
||||||
|
if (status >= 500) return `${phrase},服务器出错,请稍后重试`
|
||||||
|
if (status >= 400) return `${phrase},请检查填写内容后重试`
|
||||||
|
if (/\p{Script=Han}/u.test(msg)) return `${phrase}:${msg}`
|
||||||
|
return `${phrase},请重试`
|
||||||
|
}
|
||||||
@@ -67,6 +67,8 @@ describe('MVP view utilities', () => {
|
|||||||
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'tasks', listId: 'list-2' })
|
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'tasks', listId: 'list-2' })
|
||||||
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'memos', 'list-2')
|
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'memos', 'list-2')
|
||||||
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'memos', listId: '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' }))
|
storage.set('dodo.navigation', JSON.stringify({ view: 'invalid', listId: 'list-2' }))
|
||||||
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
|
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'>
|
type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'>
|
||||||
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
|
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'focus' | 'countdowns' | 'memos' | 'calendar' | 'settings'
|
||||||
type StoredNavigation = { view: NavigationView; listId: string }
|
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', 'focus', 'countdowns', 'memos', 'calendar', 'settings'])
|
||||||
|
|
||||||
export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation {
|
export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation {
|
||||||
try {
|
try {
|
||||||
@@ -894,5 +894,5 @@ export function formatApiErrorDetail(detail: unknown): string {
|
|||||||
if ('detail' in record) return formatApiErrorDetail(record.detail)
|
if ('detail' in record) return formatApiErrorDetail(record.detail)
|
||||||
return JSON.stringify(record)
|
return JSON.stringify(record)
|
||||||
}
|
}
|
||||||
return '请求失败'
|
return '请求失败,请检查网络后重试'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createPomodoroState, finishPomodoro, formatPomodoroTime, loadPomodoroState, pausePomodoro, recalibratePomodoro, resetPomodoro, savePomodoroState, setPomodoroDurations, shouldAcceptPomodoroState, skipBreak, startBreak, startNextFocus, startPomodoro } from './pomodoro-timer'
|
||||||
|
|
||||||
|
describe('pomodoro timer state', () => {
|
||||||
|
it('starts a 25 minute focus from idle using an absolute deadline', () => {
|
||||||
|
const state = createPomodoroState(new Date('2026-09-22T08:00:00'))
|
||||||
|
|
||||||
|
const started = startPomodoro(state, Date.parse('2026-09-22T08:00:00'))
|
||||||
|
|
||||||
|
expect(started).toMatchObject({
|
||||||
|
phase: 'focus',
|
||||||
|
status: 'running',
|
||||||
|
remainingMs: 25 * 60_000,
|
||||||
|
endsAt: Date.parse('2026-09-22T08:25:00'),
|
||||||
|
todayFocusCount: 0,
|
||||||
|
todayKey: '2026-09-22',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pauses and resumes from the deadline-derived remaining time', () => {
|
||||||
|
const initial = createPomodoroState(new Date('2026-09-22T08:00:00'))
|
||||||
|
const running = startPomodoro(initial, Date.parse('2026-09-22T08:00:00'))
|
||||||
|
|
||||||
|
const paused = pausePomodoro(running, Date.parse('2026-09-22T08:10:00'))
|
||||||
|
const resumed = startPomodoro(paused, Date.parse('2026-09-22T09:00:00'))
|
||||||
|
|
||||||
|
expect(paused).toMatchObject({ status: 'paused', remainingMs: 15 * 60_000, endsAt: null })
|
||||||
|
expect(resumed).toMatchObject({ status: 'running', remainingMs: 15 * 60_000, endsAt: Date.parse('2026-09-22T09:15:00') })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('completes focus naturally once, then waits for a manual break', () => {
|
||||||
|
const initial = createPomodoroState(new Date('2026-09-22T08:00:00'))
|
||||||
|
const running = startPomodoro(initial, Date.parse('2026-09-22T08:00:00'))
|
||||||
|
|
||||||
|
const done = finishPomodoro(running, Date.parse('2026-09-22T08:25:00'))
|
||||||
|
const repeated = finishPomodoro(done, Date.parse('2026-09-22T08:26:00'))
|
||||||
|
|
||||||
|
expect(done).toMatchObject({ phase: 'focus', status: 'done', remainingMs: 0, endsAt: null, todayFocusCount: 1 })
|
||||||
|
expect(repeated.todayFocusCount).toBe(1)
|
||||||
|
expect(startBreak(done, Date.parse('2026-09-22T08:27:00'))).toMatchObject({ phase: 'break', status: 'running', remainingMs: 5 * 60_000, endsAt: Date.parse('2026-09-22T08:32:00') })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves from a finished break to the next focus only on request', () => {
|
||||||
|
const focusDone = finishPomodoro(startPomodoro(createPomodoroState(new Date('2026-09-22T08:00:00')), Date.parse('2026-09-22T08:00:00')), Date.parse('2026-09-22T08:25:00'))
|
||||||
|
const breakRunning = startPomodoro(startBreak(focusDone, Date.parse('2026-09-22T08:26:00')), Date.parse('2026-09-22T08:26:00'))
|
||||||
|
const breakDone = finishPomodoro(breakRunning, Date.parse('2026-09-22T08:31:00'))
|
||||||
|
|
||||||
|
expect(breakDone).toMatchObject({ phase: 'break', status: 'done', todayFocusCount: 1 })
|
||||||
|
expect(startNextFocus(breakDone, Date.parse('2026-09-22T08:32:00'))).toMatchObject({ phase: 'focus', status: 'running', remainingMs: 25 * 60_000, endsAt: Date.parse('2026-09-22T08:57:00'), todayFocusCount: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not increment completed focus count when resetting or skipping', () => {
|
||||||
|
const initial = createPomodoroState(new Date('2026-09-22T08:00:00'))
|
||||||
|
const reset = resetPomodoro(startPomodoro(initial, Date.parse('2026-09-22T08:00:00')), Date.parse('2026-09-22T08:10:00'))
|
||||||
|
const focusDone = finishPomodoro(startPomodoro(initial, Date.parse('2026-09-22T08:00:00')), Date.parse('2026-09-22T08:25:00'))
|
||||||
|
const skipped = skipBreak(startBreak(focusDone, Date.parse('2026-09-22T08:26:00')), Date.parse('2026-09-22T08:27:00'))
|
||||||
|
|
||||||
|
expect(reset).toMatchObject({ phase: 'focus', status: 'idle', todayFocusCount: 0 })
|
||||||
|
expect(skipped).toMatchObject({ phase: 'focus', status: 'idle', todayFocusCount: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads only valid versioned state and safely resets corrupted or old data', () => {
|
||||||
|
const now = new Date('2026-09-22T08:00:00')
|
||||||
|
const storage = {
|
||||||
|
value: '{bad json',
|
||||||
|
getItem() { return this.value },
|
||||||
|
setItem(_key: string, value: string) { this.value = value },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(loadPomodoroState(storage, 'timer', now)).toEqual(createPomodoroState(now))
|
||||||
|
storage.value = JSON.stringify({ ...createPomodoroState(now), version: 0 })
|
||||||
|
expect(loadPomodoroState(storage, 'timer', now)).toEqual(createPomodoroState(now))
|
||||||
|
const valid = { ...createPomodoroState(now), status: 'paused' as const, remainingMs: 1234, revision: 4 }
|
||||||
|
savePomodoroState(storage, 'timer', valid)
|
||||||
|
expect(loadPomodoroState(storage, 'timer', now)).toEqual(valid)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets the daily count across a local date while an active run continues', () => {
|
||||||
|
const initial = createPomodoroState(new Date('2026-09-22T23:50:00'))
|
||||||
|
const running = { ...startPomodoro(initial, Date.parse('2026-09-22T23:50:00')), todayFocusCount: 3 }
|
||||||
|
|
||||||
|
const recalibrated = recalibratePomodoro(running, Date.parse('2026-09-23T00:01:00'))
|
||||||
|
|
||||||
|
expect(recalibrated).toMatchObject({ status: 'running', endsAt: running.endsAt, todayKey: '2026-09-23', todayFocusCount: 0 })
|
||||||
|
expect(recalibrated.remainingMs).toBe(14 * 60_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attributes an elapsed focus to its deadline date before rolling the visible count to the observation date', () => {
|
||||||
|
const initial = { ...createPomodoroState(new Date('2026-09-22T23:00:00')), todayFocusCount: 3 }
|
||||||
|
const running = startPomodoro(initial, Date.parse('2026-09-22T23:30:00'))
|
||||||
|
|
||||||
|
const done = recalibratePomodoro(running, Date.parse('2026-09-23T00:10:00'))
|
||||||
|
|
||||||
|
expect(done).toMatchObject({
|
||||||
|
status: 'done',
|
||||||
|
todayKey: '2026-09-23',
|
||||||
|
todayFocusCount: 0,
|
||||||
|
lastFocusCompletedAt: Date.parse('2026-09-22T23:55:00'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a new completion-date count when persisted count belongs to neither completion nor observation date', () => {
|
||||||
|
const stale = { ...createPomodoroState(new Date('2026-09-20T08:00:00')), todayFocusCount: 7 }
|
||||||
|
const running = startPomodoro(stale, Date.parse('2026-09-22T08:00:00'))
|
||||||
|
|
||||||
|
const done = finishPomodoro(running, Date.parse('2026-09-22T08:25:00'))
|
||||||
|
|
||||||
|
expect(done).toMatchObject({ todayKey: '2026-09-22', todayFocusCount: 1, lastFocusCompletedAt: Date.parse('2026-09-22T08:25:00') })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a natural completion when a stale tab resets later from the same revision', () => {
|
||||||
|
const base = startPomodoro(createPomodoroState(new Date('2026-09-22T08:00:00'), 'origin'), Date.parse('2026-09-22T08:00:00'), 'origin')
|
||||||
|
const completed = finishPomodoro(base, Date.parse('2026-09-22T08:25:00'), 'completion-tab')
|
||||||
|
const staleReset = resetPomodoro(base, Date.parse('2026-09-22T08:26:00'), 'stale-tab')
|
||||||
|
|
||||||
|
expect(shouldAcceptPomodoroState(staleReset, completed)).toBe(false)
|
||||||
|
expect(completed).toMatchObject({ status: 'done', todayFocusCount: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses writer identity as a deterministic tie-break for concurrent mutations with identical clocks', () => {
|
||||||
|
const base = createPomodoroState(new Date('2026-09-22T08:00:00'), 'origin')
|
||||||
|
const lowerWriter = startPomodoro(base, Date.parse('2026-09-22T08:00:00'), 'tab-a')
|
||||||
|
const higherWriter = startPomodoro(base, Date.parse('2026-09-22T08:00:00'), 'tab-b')
|
||||||
|
|
||||||
|
expect(shouldAcceptPomodoroState(higherWriter, lowerWriter)).toBe(true)
|
||||||
|
expect(shouldAcceptPomodoroState(lowerWriter, higherWriter)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finishes an elapsed run during recalibration and compares synced revisions monotonically', () => {
|
||||||
|
const running = startPomodoro(createPomodoroState(new Date('2026-09-22T08:00:00')), Date.parse('2026-09-22T08:00:00'))
|
||||||
|
const done = recalibratePomodoro(running, Date.parse('2026-09-22T08:26:00'))
|
||||||
|
|
||||||
|
expect(done).toMatchObject({ status: 'done', todayFocusCount: 1 })
|
||||||
|
expect(shouldAcceptPomodoroState({ ...done, revision: done.revision + 1 }, done)).toBe(true)
|
||||||
|
expect(shouldAcceptPomodoroState({ ...done, updatedAt: done.updatedAt + 1 }, done)).toBe(true)
|
||||||
|
expect(shouldAcceptPomodoroState(done, done)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies duration changes outside an active run and resets the visible clock', () => {
|
||||||
|
const idle = createPomodoroState(new Date('2026-09-22T08:00:00'))
|
||||||
|
|
||||||
|
const focusChanged = setPomodoroDurations(idle, 45, 10, Date.parse('2026-09-22T08:00:00'), 'tab-a')
|
||||||
|
expect(focusChanged).toMatchObject({ focusMinutes: 45, breakMinutes: 10, status: 'idle', remainingMs: 45 * 60_000, revision: 1 })
|
||||||
|
|
||||||
|
const done = finishPomodoro(startPomodoro(idle, Date.parse('2026-09-22T08:00:00')), Date.parse('2026-09-22T08:25:00'))
|
||||||
|
const fromDone = setPomodoroDurations(done, 30, 5, Date.parse('2026-09-22T08:26:00'), 'tab-a')
|
||||||
|
expect(fromDone).toMatchObject({ status: 'idle', focusMinutes: 30, remainingMs: 30 * 60_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores duration changes mid-run, on identical values, and on invalid values', () => {
|
||||||
|
const idle = createPomodoroState(new Date('2026-09-22T08:00:00'))
|
||||||
|
const running = startPomodoro(idle, Date.parse('2026-09-22T08:00:00'))
|
||||||
|
|
||||||
|
expect(setPomodoroDurations(running, 45, 10, Date.parse('2026-09-22T08:01:00'), 'tab-a')).toBe(running)
|
||||||
|
expect(setPomodoroDurations(idle, 25, 5, Date.parse('2026-09-22T08:01:00'), 'tab-a')).toBe(idle)
|
||||||
|
expect(setPomodoroDurations(idle, 0, 10, Date.parse('2026-09-22T08:01:00'), 'tab-a')).toBe(idle)
|
||||||
|
expect(setPomodoroDurations(idle, 45, -5, Date.parse('2026-09-22T08:01:00'), 'tab-a')).toBe(idle)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats the visible clock with a ceiling so it never shows early completion', () => {
|
||||||
|
expect(formatPomodoroTime(25 * 60_000)).toBe('25:00')
|
||||||
|
expect(formatPomodoroTime(60_001)).toBe('01:01')
|
||||||
|
expect(formatPomodoroTime(0)).toBe('00:00')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
export const POMODORO_VERSION = 1
|
||||||
|
export const DEFAULT_FOCUS_MS = 25 * 60_000
|
||||||
|
export const DEFAULT_BREAK_MS = 5 * 60_000
|
||||||
|
|
||||||
|
export type PomodoroPhase = 'focus' | 'break'
|
||||||
|
export type PomodoroStatus = 'idle' | 'running' | 'paused' | 'done'
|
||||||
|
|
||||||
|
export type PomodoroState = {
|
||||||
|
version: typeof POMODORO_VERSION
|
||||||
|
phase: PomodoroPhase
|
||||||
|
status: PomodoroStatus
|
||||||
|
focusMinutes: number
|
||||||
|
breakMinutes: number
|
||||||
|
remainingMs: number
|
||||||
|
endsAt: number | null
|
||||||
|
todayKey: string
|
||||||
|
todayFocusCount: number
|
||||||
|
lastFocusCompletedAt: number | null
|
||||||
|
revision: number
|
||||||
|
updatedAt: number
|
||||||
|
writerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localDateKey(date = new Date()) {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPomodoroState(now = new Date(), writerId = 'local'): PomodoroState {
|
||||||
|
return {
|
||||||
|
version: POMODORO_VERSION,
|
||||||
|
phase: 'focus',
|
||||||
|
status: 'idle',
|
||||||
|
focusMinutes: 25,
|
||||||
|
breakMinutes: 5,
|
||||||
|
remainingMs: DEFAULT_FOCUS_MS,
|
||||||
|
endsAt: null,
|
||||||
|
todayKey: localDateKey(now),
|
||||||
|
todayFocusCount: 0,
|
||||||
|
lastFocusCompletedAt: null,
|
||||||
|
revision: 0,
|
||||||
|
updatedAt: now.getTime(),
|
||||||
|
writerId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changed(state: PomodoroState, now: number, patch: Partial<PomodoroState>, writerId: string): PomodoroState {
|
||||||
|
return { ...state, ...patch, revision: state.revision + 1, updatedAt: now, writerId }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPomodoro(state: PomodoroState, now = Date.now(), writerId = state.writerId): PomodoroState {
|
||||||
|
const duration = state.phase === 'focus' ? state.focusMinutes * 60_000 : state.breakMinutes * 60_000
|
||||||
|
const remainingMs = state.status === 'paused' ? state.remainingMs : duration
|
||||||
|
return changed(state, now, { status: 'running', remainingMs, endsAt: now + remainingMs }, writerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pausePomodoro(state: PomodoroState, now = Date.now(), writerId = state.writerId): PomodoroState {
|
||||||
|
if (state.status !== 'running' || state.endsAt === null) return state
|
||||||
|
return changed(state, now, { status: 'paused', remainingMs: Math.max(0, state.endsAt - now), endsAt: null }, writerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishPomodoro(state: PomodoroState, now = Date.now(), writerId = state.writerId): PomodoroState {
|
||||||
|
if (state.status !== 'running' || state.endsAt === null || now < state.endsAt) return state
|
||||||
|
const completedAt = state.endsAt
|
||||||
|
const completionKey = localDateKey(new Date(completedAt))
|
||||||
|
const completedFocus = state.phase === 'focus'
|
||||||
|
return changed(state, now, {
|
||||||
|
status: 'done',
|
||||||
|
remainingMs: 0,
|
||||||
|
endsAt: null,
|
||||||
|
todayKey: completedFocus ? completionKey : state.todayKey,
|
||||||
|
todayFocusCount: completedFocus ? (state.todayKey === completionKey ? state.todayFocusCount : 0) + 1 : state.todayFocusCount,
|
||||||
|
lastFocusCompletedAt: completedFocus ? completedAt : state.lastFocusCompletedAt,
|
||||||
|
}, writerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToPhase(state: PomodoroState, phase: PomodoroPhase, now: number, writerId: string) {
|
||||||
|
return changed(state, now, {
|
||||||
|
phase,
|
||||||
|
status: 'idle',
|
||||||
|
remainingMs: (phase === 'focus' ? state.focusMinutes : state.breakMinutes) * 60_000,
|
||||||
|
endsAt: null,
|
||||||
|
}, writerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startBreak(state: PomodoroState, now = Date.now(), writerId = state.writerId) {
|
||||||
|
return startPomodoro(moveToPhase(state, 'break', now, writerId), now, writerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startNextFocus(state: PomodoroState, now = Date.now(), writerId = state.writerId) {
|
||||||
|
return startPomodoro(moveToPhase(state, 'focus', now, writerId), now, writerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skipBreak(state: PomodoroState, now = Date.now(), writerId = state.writerId) {
|
||||||
|
return moveToPhase(state, 'focus', now, writerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPomodoro(state: PomodoroState, now = Date.now(), writerId = state.writerId) {
|
||||||
|
return moveToPhase(state, state.phase, now, writerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPomodoroDurations(state: PomodoroState, focusMinutes: number, breakMinutes: number, now = Date.now(), writerId = state.writerId): PomodoroState {
|
||||||
|
if (state.status === 'running') return state
|
||||||
|
if (!Number.isFinite(focusMinutes) || focusMinutes <= 0 || !Number.isFinite(breakMinutes) || breakMinutes <= 0) return state
|
||||||
|
if (state.focusMinutes === focusMinutes && state.breakMinutes === breakMinutes) return state
|
||||||
|
return changed({ ...state, focusMinutes, breakMinutes }, now, {
|
||||||
|
status: 'idle',
|
||||||
|
remainingMs: (state.phase === 'focus' ? focusMinutes : breakMinutes) * 60_000,
|
||||||
|
endsAt: null,
|
||||||
|
}, writerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PomodoroStorage = Pick<Storage, 'getItem' | 'setItem'>
|
||||||
|
|
||||||
|
function isPomodoroState(value: unknown): value is PomodoroState {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const state = value as Record<string, unknown>
|
||||||
|
return state.version === POMODORO_VERSION
|
||||||
|
&& (state.phase === 'focus' || state.phase === 'break')
|
||||||
|
&& ['idle', 'running', 'paused', 'done'].includes(String(state.status))
|
||||||
|
&& Number.isFinite(state.focusMinutes) && Number(state.focusMinutes) > 0
|
||||||
|
&& Number.isFinite(state.breakMinutes) && Number(state.breakMinutes) > 0
|
||||||
|
&& Number.isFinite(state.remainingMs) && Number(state.remainingMs) >= 0
|
||||||
|
&& (state.endsAt === null || Number.isFinite(state.endsAt))
|
||||||
|
&& typeof state.todayKey === 'string'
|
||||||
|
&& Number.isInteger(state.todayFocusCount) && Number(state.todayFocusCount) >= 0
|
||||||
|
&& (state.lastFocusCompletedAt === null || Number.isFinite(state.lastFocusCompletedAt))
|
||||||
|
&& Number.isInteger(state.revision) && Number(state.revision) >= 0
|
||||||
|
&& Number.isFinite(state.updatedAt)
|
||||||
|
&& typeof state.writerId === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPomodoroState(storage: PomodoroStorage, key: string, now = new Date()) {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(storage.getItem(key) ?? 'null')
|
||||||
|
if (isPomodoroState(value)) return value
|
||||||
|
} catch { /* unavailable or invalid storage resets safely */ }
|
||||||
|
return createPomodoroState(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePomodoroState(storage: PomodoroStorage, key: string, state: PomodoroState) {
|
||||||
|
try { storage.setItem(key, JSON.stringify(state)) } catch { /* storage may be unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAcceptPomodoroState(incoming: PomodoroState, current: PomodoroState) {
|
||||||
|
const incomingCompletion = incoming.lastFocusCompletedAt ?? -Infinity
|
||||||
|
const currentCompletion = current.lastFocusCompletedAt ?? -Infinity
|
||||||
|
if (incomingCompletion !== currentCompletion) return incomingCompletion > currentCompletion
|
||||||
|
return incoming.revision > current.revision
|
||||||
|
|| (incoming.revision === current.revision && incoming.updatedAt > current.updatedAt)
|
||||||
|
|| (incoming.revision === current.revision && incoming.updatedAt === current.updatedAt && incoming.writerId > current.writerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recalibratePomodoro(state: PomodoroState, now = Date.now(), writerId = state.writerId) {
|
||||||
|
const todayKey = localDateKey(new Date(now))
|
||||||
|
let next = state
|
||||||
|
if (state.status === 'running' && state.endsAt !== null && now >= state.endsAt) {
|
||||||
|
next = finishPomodoro(state, now, writerId)
|
||||||
|
}
|
||||||
|
if (next.todayKey !== todayKey) next = changed(next, now, { todayKey, todayFocusCount: 0 }, writerId)
|
||||||
|
if (next.status !== 'running' || next.endsAt === null) return next
|
||||||
|
return { ...next, remainingMs: next.endsAt - now }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPomodoroTime(remainingMs: number) {
|
||||||
|
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000))
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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 = {
|
type TaskFixture = {
|
||||||
id: string
|
id: string
|
||||||
@@ -7,6 +7,7 @@ type TaskFixture = {
|
|||||||
description?: string
|
description?: string
|
||||||
parent_id?: string | null
|
parent_id?: string | null
|
||||||
completed?: boolean
|
completed?: boolean
|
||||||
|
due_at?: string | null
|
||||||
list_name?: string
|
list_name?: string
|
||||||
subtasks?: TaskFixture[]
|
subtasks?: TaskFixture[]
|
||||||
}
|
}
|
||||||
@@ -28,6 +29,30 @@ describe('task utilities', () => {
|
|||||||
expect(groupTaskTree([parent])).toEqual([{ task: parent, subtasks: [child] }])
|
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', () => {
|
it('classifies due edits for Today membership', () => {
|
||||||
const start = new Date('2026-09-10T00:00:00+08:00')
|
const start = new Date('2026-09-10T00:00:00+08:00')
|
||||||
const end = new Date('2026-09-11T00: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')
|
expect(() => buildTaskRrule({ frequency: 'daily', interval: 0, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('重复间隔至少为 1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('builds a distinct completion-trigger payload and parses it without RRULE', () => {
|
it('builds and parses completion-trigger intervals in days or months', () => {
|
||||||
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7 })
|
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7, after_completion_unit: 'days' })
|
||||||
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14 })
|
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '2', afterCompletionUnit: 'months' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 2, after_completion_unit: 'months' })
|
||||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数天数')
|
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14, afterCompletionUnit: 'days' })
|
||||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数天数')
|
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 3, after_completion_unit: 'months' })).toEqual({ option: 'after_completion', afterCompletionDays: 3, afterCompletionUnit: 'months' })
|
||||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数天数')
|
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数')
|
||||||
|
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数')
|
||||||
|
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('builds scheduled recurrence payloads separately from completion triggers', () => {
|
it('builds scheduled recurrence payloads separately from completion triggers', () => {
|
||||||
|
|||||||
@@ -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(
|
export function classifyTaskForToday(
|
||||||
task: Pick<MinimalTask, 'completed' | 'due_at'>,
|
task: Pick<MinimalTask, 'completed' | 'due_at'>,
|
||||||
start: Date,
|
start: Date,
|
||||||
@@ -172,15 +193,16 @@ export function parseTaskRrule(rrule = ''): TaskRepeatConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TaskRepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'after_completion' | 'custom'
|
export type TaskRepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'after_completion' | 'custom'
|
||||||
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null }
|
export type AfterCompletionUnit = 'days' | 'months'
|
||||||
|
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null; after_completion_unit?: AfterCompletionUnit | null }
|
||||||
|
|
||||||
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; repeatConfig?: TaskRepeatConfig }) {
|
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; afterCompletionUnit?: AfterCompletionUnit; repeatConfig?: TaskRepeatConfig }) {
|
||||||
if (option === 'none') return {}
|
if (option === 'none') return {}
|
||||||
if (option === 'after_completion') {
|
if (option === 'after_completion') {
|
||||||
const raw = String(values.afterCompletionDays).trim()
|
const raw = String(values.afterCompletionDays).trim()
|
||||||
const days = Number(raw)
|
const days = Number(raw)
|
||||||
if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数天数')
|
if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数')
|
||||||
return { trigger_mode: 'after_completion' as const, after_completion_days: days }
|
return { trigger_mode: 'after_completion' as const, after_completion_days: days, after_completion_unit: values.afterCompletionUnit ?? 'days' }
|
||||||
}
|
}
|
||||||
const rrule = option === 'custom'
|
const rrule = option === 'custom'
|
||||||
? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' })
|
? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' })
|
||||||
@@ -189,13 +211,13 @@ export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { a
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) {
|
export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) {
|
||||||
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1 }
|
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
|
||||||
if (recurrence.trigger_mode === 'after_completion') {
|
if (recurrence.trigger_mode === 'after_completion') {
|
||||||
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1 }
|
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1, afterCompletionUnit: recurrence.after_completion_unit ?? 'days' as AfterCompletionUnit }
|
||||||
}
|
}
|
||||||
const parsed = parseTaskRrule(recurrence.rrule ?? '')
|
const parsed = parseTaskRrule(recurrence.rrule ?? '')
|
||||||
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
|
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
|
||||||
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1 }
|
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultTaskDueAt(now = new Date()) {
|
export function defaultTaskDueAt(now = new Date()) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import './memo.css'
|
import './memo.css'
|
||||||
|
import './calendar.css'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
createApp(App).mount('#app')
|
||||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;border:0;background:var(--surface-raised);padding:9px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row time{grid-column:2;grid-row:1;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
|
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;gap:12px}.memo-scope{display:flex;gap:4px;padding:var(--space-2);border:1px solid var(--border-cream);border-radius:var(--radius-control);background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:var(--radius-control);background:transparent;padding:0 var(--space-12)}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:var(--accent-ink);font-weight:700}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;border:0;background:var(--surface-raised);padding:var(--space-8) var(--space-14);text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:var(--surface-hover)}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row time{grid-column:2;grid-row:1;align-self:center;color:var(--text-secondary);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--text-secondary);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:var(--error-wash);border-radius:var(--radius-control);padding:var(--space-10) var(--space-12)}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:var(--space-10) var(--space-16);border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:700;letter-spacing:.06em;color:var(--text-secondary)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:var(--radius-control);background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:var(--space-18)}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:var(--radius-control);background:var(--white);padding:var(--space-12);outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
|
||||||
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{display:block}.memo-scope{min-width:0}.memo-scope button{flex:1;justify-content:center;padding-inline:9px}.memo-row{height:74px;min-height:74px;max-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
@media(max-width:930px){.shell.memo-detail-open main{padding:var(--space-20) var(--space-16) 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{display:block}.memo-scope{min-width:0}.memo-scope button{flex:1;justify-content:center;padding-inline:var(--space-8)}.memo-row{height:74px;min-height:74px;max-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:var(--space-16)}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
||||||
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
||||||
|
|||||||
+183
-129
File diff suppressed because one or more lines are too long
+150
-118
@@ -14,7 +14,7 @@ const appSheet = readFileSync('src/components/AppSheet.vue', 'utf8')
|
|||||||
describe('unified task due display', () => {
|
describe('unified task due display', () => {
|
||||||
it('uses the shared display for overdue and ordinary parent task rows', () => {
|
it('uses the shared display for overdue and ordinary parent task rows', () => {
|
||||||
expect(app).toContain("import TaskDueDisplay from './components/TaskDueDisplay.vue'")
|
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).toContain(':due-at="node.task.due_at"')
|
||||||
expect(app).not.toContain(':due-at="subtask.due_at"')
|
expect(app).not.toContain(':due-at="subtask.due_at"')
|
||||||
expect(app).not.toContain('formatDue(')
|
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', () => {
|
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).not.toContain('class="meta"><TaskDueDisplay')
|
||||||
expect(app).toContain('</div><span v-if="node.task.due_at" class="task-tail"><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('restoreTask(subtask)')
|
||||||
expect(app).not.toContain('purgeTask(subtask)')
|
expect(app).not.toContain('purgeTask(subtask)')
|
||||||
expect(app).not.toContain('task-detail-trigger')
|
expect(app).not.toContain('task-detail-trigger')
|
||||||
@@ -64,7 +64,7 @@ describe('unified task due display', () => {
|
|||||||
|
|
||||||
it('scopes content metadata and keeps date semantics without a visible calendar icon', () => {
|
it('scopes content metadata and keeps date semantics without a visible calendar icon', () => {
|
||||||
expect(css).toContain('.task-due--overdue{color:var(--danger)}')
|
expect(css).toContain('.task-due--overdue{color:var(--danger)}')
|
||||||
expect(css).toContain('.task-due--neutral,.task-due--completed{color:var(--muted)}')
|
expect(css).toContain('.task-due--neutral,.task-due--completed{color:var(--text-secondary)}')
|
||||||
expect(css).toContain('.task-main{border:0;background:transparent;flex:1;min-width:0;')
|
expect(css).toContain('.task-main{border:0;background:transparent;flex:1;min-width:0;')
|
||||||
expect(css).toContain('.meta-item{display:flex;align-items:center;gap:3px}')
|
expect(css).toContain('.meta-item{display:flex;align-items:center;gap:3px}')
|
||||||
expect(css).not.toContain('.meta span{display:flex;')
|
expect(css).not.toContain('.meta span{display:flex;')
|
||||||
@@ -79,22 +79,22 @@ describe('unified task due display', () => {
|
|||||||
|
|
||||||
describe('approved cream solid button system', () => {
|
describe('approved cream solid button system', () => {
|
||||||
it('gives semantic action buttons a compact desktop size and a touch-safe mobile size', () => {
|
it('gives semantic action buttons a compact desktop size and a touch-safe mobile size', () => {
|
||||||
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:40px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 15px;border-radius:10px;font-size:13px;font-weight:700;line-height:1;white-space:nowrap;transition:transform .14s ease,background-color .14s ease,border-color .14s ease,box-shadow .14s ease,color .14s ease}')
|
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:40px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 var(--space-14);border-radius:var(--radius-control);font-size:13px;font-weight:700;line-height:1;white-space:nowrap;transition:transform .14s ease,background-color .14s ease,border-color .14s ease,box-shadow .14s ease,color .14s ease}')
|
||||||
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:16px;height:16px;flex:0 0 auto}')
|
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:16px;height:16px;flex:0 0 auto}')
|
||||||
expect(css).toContain('@media(min-width:931px){main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button),.numeric-action .soft-button,.habit-history__message .soft-button,.backup-preflight .danger-button,.app-sheet__danger :is(.soft-button,.primary-small,.danger-button,.file-button,.secondary,.restore),.purge-list-dialog :is(.secondary,.danger-button){min-height:40px}}')
|
expect(css).toContain('@media(min-width:931px){main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button),.numeric-action .soft-button,.habit-history__message .soft-button,.backup-preflight .danger-button,.app-sheet__danger :is(.soft-button,.primary-small,.danger-button,.file-button,.secondary,.restore),.purge-list-dialog :is(.secondary,.danger-button){min-height:40px}}')
|
||||||
expect(css).toContain('@media(max-width:930px){:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:44px;padding-inline:16px;gap:7px}:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:17px;height:17px}}')
|
expect(css).toContain('@media(max-width:930px){:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:44px;padding-inline:var(--space-16);gap:7px}:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:17px;height:17px}}')
|
||||||
expect(css).toContain(':is(.primary,.primary-small){border:1px solid var(--accent);background:var(--accent);color:#fff;box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}')
|
expect(css).toContain(':is(.primary,.primary-small){border:1px solid var(--accent);background:var(--accent);color:var(--white);box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}')
|
||||||
expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button){border:1px solid #ddd0c0;background:#fffaf3;color:#51483f;box-shadow:inset 0 1px 0 #fff,0 2px 5px rgba(83,62,42,.06)}')
|
expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button){border:1px solid var(--soft-button-border-2);background:var(--row-hover);color:var(--soft-button-ink);box-shadow:inset 0 1px 0 var(--white),0 2px 5px rgba(83,62,42,.06)}')
|
||||||
expect(css).toContain('.danger-button{border:1px solid #e2b7ac;background:#fff8f5;color:#b83c2c;box-shadow:inset 0 1px 0 #fff,0 2px 5px rgba(105,48,34,.05)}')
|
expect(css).toContain('.danger-button{border:1px solid var(--danger-button-border-3);background:var(--danger-btn-bg);color:var(--danger-button-ink);box-shadow:inset 0 1px 0 var(--white),0 2px 5px rgba(105,48,34,.05)}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('provides restrained interaction, focus, and disabled states without resizing buttons', () => {
|
it('provides restrained interaction, focus, and disabled states without resizing buttons', () => {
|
||||||
expect(css).toContain(':is(.primary,.primary-small):hover:not(:disabled){background:#dd4b1e;border-color:#dd4b1e}')
|
expect(css).toContain(':is(.primary,.primary-small):hover:not(:disabled){background:var(--accent-press);border-color:var(--accent-press)}')
|
||||||
expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button):hover:not(:disabled):not(.disabled){background:#f8eddf;border-color:#d5c5b2}')
|
expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button):hover:not(:disabled):not(.disabled){background:var(--soft-button-bg);border-color:var(--soft-button-border)}')
|
||||||
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):active:not(:disabled):not(.disabled){transform:translateY(1px) scale(.985)}')
|
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):active:not(:disabled):not(.disabled){transform:translateY(1px) scale(.985)}')
|
||||||
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):focus-visible{outline:3px solid rgba(241,90,41,.24);outline-offset:3px}')
|
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):focus-visible{outline:3px solid rgba(241,90,41,.24);outline-offset:3px}')
|
||||||
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):disabled,.file-button.disabled{background:#ebe2d8;border-color:#ded3c7;color:#aaa095;box-shadow:none;cursor:not-allowed;opacity:1}')
|
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):disabled,.file-button.disabled{background:var(--disabled-bg);border-color:var(--disabled-border);color:var(--disabled-ink);box-shadow:none;cursor:not-allowed;opacity:1}')
|
||||||
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button:disabled,.backup-preflight .danger-button:disabled,.password-form .primary-small:disabled){background:#ebe2d8;border-color:#ded3c7;color:#aaa095;box-shadow:none;opacity:1}')
|
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button:disabled,.backup-preflight .danger-button:disabled,.password-form .primary-small:disabled){background:var(--disabled-bg);border-color:var(--disabled-border);color:var(--disabled-ink);box-shadow:none;opacity:1}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses a real disabled file-picker button and keeps its input out of layout', () => {
|
it('uses a real disabled file-picker button and keeps its input out of layout', () => {
|
||||||
@@ -118,16 +118,16 @@ describe('approved cream solid button system', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('mobile navigation styles', () => {
|
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('aria-controls="mobile-more-menu"')
|
||||||
expect(app).not.toContain('<Ellipsis/><span>更多</span>')
|
expect(app).not.toContain('<Ellipsis/><span>更多</span>')
|
||||||
expect(app).toContain("<Settings/><span>设置</span>")
|
expect(app).toContain("<StickyNote/><span>备忘录</span>")
|
||||||
expect(app).toContain("@click=\"switchView('settings')\"")
|
expect(app).toContain("<CalendarDays/><span>日历订阅</span>")
|
||||||
expect(app).toContain('<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', () => {
|
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).toContain(`:class="{active:activeView==='${view}'}" :aria-current="activeView==='${view}' ? 'page' : undefined"`)
|
||||||
}
|
}
|
||||||
expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'")
|
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', () => {
|
it('moves Settings identity into the body and suppresses the duplicate shell title', () => {
|
||||||
expect(app).not.toContain("'settings-main':activeView==='settings'")
|
expect(app).not.toContain("'settings-main':activeView==='settings'")
|
||||||
expect(app).toContain(":class=\"{'settings-topbar':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','focus'].includes(activeView)\" class=\"topbar-title\"")
|
||||||
expect(app).not.toContain('v-if="activeView===\'settings\'" class="icon topbar-refresh settings-refresh"')
|
expect(app).not.toContain('v-if="activeView===\'settings\'" class="icon topbar-refresh settings-refresh"')
|
||||||
expect(app).not.toContain('aria-label="刷新当前页面"')
|
expect(app).not.toContain('aria-label="刷新当前页面"')
|
||||||
expect(mvpPanel).toContain('<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>')
|
expect(mvpPanel).toContain('<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>')
|
||||||
@@ -170,23 +170,23 @@ describe('approved Settings 01 paper ledger', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('uses the selected continuous-paper geometry on desktop and mobile', () => {
|
it('uses the selected continuous-paper geometry on desktop and mobile', () => {
|
||||||
expect(css).toContain('main:has(>.mvp-view .settings-sections){background:#fffdf8}')
|
expect(css).toContain('main:has(>.mvp-view .settings-sections){background:var(--surface-raised)}')
|
||||||
expect(css).toContain('.settings-sections{width:min(100%,900px);margin:0 auto;display:grid;gap:0;padding:34px 0 64px}')
|
expect(css).toContain('.settings-sections{width:min(100%,900px);margin:0 auto;display:grid;gap:0;padding:var(--space-32) 0 64px}')
|
||||||
expect(css).toContain('.settings-heading h1{font-size:34px;line-height:1.1;font-weight:700;')
|
expect(css).toContain('.settings-heading h1{font-size:34px;line-height:1.1;font-weight:700;')
|
||||||
expect(css).toContain('.settings-group{min-width:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible;padding-top:25px}')
|
expect(css).toContain('.settings-group{min-width:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible;padding-top:var(--space-24)}')
|
||||||
expect(css).toContain('.settings-group>header{height:44px;padding:0;display:flex;align-items:center;border-bottom:1px solid #e8e0d5}')
|
expect(css).toContain('.settings-group>header{height:44px;padding:0;display:flex;align-items:center;border-bottom:1px solid var(--border-hairline)}')
|
||||||
expect(css).toContain('.settings-row{min-height:62px;padding:6px 0;border-top:0;border-bottom:1px solid #e8e0d5;')
|
expect(css).toContain('.settings-row{min-height:62px;padding:var(--space-6) 0;border-top:0;border-bottom:1px solid var(--border-hairline);')
|
||||||
expect(css).toContain('.settings-topbar{margin-bottom:0}')
|
expect(css).toContain('.settings-topbar{margin-bottom:0}')
|
||||||
expect(css).not.toContain('.topbar>.settings-refresh{grid-area:filter}')
|
expect(css).not.toContain('.topbar>.settings-refresh{grid-area:filter}')
|
||||||
expect(css).toContain('@media(max-width:720px){main:has(>.mvp-view .settings-sections){padding-left:29px;padding-right:29px;padding-bottom:calc(102px + env(safe-area-inset-bottom))}')
|
expect(css).toContain('@media(max-width:720px){main:has(>.mvp-view .settings-sections){padding-left:var(--space-28);padding-right:var(--space-28);padding-bottom:calc(102px + env(safe-area-inset-bottom))}')
|
||||||
expect(css).toContain('.settings-sections{width:100%;padding-top:23px;padding-bottom:0}')
|
expect(css).toContain('.settings-sections{width:100%;padding-top:var(--space-24);padding-bottom:0}')
|
||||||
expect(css).toContain('.settings-heading h1{font-size:24px;line-height:1.1;font-weight:700}')
|
expect(css).toContain('.settings-heading h1{font-size:24px;line-height:1.1;font-weight:700}')
|
||||||
expect(css).toContain('.settings-row{min-height:64px}')
|
expect(css).toContain('.settings-row{min-height:64px}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps controls and password errors aligned with the approved form contract', () => {
|
it('keeps controls and password errors aligned with the approved form contract', () => {
|
||||||
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button){border-radius:10px}')
|
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button){border-radius:var(--radius-control)}')
|
||||||
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button,.backup-preflight .danger-button,.password-form .primary-small){background:#f15a29;border-color:#f15a29;color:#fff;box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}')
|
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button,.backup-preflight .danger-button,.password-form .primary-small){background:var(--accent);border-color:var(--accent);color:var(--white);box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}')
|
||||||
expect(css).toContain('main:has(>.mvp-view .settings-sections) .danger-text{min-height:44px;border:0;background:transparent;color:var(--danger)}')
|
expect(css).toContain('main:has(>.mvp-view .settings-sections) .danger-text{min-height:44px;border:0;background:transparent;color:var(--danger)}')
|
||||||
expect(css).toContain('.password-form.settings-form{width:100%;display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:10px}')
|
expect(css).toContain('.password-form.settings-form{width:100%;display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:10px}')
|
||||||
expect(css).toContain('.password-form.settings-form .inline-error{grid-column:1/-1;margin:0}')
|
expect(css).toContain('.password-form.settings-form .inline-error{grid-column:1/-1;margin:0}')
|
||||||
@@ -265,14 +265,14 @@ describe('solid cream material system', () => {
|
|||||||
expect(css).toContain('--text-secondary:#655b50')
|
expect(css).toContain('--text-secondary:#655b50')
|
||||||
expect(css).toContain('--border-cream:#e4d5c0')
|
expect(css).toContain('--border-cream:#e4d5c0')
|
||||||
expect(css).toContain('--accent:#f15a29')
|
expect(css).toContain('--accent:#f15a29')
|
||||||
expect(css).toContain('--success:#687e61')
|
expect(css).toContain('--success:#5f705a')
|
||||||
expect(css).toContain('--radius-list:15px')
|
expect(css).toContain('--radius-list:15px')
|
||||||
expect(css).toContain('font-family:-apple-system,BlinkMacSystemFont,"Avenir Next","PingFang SC"')
|
expect(css).toContain('font-family:-apple-system,BlinkMacSystemFont,"Avenir Next","PingFang SC"')
|
||||||
expect(css).toContain('font-variant-numeric:tabular-nums')
|
expect(css).toContain('font-variant-numeric:tabular-nums')
|
||||||
expect(css).toContain('.habit-progress-bar::-webkit-progress-value{background:var(--success)')
|
expect(css).toContain('.habit-progress-bar::-webkit-progress-value{background:var(--success)')
|
||||||
expect(css).toContain('.habit-progress-bar::-moz-progress-bar{background:var(--success)')
|
expect(css).toContain('.habit-progress-bar::-moz-progress-bar{background:var(--success)')
|
||||||
expect(css).not.toContain('linear-gradient(90deg,#f7a066,var(--accent))')
|
expect(css).not.toContain('linear-gradient(90deg,#f7a066,var(--accent))')
|
||||||
expect(css).toContain('--highlight-inner:inset 0 1px 0 #fff')
|
expect(css).toContain('--highlight-inner:inset 0 1px 0 var(--white)')
|
||||||
expect(css).toContain('--radius-control:11px')
|
expect(css).toContain('--radius-control:11px')
|
||||||
expect(css).toContain('--radius-card:14px')
|
expect(css).toContain('--radius-card:14px')
|
||||||
expect(css).toContain('--radius-panel:20px')
|
expect(css).toContain('--radius-panel:20px')
|
||||||
@@ -287,13 +287,13 @@ describe('solid cream material system', () => {
|
|||||||
|
|
||||||
it('uses opaque cream surfaces for the shell, navigation, overlays, and feedback', () => {
|
it('uses opaque cream surfaces for the shell, navigation, overlays, and feedback', () => {
|
||||||
expect(css).toContain('.shell{background:var(--surface-base)}')
|
expect(css).toContain('.shell{background:var(--surface-base)}')
|
||||||
expect(css).toContain('.sidebar{background:#f7efe3')
|
expect(css).toContain('.sidebar{background:var(--surface-canvas)')
|
||||||
expect(css).toContain('main{background:var(--surface-base)}')
|
expect(css).toContain('main{background:var(--surface-base)}')
|
||||||
expect(css).toContain('.detail,.bottom{background:var(--surface-raised)')
|
expect(css).toContain('.detail,.bottom{background:var(--surface-raised)')
|
||||||
expect(css).toContain('.app-sheet,.calendar-picker,.sidebar-popover,.archived-row-actions{background:var(--surface-raised)')
|
expect(css).toContain('.app-sheet,.calendar-picker,.sidebar-popover,.archived-row-actions{background:var(--surface-raised)')
|
||||||
expect(css).toContain('.toast{background:#3b342c')
|
expect(css).toContain('.toast{background:var(--surface-inverse)')
|
||||||
expect(css).toContain('.error-toast{background:var(--danger)')
|
expect(css).toContain('.error-toast{background:var(--danger)')
|
||||||
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
|
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--scrim)')
|
||||||
expect(css).toContain('--scrim:rgba(45,38,31,.38)')
|
expect(css).toContain('--scrim:rgba(45,38,31,.38)')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -317,22 +317,23 @@ describe('solid cream material system', () => {
|
|||||||
|
|
||||||
describe('approved UI detail direction', () => {
|
describe('approved UI detail direction', () => {
|
||||||
it('opens ordinary and overdue task details from the task body while preserving Trash actions', () => {
|
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))
|
const ordinaryRows = app.slice(ordinaryStart, app.indexOf('</section>', ordinaryStart))
|
||||||
expect(ordinaryRows).toContain("selectTaskUnlessSwiped(node.task)")
|
expect(ordinaryRows).toContain("selectTaskUnlessSwiped(node.task)")
|
||||||
expect(ordinaryRows).not.toContain('task-detail-trigger')
|
expect(ordinaryRows).not.toContain('task-detail-trigger')
|
||||||
expect(ordinaryRows).not.toContain('selectTask(subtask)')
|
expect(ordinaryRows).not.toContain('selectTask(subtask)')
|
||||||
expect(ordinaryRows).not.toContain('aria-label="删除任务"')
|
expect(ordinaryRows).not.toContain('aria-label="删除任务"')
|
||||||
expect(ordinaryRows).toContain('v-if="activeView===\'trash\'" class="task-actions"')
|
const trashRows = app.slice(app.indexOf('class="trash-groups"'), ordinaryStart)
|
||||||
expect(ordinaryRows).toContain('restoreTask(node.task)')
|
expect(trashRows).toContain('class="task-actions"')
|
||||||
expect(ordinaryRows).toContain('purgeTask(node.task)')
|
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"'))
|
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).toContain('selectTaskUnlessSwiped(node.task)')
|
||||||
expect(overdue).not.toContain('aria-label="打开任务详情"')
|
expect(overdue).not.toContain('aria-label="打开任务详情"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the due tail as the final parent-row control and opens details from the task body', () => {
|
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).toContain('<span v-if="node.task.due_at" class="task-tail">')
|
||||||
expect(app).not.toContain('task-detail-trigger')
|
expect(app).not.toContain('task-detail-trigger')
|
||||||
expect(css).not.toContain('.task-detail-trigger')
|
expect(css).not.toContain('.task-detail-trigger')
|
||||||
@@ -390,14 +391,14 @@ describe('approved UI detail direction', () => {
|
|||||||
|
|
||||||
it('matches the approved paper-flow geometry and material', () => {
|
it('matches the approved paper-flow geometry and material', () => {
|
||||||
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
|
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
|
||||||
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:24px}')
|
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:var(--space-24)}')
|
||||||
expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--line);background:#fffdf8;overflow:hidden;display:flex;flex-direction:column}')
|
expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--border-cream);background:var(--surface-raised);overflow:hidden;display:flex;flex-direction:column}')
|
||||||
expect(css).toContain('.detail-head{height:58px;flex:0 0 58px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;')
|
expect(css).toContain('.detail-head{height:58px;flex:0 0 58px;display:flex;align-items:center;justify-content:space-between;padding:0 var(--space-18);')
|
||||||
expect(css).toContain('.detail-form{min-width:0;border:0;margin:0;grid-template-columns:minmax(0,1fr);padding:18px;display:grid;gap:18px;overflow-y:auto;')
|
expect(css).toContain('.detail-form{min-width:0;border:0;margin:0;grid-template-columns:minmax(0,1fr);padding:var(--space-18);display:grid;gap:18px;overflow-y:auto;')
|
||||||
expect(css).toContain('.detail-title textarea{min-width:0;min-height:62px;padding:0;border:0;background:transparent;box-shadow:none;resize:none;outline:none;font-size:20px;line-height:27px;')
|
expect(css).toContain('.detail-title textarea{min-width:0;min-height:62px;padding:0;border:0;background:transparent;box-shadow:none;resize:none;outline:none;font-size:20px;line-height:27px;')
|
||||||
expect(css).toContain('.task-detail-arrangement{display:grid;gap:13px;padding-bottom:18px;border-bottom:1px solid #e4dbcf}')
|
expect(css).toContain('.task-detail-arrangement{display:grid;gap:13px;padding-bottom:var(--space-18);border-bottom:1px solid var(--border-section)}')
|
||||||
expect(css).toContain('.task-detail-date-time{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px}')
|
expect(css).toContain('.task-detail-date-time{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px}')
|
||||||
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:10px}')
|
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:var(--radius-control)}')
|
||||||
expect(css).toContain('.detail-actions{position:sticky;bottom:0;z-index:3;min-height:69px;')
|
expect(css).toContain('.detail-actions{position:sticky;bottom:0;z-index:3;min-height:69px;')
|
||||||
expect(css).toContain('.detail-save{min-width:88px}')
|
expect(css).toContain('.detail-save{min-width:88px}')
|
||||||
expect(css).toContain('.subtask-detail,.after-completion-fields,.repeat-custom-fields{width:100%;max-width:100%;min-width:0;border:0;border-radius:0;box-shadow:none;background:transparent;overflow:visible}')
|
expect(css).toContain('.subtask-detail,.after-completion-fields,.repeat-custom-fields{width:100%;max-width:100%;min-width:0;border:0;border-radius:0;box-shadow:none;background:transparent;overflow:visible}')
|
||||||
@@ -419,7 +420,7 @@ describe('approved UI detail direction', () => {
|
|||||||
it('keeps custom recurrence controls at 44px without overflowing narrow screens', () => {
|
it('keeps custom recurrence controls at 44px without overflowing narrow screens', () => {
|
||||||
expect(css).toContain('.repeat-custom-fields input,.repeat-custom-fields select{min-height:44px;')
|
expect(css).toContain('.repeat-custom-fields input,.repeat-custom-fields select{min-height:44px;')
|
||||||
expect(css).toContain('.weekday-picker label{width:44px;height:44px;')
|
expect(css).toContain('.weekday-picker label{width:44px;height:44px;')
|
||||||
expect(css).toContain('@media(max-width:930px){.repeat-custom-fields{padding:10px;gap:8px}')
|
expect(css).toContain('@media(max-width:930px){.repeat-custom-fields{padding:var(--space-10);gap:8px}')
|
||||||
expect(css).toContain('.repeat-custom-fields>div,.repeat-custom-fields>label{min-width:0;flex-wrap:wrap;gap:6px}')
|
expect(css).toContain('.repeat-custom-fields>div,.repeat-custom-fields>label{min-width:0;flex-wrap:wrap;gap:6px}')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -483,7 +484,7 @@ describe('completion feedback motion', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('stacks the desktop calendar copy to align with weather and gold', () => {
|
it('stacks the desktop calendar copy to align with weather and gold', () => {
|
||||||
expect(css).toContain('@media(min-width:721px){.today-environment__calendar{flex-direction:column;align-items:flex-start;gap:0}.today-environment__calendar small{margin-top:2px;font-size:10.5px}}')
|
expect(css).toContain('@media(min-width:721px){.today-environment__calendar{flex-direction:column;align-items:flex-start;gap:0}.today-environment__calendar small{margin-top:var(--space-2);font-size:11px}}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the approved internal-standard login composition and material tokens', () => {
|
it('uses the approved internal-standard login composition and material tokens', () => {
|
||||||
@@ -494,12 +495,12 @@ describe('completion feedback motion', () => {
|
|||||||
expect(app).toContain('<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>')
|
expect(app).toContain('<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>')
|
||||||
expect(app).toContain('<form class="auth-card" aria-labelledby="auth-title" @submit.prevent="submitAuth">')
|
expect(app).toContain('<form class="auth-card" aria-labelledby="auth-title" @submit.prevent="submitAuth">')
|
||||||
expect(app).toContain('<button type="submit" class="primary">')
|
expect(app).toContain('<button type="submit" class="primary">')
|
||||||
expect(css).toMatch(/\.auth-card\{[^}]*width:min\(400px,100%\)[^}]*padding:36px[^}]*border:1px solid var\(--border-cream\)[^}]*border-radius:var\(--radius-card\)/)
|
expect(css).toMatch(/\.auth-card\{[^}]*width:min\(400px,100%\)[^}]*padding:var\(--space-36\)[^}]*border:1px solid var\(--border-cream\)[^}]*border-radius:var\(--radius-card\)/)
|
||||||
expect(css).toContain('.auth-brand .brand{gap:9px;font-size:28px;letter-spacing:-2px}')
|
expect(css).toContain('.auth-brand .brand{gap:9px;font-size:28px;letter-spacing:-2px}')
|
||||||
expect(css).toContain('.auth-brand .brand-logo{width:34px;height:34px}')
|
expect(css).toContain('.auth-brand .brand-logo{width:34px;height:34px}')
|
||||||
expect(css).toMatch(/\.auth-card input\{[^}]*min-height:46px[^}]*background:var\(--surface-raised\)/)
|
expect(css).toMatch(/\.auth-card input\{[^}]*min-height:46px[^}]*background:var\(--surface-raised\)/)
|
||||||
expect(css).toContain('.auth-card>.primary{min-height:46px}')
|
expect(css).toContain('.auth-card>.primary{min-height:46px}')
|
||||||
expect(css).toMatch(/@media\(max-width:930px\)\{\.auth-shell\{padding:18px\}\.auth-card\{padding:30px 24px\}/)
|
expect(css).toMatch(/@media\(max-width:930px\)\{\.auth-shell\{padding:var\(--space-18\)\}\.auth-card\{padding:var\(--space-28\) var\(--space-24\)\}/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('syncs browser IANA timezone before loading user task data', () => {
|
it('syncs browser IANA timezone before loading user task data', () => {
|
||||||
@@ -525,8 +526,8 @@ describe('completion feedback motion', () => {
|
|||||||
expect(css).toContain('.markdown-toolbar{display:flex;')
|
expect(css).toContain('.markdown-toolbar{display:flex;')
|
||||||
expect(css).toContain('overflow-x:auto')
|
expect(css).toContain('overflow-x:auto')
|
||||||
expect(css).toContain('.markdown-toolbar button{min-width:44px;min-height:44px;')
|
expect(css).toContain('.markdown-toolbar button{min-width:44px;min-height:44px;')
|
||||||
expect(css).toContain('.markdown-preview>.contains-task-list{list-style:none;padding-left:3px}')
|
expect(css).toContain('.markdown-preview>.contains-task-list{list-style:none;padding-left:var(--space-2)}')
|
||||||
expect(css).toContain('.markdown-preview .contains-task-list .contains-task-list{padding-left:24px}')
|
expect(css).toContain('.markdown-preview .contains-task-list .contains-task-list{padding-left:var(--space-24)}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps all task detail changes on the unified save button', () => {
|
it('keeps all task detail changes on the unified save button', () => {
|
||||||
@@ -544,7 +545,7 @@ describe('completion feedback motion', () => {
|
|||||||
|
|
||||||
it('keeps the desktop task composer compact and centered', () => {
|
it('keeps the desktop task composer compact and centered', () => {
|
||||||
expect(css).toContain('@media(min-width:931px){.task-compose-sheet:not(.habit-compose-sheet){width:min(560px,calc(100vw - 48px));height:min(520px,82dvh);max-height:none;padding:0;display:flex;flex-direction:column;gap:0;overflow:hidden}')
|
expect(css).toContain('@media(min-width:931px){.task-compose-sheet:not(.habit-compose-sheet){width:min(560px,calc(100vw - 48px));height:min(520px,82dvh);max-height:none;padding:0;display:flex;flex-direction:column;gap:0;overflow:hidden}')
|
||||||
expect(css).toContain('.app-sheet-mask:has(>.task-compose-sheet:not(.habit-compose-sheet)){place-items:center;padding:24px}')
|
expect(css).toContain('.app-sheet-mask:has(>.task-compose-sheet:not(.habit-compose-sheet)){place-items:center;padding:var(--space-24)}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps all task composer touch controls at least 44px on mobile without horizontal overflow', () => {
|
it('keeps all task composer touch controls at least 44px on mobile without horizontal overflow', () => {
|
||||||
@@ -657,8 +658,8 @@ describe('mobile sheet contract', () => {
|
|||||||
expect(mvpPanel).toContain('panel-class="habit-detail-sheet"')
|
expect(mvpPanel).toContain('panel-class="habit-detail-sheet"')
|
||||||
expect(countdownPanel).toContain('panel-class="countdown-detail-sheet"')
|
expect(countdownPanel).toContain('panel-class="countdown-detail-sheet"')
|
||||||
expect(countdownPanel).toContain('panel-class="countdown-modal"')
|
expect(countdownPanel).toContain('panel-class="countdown-modal"')
|
||||||
expect(css).toContain('--sheet-radius:20px;--sheet-scrim:rgba(45,38,31,.4)')
|
expect(css).toContain('--scrim:rgba(45,38,31,.38);--radius-control:11px')
|
||||||
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
|
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--scrim)')
|
||||||
expect(css).toContain('.app-sheet__header{min-height:64px;')
|
expect(css).toContain('.app-sheet__header{min-height:64px;')
|
||||||
expect(css).toContain('.app-sheet__body{min-height:0;overflow-y:auto;')
|
expect(css).toContain('.app-sheet__body{min-height:0;overflow-y:auto;')
|
||||||
expect(css).toContain('.app-sheet__footer{position:sticky;bottom:0;')
|
expect(css).toContain('.app-sheet__footer{position:sticky;bottom:0;')
|
||||||
@@ -668,14 +669,14 @@ describe('mobile sheet contract', () => {
|
|||||||
it('keeps the danger zone reserved for archived habit deletion', () => {
|
it('keeps the danger zone reserved for archived habit deletion', () => {
|
||||||
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at" class="app-sheet__danger habit-detail-archived-actions"')
|
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at" class="app-sheet__danger habit-detail-archived-actions"')
|
||||||
expect(mvpPanel).toContain('deleteHabit(selectedHabit)')
|
expect(mvpPanel).toContain('deleteHabit(selectedHabit)')
|
||||||
expect(css).toContain('.app-sheet__danger{border-top:1px solid #f1d4cd;')
|
expect(css).toContain('.app-sheet__danger{border-top:1px solid var(--app-sheet-danger-border);')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('mobile list row language', () => {
|
describe('mobile list row language', () => {
|
||||||
it('uses one quiet bordered row surface for tasks, habits, and countdowns on mobile', () => {
|
it('uses one quiet bordered row surface for tasks, habits, and countdowns on mobile', () => {
|
||||||
expect(css).toContain('@media(max-width:930px){.task-row,.habit-row,.countdown-row{')
|
expect(css).toContain('@media(max-width:930px){.task-row,.habit-row,.countdown-row{')
|
||||||
expect(css).toMatch(/@media\(max-width:930px\)\{\.task-row,\.habit-row,\.countdown-row\{[^}]*background:#fff;[^}]*border:1px solid var\(--line\);[^}]*border-radius:13px;[^}]*box-shadow:none/)
|
expect(css).toMatch(/@media\(max-width:930px\)\{\.task-row,\.habit-row,\.countdown-row\{[^}]*background:var\(--white\);[^}]*border:1px solid var\(--border-cream\);[^}]*border-radius:13px;[^}]*box-shadow:none/)
|
||||||
expect(css).toContain('.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}')
|
expect(css).toContain('.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}')
|
||||||
expect(css).toContain('.countdown-row{grid-template-columns:minmax(0,1fr) 72px;')
|
expect(css).toContain('.countdown-row{grid-template-columns:minmax(0,1fr) 72px;')
|
||||||
expect(css).not.toContain('grid-template-columns:44px minmax(0,1fr) 72px')
|
expect(css).not.toContain('grid-template-columns:44px minmax(0,1fr) 72px')
|
||||||
@@ -798,7 +799,7 @@ describe('task and habit row decoration', () => {
|
|||||||
it('shows task drag handles only in an explicit available reorder mode', () => {
|
it('shows task drag handles only in an explicit available reorder mode', () => {
|
||||||
expect(app).toContain('const taskReorderMode = ref(false)')
|
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("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("{{ taskReorderMode ? '完成' : '调整顺序' }}")
|
||||||
expect(app).toContain('v-if="taskReorderMode" class="drag-handle task-drag-handle"')
|
expect(app).toContain('v-if="taskReorderMode" class="drag-handle task-drag-handle"')
|
||||||
expect(app).toContain('if (!taskReorderAvailable.value) taskReorderMode.value = false')
|
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', () => {
|
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 :title="subtask.title">{{subtask.title}}</strong>')
|
||||||
expect(app).not.toContain('<strong>{{node.task.title}}</strong>')
|
expect(app).not.toContain('<strong>{{node.task.title}}</strong>')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the Trash restore action at least 44px tall', () => {
|
it('keeps Trash row actions inside the ellipsis menu', () => {
|
||||||
expect(css).toMatch(/\.restore\{[^}]*min-height:44px/)
|
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', () => {
|
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 mutationBlock = app.slice(app.indexOf('async function mutateTrashTask'), app.indexOf('async function restoreTask'))
|
||||||
const restoreBlock = app.slice(app.indexOf('async function restoreTask'), app.indexOf('async function purgeTask'))
|
const restoreBlock = app.slice(app.indexOf('async function restoreTask'), app.indexOf('async function purgeTask'))
|
||||||
const purgeBlock = app.slice(app.indexOf('async function purgeTask'), app.indexOf('async function addSubtask'))
|
const purgeBlock = app.slice(app.indexOf('async function purgeTask'), app.indexOf('async function addSubtask'))
|
||||||
expect(loadBlock).toContain('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('await taskMutationReconciler.run(')
|
||||||
expect(mutationBlock).toContain('{ affectsTrash: true, affectsTaskView }')
|
expect(mutationBlock).toContain('{ affectsTrash: true, affectsTaskView }')
|
||||||
expect(mutationBlock).not.toContain('performTrashMutation(')
|
expect(mutationBlock).not.toContain('performTrashMutation(')
|
||||||
@@ -898,7 +935,7 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(ordinaryRows).not.toContain('v-for="subtask in node.subtasks"')
|
expect(ordinaryRows).not.toContain('v-for="subtask in node.subtasks"')
|
||||||
expect(app).not.toContain('collapsedTaskIds')
|
expect(app).not.toContain('collapsedTaskIds')
|
||||||
expect(app).not.toContain('toggleTaskChildren')
|
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('v-for="subtask in selectedTaskSubtasks"')
|
||||||
expect(app).toContain('class="subtask-detail"')
|
expect(app).toContain('class="subtask-detail"')
|
||||||
})
|
})
|
||||||
@@ -1016,7 +1053,7 @@ describe('task and habit row decoration', () => {
|
|||||||
const desktopMain = css.slice(css.indexOf('main{container-type:inline-size;'), css.indexOf('.topbar{'))
|
const desktopMain = css.slice(css.indexOf('main{container-type:inline-size;'), css.indexOf('.topbar{'))
|
||||||
expect(desktopMain).toContain('overflow:auto')
|
expect(desktopMain).toContain('overflow:auto')
|
||||||
expect(css).toContain('@media(min-width:931px){main{scrollbar-width:none}main::-webkit-scrollbar{display:none}}')
|
expect(css).toContain('@media(min-width:931px){main{scrollbar-width:none}main::-webkit-scrollbar{display:none}}')
|
||||||
expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}')
|
expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--border-cream);background:var(--detail-bg);overflow:auto}')
|
||||||
expect(css).not.toContain('@media(max-width:930px){main{scrollbar-width:none}')
|
expect(css).not.toContain('@media(max-width:930px){main{scrollbar-width:none}')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1035,7 +1072,7 @@ describe('task and habit row decoration', () => {
|
|||||||
const mobileLayout = css.slice(css.indexOf('@media(max-width:930px){.shell'))
|
const mobileLayout = css.slice(css.indexOf('@media(max-width:930px){.shell'))
|
||||||
expect(mobileLayout).toContain('.completed-filter-pill{min-width:138px;height:44px')
|
expect(mobileLayout).toContain('.completed-filter-pill{min-width:138px;height:44px')
|
||||||
expect(css).not.toContain('width:min(260px,100%)')
|
expect(css).not.toContain('width:min(260px,100%)')
|
||||||
expect(css).not.toContain('.today-completed-toolbar{margin-top:8px}')
|
expect(css).not.toContain('.today-completed-toolbar{margin-top:var(--space-8)}')
|
||||||
expect(css).not.toContain('.habit-toolbar{')
|
expect(css).not.toContain('.habit-toolbar{')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1132,7 +1169,7 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(mvpPanel).toContain("emit('summary', value)")
|
expect(mvpPanel).toContain("emit('summary', value)")
|
||||||
expect(app).toContain('taskComposeTitle')
|
expect(app).toContain('taskComposeTitle')
|
||||||
expect(app).toContain('添加今天任务')
|
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('class="empty-panel today-empty-panel"')
|
||||||
expect(mvpPanel).toContain('添加习惯')
|
expect(mvpPanel).toContain('添加习惯')
|
||||||
expect(css).toContain('.today-context{')
|
expect(css).toContain('.today-context{')
|
||||||
@@ -1165,8 +1202,8 @@ describe('task and habit row decoration', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('uses a gentle completion treatment instead of a discarded-task strike', () => {
|
it('uses a gentle completion treatment instead of a discarded-task strike', () => {
|
||||||
expect(css).toContain('.habit-row.done .habit-name{color:#756c61;')
|
expect(css).toContain('.habit-row.done .habit-name{color:var(--text-secondary);')
|
||||||
expect(css).toContain('text-decoration-color:#d8c8bb')
|
expect(css).toContain('text-decoration-color:var(--habit-row-strike)')
|
||||||
expect(css).toContain('text-decoration-thickness:1px')
|
expect(css).toContain('text-decoration-thickness:1px')
|
||||||
expect(css).not.toContain('.habit-row.done .habit-name{color:var(--accent);text-decoration:line-through')
|
expect(css).not.toContain('.habit-row.done .habit-name{color:var(--accent);text-decoration:line-through')
|
||||||
})
|
})
|
||||||
@@ -1180,7 +1217,7 @@ describe('task and habit row decoration', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('uses a continuous bottom divider instead of card borders', () => {
|
it('uses a continuous bottom divider instead of card borders', () => {
|
||||||
expect(css).toMatch(/\.plain-list-row,\.plain-list>\.task-row,\.plain-list>\.habit-row\{[^}]*border:0;[^}]*border-bottom:1px solid #e8e0d5/)
|
expect(css).toMatch(/\.plain-list-row,\.plain-list>\.task-row,\.plain-list>\.habit-row\{[^}]*border:0;[^}]*border-bottom:1px solid var\(--border-hairline\)/)
|
||||||
expect(css).not.toMatch(/\.plain-list-row,\.plain-list>\.task-row,\.plain-list>\.habit-row\{[^}]*border-top:/)
|
expect(css).not.toMatch(/\.plain-list-row,\.plain-list>\.task-row,\.plain-list>\.habit-row\{[^}]*border-top:/)
|
||||||
expect(css).not.toMatch(/\.plain-list-row,\.plain-list>\.task-row,\.plain-list>\.habit-row\{[^}]*border-right:/)
|
expect(css).not.toMatch(/\.plain-list-row,\.plain-list>\.task-row,\.plain-list>\.habit-row\{[^}]*border-right:/)
|
||||||
})
|
})
|
||||||
@@ -1219,7 +1256,7 @@ describe('approved habit safety and U2 title hierarchy', () => {
|
|||||||
expect(css).toContain('.settings-sections{width:min(100%,900px);')
|
expect(css).toContain('.settings-sections{width:min(100%,900px);')
|
||||||
expect(css).toContain('.settings-row{min-height:62px;')
|
expect(css).toContain('.settings-row{min-height:62px;')
|
||||||
expect(css).toContain('.backup-preflight .danger-button{min-height:44px}')
|
expect(css).toContain('.backup-preflight .danger-button{min-height:44px}')
|
||||||
expect(css).toContain('.backup-preflight.invalid{background:#fff2ef;')
|
expect(css).toContain('.backup-preflight.invalid{background:var(--error-wash);')
|
||||||
expect(mvpPanel).toContain("v-if=\"restoreMode==='replace'\" class=\"restore-replace-warning\"")
|
expect(mvpPanel).toContain("v-if=\"restoreMode==='replace'\" class=\"restore-replace-warning\"")
|
||||||
expect(mvpPanel).toContain('替换恢复会覆盖当前数据,请先导出完整备份。')
|
expect(mvpPanel).toContain('替换恢复会覆盖当前数据,请先导出完整备份。')
|
||||||
})
|
})
|
||||||
@@ -1307,7 +1344,7 @@ describe('habit detail paper-flow redesign', () => {
|
|||||||
|
|
||||||
it('matches the paper-flow surface and action hierarchy at desktop and touch widths', () => {
|
it('matches the paper-flow surface and action hierarchy at desktop and touch widths', () => {
|
||||||
expect(css).toContain('/* Approved habit detail 01: paper flow. */')
|
expect(css).toContain('/* Approved habit detail 01: paper flow. */')
|
||||||
expect(css).toMatch(/\.habit-detail-hero\{[^}]*padding:[^;}]*;[^}]*border-bottom:1px solid #e4dbcf/)
|
expect(css).toMatch(/\.habit-detail-hero\{[^}]*padding:[^;}]*;[^}]*border-bottom:1px solid var\(--border-section\)/)
|
||||||
expect(css).toMatch(/\.habit-detail-hero strong\{[^}]*font-size:28px/)
|
expect(css).toMatch(/\.habit-detail-hero strong\{[^}]*font-size:28px/)
|
||||||
expect(css).toMatch(/\.habit-detail-progress-row\{[^}]*min-height:66px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/)
|
expect(css).toMatch(/\.habit-detail-progress-row\{[^}]*min-height:66px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/)
|
||||||
expect(css).toMatch(/\.habit-detail-meta-row\{[^}]*min-height:52px;[^}]*grid-template-columns:86px minmax\(0,1fr\)/)
|
expect(css).toMatch(/\.habit-detail-meta-row\{[^}]*min-height:52px;[^}]*grid-template-columns:86px minmax\(0,1fr\)/)
|
||||||
@@ -1330,8 +1367,8 @@ describe('task detail layout', () => {
|
|||||||
expect(app).toContain('<div class="task-detail-date-time">')
|
expect(app).toContain('<div class="task-detail-date-time">')
|
||||||
expect(app).toContain('v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input"')
|
expect(app).toContain('v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input"')
|
||||||
expect(app.match(/class="task-detail-field-input"/g)).toHaveLength(2)
|
expect(app.match(/class="task-detail-field-input"/g)).toHaveLength(2)
|
||||||
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:10px}')
|
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:var(--radius-control)}')
|
||||||
expect(css).toContain('.task-detail-due-input{padding-inline:9px!important}')
|
expect(css).toContain('.task-detail-due-input{padding-inline:var(--space-8)!important}')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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('<span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat"')
|
||||||
expect(app).toContain('<option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option>')
|
expect(app).toContain('<option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option>')
|
||||||
expect(app).toContain('完成后 <input v-model="composeAfterCompletionDays"')
|
expect(app).toContain('完成后 <input v-model="composeAfterCompletionDays"')
|
||||||
|
expect(app).toContain('v-model="composeAfterCompletionUnit"')
|
||||||
expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"')
|
expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"')
|
||||||
expect(app).toContain('每次完成后,将截止时间顺延对应天数;首版永不结束')
|
expect(app).toContain('v-model="selectedAfterCompletionUnit"')
|
||||||
|
expect(app).toContain('<option value="days">天</option><option value="months">月</option>')
|
||||||
|
expect(app).toContain('月末会自动取目标月最后一天')
|
||||||
expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value')
|
expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value')
|
||||||
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })')
|
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config })')
|
||||||
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
|
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
|
||||||
const createBlock = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()'))
|
const createBlock = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()'))
|
||||||
expect(createBlock).toContain("api('/tasks',")
|
expect(createBlock).toContain("api('/tasks',")
|
||||||
@@ -1388,11 +1428,11 @@ describe('unified floating add interaction', () => {
|
|||||||
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
|
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
|
||||||
expect(saveBlock).toContain('selectedTaskRecurrence.value = null')
|
expect(saveBlock).toContain('selectedTaskRecurrence.value = null')
|
||||||
expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'")
|
expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'")
|
||||||
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)')
|
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence)')
|
||||||
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
|
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
|
||||||
expect(dueRemovalBlock).not.toContain('saveRepeat(')
|
expect(dueRemovalBlock).not.toContain('saveRepeat(')
|
||||||
expect(saveBlock).toContain("selectedRepeatError.value = ''")
|
expect(saveBlock).toContain("selectedRepeatError.value = ''")
|
||||||
expect(saveBlock).toContain("selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败'")
|
expect(saveBlock).toContain("selectedRepeatError.value = failureHint('保存失败', reason)")
|
||||||
expect(saveBlock.indexOf("toast('已保存')")).toBeGreaterThan(saveBlock.indexOf('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)'))
|
expect(saveBlock.indexOf("toast('已保存')")).toBeGreaterThan(saveBlock.indexOf('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)'))
|
||||||
expect(saveBlock).toContain('if (savingSelectedTask.value || recurrenceLoading.value) return')
|
expect(saveBlock).toContain('if (savingSelectedTask.value || recurrenceLoading.value) return')
|
||||||
expect(saveBlock).toContain('savingSelectedTask.value = true')
|
expect(saveBlock).toContain('savingSelectedTask.value = true')
|
||||||
@@ -1445,7 +1485,7 @@ describe('unified floating add interaction', () => {
|
|||||||
expect(floatingAdd).toContain('<Plus />')
|
expect(floatingAdd).toContain('<Plus />')
|
||||||
expect(floatingAdd).not.toContain('fab-cat')
|
expect(floatingAdd).not.toContain('fab-cat')
|
||||||
expect(floatingAdd).not.toContain('pupilOffset')
|
expect(floatingAdd).not.toContain('pupilOffset')
|
||||||
expect(css).toContain('.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent);color:#fff;')
|
expect(css).toContain('.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent);color:var(--white);')
|
||||||
expect(css).toContain('.unified-fab>svg{width:25px;height:25px}')
|
expect(css).toContain('.unified-fab>svg{width:25px;height:25px}')
|
||||||
expect(css).not.toContain('.fab-cat__')
|
expect(css).not.toContain('.fab-cat__')
|
||||||
})
|
})
|
||||||
@@ -1492,7 +1532,7 @@ describe('desktop task and habit detail disclosure', () => {
|
|||||||
expect(mvpPanel).toContain('if (busy.value && !force) return')
|
expect(mvpPanel).toContain('if (busy.value && !force) return')
|
||||||
expect(mvpPanel).toContain(':modal="compactLayout"')
|
expect(mvpPanel).toContain(':modal="compactLayout"')
|
||||||
expect(mvpPanel).toContain('inline-target=".shell"')
|
expect(mvpPanel).toContain('inline-target=".shell"')
|
||||||
expect(appSheet).toContain('<Teleport v-else-if="inlineTarget" :to="inlineTarget">')
|
expect(appSheet).toContain(`<Teleport v-else-if="inlineTarget && open" :key="'app-sheet-inline'" :to="inlineTarget">`)
|
||||||
expect(mvpPanel).toContain("watch(selectedHabit, (habit) => emit('detail', Boolean(habit)))")
|
expect(mvpPanel).toContain("watch(selectedHabit, (habit) => emit('detail', Boolean(habit)))")
|
||||||
expect(app).toContain('@click="closeTaskDetail"')
|
expect(app).toContain('@click="closeTaskDetail"')
|
||||||
expect(app).toContain('function closeTaskDetail()')
|
expect(app).toContain('function closeTaskDetail()')
|
||||||
@@ -1556,60 +1596,56 @@ describe('sidebar information hierarchy', () => {
|
|||||||
expect(css).toContain('width:3px;')
|
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('aria-label="打开清单操作"')
|
expect(app).toContain('aria-label="打开清单操作"')
|
||||||
expect(app).toContain('panel-class="sidebar-action-sheet"')
|
expect(app).toContain('panel-class="sidebar-action-sheet"')
|
||||||
expect(app).toContain(':label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined"')
|
expect(app).toContain('panel-class="list-editor-sheet"')
|
||||||
expect(app).toContain('class="sidebar-action-kind"')
|
expect(app).toContain('id="list-editor-title"')
|
||||||
expect(app).toContain('class="sidebar-action-group"')
|
expect(app).toContain('v-model="listEditorName"')
|
||||||
expect(app).toContain('class="sidebar-action-group-title"')
|
expect(app).toContain('v-model="listEditorFolderId"')
|
||||||
expect(app).toContain('class="sidebar-action-danger"')
|
expect(app).toContain('>所在文件夹<')
|
||||||
expect(app).toContain("sidebarAction.kind==='folders'?'文件夹':'清单'")
|
expect(app).toContain("{{listEditorBusy?'正在保存…':'保存更改'}}")
|
||||||
expect(app).toContain("sidebarAction.kind==='folders' ? `删除后,其中 ${sidebarActionFolderListCount} 个清单会移到“我的清单”` : '任务会保留,可从“已归档”恢复'")
|
expect(app).toContain('任务会保留,可从“已归档”恢复')
|
||||||
expect(css).toContain('.sidebar-action-sheet{width:min(320px,calc(100vw - 24px));')
|
expect(app).not.toContain('class="list-editor-position"')
|
||||||
expect(css).toContain('.sidebar-action-group{display:grid;gap:2px;')
|
expect(app).not.toContain('aria-label="上移清单"')
|
||||||
expect(css).toContain('.sidebar-action-danger{border-top:1px solid')
|
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:var(--space-24)}.app-sheet-mask:has(.list-editor-sheet) .list-editor-sheet{margin:0}}')
|
||||||
|
expect(css).toContain('.list-editor-form{display:grid;gap:17px;padding:var(--space-18) var(--space-20)}')
|
||||||
|
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('@keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()"')
|
||||||
expect(app).toContain('sidebarCreateOpen.value = false; sidebarAction.value = null')
|
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')
|
||||||
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', () => {
|
describe('quiet index sidebar parity', () => {
|
||||||
it('matches the approved desktop and mobile sidebar geometry and material', () => {
|
it('matches the approved desktop and mobile sidebar geometry and material', () => {
|
||||||
expect(css).toContain('.shell{height:100vh;display:grid;grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) 0;')
|
expect(css).toContain('.shell{height:100vh;display:grid;grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) 0;')
|
||||||
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
|
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
|
||||||
expect(css).toContain('.sidebar{border-right:1px solid #e4d5c3;background:#f7efe3;')
|
expect(css).toContain('.sidebar{border-right:1px solid var(--border-cream);background:var(--surface-canvas);')
|
||||||
expect(css).toContain('.brand-row{height:68px;')
|
expect(css).toContain('.brand-row{height:68px;')
|
||||||
expect(css).toContain('border-bottom:1px solid rgba(222,205,185,.75)')
|
expect(css).toContain('border-bottom:1px solid rgba(222,205,185,.75)')
|
||||||
expect(css).toContain('.primary-nav{display:grid;padding:10px 11px 8px;gap:2px}')
|
expect(css).toContain('.primary-nav{display:grid;padding:var(--space-10) var(--space-10) var(--space-8);gap:2px}')
|
||||||
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:24px}')
|
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:var(--space-24)}')
|
||||||
expect(css).not.toContain('grid-template-columns:220px')
|
expect(css).not.toContain('grid-template-columns:220px')
|
||||||
expect(css).toContain('@media(max-width:930px){.shell')
|
expect(css).toContain('@media(max-width:930px){.shell')
|
||||||
expect(css).toContain('left:0;width:236px;max-width:86vw;')
|
expect(css).toContain('left:0;width:236px;max-width:86vw;')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('matches the approved quiet navigation and list hierarchy', () => {
|
it('matches the approved quiet navigation and list hierarchy', () => {
|
||||||
expect(css).toContain('.primary-nav button.active{background:#f9e2d5;color:#b63c1b;')
|
expect(css).toContain('.primary-nav button.active{background:var(--nav-active-bg);color:var(--accent-ink);')
|
||||||
expect(css).toContain('.primary-nav button.active::before,.list-row.active::before,.sidebar-management button.active::before{')
|
expect(css).toContain('.primary-nav button.active::before,.list-row.active::before,.sidebar-management button.active::before{')
|
||||||
expect(css).toContain('width:3px;height:18px;')
|
expect(css).toContain('width:3px;height:18px;')
|
||||||
expect(css).toContain('.section-title{padding:14px 15px 5px 20px;')
|
expect(css).toContain('.section-title{padding:var(--space-14) var(--space-14) var(--space-4) var(--space-20);')
|
||||||
expect(css).toContain('.folders{flex:1;min-height:0;padding:0 10px;')
|
expect(css).toContain('.folders{flex:1;min-height:0;padding:0 var(--space-10);')
|
||||||
expect(css).toContain('.folder-row>button,.list-row-main{flex:1;min-width:0;min-height:44px;border:0;background:transparent;padding:0 8px;')
|
expect(css).toContain('.folder-row>button,.list-row-main{flex:1;min-width:0;min-height:44px;border:0;background:transparent;padding:0 var(--space-8);')
|
||||||
expect(css).toContain('.list-row.active{background:#fff8ef;color:#bc431f;box-shadow:inset 0 0 0 1px #ead5c4}')
|
expect(css).toContain('.list-row.active{background:var(--list-row-bg);color:var(--accent-ink);box-shadow:inset 0 0 0 1px var(--list-row-shadow)}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removes decorative markers from both ordinary list row renderers', () => {
|
it('removes decorative markers from both ordinary list row renderers', () => {
|
||||||
@@ -1684,7 +1720,7 @@ describe('sidebar layout', () => {
|
|||||||
expect(app).not.toContain('list.is_inbox" class="list-drag-handle"')
|
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('@pointerdown="startListLongPress(list, $event)"')
|
||||||
expect(app).not.toContain('function startListLongPress')
|
expect(app).not.toContain('function startListLongPress')
|
||||||
expect(app).not.toContain('listLongPressTimer')
|
expect(app).not.toContain('listLongPressTimer')
|
||||||
@@ -1699,14 +1735,10 @@ describe('sidebar layout', () => {
|
|||||||
expect(app).toContain('@pointerup.stop="finishListDrag(list,$event)"')
|
expect(app).toContain('@pointerup.stop="finishListDrag(list,$event)"')
|
||||||
expect(app).toContain('@pointercancel.stop="cancelListDrag"')
|
expect(app).toContain('@pointercancel.stop="cancelListDrag"')
|
||||||
expect(app).toContain('listHandlePending = undefined')
|
expect(app).toContain('listHandlePending = undefined')
|
||||||
expect(app).toContain('aria-label="上移清单"')
|
expect(app).not.toContain('aria-label="上移清单"')
|
||||||
expect(app).toContain('aria-label="下移清单"')
|
expect(app).not.toContain('aria-label="下移清单"')
|
||||||
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'up')")
|
expect(app).not.toContain('class="list-editor-position"')
|
||||||
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'down')")
|
expect(app).toContain('v-model="listEditorFolderId"')
|
||||||
expect(app).toContain('aria-label="移动到文件夹"')
|
|
||||||
expect(app).toContain('role="menu"')
|
|
||||||
expect(app).toContain('role="menuitem"')
|
|
||||||
expect(app).toContain('移出文件夹')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows drag lift, folder highlighting, and insertion targets', () => {
|
it('shows drag lift, folder highlighting, and insertion targets', () => {
|
||||||
@@ -1758,7 +1790,7 @@ describe('sidebar layout', () => {
|
|||||||
expect(app).toContain('@close="closePurgeList"')
|
expect(app).toContain('@close="closePurgeList"')
|
||||||
expect(app).not.toContain('handlePurgeDialogKeydown')
|
expect(app).not.toContain('handlePurgeDialogKeydown')
|
||||||
expect(app).toContain('if (purgeListSubmitting.value) return')
|
expect(app).toContain('if (purgeListSubmitting.value) return')
|
||||||
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'')
|
expect(app).toContain("purgeListError.value = failureHint('永久删除失败', reason)")
|
||||||
expect(app).toContain(':disabled="purgeListSubmitting"')
|
expect(app).toContain(':disabled="purgeListSubmitting"')
|
||||||
expect(app).toContain('role="alert" class="purge-list-error"')
|
expect(app).toContain('role="alert" class="purge-list-error"')
|
||||||
expect(css).toContain('.purge-list-dialog button{min-height:44px;')
|
expect(css).toContain('.purge-list-dialog button{min-height:44px;')
|
||||||
@@ -1786,7 +1818,7 @@ describe('original circular floating add button', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('uses the original orange circle visuals and restrained motion', () => {
|
it('uses the original orange circle visuals and restrained motion', () => {
|
||||||
expect(css).toContain('.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent);color:#fff;')
|
expect(css).toContain('.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent);color:var(--white);')
|
||||||
expect(css).toContain('.unified-fab.dragging{transform:scale(1.06);')
|
expect(css).toContain('.unified-fab.dragging{transform:scale(1.06);')
|
||||||
expect(css).toContain('.unified-fab.snapping{box-shadow:0 10px 24px rgba(241,90,41,.32)}')
|
expect(css).toContain('.unified-fab.snapping{box-shadow:0 10px 24px rgba(241,90,41,.32)}')
|
||||||
expect(css).toContain('.unified-fab:focus-visible{outline:3px solid var(--focus-ring);outline-offset:3px}')
|
expect(css).toContain('.unified-fab:focus-visible{outline:3px solid var(--focus-ring);outline-offset:3px}')
|
||||||
|
|||||||
@@ -1 +1,17 @@
|
|||||||
.today-section-toggle{width:100%;min-height:44px;display:flex;align-items:center;padding:0;border:0;border-bottom:1px solid #e8e0d5;background:transparent;color:var(--text-primary);text-align:left;border-radius:0}.today-section-toggle:hover{background:transparent}.today-section-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.today-section-title{display:flex;align-items:center;font-weight:700;font-size:13px;letter-spacing:.02em}.today-section-summary{margin-left:auto;color:var(--muted);font-size:12px;font-weight:400}.today-section-chevron{width:14px;margin-left:3px;color:var(--muted);font-size:13px;line-height:1;text-align:right}.today-collapsible-section,.today-habits-section{min-width:0}.today-habits-section.today-section-anchor{scroll-margin-top:18px}
|
.today-section-toggle{width:100%;min-height:44px;display:flex;align-items:center;padding:0;border:0;border-bottom:1px solid var(--border-hairline);background:transparent;color:var(--text-primary);text-align:left;border-radius:0}.today-section-toggle:hover{background:transparent}.today-section-toggle:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}.today-section-title{display:flex;align-items:center;font-weight:700;font-size:13px;letter-spacing:.02em}.today-section-summary{margin-left:auto;color:var(--text-secondary);font-size:12px;font-weight:400}.today-section-chevron{width:14px;margin-left:var(--space-2);color:var(--text-secondary);font-size:13px;line-height:1;text-align:right}.today-collapsible-section,.today-habits-section{min-width:0}.today-habits-section.today-section-anchor{scroll-margin-top:18px}
|
||||||
|
|
||||||
|
|
||||||
|
/* Restrained motion: paper unfold/put-away for the three Today sections (grid-rows 0fr<->1fr 180ms). */
|
||||||
|
#today-tasks{grid-template-rows:1fr;transition:grid-template-rows 180ms ease-out}
|
||||||
|
#today-tasks.is-collapsed{grid-template-rows:0fr}
|
||||||
|
#today-tasks>.today-collapse-inner{min-height:0;overflow:hidden}
|
||||||
|
.overdue-section{display:grid;grid-template-rows:auto 1fr;transition:grid-template-rows 180ms ease-out}
|
||||||
|
.overdue-section.is-collapsed{grid-template-rows:auto 0fr}
|
||||||
|
#today-overdue{min-height:0;overflow:hidden}
|
||||||
|
.overdue-section.is-collapsed #today-overdue{margin-bottom:0}
|
||||||
|
#today-habits{display:grid;grid-template-rows:1fr;transition:grid-template-rows 180ms ease-out}
|
||||||
|
#today-habits.is-collapsed{grid-template-rows:0fr}
|
||||||
|
#today-habits>*{min-height:0;overflow:hidden}
|
||||||
|
#today-tasks.is-collapsed>.today-collapse-inner,.overdue-section.is-collapsed>#today-overdue,#today-habits.is-collapsed>*{visibility:hidden;transition:visibility 0s linear 180ms}
|
||||||
|
.today-section-chevron{transition:transform 160ms ease-out}
|
||||||
|
.today-section-chevron.open{transform:rotate(90deg)}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ describe('approved five-detail polish', () => {
|
|||||||
expect(css).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.countdown-focus\{[^}]*height:140px/)
|
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('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('<h1 class="list-page-title" :title="activeName">{{ activeName }}</h1>')
|
||||||
expect(app).toContain('<p class="list-page-summary">{{ taskOpenTotal === null ? \'待完成统计暂不可用\' : `还有 ${taskOpenTotal} 项待完成` }}</p>')
|
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('<span id="task-list-title" class="list-section-title">任务</span>')
|
||||||
expect(app).toContain("v-if=\"activeView==='tasks' && taskReorderAvailable\"")
|
expect(app).toContain("v-if=\"activeView==='tasks' && taskReorderAvailable\"")
|
||||||
expect(app).toContain('<span class="list-section-count">{{ totalTasks }}</span>')
|
expect(app).toContain('<span class="list-section-count">{{ totalTasks }}</span>')
|
||||||
expect(app).toContain("v-if=\"activeView==='tasks' && totalPages > 1\" class=\"list-page-meta\"")
|
expect(app).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:var(--space-8) 0 var(--space-14);border-top:1px solid var(--border-hairline);background:transparent}')
|
||||||
|
expect(css).toContain('.pager-button{min-width:0;min-height:44px;padding:0 var(--space-8);display:flex;align-items:center;justify-content:center;gap:6px;border:0;border-radius:var(--radius-control);background:transparent;color:var(--accent-ink);font-size:13px;font-weight:600;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(--text-secondary);font-size:11px;font-variant-numeric:tabular-nums}')
|
||||||
|
expect(css).toContain('@media(max-width:390px){.pager-button{padding:0 var(--space-4)}.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("if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) }")
|
||||||
expect(app).toContain("new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)")
|
expect(app).toContain("new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)")
|
||||||
expect(app).not.toContain('class="list-search-clear"')
|
expect(app).not.toContain('class="list-search-clear"')
|
||||||
@@ -89,21 +105,22 @@ describe('approved five-detail polish', () => {
|
|||||||
expect(app).toContain(":aria-labelledby=\"activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined\"")
|
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>'))
|
const taskTopbar = app.slice(app.indexOf('<header class="topbar"'), app.indexOf('</header>'))
|
||||||
expect(taskTopbar).not.toContain('CompletedFilterPill v-if="activeView!==\'today\'"')
|
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-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-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}')
|
expect(css).toContain('.list-page-summary{margin:var(--space-8) 0 var(--space-18);color:var(--text-secondary);font-size:13px}')
|
||||||
expect(css).toContain('@media(min-width:1440px){main.list-main>.list-page-context')
|
expect(css).toContain('@media(min-width:1440px){main.list-main>.list-page-context')
|
||||||
expect(css).toContain('@media(max-width:720px){main.list-main{padding-left:29px;padding-right:29px}main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}')
|
expect(css).toContain('@media(max-width:720px){main.list-main{padding-left:var(--space-28);padding-right:var(--space-28)}main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}')
|
||||||
expect(css).toContain('@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}')
|
expect(css).toContain('@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}')
|
||||||
expect(css).not.toContain('.list-search-reveal{')
|
expect(css).not.toContain('.list-search-reveal{')
|
||||||
expect(css).toContain('@media(max-width:720px){')
|
expect(css).toContain('@media(max-width:720px){')
|
||||||
expect(css).not.toContain('top:-62px')
|
expect(css).not.toContain('top:-62px')
|
||||||
expect(css).toContain('.today-context .completed-filter-pill:hover:not(:disabled),.today-context .completed-filter-pill:active:not(:disabled),.list-inline-filter:hover:not(:disabled),.list-inline-filter:active:not(:disabled),.habit-inline-filter:hover:not(:disabled),.habit-inline-filter:active:not(:disabled){background:transparent;border-color:transparent}')
|
expect(css).toContain('.today-context .completed-filter-pill:hover:not(:disabled),.today-context .completed-filter-pill:active:not(:disabled),.list-inline-filter:hover:not(:disabled),.list-inline-filter:active:not(:disabled),.habit-inline-filter:hover:not(:disabled),.habit-inline-filter:active:not(:disabled){background:transparent;border-color:transparent}')
|
||||||
expect(css).toContain('.today-inline-filter,.list-inline-filter,.habit-inline-filter{-webkit-tap-highlight-color:transparent}')
|
expect(css).toContain('.today-inline-filter,.list-inline-filter,.habit-inline-filter{-webkit-tap-highlight-color:transparent}')
|
||||||
expect(css).toContain('.list-inline-filter .completed-filter-pill__track,.habit-inline-filter .completed-filter-pill__track{width:31px;height:18px;background:#d8d0c5}')
|
expect(css).toContain('.list-inline-filter .completed-filter-pill__track,.habit-inline-filter .completed-filter-pill__track{width:31px;height:18px;background:var(--list-inline-filter-bg)}')
|
||||||
expect(css).toContain('.list-inline-filter .completed-filter-pill__thumb,.habit-inline-filter .completed-filter-pill__thumb{width:14px;height:14px}')
|
expect(css).toContain('.list-inline-filter .completed-filter-pill__thumb,.habit-inline-filter .completed-filter-pill__thumb{width:14px;height:14px}')
|
||||||
expect(css).toContain('.list-section-heading{width:min(100%,630px);min-height:44px;margin:0 auto;display:flex;align-items:center;border-bottom:1px solid #e8e0d5}')
|
expect(css).toContain('.list-section-heading{width:min(100%,630px);min-height:44px;margin:0 auto;display:flex;align-items:center;border-bottom:1px solid var(--border-hairline)}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses approved Today-sourced page headers for full Habits and keeps archive lazy section', () => {
|
it('uses approved Today-sourced page headers for full Habits and keeps archive lazy section', () => {
|
||||||
@@ -122,25 +139,27 @@ describe('approved five-detail polish', () => {
|
|||||||
expect(css).toContain('.habit-section-heading{margin-top:0}')
|
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{gap:0}')
|
||||||
expect(css).toContain('main.list-main>.mvp-view>.habit-section-heading{width:100%}')
|
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:var(--space-24);padding-top:var(--space-12);border-top:1px solid var(--border-hairline)}')
|
||||||
|
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 var(--space-4);border:0;background:transparent;box-shadow:none')
|
||||||
|
expect(css).toContain('.archived-habits{margin-top:var(--space-2);')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses one 58px plain-list contract for active task and habit rows', () => {
|
it('uses one 58px plain-list contract for active task and habit rows', () => {
|
||||||
expect(app).toContain("class=\"task-list plain-list\"")
|
expect(app).toContain("class=\"task-list plain-list\"")
|
||||||
expect(app).toContain("'task-row--trash':activeView==='trash'")
|
expect(app).toContain('class="task-row task-row--trash"')
|
||||||
expect(app).toContain(':role="activeView===\'trash\' ? undefined : \'button\'"')
|
expect(app).toContain('class="task-main" role="button" tabindex="0"')
|
||||||
expect(app).toContain(':tabindex="activeView===\'trash\' ? undefined : 0"')
|
expect(app).toContain('@click="selectTaskUnlessSwiped(node.task)"')
|
||||||
expect(app).toContain('@click="activeView===\'trash\'?undefined:selectTaskUnlessSwiped(node.task)"')
|
expect(app).toContain('class="task-check" :aria-label="node.task.completed')
|
||||||
expect(app).toContain('v-if="activeView!==\'trash\'" class="task-check"')
|
|
||||||
expect(habits).toContain('class=\"habit-list plain-list\"')
|
expect(habits).toContain('class=\"habit-list plain-list\"')
|
||||||
expect(habits).toContain('class=\"habit-row habit-row--full swipeable\"')
|
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}')
|
expect(css).toContain('.plain-list{display:grid;gap:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible}')
|
||||||
expect(css).toContain('.plain-list-row,.plain-list>.task-row,.plain-list>.habit-row{height:58px;min-height:58px;max-height:58px;background:transparent;border:0;border-bottom:1px solid #e8e0d5;border-radius:0;box-shadow:none}')
|
expect(css).toContain('.plain-list-row,.plain-list>.task-row,.plain-list>.habit-row{height:58px;min-height:58px;max-height:58px;background:transparent;border:0;border-bottom:1px solid var(--border-hairline);border-radius:0;box-shadow:none}')
|
||||||
expect(css).toContain('.task-row{grid-template-columns:44px minmax(0,1fr) auto}')
|
expect(css).toContain('.task-row{grid-template-columns:44px minmax(0,1fr) auto}')
|
||||||
expect(css).toContain('.habit-row{grid-template-columns:44px minmax(0,1fr) auto;grid-template-rows:18px 4px;align-content:center;row-gap:5px}')
|
expect(css).toContain('.habit-row{grid-template-columns:44px minmax(0,1fr) auto;grid-template-rows:18px 4px;align-content:center;row-gap:5px}')
|
||||||
expect(css).toContain('.habit-row:has(>.habit-progress-bar)>.habit-main,.habit-row:has(>.habit-progress-bar)>.habit-progress-bar{transform:translateY(3px)}')
|
expect(css).toContain('.habit-row:has(>.habit-progress-bar)>.habit-main,.habit-row:has(>.habit-progress-bar)>.habit-progress-bar{transform:translateY(3px)}')
|
||||||
expect(css).toContain('.task-main strong,.habit-name{display:block;min-width:0;font-size:15px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}')
|
expect(css).toContain('.task-main strong,.habit-name{display:block;min-width:0;font-size:15px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}')
|
||||||
expect(css).toContain('.task-tail,.habit-row-meta{min-width:0;max-width:132px;padding-left:12px;font-size:12px;font-weight:400;white-space:nowrap;text-align:right;overflow:hidden;text-overflow:ellipsis}')
|
expect(css).toContain('.task-tail,.habit-row-meta{min-width:0;max-width:132px;padding-left:var(--space-12);font-size:12px;font-weight:400;white-space:nowrap;text-align:right;overflow:hidden;text-overflow:ellipsis}')
|
||||||
expect(css).toContain('.task-check{width:44px;')
|
expect(css).toContain('.task-check{width:44px;')
|
||||||
expect(css).toContain('.plain-list .habit-check{margin-left:0}')
|
expect(css).toContain('.plain-list .habit-check{margin-left:0}')
|
||||||
expect(memoCss).toContain('.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;')
|
expect(memoCss).toContain('.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;')
|
||||||
@@ -159,12 +178,12 @@ describe('approved five-detail polish', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('gives task and habit rows distinct hover, selected, and completed feedback', () => {
|
it('gives task and habit rows distinct hover, selected, and completed feedback', () => {
|
||||||
expect(css).toContain('@media(hover:hover) and (pointer:fine){.plain-list>.task-row:not(.task-row--trash):hover,.plain-list>.habit-row:hover{background:#fff8ee}}')
|
expect(css).toContain('@media(hover:hover) and (pointer:fine){.plain-list>.task-row:not(.task-row--trash):hover,.plain-list>.habit-row:hover{background:var(--event-hover)}}')
|
||||||
expect(css).toContain('@media(hover:hover) and (pointer:fine){.task-row:hover:not(.reordering):not(.task-row--trash),.habit-row:hover:not(.reordering){transform:translate(calc(var(--swipe-x) + 2px),var(--reorder-y,0px))}}')
|
expect(css).toContain('@media(hover:hover) and (pointer:fine){.task-row:hover:not(.reordering):not(.task-row--trash),.habit-row:hover:not(.reordering){transform:translate(calc(var(--swipe-x) + 2px),var(--reorder-y,0px))}}')
|
||||||
expect(css).toContain('.plain-list>.task-row:not(.task-row--trash).selected,.plain-list>.task-row:not(.task-row--trash).selected.done,.plain-list>.task-row:not(.task-row--trash).selected.overdue-task,.plain-list>.habit-row.selected,.plain-list>.habit-row.selected.done{background:#fff4e5}')
|
expect(css).toContain('.plain-list>.task-row:not(.task-row--trash).selected,.plain-list>.task-row:not(.task-row--trash).selected.done,.plain-list>.task-row:not(.task-row--trash).selected.overdue-task,.plain-list>.habit-row.selected,.plain-list>.habit-row.selected.done{background:var(--plain-list-bg-2)}')
|
||||||
expect(css).toContain('.plain-list>.task-row:not(.task-row--trash).selected::before,.plain-list>.habit-row.selected::before{background:var(--accent)}')
|
expect(css).toContain('.plain-list>.task-row:not(.task-row--trash).selected::before,.plain-list>.habit-row.selected::before{background:var(--accent)}')
|
||||||
expect(css).toContain('.plain-list>.task-row:not(.task-row--trash).done,.plain-list>.habit-row.done{background:#fbf7f0}')
|
expect(css).toContain('.plain-list>.task-row:not(.task-row--trash).done,.plain-list>.habit-row.done{background:var(--plain-list-bg)}')
|
||||||
expect(css).toContain('.plain-list>.task-row:not(.task-row--trash).overdue-task{background:#fff7f4}')
|
expect(css).toContain('.plain-list>.task-row:not(.task-row--trash).overdue-task{background:var(--plain-list-bg-3)}')
|
||||||
expect(habits.match(/selected:selectedHabit\?\.id===h\.id/g)?.length).toBe(2)
|
expect(habits.match(/selected:selectedHabit\?\.id===h\.id/g)?.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+28
-3
@@ -1,9 +1,34 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, type Plugin } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
const GUARD_MARKER = '[dodo:vue-guard]'
|
||||||
|
|
||||||
|
// vuejs/core#5184/#8146: a stale component vnode that never mounted (component === null)
|
||||||
|
// reaches processComponent -> updateComponent -> shouldUpdateComponent and crashes on
|
||||||
|
// `component.emitsOptions`. Detect it here and self-heal with a clean remount instead,
|
||||||
|
// logging the component pair so the next production occurrence names itself in console.
|
||||||
|
function vueNullComponentGuard(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'dodo:vue-null-component-guard',
|
||||||
|
enforce: 'pre',
|
||||||
|
transform(code, id) {
|
||||||
|
if (!id.includes('runtime-core.esm-bundler.js')) return null
|
||||||
|
const header = code.indexOf('const processComponent = ')
|
||||||
|
if (header < 0) throw new Error(`${GUARD_MARKER} processComponent not found in runtime-core — Vue internals changed, update the guard anchor`)
|
||||||
|
const anchor = code.indexOf('if (n1 == null) {', header)
|
||||||
|
if (anchor < 0 || anchor - header > 400) throw new Error(`${GUARD_MARKER} mount-branch anchor not found — update the guard anchor`)
|
||||||
|
if (code.slice(header, anchor).includes(GUARD_MARKER)) return null
|
||||||
|
const inject = `if (n1 != null && n1.component == null) {\n try { const nm = (x) => (x && (x.name || x.__name)) || (typeof x === "string" ? x : typeof x === "symbol" ? (x.description || "symbol") : "?"); console.error("${GUARD_MARKER} never-mounted component vnode — self-healing remount", { oldType: nm(n1.type), newType: nm(n2.type), oldKey: n1.key, newKey: n2.key, el: !!n1.el, oldChildren: Array.isArray(n1.children) ? n1.children.map((c) => nm(c.type)) : String(n1.children).slice(0, 60) }); } catch (e) {}\n n1 = null;\n }\n `
|
||||||
|
return { code: code.slice(0, anchor) + inject + code.slice(anchor), map: null }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue(), tailwindcss()],
|
plugins: [vue(), tailwindcss(), vueNullComponentGuard()],
|
||||||
|
// Serve vue through the normal transform pipeline so the guard applies in dev too.
|
||||||
|
optimizeDeps: { exclude: ['vue', '@vue/runtime-dom', '@vue/runtime-core', '@vue/reactivity', '@vue/shared'] },
|
||||||
server: { proxy: { '/api': 'http://localhost:8781', '/health': 'http://localhost:8781' } },
|
server: { proxy: { '/api': 'http://localhost:8781', '/health': 'http://localhost:8781' } },
|
||||||
test: { environment: 'jsdom' },
|
test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'] },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Synchronous requestAnimationFrame so Vue <Transition> leave (double-rAF)
|
||||||
|
// settles in the same tick as close(), preserving tests' "dialog removed
|
||||||
|
// immediately" contract. Production keeps real browser frames.
|
||||||
|
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
||||||
|
cb(Date.now())
|
||||||
|
return 1
|
||||||
|
}) as typeof globalThis.requestAnimationFrame
|
||||||
|
|
||||||
|
globalThis.cancelAnimationFrame = (() => {}) as typeof globalThis.cancelAnimationFrame
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -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")
|
||||||
@@ -16,6 +16,8 @@ dependencies = [
|
|||||||
"structlog>=25,<26",
|
"structlog>=25,<26",
|
||||||
"lunar-python>=1.2,<2",
|
"lunar-python>=1.2,<2",
|
||||||
"httpx>=0.28,<1",
|
"httpx>=0.28,<1",
|
||||||
|
"icalendar>=6,<7",
|
||||||
|
"python-dateutil>=2.9,<3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
|
|||||||
due_has_time=True,
|
due_has_time=True,
|
||||||
)
|
)
|
||||||
gap_due = recurrence_service._after_completion_due(
|
gap_due = recurrence_service._after_completion_due(
|
||||||
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, user
|
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, "days", user
|
||||||
)
|
)
|
||||||
assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap
|
assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
|
|||||||
due_has_time=True,
|
due_has_time=True,
|
||||||
)
|
)
|
||||||
fold_due = recurrence_service._after_completion_due(
|
fold_due = recurrence_service._after_completion_due(
|
||||||
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, user
|
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, "days", user
|
||||||
)
|
)
|
||||||
assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0
|
assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0
|
||||||
|
|
||||||
@@ -89,6 +89,30 @@ def test_user_timezone_is_the_calendar_contract_for_after_completion(client, mon
|
|||||||
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC)
|
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_interval_uses_calendar_month_clamping(client, monkeypatch):
|
||||||
|
inbox = boot(client)
|
||||||
|
task = create_after_completion_task(
|
||||||
|
client,
|
||||||
|
inbox,
|
||||||
|
due_at="2026-01-31T01:30:00Z",
|
||||||
|
after_completion_days=1,
|
||||||
|
after_completion_unit="months",
|
||||||
|
).json()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.recurrence_service.utcnow",
|
||||||
|
lambda: datetime(2026, 1, 30, 16, 30, tzinfo=UTC), # 2026-01-31 00:30 Asia/Shanghai
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = client.patch(
|
||||||
|
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert completed.status_code == 200
|
||||||
|
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 2, 28, 1, 30, tzinfo=UTC)
|
||||||
|
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
|
||||||
|
assert recurrence["after_completion_unit"] == "months"
|
||||||
|
|
||||||
|
|
||||||
def test_atomic_task_create_and_read_after_completion_recurrence(client):
|
def test_atomic_task_create_and_read_after_completion_recurrence(client):
|
||||||
inbox = boot(client)
|
inbox = boot(client)
|
||||||
|
|
||||||
@@ -105,6 +129,7 @@ def test_atomic_task_create_and_read_after_completion_recurrence(client):
|
|||||||
"ends_at": None,
|
"ends_at": None,
|
||||||
"trigger_mode": "after_completion",
|
"trigger_mode": "after_completion",
|
||||||
"after_completion_days": 2,
|
"after_completion_days": 2,
|
||||||
|
"after_completion_unit": "days",
|
||||||
"last_completed_at": None,
|
"last_completed_at": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +147,8 @@ def test_after_completion_configuration_validation(client):
|
|||||||
{"title": "零天", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 0},
|
{"title": "零天", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 0},
|
||||||
{"title": "太长", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 3651},
|
{"title": "太长", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 3651},
|
||||||
{"title": "子任务", "list_id": inbox["id"], "parent_id": child["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 1},
|
{"title": "子任务", "list_id": inbox["id"], "parent_id": child["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 1},
|
||||||
|
{"title": "只有单位", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "after_completion_unit": "months"},
|
||||||
|
{"title": "定期带月份", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "scheduled", "rrule": "FREQ=DAILY", "after_completion_unit": "months"},
|
||||||
]
|
]
|
||||||
for payload in invalid_payloads:
|
for payload in invalid_payloads:
|
||||||
assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422}
|
assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422}
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ def test_task_subtasks_and_recycle_bin(client):
|
|||||||
assert client.delete(f"/api/v1/tasks/{parent['id']}").status_code == 204
|
assert client.delete(f"/api/v1/tasks/{parent['id']}").status_code == 204
|
||||||
trash = client.get("/api/v1/trash").json()["items"]
|
trash = client.get("/api/v1/trash").json()["items"]
|
||||||
assert len(trash) == 1
|
assert len(trash) == 1
|
||||||
|
assert trash[0]["subtasks"][0]["title"] == "比较价格"
|
||||||
assert client.post(f"/api/v1/tasks/{parent['id']}/restore").status_code == 200
|
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
|
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):
|
def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client):
|
||||||
client = initialized_client(client)
|
client = initialized_client(client)
|
||||||
inbox = client.get("/api/v1/lists").json()[0]
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
|||||||
@@ -200,6 +200,36 @@ def test_merge_same_backup_is_idempotent_via_import_ledger(client):
|
|||||||
assert asyncio.run(counts()) == (1, 1)
|
assert asyncio.run(counts()) == (1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_backup_without_completion_unit_is_idempotent(client):
|
||||||
|
inbox = boot(client)
|
||||||
|
client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={
|
||||||
|
"title": "旧版完成后重复",
|
||||||
|
"list_id": inbox["id"],
|
||||||
|
"due_at": "2026-03-08T01:30:00Z",
|
||||||
|
"trigger_mode": "after_completion",
|
||||||
|
"after_completion_days": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
recurrences = _archive_rows(content, "recurrences")
|
||||||
|
for recurrence in recurrences:
|
||||||
|
recurrence.pop("after_completion_unit", None)
|
||||||
|
legacy_content = _replace_entities(content, {"recurrences": recurrences})
|
||||||
|
|
||||||
|
first_token = _preflight(client, legacy_content).json()["preflight_token"]
|
||||||
|
assert client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}
|
||||||
|
).status_code == 200
|
||||||
|
second_token = _preflight(client, legacy_content).json()["preflight_token"]
|
||||||
|
second = client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": second_token, "mode": "merge"}
|
||||||
|
)
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["already_imported"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_zip_variants_are_rejected_before_any_write(client):
|
def test_invalid_zip_variants_are_rejected_before_any_write(client):
|
||||||
inbox = boot(client)
|
inbox = boot(client)
|
||||||
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
|
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -277,8 +277,10 @@ dependencies = [
|
|||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "icalendar" },
|
||||||
{ name = "lunar-python" },
|
{ name = "lunar-python" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "python-dateutil" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||||
{ name = "structlog" },
|
{ name = "structlog" },
|
||||||
@@ -301,8 +303,10 @@ requires-dist = [
|
|||||||
{ name = "asyncpg", specifier = ">=0.30,<1" },
|
{ name = "asyncpg", specifier = ">=0.30,<1" },
|
||||||
{ name = "fastapi", specifier = ">=0.116,<1" },
|
{ name = "fastapi", specifier = ">=0.116,<1" },
|
||||||
{ name = "httpx", specifier = ">=0.28,<1" },
|
{ name = "httpx", specifier = ">=0.28,<1" },
|
||||||
|
{ name = "icalendar", specifier = ">=6,<7" },
|
||||||
{ name = "lunar-python", specifier = ">=1.2,<2" },
|
{ name = "lunar-python", specifier = ">=1.2,<2" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.10,<3" },
|
{ name = "pydantic-settings", specifier = ">=2.10,<3" },
|
||||||
|
{ name = "python-dateutil", specifier = ">=2.9,<3" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.20,<1" },
|
{ name = "python-multipart", specifier = ">=0.0.20,<1" },
|
||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" },
|
||||||
{ name = "structlog", specifier = ">=25,<26" },
|
{ name = "structlog", specifier = ">=25,<26" },
|
||||||
@@ -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 },
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icalendar"
|
||||||
|
version = "6.3.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "tzdata" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5d/70/458092b3e7c15783423fe64d07e63ea3311a597e723be6a1060513e3db93/icalendar-6.3.2.tar.gz", hash = "sha256:e0c10ecbfcebe958d33af7d491f6e6b7580d11d475f2eeb29532d0424f9110a1", size = 178422 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/ee/2ff96bb5bd88fe03ab90aedf5180f96dc0f3ae4648ca264b473055bcaaff/icalendar-6.3.2-py3-none-any.whl", hash = "sha256:d400e9c9bb8c025e5a3c77c236941bb690494be52528a0b43cc7e8b7c9505064", size = 242403 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.19"
|
version = "3.19"
|
||||||
@@ -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 },
|
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.9.0.post0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -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 },
|
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.52"
|
version = "2.0.52"
|
||||||
@@ -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 },
|
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2026.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid-utils"
|
name = "uuid-utils"
|
||||||
version = "0.17.0"
|
version = "0.17.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user