Author SHA1 Message Date
bboysoul 34691b6fe8 fix: move task pagination below list
ci / gitleaks (push) Successful in 36s
ci / docker (push) Successful in 5m10s
2026-09-20 23:18:04 +08:00
bboysoul 5e338fa90f fix: restore list archiving
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 5m30s
2026-09-20 22:48:12 +08:00
bboysoul 99da524070 fix: show subscribed calendar event details
ci / gitleaks (push) Successful in 1m23s
ci / docker (push) Successful in 4m14s
2026-09-20 22:16:13 +08:00
bboysoul 3a73473c4d [verified] fix calendar subscription layout
ci / gitleaks (push) Successful in 38s
ci / docker (push) Successful in 7m20s
2026-09-20 21:39:01 +08:00
bboysoul 3155155f53 [verified] feat: support monthly completion recurrence
ci / gitleaks (push) Successful in 23s
ci / docker (push) Successful in 7m16s
2026-09-20 21:04:31 +08:00
bboysoul e6d720b85c Revert "fix: align calendar sheets with detail pane"
ci / gitleaks (push) Successful in 58s
ci / docker (push) Successful in 5m35s
This reverts commit 6e3e09e8b6.
2026-09-20 18:12:12 +08:00
bboysoul 6e3e09e8b6 fix: align calendar sheets with detail pane
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 4m48s
2026-09-20 17:55:06 +08:00
bboysoul bb59dc9346 fix: restore selected calendar day contrast
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 6m36s
2026-09-20 17:35:44 +08:00
bboysoul 7140102aeb feat: redesign calendar around weekly focus
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m5s
2026-09-20 17:23:02 +08:00
bboysoul f3ad1eec03 feat: add iCal calendar subscriptions
ci / gitleaks (push) Successful in 56s
ci / docker (push) Successful in 5m55s
2026-09-20 16:37:43 +08:00
bboysoul af52fe0cad fix: add tombstone migration for reverted calendar subscriptions
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 5m34s
2026-09-20 14:11:06 +08:00
bboysoul 2715b74f2e Revert "[verified] feat: add external calendar subscriptions"
ci / gitleaks (push) Successful in 9s
ci / docker (push) Failing after 11m21s
This reverts commit 583b6a8a9d.
2026-09-20 13:57:35 +08:00
bboysoul 6528cbbb1e Revert "feat: add calendar frontend"
This reverts commit af83a68fe1.
2026-09-20 13:57:35 +08:00
bboysoul af83a68fe1 feat: add calendar frontend
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 4m41s
2026-09-20 13:21:47 +08:00
31 changed files with 714 additions and 87 deletions
+5
View File
@@ -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
View File
@@ -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"]))
+2 -1
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -154,6 +154,7 @@ class RecurrenceTemplate(Base):
ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
trigger_mode: Mapped[str] = mapped_column(String(32), default="scheduled")
after_completion_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
after_completion_unit: Mapped[str | None] = mapped_column(String(8), nullable=True)
last_completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
+30 -4
View File
@@ -251,17 +251,22 @@ class RecurrenceCreate(BaseModel):
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
trigger_mode: str = Field(default="scheduled", pattern="^(scheduled|after_completion)$")
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
@model_validator(mode="after")
def validate_mode(self):
if self.trigger_mode == "scheduled" and (
self.rrule is None or self.after_completion_days is not None
self.rrule is None
or self.after_completion_days is not None
or self.after_completion_unit is not None
):
raise ValueError("scheduled recurrence requires rrule and no completion interval")
if self.trigger_mode == "after_completion" and (
self.after_completion_days is None or self.rrule is not None
):
raise ValueError("after_completion requires days and no rrule")
if self.trigger_mode == "after_completion" and self.after_completion_unit is None:
self.after_completion_unit = "days"
return self
@@ -271,6 +276,7 @@ class RecurrenceChange(BaseModel):
rrule: str | None = None
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
class OccurrenceComplete(BaseModel):
@@ -430,6 +436,7 @@ async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user),
"ends_at": row.ends_at,
"trigger_mode": row.trigger_mode,
"after_completion_days": row.after_completion_days,
"after_completion_unit": row.after_completion_unit,
"last_completed_at": row.last_completed_at,
}
@@ -452,6 +459,9 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
starts_at=task.due_at,
trigger_mode=payload.trigger_mode,
after_completion_days=payload.after_completion_days,
after_completion_unit=(payload.after_completion_unit or "days")
if payload.trigger_mode == "after_completion"
else None,
)
db.add(row)
await db.commit(); await db.refresh(row)
@@ -463,6 +473,7 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
"ends_at": row.ends_at,
"trigger_mode": row.trigger_mode,
"after_completion_days": row.after_completion_days,
"after_completion_unit": row.after_completion_unit,
"last_completed_at": row.last_completed_at,
}
@@ -504,12 +515,18 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
if "after_completion_days" in payload.model_fields_set
else template.after_completion_days
)
requested_unit = (
payload.after_completion_unit
if "after_completion_unit" in payload.model_fields_set
else template.after_completion_unit
)
requested_rrule = payload.rrule if payload.rrule is not None else template.rrule
if requested_mode == "after_completion":
if requested_days is None:
raise HTTPException(422, "完成后重复需要间隔天数")
template.trigger_mode = requested_mode
template.after_completion_days = requested_days
template.after_completion_unit = requested_unit or "days"
template.rrule = None
else:
if requested_rrule is None:
@@ -517,6 +534,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
parse_rrule(requested_rrule)
template.trigger_mode = requested_mode
template.after_completion_days = None
template.after_completion_unit = None
template.rrule = requested_rrule.upper()
if payload.title is not None:
task.title = payload.title
@@ -533,6 +551,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
"ends_at": template.ends_at,
"trigger_mode": template.trigger_mode,
"after_completion_days": template.after_completion_days,
"after_completion_unit": template.after_completion_unit,
"last_completed_at": template.last_completed_at,
}
@@ -1358,7 +1377,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memo
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "completed_at", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "after_completion_unit", "last_completed_at"]) for x in recurrences],
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
"memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos],
@@ -1570,11 +1589,17 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
rrule = raw.get("rrule")
if trigger_mode not in {"scheduled", "after_completion"}:
raise HTTPException(422, "无效的重复触发模式")
unit = raw.get("after_completion_unit")
if unit is not None and unit not in {"days", "months"}:
raise HTTPException(422, "无效的完成后重复单位")
if trigger_mode == "after_completion":
if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650 or rrule is not None:
raise HTTPException(422, "无效的完成后重复备份")
elif not isinstance(rrule, str):
raise HTTPException(422, "定期重复缺少 RRULE")
unit = unit or "days"
else:
if not isinstance(rrule, str) or days is not None or unit is not None:
raise HTTPException(422, "定期重复备份包含无效字段")
unit = None
db.add(RecurrenceTemplate(
user_id=user.id,
task_id=task_id,
@@ -1583,6 +1608,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
trigger_mode=trigger_mode,
after_completion_days=days,
after_completion_unit=unit,
last_completed_at=datetime.fromisoformat(raw["last_completed_at"])
if raw.get("last_completed_at") else None,
))
+13 -3
View File
@@ -2,6 +2,7 @@ from datetime import UTC, datetime, time, timedelta
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from dateutil.relativedelta import relativedelta
from fastapi import HTTPException
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -25,10 +26,15 @@ def _user_zone(user: User) -> ZoneInfo:
raise HTTPException(422, "用户时区无效") from exc
def _after_completion_due(task: Task, completed_at: datetime, days: int, user: User) -> datetime:
def _after_completion_due(
task: Task, completed_at: datetime, value: int, unit: str | None, user: User
) -> datetime:
zone = _user_zone(user)
completed_local = completed_at.astimezone(zone)
target_date = completed_local.date() + timedelta(days=days)
if unit == "months":
target_date = completed_local.date() + relativedelta(months=value)
else:
target_date = completed_local.date() + timedelta(days=value)
if task.due_has_time:
due_local = task.due_at.astimezone(zone)
wall_time = due_local.timetz().replace(tzinfo=None)
@@ -67,7 +73,11 @@ async def apply_task_changes(
if recurrence.trigger_mode == "after_completion":
completed_at = utcnow()
next_due = _after_completion_due(
task, completed_at, recurrence.after_completion_days, user
task,
completed_at,
recurrence.after_completion_days,
recurrence.after_completion_unit,
user,
)
changes["completed"] = False
changes["due_at"] = next_due
+16 -5
View File
@@ -125,6 +125,7 @@ class TaskCreate(BaseModel):
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
@field_validator("title")
@classmethod
@@ -136,7 +137,12 @@ class TaskCreate(BaseModel):
@model_validator(mode="after")
def validate_recurrence(self):
has_recurrence = self.rrule is not None or self.trigger_mode is not None or self.after_completion_days is not None
has_recurrence = (
self.rrule is not None
or self.trigger_mode is not None
or self.after_completion_days is not None
or self.after_completion_unit is not None
)
if has_recurrence and self.due_at is None:
raise ValueError("recurrence requires due_at")
if has_recurrence and self.parent_id is not None:
@@ -144,10 +150,15 @@ class TaskCreate(BaseModel):
if self.trigger_mode == "after_completion":
if self.after_completion_days is None or self.rrule is not None:
raise ValueError("after_completion requires days and no rrule")
elif self.trigger_mode == "scheduled" and self.rrule is None:
raise ValueError("scheduled recurrence requires rrule")
elif self.trigger_mode is None and self.after_completion_days is not None:
raise ValueError("after_completion_days requires after_completion mode")
elif self.trigger_mode == "scheduled":
if (
self.rrule is None
or self.after_completion_days is not None
or self.after_completion_unit is not None
):
raise ValueError("scheduled recurrence requires rrule and no completion interval")
elif self.after_completion_days is not None or self.after_completion_unit is not None:
raise ValueError("completion interval requires after_completion mode")
return self
+6 -1
View File
@@ -8,6 +8,11 @@ function bottomTab(page: Page, name: string) {
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
}
async function openSidebarView(page: Page, name: string) {
await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click()
await page.locator('.sidebar').getByRole('button', { name, exact: true }).click()
}
async function csrf(request: APIRequestContext, baseURL: string) {
const state = await request.storageState()
return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value
@@ -50,7 +55,7 @@ test('complete ZIP backup preflights and replace-restores task, habit history, c
expect(countdownResponse.ok()).toBeTruthy()
await page.goto('/')
await bottomTab(page, '设置').click()
await openSidebarView(page, '设置')
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: '导出 ZIP' }).click()
const download = await downloadPromise
+58 -12
View File
@@ -6,11 +6,6 @@ function bottomTab(page: Page, name: string) {
}
async function openSettings(page: Page) {
const mobileTab = bottomTab(page, '设置')
if (await mobileTab.isVisible()) {
await mobileTab.click()
return
}
const desktopSettings = page.getByRole('navigation', { name: '管理' }).getByRole('button', { name: '设置', exact: true })
const box = await desktopSettings.boundingBox()
if (box && box.x + box.width > 0 && box.y + box.height > 0 && box.x < (await page.viewportSize())!.width) await desktopSettings.click()
@@ -159,15 +154,21 @@ test('bottom navigation keeps its safe-area gap after dragging', async ({ page }
})
test('all bottom destinations expose one active page and desktop layout stays unchanged', async ({ page }) => {
await page.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: [] }))
await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [], sources: [] } }))
await page.goto('/')
const navigation = page.getByRole('navigation', { name: '主要导航' })
for (const label of ['今天', '习惯', '倒数日', '设置']) {
await bottomTab(page, label).click()
await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1)
await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page')
const mobile = (await page.viewportSize())!.width <= 930
if (mobile) {
for (const label of ['今天', '习惯', '倒数日', '备忘录', '日历订阅']) {
await bottomTab(page, label).click()
await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1)
await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page')
}
await bottomTab(page, '今天').click()
} else {
await expect(navigation).toBeHidden()
}
await bottomTab(page, '今天').click()
await page.setViewportSize({ width: 1440, height: 900 })
const desktop = await page.locator('.shell').evaluate(element => {
const shell = getComputedStyle(element)
@@ -199,10 +200,55 @@ test('all bottom destinations expose one active page and desktop layout stays un
expect(desktopBottomGap).toBe(84)
})
test('calendar week focus keeps seven usable day controls without page overflow', async ({ page }) => {
const calendarSources = ['天气','法定节假日','老黄历','节日节气','星座','影视上新','股市指数','街舞赛事','F1'].map((name, index) => ({ id:`calendar-source-${index}`, name, url:`https://example.com/${index}.ics`, color:'#f15a29', enabled:true, refreshed_at:null, last_error:null, stale:false }))
await page.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: calendarSources }))
await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [], sources: [] } }))
await page.goto('/')
const mobile = (await page.viewportSize())!.width <= 930
if (mobile) await bottomTab(page, '日历订阅').click()
else await page.locator('.primary-nav').getByRole('button', { name: '日历订阅', exact: true }).click()
const strip = page.getByRole('group', { name: '选择日期' })
const filters = page.locator('.calendar-filters')
await expect(strip).toBeVisible()
await expect(filters).toBeVisible()
await expect(strip.locator('.calendar-week-day')).toHaveCount(7)
const metrics = await strip.evaluate(element => {
const filters = document.querySelector<HTMLElement>('.calendar-filters')!
const calendar = document.querySelector<HTMLElement>('.calendar-view')!
const main = calendar.closest('main') as HTMLElement
return {
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
bodyOverflow: document.body.scrollWidth - document.body.clientWidth,
mainOverflow: main.scrollWidth - main.clientWidth,
calendarOverflow: calendar.scrollWidth - calendar.clientWidth,
filters: { clientWidth: filters.clientWidth, scrollWidth: filters.scrollWidth, overflowX: getComputedStyle(filters).overflowX },
stripOverflow: element.scrollWidth > element.clientWidth,
buttons: [...element.querySelectorAll<HTMLElement>('.calendar-week-day')].map(button => ({ width: button.getBoundingClientRect().width, height: button.getBoundingClientRect().height })),
}
})
expect(metrics.documentOverflow).toBe(0)
expect(metrics.bodyOverflow).toBe(0)
expect(metrics.mainOverflow).toBe(0)
expect(metrics.calendarOverflow).toBe(0)
if ((await page.viewportSize())!.width <= 930) expect(metrics.filters.clientWidth).toBeLessThan(metrics.filters.scrollWidth)
else expect(metrics.filters.clientWidth).toBeGreaterThanOrEqual(metrics.filters.scrollWidth)
expect(metrics.filters.overflowX).toBe('auto')
expect(metrics.buttons.every(button => button.width >= 44 && button.height >= 44)).toBeTruthy()
const selectedColors = await strip.locator('.calendar-week-day.is-selected').evaluate(element => {
const style = getComputedStyle(element)
return { background: style.backgroundColor, color: style.color }
})
expect(selectedColors).toEqual({ background: 'rgb(241, 90, 41)', color: 'rgb(255, 255, 255)' })
if ((await page.viewportSize())!.width <= 390) expect(metrics.stripOverflow).toBeTruthy()
})
test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => {
await page.goto('/')
await openSettings(page)
if ((await page.viewportSize())!.width <= 720) await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page')
if ((await page.viewportSize())!.width <= 720) {
await expect(page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name: '设置', exact: true })).toHaveCount(0)
}
const groups = page.locator('.settings-group')
await expect(groups).toHaveCount(4)
const layout = await page.locator('.settings-sections').evaluate(element => {
@@ -0,0 +1,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)
})
+1 -1
View File
@@ -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
View File
@@ -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>
+9
View File
@@ -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%;')})
})
+164
View File
@@ -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('订阅地址无法访问')
})
})
+74
View File
@@ -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>
+3 -3
View File
@@ -7,13 +7,13 @@ const panel = readFileSync('src/MemoPanel.vue', 'utf8')
const editor = readFileSync('src/components/MemoEditor.vue', 'utf8')
describe('memo shell integration', () => {
it('places Memos immediately after Countdowns in desktop navigation and keeps mobile tabs unchanged', () => {
it('places Memos immediately after Countdowns in desktop navigation and exposes it as a direct mobile destination', () => {
const nav = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">')))
expect(nav.indexOf("switchView('memos')")).toBeGreaterThan(nav.indexOf("switchView('countdowns')"))
expect(nav.match(/switchView\('memos'\)/g)).toHaveLength(1)
const bottom = app.slice(app.indexOf('<nav class="bottom"'), app.indexOf('</nav>', app.indexOf('<nav class="bottom"')))
expect(bottom).not.toContain("switchView('memos')")
expect(bottom.match(/aria-current=/g)).toHaveLength(4)
expect(bottom).toContain("switchView('memos')")
expect(bottom.match(/aria-current=/g)).toHaveLength(5)
})
it('routes the shared cat FAB to a local memo draft, hides it in trash, and defers POST until save', () => {
File diff suppressed because one or more lines are too long
+2
View File
@@ -67,6 +67,8 @@ describe('MVP view utilities', () => {
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'tasks', listId: 'list-2' })
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'memos', 'list-2')
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'memos', listId: 'list-2' })
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'calendar', 'list-2')
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'calendar', listId: 'list-2' })
storage.set('dodo.navigation', JSON.stringify({ view: 'invalid', listId: 'list-2' }))
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
})
+2 -2
View File
@@ -1,7 +1,7 @@
type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'>
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
type StoredNavigation = { view: NavigationView; listId: string }
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'settings'])
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'calendar', 'settings'])
export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation {
try {
+8 -6
View File
@@ -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', () => {
+8 -7
View File
@@ -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()) {
+1
View File
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
import './memo.css'
import './calendar.css'
createApp(App).mount('#app')
if ('serviceWorker' in navigator && import.meta.env.PROD) {
+2 -2
View File
@@ -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}}
+18 -9
View File
@@ -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')
+8
View File
@@ -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")
+29 -2
View File
@@ -48,7 +48,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
due_has_time=True,
)
gap_due = recurrence_service._after_completion_due(
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, user
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, "days", user
)
assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap
@@ -60,7 +60,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
due_has_time=True,
)
fold_due = recurrence_service._after_completion_due(
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, user
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, "days", user
)
assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0
@@ -89,6 +89,30 @@ def test_user_timezone_is_the_calendar_contract_for_after_completion(client, mon
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC)
def test_month_interval_uses_calendar_month_clamping(client, monkeypatch):
inbox = boot(client)
task = create_after_completion_task(
client,
inbox,
due_at="2026-01-31T01:30:00Z",
after_completion_days=1,
after_completion_unit="months",
).json()
monkeypatch.setattr(
"backend.recurrence_service.utcnow",
lambda: datetime(2026, 1, 30, 16, 30, tzinfo=UTC), # 2026-01-31 00:30 Asia/Shanghai
)
completed = client.patch(
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
)
assert completed.status_code == 200
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 2, 28, 1, 30, tzinfo=UTC)
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
assert recurrence["after_completion_unit"] == "months"
def test_atomic_task_create_and_read_after_completion_recurrence(client):
inbox = boot(client)
@@ -105,6 +129,7 @@ def test_atomic_task_create_and_read_after_completion_recurrence(client):
"ends_at": None,
"trigger_mode": "after_completion",
"after_completion_days": 2,
"after_completion_unit": "days",
"last_completed_at": None,
}
@@ -122,6 +147,8 @@ def test_after_completion_configuration_validation(client):
{"title": "零天", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 0},
{"title": "太长", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 3651},
{"title": "子任务", "list_id": inbox["id"], "parent_id": child["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 1},
{"title": "只有单位", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "after_completion_unit": "months"},
{"title": "定期带月份", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "scheduled", "rrule": "FREQ=DAILY", "after_completion_unit": "months"},
]
for payload in invalid_payloads:
assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422}
+30
View File
@@ -200,6 +200,36 @@ def test_merge_same_backup_is_idempotent_via_import_ledger(client):
assert asyncio.run(counts()) == (1, 1)
def test_legacy_backup_without_completion_unit_is_idempotent(client):
inbox = boot(client)
client.post(
"/api/v1/tasks",
json={
"title": "旧版完成后重复",
"list_id": inbox["id"],
"due_at": "2026-03-08T01:30:00Z",
"trigger_mode": "after_completion",
"after_completion_days": 2,
},
)
content = client.get("/api/v1/backup/export.zip").content
recurrences = _archive_rows(content, "recurrences")
for recurrence in recurrences:
recurrence.pop("after_completion_unit", None)
legacy_content = _replace_entities(content, {"recurrences": recurrences})
first_token = _preflight(client, legacy_content).json()["preflight_token"]
assert client.post(
"/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}
).status_code == 200
second_token = _preflight(client, legacy_content).json()["preflight_token"]
second = client.post(
"/api/v1/backup/restore", json={"preflight_token": second_token, "mode": "merge"}
)
assert second.status_code == 200
assert second.json()["already_imported"] is True
def test_invalid_zip_variants_are_rejected_before_any_write(client):
inbox = boot(client)
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
+45
View File
@@ -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(