fix: calendar bounds and habit stats query
ci / docker (push) Successful in 4m36s

This commit is contained in:
2026-09-05 20:08:52 +08:00
parent 2987a787d3
commit 2e7c572af2
5 changed files with 72 additions and 26 deletions
+1 -4
View File
@@ -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)
+36 -15
View File
@@ -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 = []