From 2e7c572af22dabbec7f5b783d50403fd8ea6f600 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Sat, 5 Sep 2026 20:08:52 +0800 Subject: [PATCH] fix: calendar bounds and habit stats query --- backend/models.py | 5 +-- backend/mvp.py | 51 ++++++++++++++++------- frontend/src/MvpPanel.vue | 3 +- migrations/versions/0005_query_indexes.py | 6 --- tests/test_mvp_backend.py | 33 +++++++++++++++ 5 files changed, 72 insertions(+), 26 deletions(-) diff --git a/backend/models.py b/backend/models.py index aa1f98f..d80374f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -160,10 +160,7 @@ class Habit(Base): class HabitLog(Base): __tablename__ = "habit_logs" - __table_args__ = ( - UniqueConstraint("habit_id", "day", name="uq_habit_log_day"), - Index("ix_habit_logs_habit_day", "habit_id", "day"), - ) + __table_args__ = (UniqueConstraint("habit_id", "day", name="uq_habit_log_day"),) id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) habit_id: Mapped[UUID] = mapped_column(ForeignKey("habits.id", ondelete="CASCADE"), index=True) day: Mapped[date] = mapped_column(Date) diff --git a/backend/mvp.py b/backend/mvp.py index 79b7c9e..8bd2032 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -8,7 +8,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field, model_validator -from sqlalchemy import delete, select +from sqlalchemy import case, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from .auth import current_user @@ -144,11 +144,12 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr @router.get("/calendar") -async def calendar(start: date, end: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): +async def calendar(start: date, end: date, timezone_offset: int = Query(default=0, 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, "日期范围无效或超过一年") - start_dt = datetime.combine(start, time.min, tzinfo=UTC) - end_dt = datetime.combine(end, time.max, tzinfo=UTC) + offset = timedelta(minutes=timezone_offset) + start_dt = datetime.combine(start, time.min, tzinfo=UTC) - offset + end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=UTC) - offset - timedelta(microseconds=1) 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()) @@ -156,6 +157,10 @@ async def calendar(start: date, end: date, user: User = Depends(current_user), d for exception in exception_rows: key = exception.occurrence_at.replace(tzinfo=UTC) if exception.occurrence_at.tzinfo is None else exception.occurrence_at exceptions_by_template.setdefault(exception.template_id, {})[key] = exception + + def as_utc(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value + recurring_task_ids = {task.id for _, task in recurrence_rows} normal_query = select(Task).where( Task.user_id == user.id, @@ -179,11 +184,23 @@ async def calendar(start: date, end: date, user: User = Depends(current_user), d } for task in normal_tasks] for template, task in recurrence_rows: exceptions = exceptions_by_template.get(template.id, {}) - for at in occurrences(template.rrule, template.starts_at, start_dt, end_dt, template.ends_at): + generation_start = min(start_dt, *(exceptions.keys() or [start_dt])) + generated = occurrences(template.rrule, template.starts_at, generation_start, end_dt, template.ends_at) + occurrence_keys = {as_utc(at) for at in generated} + for at in generated: exception = exceptions.get(at) if exception and exception.deleted: 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": exception.due_at if exception and exception.due_at else at, "completed": bool(exception and exception.completed)}) + 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)}) + for occurrence_at, exception in exceptions.items(): + if occurrence_at in occurrence_keys or exception.deleted or exception.due_at is None: + 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": 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"]) @@ -363,21 +380,25 @@ async def habits_grid(week: date, user: User = Depends(current_user), db: AsyncS return {"days": days, "habits": []} habit_ids = [habit.id for habit in habits] week_logs = (await db.scalars(select(HabitLog).where(HabitLog.habit_id.in_(habit_ids), HabitLog.day.between(days[0], days[-1])))).all() - all_logs = (await db.scalars(select(HabitLog).where(HabitLog.habit_id.in_(habit_ids)))).all() + stats_rows = (await db.execute( + select( + HabitLog.habit_id, + func.sum(HabitLog.value), + func.count(HabitLog.id), + func.sum(case((HabitLog.value >= Habit.target, 1), else_=0)), + ) + .join(Habit, Habit.id == HabitLog.habit_id) + .where(HabitLog.habit_id.in_(habit_ids)) + .group_by(HabitLog.habit_id) + )).all() pause_rows = (await db.scalars(select(HabitPause).where(HabitPause.habit_id.in_(habit_ids), HabitPause.end_date >= days[0], HabitPause.start_date <= days[-1]))).all() logs_by_habit = {} stats_by_habit = {} pauses_by_habit = {} for log in week_logs: logs_by_habit.setdefault(log.habit_id, {})[log.day] = log.value - for log in all_logs: - stats = stats_by_habit.setdefault(log.habit_id, {"total": 0, "completed_days": 0, "logged_days": 0}) - stats["total"] += log.value - stats["logged_days"] += 1 - targets = {habit.id: habit.target for habit in habits} - for log in all_logs: - if log.value >= targets[log.habit_id]: - stats_by_habit[log.habit_id]["completed_days"] += 1 + for habit_id, total, logged_days, completed_days in stats_rows: + stats_by_habit[habit_id] = {"total": total or 0, "completed_days": completed_days or 0, "logged_days": logged_days or 0} for pause in pause_rows: pauses_by_habit.setdefault(pause.habit_id, []).append(pause) output = [] diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index a3c14dd..cae7154 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -65,7 +65,8 @@ async function loadCalendar(range?: { startStr?: string; endStr?: string }) { endExclusive.setDate(endExclusive.getDate() - 1) const end = dateKey(endExclusive) await safe(async()=>{ - const rows = await request(`/calendar?start=${start}&end=${end}`) as Array<{id?:string;task_id:string;recurrence_id?:string|null;title:string;due_at?:string;occurrence_at:string;version?:number}> + const tzOffset = -new Date().getTimezoneOffset() + const rows = await request(`/calendar?start=${start}&end=${end}&timezone_offset=${tzOffset}`) as Array<{id?:string;task_id:string;recurrence_id?:string|null;title:string;due_at?:string;occurrence_at:string;version?:number}> calendarTasks.value = rows.map(row=>({ id: row.recurrence_id ? `${row.task_id}:${row.occurrence_at}` : (row.id ?? row.task_id), title: row.title, diff --git a/migrations/versions/0005_query_indexes.py b/migrations/versions/0005_query_indexes.py index 6b006b8..98c8257 100644 --- a/migrations/versions/0005_query_indexes.py +++ b/migrations/versions/0005_query_indexes.py @@ -27,15 +27,9 @@ def upgrade() -> None: "tasks", ["user_id", "list_id", "deleted_at", "parent_id", "created_at", "id"], ) - op.create_index( - "ix_habit_logs_habit_day", - "habit_logs", - ["habit_id", "day"], - ) def downgrade() -> None: - op.drop_index("ix_habit_logs_habit_day", table_name="habit_logs") op.drop_index("ix_tasks_user_list_active", table_name="tasks") op.drop_index("ix_tasks_user_due", table_name="tasks") op.drop_index("ix_tasks_user_active_created", table_name="tasks") diff --git a/tests/test_mvp_backend.py b/tests/test_mvp_backend.py index b5c9725..b93fa91 100644 --- a/tests/test_mvp_backend.py +++ b/tests/test_mvp_backend.py @@ -64,6 +64,39 @@ def test_recurring_calendar_exceptions_and_scopes(client): assert any(row.get("id") == normal_task["id"] for row in remaining) +def test_calendar_respects_timezone_boundaries_and_moved_occurrences(client): + inbox = boot(client) + inside = client.post( + "/api/v1/tasks", + json={"title": "上海九月第一刻", "list_id": inbox["id"], "due_at": "2026-08-31T16:30:00Z"}, + ).json() + client.post( + "/api/v1/tasks", + json={"title": "上海十月第一刻", "list_id": inbox["id"], "due_at": "2026-09-30T16:30:00Z"}, + ) + recurring = client.post( + "/api/v1/tasks", + json={"title": "跨月重复任务", "list_id": inbox["id"], "due_at": "2026-10-01T09:00:00Z"}, + ).json() + recurrence = client.post( + "/api/v1/recurrences", json={"task_id": recurring["id"], "rrule": "FREQ=WEEKLY;COUNT=2"} + ).json() + client.patch( + f"/api/v1/recurrences/{recurrence['id']}", + params={"scope": "this", "occurrence_at": "2026-10-01T09:00:00Z"}, + json={"due_at": "2026-09-20T09:00:00Z"}, + ) + + calendar = client.get( + "/api/v1/calendar", + params={"start": "2026-09-01", "end": "2026-09-30", "timezone_offset": 480}, + ).json() + + assert any(row.get("id") == inside["id"] for row in calendar) + assert not any(row["title"] == "上海十月第一刻" for row in calendar) + assert any(row.get("recurrence_id") == recurrence["id"] and row["due_at"].startswith("2026-09-20") for row in calendar) + + def test_habit_logs_support_date_range_filter(client): boot(client) habit = client.post("/api/v1/habits", json={"name": "跑步", "kind": "boolean", "schedule_type": "daily"}).json()