Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34691b6fe8 | ||
|
|
5e338fa90f | ||
|
|
99da524070 | ||
|
|
3a73473c4d | ||
|
|
3155155f53 | ||
|
|
e6d720b85c | ||
|
|
6e3e09e8b6 | ||
|
|
bb59dc9346 | ||
|
|
7140102aeb | ||
|
|
f3ad1eec03 | ||
|
|
af52fe0cad | ||
|
|
2715b74f2e | ||
|
|
6528cbbb1e | ||
|
|
af83a68fe1 |
@@ -354,6 +354,9 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
|
||||
"rrule": row.get("rrule"),
|
||||
"trigger_mode": row.get("trigger_mode", "scheduled"),
|
||||
"after_completion_days": row.get("after_completion_days"),
|
||||
"after_completion_unit": row.get("after_completion_unit")
|
||||
if row.get("trigger_mode", "scheduled") == "after_completion"
|
||||
else None,
|
||||
})
|
||||
starts_at = datetime.fromisoformat(row["starts_at"])
|
||||
ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None
|
||||
@@ -523,6 +526,8 @@ def _coerce(model, raw: dict, mapping: dict[str, dict[str, UUID]], user_id: UUID
|
||||
elif isinstance(effective_type, Date) and isinstance(value, str):
|
||||
value = date.fromisoformat(value)
|
||||
values[name] = value
|
||||
if model is RecurrenceTemplate and "after_completion_unit" not in raw:
|
||||
values["after_completion_unit"] = "days" if raw.get("trigger_mode") == "after_completion" else None
|
||||
return values
|
||||
|
||||
|
||||
|
||||
+26
-5
@@ -18,6 +18,8 @@ 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"}
|
||||
|
||||
@@ -156,15 +158,20 @@ def _overlaps(start: datetime, end: datetime, window_start: datetime, window_end
|
||||
return start < window_end and end > window_start
|
||||
|
||||
|
||||
def _event_dict(event: Any, source: str, color: str, start: datetime, end: datetime, all_day: bool) -> dict:
|
||||
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,
|
||||
}
|
||||
@@ -178,6 +185,7 @@ def parse_ics_events(
|
||||
window_end: datetime,
|
||||
timezone_name: str,
|
||||
*,
|
||||
source_id: str = "",
|
||||
recurrence_limit: int = DEFAULT_RECURRENCE_LIMIT,
|
||||
) -> list[dict]:
|
||||
try:
|
||||
@@ -189,6 +197,13 @@ def parse_ics_events(
|
||||
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")
|
||||
@@ -218,16 +233,22 @@ def parse_ics_events(
|
||||
continue
|
||||
end = occurrence + duration
|
||||
if _overlaps(occurrence, end, window_start, window_end):
|
||||
events.append(_event_dict(event, source_name, color, occurrence, end, all_day))
|
||||
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_name, color, start, end, all_day))
|
||||
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)
|
||||
end = start + _duration(event, start, all_day, 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_name, color, start, end, all_day))
|
||||
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"]))
|
||||
|
||||
@@ -212,7 +212,8 @@ async def calendar_events(
|
||||
await _refresh(db, row)
|
||||
try:
|
||||
parsed = calendar_service.parse_ics_events(
|
||||
row.ics_cache or "", row.name, row.color, start, end, user.timezone
|
||||
row.ics_cache or "", row.name, row.color, start, end, user.timezone,
|
||||
source_id=str(row.id),
|
||||
)
|
||||
events.extend(parsed)
|
||||
except ValueError as exc:
|
||||
|
||||
+4
-1
@@ -999,7 +999,7 @@ async def create_task(
|
||||
from .mvp import parse_rrule
|
||||
if payload.rrule:
|
||||
parse_rrule(payload.rrule)
|
||||
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days"})
|
||||
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days", "after_completion_unit"})
|
||||
parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None)
|
||||
max_position = await db.scalar(select(func.max(Task.position)).where(
|
||||
Task.user_id == user.id,
|
||||
@@ -1019,6 +1019,9 @@ async def create_task(
|
||||
starts_at=task.due_at,
|
||||
trigger_mode=payload.trigger_mode or "scheduled",
|
||||
after_completion_days=payload.after_completion_days,
|
||||
after_completion_unit=(payload.after_completion_unit or "days")
|
||||
if payload.trigger_mode == "after_completion"
|
||||
else None,
|
||||
)
|
||||
)
|
||||
audit(db, user.id, "create", "task", task.id)
|
||||
|
||||
@@ -154,6 +154,7 @@ class RecurrenceTemplate(Base):
|
||||
ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
trigger_mode: Mapped[str] = mapped_column(String(32), default="scheduled")
|
||||
after_completion_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
after_completion_unit: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||
last_completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||
|
||||
|
||||
+30
-4
@@ -251,17 +251,22 @@ class RecurrenceCreate(BaseModel):
|
||||
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
||||
trigger_mode: str = Field(default="scheduled", pattern="^(scheduled|after_completion)$")
|
||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode(self):
|
||||
if self.trigger_mode == "scheduled" and (
|
||||
self.rrule is None or self.after_completion_days is not None
|
||||
self.rrule is None
|
||||
or self.after_completion_days is not None
|
||||
or self.after_completion_unit is not None
|
||||
):
|
||||
raise ValueError("scheduled recurrence requires rrule and no completion interval")
|
||||
if self.trigger_mode == "after_completion" and (
|
||||
self.after_completion_days is None or self.rrule is not None
|
||||
):
|
||||
raise ValueError("after_completion requires days and no rrule")
|
||||
if self.trigger_mode == "after_completion" and self.after_completion_unit is None:
|
||||
self.after_completion_unit = "days"
|
||||
return self
|
||||
|
||||
|
||||
@@ -271,6 +276,7 @@ class RecurrenceChange(BaseModel):
|
||||
rrule: str | None = None
|
||||
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
|
||||
|
||||
|
||||
class OccurrenceComplete(BaseModel):
|
||||
@@ -430,6 +436,7 @@ async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user),
|
||||
"ends_at": row.ends_at,
|
||||
"trigger_mode": row.trigger_mode,
|
||||
"after_completion_days": row.after_completion_days,
|
||||
"after_completion_unit": row.after_completion_unit,
|
||||
"last_completed_at": row.last_completed_at,
|
||||
}
|
||||
|
||||
@@ -452,6 +459,9 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
|
||||
starts_at=task.due_at,
|
||||
trigger_mode=payload.trigger_mode,
|
||||
after_completion_days=payload.after_completion_days,
|
||||
after_completion_unit=(payload.after_completion_unit or "days")
|
||||
if payload.trigger_mode == "after_completion"
|
||||
else None,
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit(); await db.refresh(row)
|
||||
@@ -463,6 +473,7 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
|
||||
"ends_at": row.ends_at,
|
||||
"trigger_mode": row.trigger_mode,
|
||||
"after_completion_days": row.after_completion_days,
|
||||
"after_completion_unit": row.after_completion_unit,
|
||||
"last_completed_at": row.last_completed_at,
|
||||
}
|
||||
|
||||
@@ -504,12 +515,18 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
||||
if "after_completion_days" in payload.model_fields_set
|
||||
else template.after_completion_days
|
||||
)
|
||||
requested_unit = (
|
||||
payload.after_completion_unit
|
||||
if "after_completion_unit" in payload.model_fields_set
|
||||
else template.after_completion_unit
|
||||
)
|
||||
requested_rrule = payload.rrule if payload.rrule is not None else template.rrule
|
||||
if requested_mode == "after_completion":
|
||||
if requested_days is None:
|
||||
raise HTTPException(422, "完成后重复需要间隔天数")
|
||||
template.trigger_mode = requested_mode
|
||||
template.after_completion_days = requested_days
|
||||
template.after_completion_unit = requested_unit or "days"
|
||||
template.rrule = None
|
||||
else:
|
||||
if requested_rrule is None:
|
||||
@@ -517,6 +534,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
||||
parse_rrule(requested_rrule)
|
||||
template.trigger_mode = requested_mode
|
||||
template.after_completion_days = None
|
||||
template.after_completion_unit = None
|
||||
template.rrule = requested_rrule.upper()
|
||||
if payload.title is not None:
|
||||
task.title = payload.title
|
||||
@@ -533,6 +551,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
||||
"ends_at": template.ends_at,
|
||||
"trigger_mode": template.trigger_mode,
|
||||
"after_completion_days": template.after_completion_days,
|
||||
"after_completion_unit": template.after_completion_unit,
|
||||
"last_completed_at": template.last_completed_at,
|
||||
}
|
||||
|
||||
@@ -1358,7 +1377,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memo
|
||||
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
|
||||
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
|
||||
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "completed_at", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
|
||||
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
|
||||
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "after_completion_unit", "last_completed_at"]) for x in recurrences],
|
||||
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
|
||||
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
|
||||
"memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos],
|
||||
@@ -1570,11 +1589,17 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
||||
rrule = raw.get("rrule")
|
||||
if trigger_mode not in {"scheduled", "after_completion"}:
|
||||
raise HTTPException(422, "无效的重复触发模式")
|
||||
unit = raw.get("after_completion_unit")
|
||||
if unit is not None and unit not in {"days", "months"}:
|
||||
raise HTTPException(422, "无效的完成后重复单位")
|
||||
if trigger_mode == "after_completion":
|
||||
if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650 or rrule is not None:
|
||||
raise HTTPException(422, "无效的完成后重复备份")
|
||||
elif not isinstance(rrule, str):
|
||||
raise HTTPException(422, "定期重复缺少 RRULE")
|
||||
unit = unit or "days"
|
||||
else:
|
||||
if not isinstance(rrule, str) or days is not None or unit is not None:
|
||||
raise HTTPException(422, "定期重复备份包含无效字段")
|
||||
unit = None
|
||||
db.add(RecurrenceTemplate(
|
||||
user_id=user.id,
|
||||
task_id=task_id,
|
||||
@@ -1583,6 +1608,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
||||
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
|
||||
trigger_mode=trigger_mode,
|
||||
after_completion_days=days,
|
||||
after_completion_unit=unit,
|
||||
last_completed_at=datetime.fromisoformat(raw["last_completed_at"])
|
||||
if raw.get("last_completed_at") else None,
|
||||
))
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import UTC, datetime, time, timedelta
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -25,10 +26,15 @@ def _user_zone(user: User) -> ZoneInfo:
|
||||
raise HTTPException(422, "用户时区无效") from exc
|
||||
|
||||
|
||||
def _after_completion_due(task: Task, completed_at: datetime, days: int, user: User) -> datetime:
|
||||
def _after_completion_due(
|
||||
task: Task, completed_at: datetime, value: int, unit: str | None, user: User
|
||||
) -> datetime:
|
||||
zone = _user_zone(user)
|
||||
completed_local = completed_at.astimezone(zone)
|
||||
target_date = completed_local.date() + timedelta(days=days)
|
||||
if unit == "months":
|
||||
target_date = completed_local.date() + relativedelta(months=value)
|
||||
else:
|
||||
target_date = completed_local.date() + timedelta(days=value)
|
||||
if task.due_has_time:
|
||||
due_local = task.due_at.astimezone(zone)
|
||||
wall_time = due_local.timetz().replace(tzinfo=None)
|
||||
@@ -67,7 +73,11 @@ async def apply_task_changes(
|
||||
if recurrence.trigger_mode == "after_completion":
|
||||
completed_at = utcnow()
|
||||
next_due = _after_completion_due(
|
||||
task, completed_at, recurrence.after_completion_days, user
|
||||
task,
|
||||
completed_at,
|
||||
recurrence.after_completion_days,
|
||||
recurrence.after_completion_unit,
|
||||
user,
|
||||
)
|
||||
changes["completed"] = False
|
||||
changes["due_at"] = next_due
|
||||
|
||||
+16
-5
@@ -125,6 +125,7 @@ class TaskCreate(BaseModel):
|
||||
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
||||
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
@@ -136,7 +137,12 @@ class TaskCreate(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_recurrence(self):
|
||||
has_recurrence = self.rrule is not None or self.trigger_mode is not None or self.after_completion_days is not None
|
||||
has_recurrence = (
|
||||
self.rrule is not None
|
||||
or self.trigger_mode is not None
|
||||
or self.after_completion_days is not None
|
||||
or self.after_completion_unit is not None
|
||||
)
|
||||
if has_recurrence and self.due_at is None:
|
||||
raise ValueError("recurrence requires due_at")
|
||||
if has_recurrence and self.parent_id is not None:
|
||||
@@ -144,10 +150,15 @@ class TaskCreate(BaseModel):
|
||||
if self.trigger_mode == "after_completion":
|
||||
if self.after_completion_days is None or self.rrule is not None:
|
||||
raise ValueError("after_completion requires days and no rrule")
|
||||
elif self.trigger_mode == "scheduled" and self.rrule is None:
|
||||
raise ValueError("scheduled recurrence requires rrule")
|
||||
elif self.trigger_mode is None and self.after_completion_days is not None:
|
||||
raise ValueError("after_completion_days requires after_completion mode")
|
||||
elif self.trigger_mode == "scheduled":
|
||||
if (
|
||||
self.rrule is None
|
||||
or self.after_completion_days is not None
|
||||
or self.after_completion_unit is not None
|
||||
):
|
||||
raise ValueError("scheduled recurrence requires rrule and no completion interval")
|
||||
elif self.after_completion_days is not None or self.after_completion_unit is not None:
|
||||
raise ValueError("completion interval requires after_completion mode")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ function bottomTab(page: Page, name: string) {
|
||||
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
||||
}
|
||||
|
||||
async function openSidebarView(page: Page, name: string) {
|
||||
await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click()
|
||||
await page.locator('.sidebar').getByRole('button', { name, exact: true }).click()
|
||||
}
|
||||
|
||||
async function csrf(request: APIRequestContext, baseURL: string) {
|
||||
const state = await request.storageState()
|
||||
return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value
|
||||
@@ -50,7 +55,7 @@ test('complete ZIP backup preflights and replace-restores task, habit history, c
|
||||
expect(countdownResponse.ok()).toBeTruthy()
|
||||
|
||||
await page.goto('/')
|
||||
await bottomTab(page, '设置').click()
|
||||
await openSidebarView(page, '设置')
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.getByRole('button', { name: '导出 ZIP' }).click()
|
||||
const download = await downloadPromise
|
||||
|
||||
@@ -6,11 +6,6 @@ function bottomTab(page: Page, name: string) {
|
||||
}
|
||||
|
||||
async function openSettings(page: Page) {
|
||||
const mobileTab = bottomTab(page, '设置')
|
||||
if (await mobileTab.isVisible()) {
|
||||
await mobileTab.click()
|
||||
return
|
||||
}
|
||||
const desktopSettings = page.getByRole('navigation', { name: '管理' }).getByRole('button', { name: '设置', exact: true })
|
||||
const box = await desktopSettings.boundingBox()
|
||||
if (box && box.x + box.width > 0 && box.y + box.height > 0 && box.x < (await page.viewportSize())!.width) await desktopSettings.click()
|
||||
@@ -159,15 +154,21 @@ test('bottom navigation keeps its safe-area gap after dragging', async ({ page }
|
||||
})
|
||||
|
||||
test('all bottom destinations expose one active page and desktop layout stays unchanged', async ({ page }) => {
|
||||
await page.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: [] }))
|
||||
await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [], sources: [] } }))
|
||||
await page.goto('/')
|
||||
const navigation = page.getByRole('navigation', { name: '主要导航' })
|
||||
for (const label of ['今天', '习惯', '倒数日', '设置']) {
|
||||
await bottomTab(page, label).click()
|
||||
await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1)
|
||||
await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page')
|
||||
const mobile = (await page.viewportSize())!.width <= 930
|
||||
if (mobile) {
|
||||
for (const label of ['今天', '习惯', '倒数日', '备忘录', '日历订阅']) {
|
||||
await bottomTab(page, label).click()
|
||||
await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1)
|
||||
await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page')
|
||||
}
|
||||
await bottomTab(page, '今天').click()
|
||||
} else {
|
||||
await expect(navigation).toBeHidden()
|
||||
}
|
||||
|
||||
await bottomTab(page, '今天').click()
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
const desktop = await page.locator('.shell').evaluate(element => {
|
||||
const shell = getComputedStyle(element)
|
||||
@@ -199,10 +200,55 @@ test('all bottom destinations expose one active page and desktop layout stays un
|
||||
expect(desktopBottomGap).toBe(84)
|
||||
})
|
||||
|
||||
test('calendar week focus keeps seven usable day controls without page overflow', async ({ page }) => {
|
||||
const calendarSources = ['天气','法定节假日','老黄历','节日节气','星座','影视上新','股市指数','街舞赛事','F1'].map((name, index) => ({ id:`calendar-source-${index}`, name, url:`https://example.com/${index}.ics`, color:'#f15a29', enabled:true, refreshed_at:null, last_error:null, stale:false }))
|
||||
await page.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: calendarSources }))
|
||||
await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [], sources: [] } }))
|
||||
await page.goto('/')
|
||||
const mobile = (await page.viewportSize())!.width <= 930
|
||||
if (mobile) await bottomTab(page, '日历订阅').click()
|
||||
else await page.locator('.primary-nav').getByRole('button', { name: '日历订阅', exact: true }).click()
|
||||
const strip = page.getByRole('group', { name: '选择日期' })
|
||||
const filters = page.locator('.calendar-filters')
|
||||
await expect(strip).toBeVisible()
|
||||
await expect(filters).toBeVisible()
|
||||
await expect(strip.locator('.calendar-week-day')).toHaveCount(7)
|
||||
const metrics = await strip.evaluate(element => {
|
||||
const filters = document.querySelector<HTMLElement>('.calendar-filters')!
|
||||
const calendar = document.querySelector<HTMLElement>('.calendar-view')!
|
||||
const main = calendar.closest('main') as HTMLElement
|
||||
return {
|
||||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
bodyOverflow: document.body.scrollWidth - document.body.clientWidth,
|
||||
mainOverflow: main.scrollWidth - main.clientWidth,
|
||||
calendarOverflow: calendar.scrollWidth - calendar.clientWidth,
|
||||
filters: { clientWidth: filters.clientWidth, scrollWidth: filters.scrollWidth, overflowX: getComputedStyle(filters).overflowX },
|
||||
stripOverflow: element.scrollWidth > element.clientWidth,
|
||||
buttons: [...element.querySelectorAll<HTMLElement>('.calendar-week-day')].map(button => ({ width: button.getBoundingClientRect().width, height: button.getBoundingClientRect().height })),
|
||||
}
|
||||
})
|
||||
expect(metrics.documentOverflow).toBe(0)
|
||||
expect(metrics.bodyOverflow).toBe(0)
|
||||
expect(metrics.mainOverflow).toBe(0)
|
||||
expect(metrics.calendarOverflow).toBe(0)
|
||||
if ((await page.viewportSize())!.width <= 930) expect(metrics.filters.clientWidth).toBeLessThan(metrics.filters.scrollWidth)
|
||||
else expect(metrics.filters.clientWidth).toBeGreaterThanOrEqual(metrics.filters.scrollWidth)
|
||||
expect(metrics.filters.overflowX).toBe('auto')
|
||||
expect(metrics.buttons.every(button => button.width >= 44 && button.height >= 44)).toBeTruthy()
|
||||
const selectedColors = await strip.locator('.calendar-week-day.is-selected').evaluate(element => {
|
||||
const style = getComputedStyle(element)
|
||||
return { background: style.backgroundColor, color: style.color }
|
||||
})
|
||||
expect(selectedColors).toEqual({ background: 'rgb(241, 90, 41)', color: 'rgb(255, 255, 255)' })
|
||||
if ((await page.viewportSize())!.width <= 390) expect(metrics.stripOverflow).toBeTruthy()
|
||||
})
|
||||
|
||||
test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await openSettings(page)
|
||||
if ((await page.viewportSize())!.width <= 720) await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page')
|
||||
if ((await page.viewportSize())!.width <= 720) {
|
||||
await expect(page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name: '设置', exact: true })).toHaveCount(0)
|
||||
}
|
||||
const groups = page.locator('.settings-group')
|
||||
await expect(groups).toHaveCount(4)
|
||||
const layout = await page.locator('.settings-sections').evaluate(element => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { APIRequestContext, Page } from '@playwright/test'
|
||||
import { expect, test } from './fixtures'
|
||||
|
||||
async function csrf(request: APIRequestContext) {
|
||||
const state = await request.storageState()
|
||||
return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? ''
|
||||
}
|
||||
|
||||
async function createTask(request: APIRequestContext, baseURL: string, listId: string, title: string) {
|
||||
const response = await request.post('/api/v1/tasks', {
|
||||
data: { title, list_id: listId },
|
||||
headers: { 'x-csrf-token': await csrf(request), origin: baseURL },
|
||||
})
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
}
|
||||
|
||||
async function openInbox(page: Page) {
|
||||
const inbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true })
|
||||
if (await page.evaluate(() => window.innerWidth <= 930)) {
|
||||
await page.locator('main .topbar > button').first().click()
|
||||
}
|
||||
await inbox.click()
|
||||
}
|
||||
|
||||
test('task pagination stays below the list and returns to the list start after navigation', async ({ page, request, baseURL }) => {
|
||||
const bootstrapResponse = await request.get('/api/v1/bootstrap')
|
||||
expect(bootstrapResponse.ok()).toBeTruthy()
|
||||
const inbox = (await bootstrapResponse.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||
expect(inbox).toBeTruthy()
|
||||
|
||||
for (let index = 1; index <= 51; index += 1) {
|
||||
await createTask(request, baseURL!, inbox.id, `分页验收任务 ${String(index).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
await page.goto('/')
|
||||
await openInbox(page)
|
||||
|
||||
const taskList = page.locator('.task-list')
|
||||
const pager = page.locator('.pager')
|
||||
await expect(taskList.locator('.task-row')).toHaveCount(50)
|
||||
await expect(pager).toBeVisible()
|
||||
await expect(pager).toContainText('1 / 2')
|
||||
|
||||
const firstPageGeometry = await page.evaluate(() => {
|
||||
const list = document.querySelector<HTMLElement>('.task-list')!.getBoundingClientRect()
|
||||
const pagerBox = document.querySelector<HTMLElement>('.pager')!.getBoundingClientRect()
|
||||
return { listBottom: list.bottom, pagerTop: pagerBox.top }
|
||||
})
|
||||
expect(firstPageGeometry.pagerTop).toBeGreaterThanOrEqual(firstPageGeometry.listBottom)
|
||||
|
||||
await pager.getByRole('button', { name: '下一页' }).click()
|
||||
await expect(pager).toContainText('2 / 2')
|
||||
await expect(taskList.locator('.task-row')).toHaveCount(1)
|
||||
|
||||
const secondPageGeometry = await page.evaluate(() => {
|
||||
const list = document.querySelector<HTMLElement>('.task-list')!.getBoundingClientRect()
|
||||
const pagerBox = document.querySelector<HTMLElement>('.pager')!.getBoundingClientRect()
|
||||
return { listTop: list.top, listBottom: list.bottom, pagerTop: pagerBox.top, viewportHeight: innerHeight }
|
||||
})
|
||||
expect(secondPageGeometry.listTop).toBeGreaterThanOrEqual(-1)
|
||||
expect(secondPageGeometry.listTop).toBeLessThan(secondPageGeometry.viewportHeight / 2)
|
||||
expect(secondPageGeometry.pagerTop).toBeGreaterThanOrEqual(secondPageGeometry.listBottom)
|
||||
})
|
||||
@@ -130,7 +130,7 @@ test('task rows use the body for detail and Trash keeps distinct actions', async
|
||||
|
||||
test('Settings removes intro/empty danger and places mode-specific restore risk copy correctly', async ({ page }, testInfo) => {
|
||||
await page.goto('/')
|
||||
await bottomTab(page, '设置').click()
|
||||
await openSidebarView(page, '设置')
|
||||
expect(await page.locator('.view-intro').count()).toBe(0)
|
||||
await expect(page.locator('.settings-group')).toHaveCount(4)
|
||||
expect(await page.locator('.settings-danger').count()).toBe(0)
|
||||
|
||||
+45
-22
@@ -5,7 +5,7 @@ import {
|
||||
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote,
|
||||
Settings, Trash2, X, Repeat2, StickyNote,
|
||||
} from 'lucide-vue-next'
|
||||
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
||||
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
||||
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||
@@ -16,6 +16,7 @@ import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/arch
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
import CountdownPanel from './CountdownPanel.vue'
|
||||
import MemoPanel from './MemoPanel.vue'
|
||||
import CalendarPanel from './CalendarPanel.vue'
|
||||
import FloatingAddButton from './components/FloatingAddButton.vue'
|
||||
import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
||||
import CalendarPicker from './components/CalendarPicker.vue'
|
||||
@@ -31,8 +32,8 @@ type FolderItem = { id: string; name: string }
|
||||
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
||||
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
|
||||
type RepeatOption = TaskRepeatOption
|
||||
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
|
||||
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null; after_completion_unit: AfterCompletionUnit | null }
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
|
||||
|
||||
const initialized = ref<boolean | null>(null)
|
||||
const authReady = ref(false)
|
||||
@@ -55,6 +56,7 @@ let purgeListTrigger: HTMLElement | null = null
|
||||
const tasks = ref<Task[]>([])
|
||||
const overdueTasks = ref<Task[]>([])
|
||||
const trash = ref<Task[]>([])
|
||||
const taskListElement = ref<HTMLElement | null>(null)
|
||||
const NAVIGATION_STORAGE_KEY = 'dodo.navigation'
|
||||
const restoredNavigation = readStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY)
|
||||
const activeList = ref(restoredNavigation.listId)
|
||||
@@ -157,9 +159,11 @@ const composePriority = ref(0)
|
||||
const composeDescription = ref('')
|
||||
const composeRepeat = ref<RepeatOption>('none')
|
||||
const composeAfterCompletionDays = ref('1')
|
||||
const composeAfterCompletionUnit = ref<AfterCompletionUnit>('days')
|
||||
const composeRepeatError = ref('')
|
||||
const selectedTaskRepeat = ref<RepeatOption>('none')
|
||||
const selectedAfterCompletionDays = ref('1')
|
||||
const selectedAfterCompletionUnit = ref<AfterCompletionUnit>('days')
|
||||
const selectedRepeatError = ref('')
|
||||
const selectedTaskRecurrence = ref<Recurrence | null>(null)
|
||||
const recurrenceLoading = ref(false)
|
||||
@@ -204,6 +208,7 @@ function openTaskCompose() {
|
||||
composeDescription.value = ''
|
||||
composeRepeat.value = 'none'
|
||||
composeAfterCompletionDays.value = '1'
|
||||
composeAfterCompletionUnit.value = 'days'
|
||||
composeRepeatError.value = ''
|
||||
composeRepeatConfig.value = defaultRepeatConfig()
|
||||
composeCalendarOpen.value = false
|
||||
@@ -242,13 +247,13 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
|
||||
else if (activeView.value === 'memos') void memoPanel.value?.createMemo()
|
||||
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
|
||||
}
|
||||
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, recurrence: Recurrence | null) {
|
||||
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, afterCompletionUnit: AfterCompletionUnit, recurrence: Recurrence | null) {
|
||||
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
|
||||
if (value === 'none') {
|
||||
if (recurrence) await api(`/recurrences/${recurrence.id}`, { method: 'DELETE' })
|
||||
return null
|
||||
}
|
||||
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })
|
||||
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config })
|
||||
if (recurrence) {
|
||||
return await api(`/recurrences/${recurrence.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence
|
||||
}
|
||||
@@ -262,6 +267,7 @@ async function loadTaskRecurrence(task: Task) {
|
||||
selectedTaskRecurrence.value = null
|
||||
selectedTaskRepeat.value = 'none'
|
||||
selectedAfterCompletionDays.value = '1'
|
||||
selectedAfterCompletionUnit.value = 'days'
|
||||
selectedRepeatError.value = ''
|
||||
try {
|
||||
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
|
||||
@@ -270,6 +276,7 @@ async function loadTaskRecurrence(task: Task) {
|
||||
const parsed = parseTaskRecurrence(recurrence)
|
||||
selectedTaskRepeat.value = parsed.option
|
||||
selectedAfterCompletionDays.value = String(parsed.afterCompletionDays)
|
||||
selectedAfterCompletionUnit.value = parsed.afterCompletionUnit
|
||||
selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
|
||||
} catch (reason) {
|
||||
if (selectionIsCurrent()) fail(reason)
|
||||
@@ -292,7 +299,7 @@ async function submitTaskCompose() {
|
||||
const targetListId = composeListId.value
|
||||
creatingTask.value = true
|
||||
try {
|
||||
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, repeatConfig: composeRepeatConfig.value })
|
||||
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, afterCompletionUnit: composeAfterCompletionUnit.value, repeatConfig: composeRepeatConfig.value })
|
||||
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
|
||||
if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间')
|
||||
await taskMutationReconciler.run(
|
||||
@@ -389,6 +396,7 @@ const activeName = computed(() => {
|
||||
if (activeView.value === 'habits') return '习惯'
|
||||
if (activeView.value === 'countdowns') return '倒数日'
|
||||
if (activeView.value === 'memos') return '备忘录'
|
||||
if (activeView.value === 'calendar') return '日历订阅'
|
||||
if (activeView.value === 'settings') return '设置与数据'
|
||||
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
||||
})
|
||||
@@ -409,7 +417,7 @@ const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value :
|
||||
const visibleTasks = computed(() => {
|
||||
const now = new Date()
|
||||
let result = sourceTasks.value
|
||||
if (['habits','settings','countdowns','memos'].includes(activeView.value)) return []
|
||||
if (['habits','settings','countdowns','memos','calendar'].includes(activeView.value)) return []
|
||||
if (activeView.value === 'today') result = result.filter((task) => {
|
||||
const dueToday = task.due_at && new Date(task.due_at).toDateString() === now.toDateString()
|
||||
const completedToday = task.completed_at && new Date(task.completed_at).toDateString() === now.toDateString()
|
||||
@@ -717,7 +725,7 @@ async function loadTrashPage() {
|
||||
async function loadTrash() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
return await runLatestRequest('trash', loadTrashPage, {
|
||||
const committed = await runLatestRequest('trash', loadTrashPage, {
|
||||
success: (data) => {
|
||||
trash.value = data.items ?? []
|
||||
totalTasks.value = data.total ?? trash.value.length
|
||||
@@ -725,6 +733,11 @@ async function loadTrash() {
|
||||
error: fail,
|
||||
finally: () => { loading.value = false },
|
||||
})
|
||||
if (committed && page.value > totalPages.value) {
|
||||
page.value = totalPages.value
|
||||
return await loadTrash()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
async function switchView(view: View, listId?: string) {
|
||||
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return
|
||||
@@ -1091,6 +1104,7 @@ async function saveSelectedTaskChanges() {
|
||||
const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'
|
||||
const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig
|
||||
const afterCompletionDays = selectedAfterCompletionDays.value
|
||||
const afterCompletionUnit = selectedAfterCompletionUnit.value
|
||||
const recurrence = selectedTaskRecurrence.value
|
||||
selectedRepeatError.value = ''
|
||||
try {
|
||||
@@ -1100,7 +1114,7 @@ async function saveSelectedTaskChanges() {
|
||||
selectedTaskRecurrence.value = null
|
||||
selectedTaskRepeat.value = 'none'
|
||||
} else {
|
||||
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)
|
||||
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence)
|
||||
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
|
||||
selectedTaskRecurrence.value = updatedRecurrence
|
||||
}
|
||||
@@ -1274,8 +1288,7 @@ async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskLi
|
||||
}
|
||||
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
||||
if (kind === 'lists') {
|
||||
const answer = await askText(`归档清单「${item.name}」?`, '', '', '归档')
|
||||
if (answer === null) return
|
||||
if (!(await confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复'))) return
|
||||
try {
|
||||
const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id
|
||||
await api(`/${kind}/${item.id}`, { method: 'DELETE' })
|
||||
@@ -1528,15 +1541,23 @@ function moveListWithinScope(item: TaskList, direction: 'up' | 'down') {
|
||||
void persistListMove(item, item.folder_id, move.targetId, move.placement)
|
||||
closeSidebarAction()
|
||||
}
|
||||
function previousPage() {
|
||||
async function scrollToTaskPageStart() {
|
||||
await nextTick()
|
||||
taskListElement.value?.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
async function previousPage() {
|
||||
if (page.value <= 1 || loading.value) return
|
||||
page.value -= 1
|
||||
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
|
||||
if (activeView.value === 'trash') await loadTrash()
|
||||
else if (isTaskView(activeView.value)) await loadAll()
|
||||
await scrollToTaskPageStart()
|
||||
}
|
||||
function nextPage() {
|
||||
async function nextPage() {
|
||||
if (page.value >= totalPages.value || loading.value) return
|
||||
page.value += 1
|
||||
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
|
||||
if (activeView.value === 'trash') await loadTrash()
|
||||
else if (isTaskView(activeView.value)) await loadAll()
|
||||
await scrollToTaskPageStart()
|
||||
}
|
||||
|
||||
function reconcileDesktopPaneWidths() {
|
||||
@@ -1613,6 +1634,7 @@ onUnmounted(() => {
|
||||
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
|
||||
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
|
||||
<button :class="{ active: activeView==='memos' }" @click="switchView('memos')"><StickyNote />备忘录</button>
|
||||
<button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarDays />日历订阅</button>
|
||||
</nav>
|
||||
<div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
|
||||
<div class="folders">
|
||||
@@ -1684,6 +1706,7 @@ onUnmounted(() => {
|
||||
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" :compact-layout="compactLayout" @update:show-completed="showCompleted=$event" @detail="handleHabitDetail" @changed="refreshAll" @notice="toast" @logout="completeLogout" />
|
||||
</template>
|
||||
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @detail="memoDetailOpen=$event" @notice="toast" />
|
||||
<CalendarPanel v-else-if="activeView==='calendar'" @notice="toast" />
|
||||
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
||||
<template v-else>
|
||||
<section v-if="activeView==='today'" class="today-context" aria-label="今日概览">
|
||||
@@ -1709,10 +1732,8 @@ onUnmounted(() => {
|
||||
<button id="today-tasks-heading" class="today-section-toggle today-section-anchor" type="button" :aria-expanded="!todaySectionCollapse.tasks" aria-controls="today-tasks" @click="toggleTodaySection('tasks')"><span class="today-section-title">今天</span><span class="today-section-summary">{{totalTasks}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.tasks ? '›' : '⌄' }}</span></button>
|
||||
</template>
|
||||
<div v-if="activeView==='tasks' || activeView==='upcoming'" id="task-list-heading" class="list-section-heading"><span id="task-list-title" class="list-section-title">任务</span><span class="list-section-count">{{ totalTasks }}</span><button v-if="activeView==='tasks' && taskReorderAvailable" class="list-section-action" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></div>
|
||||
<div v-if="activeView==='trash'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></div>
|
||||
<div v-if="activeView==='tasks' && totalPages > 1" class="list-page-meta"><span>第 {{ page }} / {{ totalPages }} 页 · 共 {{ totalTasks }} 项</span></div>
|
||||
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
|
||||
<section :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list plain-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined">
|
||||
<div v-if="activeView==='trash'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></div>
|
||||
<section :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list plain-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined" ref="taskListElement">
|
||||
<template v-for="node in taskTree" :key="node.task.id">
|
||||
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'task-row--trash':activeView==='trash','just-completed': justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
|
||||
<button v-if="taskReorderMode" class="drag-handle task-drag-handle" :disabled="totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
|
||||
@@ -1724,6 +1745,8 @@ onUnmounted(() => {
|
||||
<div v-if="activeView==='today' && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div>
|
||||
<div v-else-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span></div>
|
||||
</section>
|
||||
<div v-if="activeView==='tasks' && totalPages > 1" class="list-page-meta" aria-live="polite"><span>第 {{ page }} / {{ totalPages }} 页 · 共 {{ totalTasks }} 项</span></div>
|
||||
<div v-if="totalPages > 1 && (activeView!=='today' || !todaySectionCollapse.tasks)" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span aria-live="polite">{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
|
||||
<div v-if="activeView==='today'" class="today-habits-section today-section-anchor">
|
||||
<button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title">习惯</span><span class="today-section-summary">{{todayHabitTotal}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.habits ? '›' : '⌄' }}</span></button>
|
||||
<div v-show="!todaySectionCollapse.habits" id="today-habits" role="region" aria-labelledby="today-habits-heading">
|
||||
@@ -1745,7 +1768,7 @@ onUnmounted(() => {
|
||||
<div class="task-detail-field"><span class="task-detail-field-label">时间</span><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add task-detail-time-control" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip task-detail-time-control"><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label><span v-else class="task-detail-time-empty" aria-hidden="true">—</span></div>
|
||||
</div>
|
||||
<label class="task-detail-field"><span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
||||
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 天重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
|
||||
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="selectedAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months">月</option></select>重复</div><small>每次完成后,将截止日期按所选间隔顺延;月末会自动取目标月最后一天</small></section>
|
||||
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
|
||||
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
|
||||
</section>
|
||||
@@ -1765,7 +1788,7 @@ onUnmounted(() => {
|
||||
<footer class="detail-actions"><button class="danger-text detail-trash" type="button" :disabled="taskDetailBusy" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button><button class="primary detail-save" type="submit" :disabled="taskDetailBusy">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button></footer>
|
||||
</AppSheet>
|
||||
|
||||
<nav class="bottom" :inert="memoBackgroundInert ? true : undefined" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
||||
<nav class="bottom" :inert="memoBackgroundInert ? true : undefined" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='memos'}" :aria-current="activeView==='memos' ? 'page' : undefined" @click="switchView('memos')"><StickyNote/><span>备忘录</span></button><button :class="{active:activeView==='calendar'}" :aria-current="activeView==='calendar' ? 'page' : undefined" @click="switchView('calendar')"><CalendarDays/><span>日历订阅</span></button></nav>
|
||||
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
|
||||
<AppSheet :open="taskComposeOpen" variant="create" panel-class="task-compose-sheet" title-id="task-compose-title" initial-focus=".task-compose-input" :style="taskComposeStyle" @close="closeTaskCompose" @submit.prevent="submitTaskCompose">
|
||||
<header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
|
||||
@@ -1784,7 +1807,7 @@ onUnmounted(() => {
|
||||
<label v-else-if="composeDueAt" class="task-compose-time-chip"><span>时间</span><input ref="composeTimePicker" v-model="composeTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="composeHasTime=false"><X/></button></label>
|
||||
</div>
|
||||
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
||||
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 天重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
|
||||
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="composeAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months">月</option></select>重复</div><small>每次完成后,将截止日期按所选间隔顺延;月末会自动取目标月最后一天</small></section>
|
||||
<small v-if="composeRepeatError" role="alert" class="field-error">{{composeRepeatError}}</small>
|
||||
<section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section>
|
||||
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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('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%;')})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
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>){ vi.stubGlobal('fetch',fetchMock); const host=document.createElement('div');document.body.append(host);const notices:string[]=[];const app=createApp(()=>h(CalendarPanel,{onNotice:(v:string)=>notices.push(v)}));app.mount(host);cleanups.push(()=>{app.unmount();host.remove()});await flush();return {host,notices} }
|
||||
afterEach(()=>{cleanups.splice(0).forEach(fn=>fn());vi.useRealTimers();vi.unstubAllGlobals();vi.restoreAllMocks()})
|
||||
|
||||
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('产品发布')
|
||||
host.querySelector<HTMLInputElement>('input[aria-label="筛选工作"]')!.click();await nextTick()
|
||||
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('filters duplicate source names by source id',async()=>{
|
||||
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
|
||||
const duplicateSubscriptions=[subscriptions[0],{...subscriptions[0],id:'s2',url:'https://example.com/personal.ics',color:'#334455'}]
|
||||
const duplicateEvents=[events[0],{...events[0],id:'e2',title:'私人日程',source_id:'s2',color:'#334455'}]
|
||||
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:duplicateEvents,sources:[]}:duplicateSubscriptions)))
|
||||
const {host}=await mount(fetchMock)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
|
||||
host.querySelector<HTMLButtonElement>('[data-day="2026-09-22"]')!.click();await nextTick()
|
||||
host.querySelectorAll<HTMLInputElement>('input[aria-label="筛选工作"]')[0].click();await nextTick()
|
||||
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
|
||||
expect(host.querySelector('[data-event-id="e2"]')).not.toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('groups UTC events by the browser-local calendar day',async()=>{
|
||||
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()=>{
|
||||
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('旧一周')
|
||||
})
|
||||
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,74 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-vue-next'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { formatApiErrorDetail } from './lib/mvp-utils'
|
||||
import AppSheet from './components/AppSheet.vue'
|
||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||
|
||||
type Subscription = { id:string; name:string; url:string; color:string; enabled:boolean; refreshed_at:string|null; last_error:string|null; stale:boolean }
|
||||
type CalendarEvent = { id:string; title:string; starts_at:string; ends_at:string; all_day:boolean; source_id:string; source_name:string; color:string; description?:string|null; location?:string|null }
|
||||
type EventResponse = { events:CalendarEvent[]; sources:Array<{id:string;name:string;stale:boolean}> }
|
||||
type Form = { name:string; url:string; color:string; enabled:boolean }
|
||||
const emit=defineEmits<{notice:[message:string]}>()
|
||||
const subscriptions=ref<Subscription[]>([]),events=ref<CalendarEvent[]>([]),loading=ref(false),error=ref('')
|
||||
let eventsRequestGeneration=0
|
||||
const selectedDay=ref(new Date(new Date().getFullYear(),new Date().getMonth(),new Date().getDate())),hiddenSources=ref(new Set<string>())
|
||||
const selected=ref<CalendarEvent|null>(null),manageOpen=ref(false),formOpen=ref(false),editing=ref<Subscription|null>(null),busyId=ref('')
|
||||
const form=ref<Form>({name:'',url:'',color:'#f15a29',enabled:true})
|
||||
const appDialog=ref<{show:(options:AppDialogOptions)=>Promise<boolean|string|null>}|null>(null)
|
||||
const request=async(path:string,options:RequestInit={})=>{const headers:Record<string,string>={...(options.headers as Record<string,string>||{})};if(options.body)headers['Content-Type']='application/json';Object.assign(headers,csrfHeader(options.method));const response=await fetch('/api/v1'+path,{credentials:'include',...options,headers});if(!response.ok){const body=await response.json().catch(()=>({}));throw new Error(formatApiErrorDetail((body as {detail?:unknown}).detail??body))}return response.status===204?null:response.json()}
|
||||
const key=(date:Date)=>`${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`
|
||||
const startOfWeek=(value:Date)=>{const date=new Date(value.getFullYear(),value.getMonth(),value.getDate());date.setDate(date.getDate()-((date.getDay()+6)%7));return date}
|
||||
const weekStart=computed(()=>startOfWeek(selectedDay.value))
|
||||
const weekDays=computed(()=>Array.from({length:7},(_,index)=>{const date=new Date(weekStart.value);date.setDate(date.getDate()+index);return date}))
|
||||
const range=computed(()=>{const start=weekStart.value;const end=new Date(start);end.setDate(end.getDate()+7);return{start:start.toISOString(),end:end.toISOString()}})
|
||||
const weekLabel=computed(()=>{const start=weekDays.value[0],end=weekDays.value[6];if(start.getFullYear()!==end.getFullYear())return `${start.getFullYear()}年${start.getMonth()+1}月${start.getDate()}日 - ${end.getFullYear()}年${end.getMonth()+1}月${end.getDate()}日`;return start.getMonth()===end.getMonth()?`${start.getFullYear()}年${start.getMonth()+1}月`:`${start.getFullYear()}年${start.getMonth()+1}月${start.getDate()}日 - ${end.getMonth()+1}月${end.getDate()}日`})
|
||||
const eventStart=(event:CalendarEvent)=>event.starts_at
|
||||
const eventEnd=(event:CalendarEvent)=>event.ends_at
|
||||
const eventKey=(event:CalendarEvent)=>event.id
|
||||
const filteredEvents=computed(()=>events.value.filter(event=>!hiddenSources.value.has(event.source_id)).sort((a,b)=>eventStart(a).localeCompare(eventStart(b))))
|
||||
const localDayKey=(value:string)=>{const date=new Date(value);return Number.isNaN(date.getTime())?value.slice(0,10):key(date)}
|
||||
const selectedDayKey=computed(()=>key(selectedDay.value))
|
||||
const visibleEvents=computed(()=>filteredEvents.value.filter(event=>localDayKey(eventStart(event))===selectedDayKey.value))
|
||||
const selectedDayLabel=computed(()=>displayDay(selectedDayKey.value))
|
||||
const dayEventCount=(date:Date)=>filteredEvents.value.filter(event=>localDayKey(eventStart(event))===key(date)).length
|
||||
const isToday=(date:Date)=>key(date)===key(new Date())
|
||||
const weekDayLabel=(date:Date)=>new Intl.DateTimeFormat('zh-CN',{weekday:'short'}).format(date).replace('周','')
|
||||
const eventTitle=(event:CalendarEvent)=>event.title||'未命名事件'
|
||||
const eventSource=(event:CalendarEvent)=>event.source_name||'日历'
|
||||
const eventColor=(event:CalendarEvent)=>event.color||'#f15a29'
|
||||
function displayDay(day:string){const [y,m,d]=day.split('-').map(Number);return new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'short'}).format(new Date(y,m-1,d))}
|
||||
function displayTime(event:CalendarEvent){if(event.all_day)return'全天';const date=new Date(eventStart(event));return Number.isNaN(date.getTime())?'时间待定':new Intl.DateTimeFormat('zh-CN',{hour:'2-digit',minute:'2-digit'}).format(date)}
|
||||
async function loadSubscriptions(){subscriptions.value=await request('/calendar-subscriptions') as Subscription[]}
|
||||
async function loadEvents(){const generation=++eventsRequestGeneration;const requestedRange=range.value;const data=await request(`/calendar-events?start=${encodeURIComponent(requestedRange.start)}&end=${encodeURIComponent(requestedRange.end)}`) as EventResponse;if(generation===eventsRequestGeneration)events.value=data.events}
|
||||
async function load(){loading.value=true;error.value='';try{await Promise.all([loadSubscriptions(),loadEvents()])}catch(reason){error.value=reason instanceof Error?reason.message:'日历载入失败'}finally{loading.value=false}}
|
||||
async function moveWeek(offset:number){const next=new Date(weekStart.value);next.setDate(next.getDate()+offset*7);selectedDay.value=next;await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')}
|
||||
async function today(){const now=new Date();selectedDay.value=new Date(now.getFullYear(),now.getMonth(),now.getDate());await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')}
|
||||
function selectDay(date:Date){selectedDay.value=new Date(date.getFullYear(),date.getMonth(),date.getDate())}
|
||||
function toggleFilter(id:string){const next=new Set(hiddenSources.value);next.has(id)?next.delete(id):next.add(id);hiddenSources.value=next}
|
||||
function openCreate(){editing.value=null;form.value={name:'',url:'',color:'#f15a29',enabled:true};formOpen.value=true}
|
||||
function openEdit(item:Subscription){editing.value=item;form.value={name:item.name,url:item.url,color:item.color||'#f15a29',enabled:item.enabled};formOpen.value=true}
|
||||
async function save(){if(busyId.value||!form.value.name.trim()||!form.value.url.trim())return;busyId.value='form';error.value='';try{await request(editing.value?`/calendar-subscriptions/${editing.value.id}`:'/calendar-subscriptions',{method:editing.value?'PATCH':'POST',body:JSON.stringify({...form.value,name:form.value.name.trim(),url:form.value.url.trim()})});formOpen.value=false;await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',editing.value?'日历订阅已更新':'日历订阅已添加')}catch(reason){error.value=reason instanceof Error?reason.message:'保存失败'}finally{busyId.value=''}}
|
||||
async function toggleEnabled(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'PATCH',body:JSON.stringify({enabled:!item.enabled})});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',item.enabled?'日历订阅已停用':'日历订阅已启用')}catch(reason){error.value=reason instanceof Error?reason.message:'更新失败'}finally{busyId.value=''}}
|
||||
async function refresh(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}/refresh`,{method:'POST'});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',`${item.name}已刷新`)}catch(reason){error.value=reason instanceof Error?reason.message:'刷新失败'}finally{busyId.value=''}}
|
||||
async function remove(item:Subscription){if(busyId.value)return;if(await appDialog.value?.show({title:`删除“${item.name}”?`,description:'该来源的事件也会从日历中移除。',danger:true,confirmText:'删除'})!==true)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'DELETE'});hiddenSources.value.delete(item.id);await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice','日历订阅已删除')}catch(reason){error.value=reason instanceof Error?reason.message:'删除失败'}finally{busyId.value=''}}
|
||||
watch(manageOpen,open=>{if(!open)formOpen.value=false})
|
||||
onMounted(()=>void load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="calendar-view" :class="{loading}">
|
||||
<header class="calendar-heading"><p>{{filteredEvents.length}} 个日程 · {{subscriptions.length}} 个来源</p><button class="soft-button calendar-manage" aria-label="管理日历源" @click="manageOpen=true"><Settings2/>日历源</button></header>
|
||||
<p v-if="error" class="inline-error" role="alert">{{error}}</p>
|
||||
<div class="calendar-toolbar"><button aria-label="上一周" @click="moveWeek(-1)"><ChevronLeft/></button><button class="calendar-today" aria-label="回到今天" @click="today">今天</button><strong>{{weekLabel}}</strong><button aria-label="下一周" @click="moveWeek(1)"><ChevronRight/></button></div>
|
||||
<div class="calendar-week-strip" role="group" aria-label="选择日期"><button v-for="day in weekDays" :key="key(day)" class="calendar-week-day" :class="{'is-selected':key(day)===selectedDayKey,'is-today':isToday(day)}" :data-day="key(day)" :aria-label="`${displayDay(key(day))}${dayEventCount(day)?`,${dayEventCount(day)}个日程`:',无日程'}`" :aria-pressed="key(day)===selectedDayKey" @click="selectDay(day)"><small>{{weekDayLabel(day)}}</small><b>{{day.getDate()}}</b><i v-if="dayEventCount(day)" aria-hidden="true">{{dayEventCount(day)}}</i></button></div>
|
||||
<div v-if="subscriptions.length" class="calendar-filters" aria-label="筛选日历源"><label v-for="source in subscriptions" :key="source.id"><input type="checkbox" :aria-label="`筛选${source.name}`" :checked="!hiddenSources.has(source.id)" @change="toggleFilter(source.id)"><i :style="{background:source.color}"/>{{source.name}}</label></div>
|
||||
<div v-if="visibleEvents.length" class="calendar-agenda"><section><h2>{{selectedDayLabel}} · {{visibleEvents.length}} 个日程</h2><button v-for="event in visibleEvents" :key="eventKey(event)" :data-event-id="event.id" class="calendar-event-row" @click="selected=event"><i :style="{background:eventColor(event)}"/><time>{{displayTime(event)}}</time><span><b>{{eventTitle(event)}}</b><small>{{eventSource(event)}}<template v-if="event.location"> · {{event.location}}</template></small></span><ChevronRight/></button></section></div>
|
||||
<div v-else-if="!loading" class="calendar-empty"><CalendarDays/><b>这一天还没有日程</b><span>{{subscriptions.length?'可以选择本周其他日期或检查来源筛选':'先添加一个 iCal 日历订阅'}}</span><button v-if="!subscriptions.length" class="primary-small" @click="manageOpen=true;openCreate()">添加日历源</button></div>
|
||||
<AppSheet :open="Boolean(selected)" variant="detail" panel-class="calendar-event-detail" title-id="calendar-event-title" initial-focus="button[aria-label='关闭日程详情']" @close="selected=null"><template v-if="selected"><header class="app-sheet__header"><h3 id="calendar-event-title">{{eventTitle(selected)}}</h3><button aria-label="关闭日程详情" @click="selected=null"><X/></button></header><div class="app-sheet__body"><dl><div><dt>时间</dt><dd>{{displayDay(localDayKey(eventStart(selected)))}} {{displayTime(selected)}}<template v-if="eventEnd(selected) && !selected.all_day"> – {{displayTime({...selected,starts_at:eventEnd(selected)})}}</template></dd></div><div><dt>来源</dt><dd><i :style="{background:eventColor(selected)}"/>{{eventSource(selected)}}</dd></div><div v-if="selected.location"><dt>地点</dt><dd>{{selected.location}}</dd></div></dl><section v-if="selected.description"><h4>备注</h4><p>{{selected.description}}</p></section></div></template></AppSheet>
|
||||
<AppSheet :open="manageOpen" variant="detail" panel-class="calendar-sources-sheet" title-id="calendar-sources-title" initial-focus="button[aria-label='关闭日历源']" @close="manageOpen=false"><header class="app-sheet__header"><h3 id="calendar-sources-title">日历源</h3><button aria-label="关闭日历源" @click="manageOpen=false"><X/></button></header><div class="app-sheet__body"><button class="primary-small calendar-source-add" aria-label="添加日历订阅" @click="openCreate"><Plus/>添加订阅</button><div class="calendar-source-list"><article v-for="source in subscriptions" :key="source.id"><div class="calendar-source-copy"><b><i :style="{background:source.color}"/>{{source.name}}</b><small>{{source.url}}</small><small v-if="source.last_error" class="calendar-source-error" role="alert">{{source.last_error}}</small></div><label class="calendar-source-toggle"><input type="checkbox" :aria-label="`启用${source.name}`" :checked="source.enabled" :disabled="Boolean(busyId)" @change="toggleEnabled(source)"><span>启用</span></label><button :aria-label="`刷新${source.name}`" :disabled="Boolean(busyId)" @click="refresh(source)"><RefreshCw/></button><button :aria-label="`编辑${source.name}`" :disabled="Boolean(busyId)" @click="openEdit(source)"><Pencil/></button><button class="danger-text" :aria-label="`删除${source.name}`" :disabled="Boolean(busyId)" @click="remove(source)"><Trash2/></button></article></div></div></AppSheet>
|
||||
<AppSheet :open="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>
|
||||
@@ -7,13 +7,13 @@ const panel = readFileSync('src/MemoPanel.vue', 'utf8')
|
||||
const editor = readFileSync('src/components/MemoEditor.vue', 'utf8')
|
||||
|
||||
describe('memo shell integration', () => {
|
||||
it('places Memos immediately after Countdowns in desktop navigation and keeps mobile tabs unchanged', () => {
|
||||
it('places Memos immediately after Countdowns in desktop navigation and exposes it as a direct mobile destination', () => {
|
||||
const nav = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">')))
|
||||
expect(nav.indexOf("switchView('memos')")).toBeGreaterThan(nav.indexOf("switchView('countdowns')"))
|
||||
expect(nav.match(/switchView\('memos'\)/g)).toHaveLength(1)
|
||||
const bottom = app.slice(app.indexOf('<nav class="bottom"'), app.indexOf('</nav>', app.indexOf('<nav class="bottom"')))
|
||||
expect(bottom).not.toContain("switchView('memos')")
|
||||
expect(bottom.match(/aria-current=/g)).toHaveLength(4)
|
||||
expect(bottom).toContain("switchView('memos')")
|
||||
expect(bottom.match(/aria-current=/g)).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('routes the shared cat FAB to a local memo draft, hides it in trash, and defers POST until save', () => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -67,6 +67,8 @@ describe('MVP view utilities', () => {
|
||||
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'tasks', listId: 'list-2' })
|
||||
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'memos', 'list-2')
|
||||
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'memos', listId: 'list-2' })
|
||||
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'calendar', 'list-2')
|
||||
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'calendar', listId: 'list-2' })
|
||||
storage.set('dodo.navigation', JSON.stringify({ view: 'invalid', listId: 'list-2' }))
|
||||
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'>
|
||||
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
|
||||
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
|
||||
type StoredNavigation = { view: NavigationView; listId: string }
|
||||
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'settings'])
|
||||
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'calendar', 'settings'])
|
||||
|
||||
export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation {
|
||||
try {
|
||||
|
||||
@@ -75,12 +75,14 @@ describe('task utilities', () => {
|
||||
expect(() => buildTaskRrule({ frequency: 'daily', interval: 0, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('重复间隔至少为 1')
|
||||
})
|
||||
|
||||
it('builds a distinct completion-trigger payload and parses it without RRULE', () => {
|
||||
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7 })
|
||||
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14 })
|
||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数天数')
|
||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数天数')
|
||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数天数')
|
||||
it('builds and parses completion-trigger intervals in days or months', () => {
|
||||
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7, after_completion_unit: 'days' })
|
||||
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '2', afterCompletionUnit: 'months' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 2, after_completion_unit: 'months' })
|
||||
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14, afterCompletionUnit: 'days' })
|
||||
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 3, after_completion_unit: 'months' })).toEqual({ option: 'after_completion', afterCompletionDays: 3, afterCompletionUnit: 'months' })
|
||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数')
|
||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数')
|
||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数')
|
||||
})
|
||||
|
||||
it('builds scheduled recurrence payloads separately from completion triggers', () => {
|
||||
|
||||
@@ -172,15 +172,16 @@ export function parseTaskRrule(rrule = ''): TaskRepeatConfig {
|
||||
}
|
||||
|
||||
export type TaskRepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'after_completion' | 'custom'
|
||||
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null }
|
||||
export type AfterCompletionUnit = 'days' | 'months'
|
||||
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null; after_completion_unit?: AfterCompletionUnit | null }
|
||||
|
||||
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; repeatConfig?: TaskRepeatConfig }) {
|
||||
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; afterCompletionUnit?: AfterCompletionUnit; repeatConfig?: TaskRepeatConfig }) {
|
||||
if (option === 'none') return {}
|
||||
if (option === 'after_completion') {
|
||||
const raw = String(values.afterCompletionDays).trim()
|
||||
const days = Number(raw)
|
||||
if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数天数')
|
||||
return { trigger_mode: 'after_completion' as const, after_completion_days: days }
|
||||
if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数')
|
||||
return { trigger_mode: 'after_completion' as const, after_completion_days: days, after_completion_unit: values.afterCompletionUnit ?? 'days' }
|
||||
}
|
||||
const rrule = option === 'custom'
|
||||
? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' })
|
||||
@@ -189,13 +190,13 @@ export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { a
|
||||
}
|
||||
|
||||
export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) {
|
||||
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1 }
|
||||
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
|
||||
if (recurrence.trigger_mode === 'after_completion') {
|
||||
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1 }
|
||||
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1, afterCompletionUnit: recurrence.after_completion_unit ?? 'days' as AfterCompletionUnit }
|
||||
}
|
||||
const parsed = parseTaskRrule(recurrence.rrule ?? '')
|
||||
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
|
||||
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1 }
|
||||
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
|
||||
}
|
||||
|
||||
export function defaultTaskDueAt(now = new Date()) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
import './memo.css'
|
||||
import './calendar.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
|
||||
@@ -83,7 +83,7 @@ input,select,textarea{background:var(--surface-raised);border-color:var(--border
|
||||
.toast{background:#3b342c;color:#fff;border:1px solid #574d42;border-radius:var(--radius-control);box-shadow:var(--shadow-raised)}.error-toast{background:var(--danger);color:#fff;border:1px solid #9f2f22;border-radius:var(--radius-control);box-shadow:var(--shadow-raised)}
|
||||
:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}.task-check:focus-visible,.archived-lists-toggle:focus-visible,.archived-row-menu-trigger:focus-visible,.archived-row-actions button:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}
|
||||
@media(max-width:930px){.countdown-focus{height:144px;min-height:144px;max-height:144px;padding:10px 16px;gap:2px}.countdown-focus h3{margin:2px 0 0}.countdown-number{margin:0}}
|
||||
@media(max-width:930px){.bottom{left:0;right:0;bottom:0;height:calc(56px + var(--safe-area-bottom));display:grid;grid-template-columns:repeat(4,minmax(0,1fr));background:var(--surface-raised);border:0;border-top:1px solid var(--border-cream);border-radius:0;padding:4px 10px var(--safe-area-bottom);box-shadow:none}.bottom button{position:relative;min-width:0;min-height:44px;border-radius:0;padding:2px 4px;line-height:1.1}.bottom button svg{width:19px;height:19px}.bottom button.active{background:transparent;color:var(--accent)}.bottom button.active:before{content:"";position:absolute;left:23%;right:23%;top:-5px;height:3px;border-radius:0 0 3px 3px;background:var(--accent)}.sidebar{border-radius:0 var(--radius-panel) var(--radius-panel) 0}.detail,.app-sheet,.task-compose-sheet,.habit-detail-sheet,.countdown-detail-sheet{border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important}.task-list,.habit-list,.countdown-timeline{display:grid;gap:0}.task-row,.habit-row,.countdown-row{min-height:62px;background:var(--surface-raised);border:0;border-radius:0;box-shadow:none}.task-row+.task-row,.habit-row+.habit-row,.countdown-row+.countdown-row{border-top:1px solid var(--border-cream)}.countdown-row:first-of-type{border-top:0}.unified-fab{bottom:calc(68px + var(--safe-area-bottom))}}
|
||||
@media(max-width:930px){.bottom{left:0;right:0;bottom:0;height:calc(56px + var(--safe-area-bottom));display:grid;grid-template-columns:repeat(5,minmax(0,1fr));background:var(--surface-raised);border:0;border-top:1px solid var(--border-cream);border-radius:0;padding:4px 10px var(--safe-area-bottom);box-shadow:none}.bottom button{position:relative;min-width:0;min-height:44px;border-radius:0;padding:2px 4px;line-height:1.1}.bottom button svg{width:19px;height:19px}.bottom button.active{background:transparent;color:var(--accent)}.bottom button.active:before{content:"";position:absolute;left:23%;right:23%;top:-5px;height:3px;border-radius:0 0 3px 3px;background:var(--accent)}.sidebar{border-radius:0 var(--radius-panel) var(--radius-panel) 0}.detail,.app-sheet,.task-compose-sheet,.habit-detail-sheet,.countdown-detail-sheet{border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important}.task-list,.habit-list,.countdown-timeline{display:grid;gap:0}.task-row,.habit-row,.countdown-row{min-height:62px;background:var(--surface-raised);border:0;border-radius:0;box-shadow:none}.task-row+.task-row,.habit-row+.habit-row,.countdown-row+.countdown-row{border-top:1px solid var(--border-cream)}.countdown-row:first-of-type{border-top:0}.unified-fab{bottom:calc(68px + var(--safe-area-bottom))}}
|
||||
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}.completed-filter-pill,.completed-filter-pill__track,.completed-filter-pill__thumb{transition:none!important}.completed-filter-pill:active:not(:disabled){transform:none}.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}}
|
||||
/* Shared plain-list rows for active tasks and habits. */
|
||||
.plain-list{display:grid;gap:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible}
|
||||
@@ -134,7 +134,7 @@ main.list-main>.mvp-view>.habit-archive-section{border-top:1px solid #e8e0d5}
|
||||
.list-section-heading{width:min(100%,630px);min-height:44px;margin:0 auto;display:flex;align-items:center;border-bottom:1px solid #e8e0d5}
|
||||
.list-section-title{font-size:13px;font-weight:700}.list-section-count{margin-left:7px;font-size:12px;font-weight:400;color:var(--muted)}
|
||||
.list-section-action{min-height:44px;margin-left:auto;padding:0;border:0;background:transparent;color:var(--accent);font-size:12px;font-weight:650}
|
||||
.list-page-meta{min-height:36px;display:flex;align-items:center;justify-content:flex-end;gap:10px;color:var(--muted);font-size:12px}
|
||||
.list-page-meta{min-height:36px;margin-top:10px;display:flex;align-items:center;justify-content:flex-end;gap:10px;color:var(--muted);font-size:12px}
|
||||
@media(min-width:1440px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,900px)}}
|
||||
@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}}
|
||||
@media(max-width:720px){main.list-main{padding-left:29px;padding-right:29px}main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}.list-page-title{font-size:24px}}
|
||||
|
||||
@@ -118,16 +118,16 @@ describe('approved cream solid button system', () => {
|
||||
})
|
||||
|
||||
describe('mobile navigation styles', () => {
|
||||
it('renames the bottom More tab to a direct Settings tab', () => {
|
||||
it('uses five direct mobile destinations and keeps Settings in the sidebar', () => {
|
||||
expect(app).not.toContain('aria-controls="mobile-more-menu"')
|
||||
expect(app).not.toContain('<Ellipsis/><span>更多</span>')
|
||||
expect(app).toContain("<Settings/><span>设置</span>")
|
||||
expect(app).toContain("@click=\"switchView('settings')\"")
|
||||
expect(app).toContain('<span>设置</span>')
|
||||
expect(app).toContain("<StickyNote/><span>备忘录</span>")
|
||||
expect(app).toContain("<CalendarDays/><span>日历订阅</span>")
|
||||
expect(app).not.toContain("@click=\"switchView('settings')\"><Settings/><span>设置</span>")
|
||||
})
|
||||
|
||||
it('marks only exact mobile destinations active and exposes aria-current only there', () => {
|
||||
for (const view of ['today', 'habits', 'countdowns', 'settings']) {
|
||||
for (const view of ['today', 'habits', 'countdowns', 'memos', 'calendar']) {
|
||||
expect(app).toContain(`:class="{active:activeView==='${view}'}" :aria-current="activeView==='${view}' ? 'page' : undefined"`)
|
||||
}
|
||||
expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'")
|
||||
@@ -849,7 +849,10 @@ describe('task and habit row decoration', () => {
|
||||
const mutationBlock = app.slice(app.indexOf('async function mutateTrashTask'), app.indexOf('async function restoreTask'))
|
||||
const restoreBlock = app.slice(app.indexOf('async function restoreTask'), app.indexOf('async function purgeTask'))
|
||||
const purgeBlock = app.slice(app.indexOf('async function purgeTask'), app.indexOf('async function addSubtask'))
|
||||
expect(loadBlock).toContain('return await runLatestRequest')
|
||||
expect(loadBlock).toContain("const committed = await runLatestRequest('trash'")
|
||||
expect(loadBlock).toContain('if (committed && page.value > totalPages.value)')
|
||||
expect(loadBlock).toContain('page.value = totalPages.value')
|
||||
expect(loadBlock).toContain('return await loadTrash()')
|
||||
expect(mutationBlock).toContain('await taskMutationReconciler.run(')
|
||||
expect(mutationBlock).toContain('{ affectsTrash: true, affectsTaskView }')
|
||||
expect(mutationBlock).not.toContain('performTrashMutation(')
|
||||
@@ -1370,10 +1373,13 @@ describe('unified floating add interaction', () => {
|
||||
expect(app).toContain('<span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat"')
|
||||
expect(app).toContain('<option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option>')
|
||||
expect(app).toContain('完成后 <input v-model="composeAfterCompletionDays"')
|
||||
expect(app).toContain('v-model="composeAfterCompletionUnit"')
|
||||
expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"')
|
||||
expect(app).toContain('每次完成后,将截止时间顺延对应天数;首版永不结束')
|
||||
expect(app).toContain('v-model="selectedAfterCompletionUnit"')
|
||||
expect(app).toContain('<option value="days">天</option><option value="months">月</option>')
|
||||
expect(app).toContain('月末会自动取目标月最后一天')
|
||||
expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value')
|
||||
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })')
|
||||
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config })')
|
||||
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
|
||||
const createBlock = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()'))
|
||||
expect(createBlock).toContain("api('/tasks',")
|
||||
@@ -1388,7 +1394,7 @@ describe('unified floating add interaction', () => {
|
||||
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
|
||||
expect(saveBlock).toContain('selectedTaskRecurrence.value = null')
|
||||
expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'")
|
||||
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)')
|
||||
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence)')
|
||||
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
|
||||
expect(dueRemovalBlock).not.toContain('saveRepeat(')
|
||||
expect(saveBlock).toContain("selectedRepeatError.value = ''")
|
||||
@@ -1567,6 +1573,9 @@ describe('sidebar information hierarchy', () => {
|
||||
expect(app).toContain('class="sidebar-action-danger"')
|
||||
expect(app).toContain("sidebarAction.kind==='folders'?'文件夹':'清单'")
|
||||
expect(app).toContain("sidebarAction.kind==='folders' ? `删除后,其中 ${sidebarActionFolderListCount} 个清单会移到“我的清单”` : '任务会保留,可从“已归档”恢复'")
|
||||
const deleteEntityBlock = app.slice(app.indexOf('async function deleteEntity'), app.indexOf('async function loadArchivedLists'))
|
||||
expect(deleteEntityBlock).toContain("confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复')")
|
||||
expect(deleteEntityBlock).not.toContain("askText(`归档清单")
|
||||
expect(css).toContain('.sidebar-action-sheet{width:min(320px,calc(100vw - 24px));')
|
||||
expect(css).toContain('.sidebar-action-group{display:grid;gap:2px;')
|
||||
expect(css).toContain('.sidebar-action-danger{border-top:1px solid')
|
||||
|
||||
@@ -81,6 +81,14 @@ describe('approved five-detail polish', () => {
|
||||
expect(app).toContain("v-if=\"activeView==='tasks' && taskReorderAvailable\"")
|
||||
expect(app).toContain('<span class="list-section-count">{{ totalTasks }}</span>')
|
||||
expect(app).toContain("v-if=\"activeView==='tasks' && totalPages > 1\" class=\"list-page-meta\"")
|
||||
const taskListEnd = app.indexOf('</section>', app.indexOf('class=\"task-list plain-list\"'))
|
||||
expect(app.indexOf('class=\"list-page-meta\"')).toBeGreaterThan(taskListEnd)
|
||||
expect(app.indexOf('class=\"pager\"')).toBeGreaterThan(taskListEnd)
|
||||
expect(app).toContain('ref="taskListElement"')
|
||||
expect(app).toContain("totalPages > 1 && (activeView!=='today' || !todaySectionCollapse.tasks)")
|
||||
expect(app).toContain("taskListElement.value?.scrollIntoView({ block: 'start' })")
|
||||
expect(app).toContain('class="list-page-meta" aria-live="polite"')
|
||||
expect(app).toContain('<span aria-live="polite">{{page}} / {{totalPages}}</span>')
|
||||
expect(app).toContain("if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) }")
|
||||
expect(app).toContain("new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)")
|
||||
expect(app).not.toContain('class="list-search-clear"')
|
||||
|
||||
@@ -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")
|
||||
@@ -1,7 +1,11 @@
|
||||
"""restore persistent external calendar subscriptions
|
||||
"""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
|
||||
@@ -14,6 +18,9 @@ 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),
|
||||
@@ -38,5 +45,8 @@ def upgrade() -> None:
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
|
||||
due_has_time=True,
|
||||
)
|
||||
gap_due = recurrence_service._after_completion_due(
|
||||
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, user
|
||||
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, "days", user
|
||||
)
|
||||
assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap
|
||||
|
||||
@@ -60,7 +60,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
|
||||
due_has_time=True,
|
||||
)
|
||||
fold_due = recurrence_service._after_completion_due(
|
||||
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, user
|
||||
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, "days", user
|
||||
)
|
||||
assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0
|
||||
|
||||
@@ -89,6 +89,30 @@ def test_user_timezone_is_the_calendar_contract_for_after_completion(client, mon
|
||||
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_month_interval_uses_calendar_month_clamping(client, monkeypatch):
|
||||
inbox = boot(client)
|
||||
task = create_after_completion_task(
|
||||
client,
|
||||
inbox,
|
||||
due_at="2026-01-31T01:30:00Z",
|
||||
after_completion_days=1,
|
||||
after_completion_unit="months",
|
||||
).json()
|
||||
monkeypatch.setattr(
|
||||
"backend.recurrence_service.utcnow",
|
||||
lambda: datetime(2026, 1, 30, 16, 30, tzinfo=UTC), # 2026-01-31 00:30 Asia/Shanghai
|
||||
)
|
||||
|
||||
completed = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
|
||||
)
|
||||
|
||||
assert completed.status_code == 200
|
||||
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 2, 28, 1, 30, tzinfo=UTC)
|
||||
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
|
||||
assert recurrence["after_completion_unit"] == "months"
|
||||
|
||||
|
||||
def test_atomic_task_create_and_read_after_completion_recurrence(client):
|
||||
inbox = boot(client)
|
||||
|
||||
@@ -105,6 +129,7 @@ def test_atomic_task_create_and_read_after_completion_recurrence(client):
|
||||
"ends_at": None,
|
||||
"trigger_mode": "after_completion",
|
||||
"after_completion_days": 2,
|
||||
"after_completion_unit": "days",
|
||||
"last_completed_at": None,
|
||||
}
|
||||
|
||||
@@ -122,6 +147,8 @@ def test_after_completion_configuration_validation(client):
|
||||
{"title": "零天", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 0},
|
||||
{"title": "太长", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 3651},
|
||||
{"title": "子任务", "list_id": inbox["id"], "parent_id": child["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 1},
|
||||
{"title": "只有单位", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "after_completion_unit": "months"},
|
||||
{"title": "定期带月份", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "scheduled", "rrule": "FREQ=DAILY", "after_completion_unit": "months"},
|
||||
]
|
||||
for payload in invalid_payloads:
|
||||
assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422}
|
||||
|
||||
@@ -200,6 +200,36 @@ def test_merge_same_backup_is_idempotent_via_import_ledger(client):
|
||||
assert asyncio.run(counts()) == (1, 1)
|
||||
|
||||
|
||||
def test_legacy_backup_without_completion_unit_is_idempotent(client):
|
||||
inbox = boot(client)
|
||||
client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"title": "旧版完成后重复",
|
||||
"list_id": inbox["id"],
|
||||
"due_at": "2026-03-08T01:30:00Z",
|
||||
"trigger_mode": "after_completion",
|
||||
"after_completion_days": 2,
|
||||
},
|
||||
)
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
recurrences = _archive_rows(content, "recurrences")
|
||||
for recurrence in recurrences:
|
||||
recurrence.pop("after_completion_unit", None)
|
||||
legacy_content = _replace_entities(content, {"recurrences": recurrences})
|
||||
|
||||
first_token = _preflight(client, legacy_content).json()["preflight_token"]
|
||||
assert client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}
|
||||
).status_code == 200
|
||||
second_token = _preflight(client, legacy_content).json()["preflight_token"]
|
||||
second = client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": second_token, "mode": "merge"}
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["already_imported"] is True
|
||||
|
||||
|
||||
def test_invalid_zip_variants_are_rejected_before_any_write(client):
|
||||
inbox = boot(client)
|
||||
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
|
||||
|
||||
@@ -17,6 +17,8 @@ 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
|
||||
"""
|
||||
@@ -39,6 +41,12 @@ 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"):
|
||||
@@ -65,6 +73,21 @@ def test_parser_restored_with_recurrence_exdates_overrides_and_timezone():
|
||||
]
|
||||
|
||||
|
||||
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"):
|
||||
@@ -127,6 +150,8 @@ def test_subscription_crud_refresh_events_and_stale_cache(client, monkeypatch):
|
||||
)
|
||||
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")
|
||||
@@ -145,6 +170,26 @@ def test_subscription_crud_refresh_events_and_stale_cache(client, monkeypatch):
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user