feat: remove calendar and refine habits
ci / docker (push) Successful in 3m53s

This commit is contained in:
2026-09-06 07:56:23 +08:00
parent ceaaaa593e
commit 35e562026c
16 changed files with 277 additions and 500 deletions
+2
View File
@@ -806,6 +806,8 @@ if static_dir.exists():
@app.get("/{path:path}", include_in_schema=False)
async def spa(path: str):
if path == "api" or path.startswith("api/"):
raise HTTPException(status_code=404, detail="Not Found")
root = static_dir.resolve()
target = (root / path).resolve()
headers = {"Cache-Control": "no-cache, no-store, must-revalidate, max-age=0"}
+3 -122
View File
@@ -4,7 +4,6 @@ import re
from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
from fastapi.responses import FileResponse
@@ -127,7 +126,6 @@ def occurrences(rule: str, starts: datetime, start: datetime, end: datetime, cut
def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
"""Return True when `at` is the exact timestamp of an occurrence this rule generates."""
parts = parse_rrule(rule)
interval = int(parts.get("INTERVAL", 1))
if starts.tzinfo is None:
starts = starts.replace(tzinfo=UTC)
else:
@@ -143,38 +141,9 @@ def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
until = until.replace(tzinfo=UTC) if until.tzinfo is None else until.astimezone(UTC)
if at > until:
return False
if "COUNT" in parts:
count = int(parts["COUNT"])
match = 0
cursor = starts
guard = 0
while cursor <= at and guard < 40000:
if parts["FREQ"] == "DAILY":
include = (cursor.date() - starts.date()).days % interval == 0
elif parts["FREQ"] == "WEEKLY":
days = {_WEEKDAYS[x] for x in parts.get("BYDAY", list(_WEEKDAYS)[starts.weekday()]).split(",")}
include = cursor.weekday() in days and ((cursor.date() - starts.date()).days // 7) % interval == 0
elif parts["FREQ"] == "MONTHLY":
month_delta = (cursor.year - starts.year) * 12 + cursor.month - starts.month
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
include = month_delta % interval == 0 and cursor.day in month_days
else:
years = cursor.year - starts.year
months = {int(x) for x in parts.get("BYMONTH", str(starts.month)).split(",")}
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
include = years % interval == 0 and cursor.month in months and cursor.day in month_days
if include and cursor >= starts:
match += 1
if cursor == at:
return True
if match >= count:
return False
guard += 1
cursor += timedelta(days=1)
return False
# Exact-match validation via the same canonical day generator used for
# rendering, so time-of-day is preserved and nothing outside the rule is
# accepted as a valid occurrence.
# Exact-match validation via the same canonical generator used for recurrence
# mutations, so COUNT/UNTIL, time-of-day, BYDAY/BYMONTHDAY and sparse yearly
# rules all share one behavior.
candidates = occurrences(rule, starts, starts, at, None)
return at in candidates
@@ -222,94 +191,6 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
return {"id": row.id, "task_id": row.task_id, "rrule": row.rrule, "starts_at": row.starts_at}
@router.get("/calendar")
async def calendar(start: date, end: date, timezone: str = Query(default="UTC", pattern=r"^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+)*$"), timezone_offset: int | None = Query(default=None, ge=-840, le=840), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
if end < start or (end - start).days > 366:
raise HTTPException(422, "日期范围无效或超过一年")
try:
zone = ZoneInfo(timezone)
except ZoneInfoNotFoundError:
raise HTTPException(422, "未知时区")
if timezone_offset is not None and timezone == "UTC":
# Legacy clients pass an offset instead of an IANA name.
zone = ZoneInfo("UTC")
start_dt = datetime.combine(start, time.min, tzinfo=UTC) - timedelta(minutes=timezone_offset)
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=UTC) - timedelta(minutes=timezone_offset) - timedelta(microseconds=1)
else:
start_dt = datetime.combine(start, time.min, tzinfo=zone).astimezone(UTC)
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=zone).astimezone(UTC) - timedelta(microseconds=1)
def as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
recurrence_rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all()
template_ids = [template.id for template, _ in recurrence_rows]
exception_rows = [] if not template_ids else list((await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id.in_(template_ids)))).all())
exceptions_by_template = {}
for exception in exception_rows:
key = as_utc(exception.occurrence_at)
exceptions_by_template.setdefault(exception.template_id, {})[key] = exception
recurring_task_ids = {task.id for _, task in recurrence_rows}
normal_query = select(Task).where(
Task.user_id == user.id,
Task.deleted_at.is_(None),
Task.parent_id.is_(None),
Task.due_at >= start_dt,
Task.due_at <= end_dt,
)
if recurring_task_ids:
normal_query = normal_query.where(Task.id.not_in(recurring_task_ids))
normal_tasks = list((await db.scalars(normal_query)).all())
output = [{
"id": task.id,
"recurrence_id": None,
"task_id": task.id,
"occurrence_at": task.due_at,
"title": task.title,
"due_at": task.due_at,
"completed": task.completed,
"version": task.version,
} for task in normal_tasks]
emitted = set()
for template, task in recurrence_rows:
exceptions = exceptions_by_template.get(template.id, {})
# Canonical series from the template's true start so every exception
# whose occurrence is real and inside this series is considered.
series_start = template.starts_at
if exceptions:
first_exception = min(as_utc(at) for at in exceptions)
series_start = min(as_utc(series_start), first_exception)
generated = occurrences(template.rrule, template.starts_at, as_utc(series_start), end_dt, template.ends_at)
for at in generated:
exception = exceptions.get(as_utc(at))
if exception and exception.deleted:
continue
due_at = as_utc(exception.due_at) if exception and exception.due_at else at
if due_at < start_dt or due_at > end_dt:
continue
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": at, "title": exception.title if exception and exception.title else task.title, "due_at": due_at, "completed": bool(exception and exception.completed)})
emitted.add(as_utc(at))
# Fall back for stored exceptions whose original slot is real but which
# the canonical generation skipped only because their original date is
# outside the requested window (e.g. moved backwards across the month).
for occurrence_at, exception in exceptions.items():
if exception.deleted or exception.due_at is None:
continue
if occurrence_at in emitted:
continue
if not is_occurrence(template.rrule, template.starts_at, occurrence_at):
continue
if template.ends_at and occurrence_at > as_utc(template.ends_at):
continue
due_at = as_utc(exception.due_at)
if start_dt <= due_at <= end_dt:
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": occurrence_at, "title": exception.title or task.title, "due_at": due_at, "completed": bool(exception.completed)})
return sorted(output, key=lambda item: item["occurrence_at"].replace(tzinfo=UTC) if item["occurrence_at"].tzinfo is None else item["occurrence_at"])
async def upsert_exception(db, template_id, at):
row = await db.scalar(select(RecurrenceException).where(RecurrenceException.template_id == template_id, RecurrenceException.occurrence_at == at))
if not row:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-Dq8LoBCn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CpBTIN38.css">
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-CmIuUToo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nhCaMkMR.css">
</head><body><div id="app"></div></body></html>
+8 -3
View File
@@ -1,5 +1,5 @@
const CACHE = 'dodo-shell-v2'
const SHELL = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png']
const CACHE = 'dodo-shell-v3'
const SHELL = ['/manifest.json', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png']
self.addEventListener('install', (event) => {
self.skipWaiting()
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)))
@@ -11,11 +11,16 @@ self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (url.pathname.startsWith('/api/')) return
if (event.request.method !== 'GET') return
const isNavigation = event.request.mode === 'navigate'
if (isNavigation) {
event.respondWith(fetch(event.request).catch(() => caches.match('/offline.html')))
return
}
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request).then((response) => {
const copy = response.clone()
caches.open(CACHE).then((cache) => cache.put(event.request, copy))
return response
}).catch(() => caches.match('/'))),
})),
)
})