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): class HabitLog(Base):
__tablename__ = "habit_logs" __tablename__ = "habit_logs"
__table_args__ = ( __table_args__ = (UniqueConstraint("habit_id", "day", name="uq_habit_log_day"),)
UniqueConstraint("habit_id", "day", name="uq_habit_log_day"),
Index("ix_habit_logs_habit_day", "habit_id", "day"),
)
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
habit_id: Mapped[UUID] = mapped_column(ForeignKey("habits.id", ondelete="CASCADE"), index=True) habit_id: Mapped[UUID] = mapped_column(ForeignKey("habits.id", ondelete="CASCADE"), index=True)
day: Mapped[date] = mapped_column(Date) 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 import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, model_validator 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 sqlalchemy.ext.asyncio import AsyncSession
from .auth import current_user from .auth import current_user
@@ -144,11 +144,12 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
@router.get("/calendar") @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: if end < start or (end - start).days > 366:
raise HTTPException(422, "日期范围无效或超过一年") raise HTTPException(422, "日期范围无效或超过一年")
start_dt = datetime.combine(start, time.min, tzinfo=UTC) offset = timedelta(minutes=timezone_offset)
end_dt = datetime.combine(end, time.max, tzinfo=UTC) 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() 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] 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()) 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: for exception in exception_rows:
key = exception.occurrence_at.replace(tzinfo=UTC) if exception.occurrence_at.tzinfo is None else exception.occurrence_at 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 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} recurring_task_ids = {task.id for _, task in recurrence_rows}
normal_query = select(Task).where( normal_query = select(Task).where(
Task.user_id == user.id, 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 task in normal_tasks]
for template, task in recurrence_rows: for template, task in recurrence_rows:
exceptions = exceptions_by_template.get(template.id, {}) 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) exception = exceptions.get(at)
if exception and exception.deleted: if exception and exception.deleted:
continue 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"]) 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": []} return {"days": days, "habits": []}
habit_ids = [habit.id for habit in 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() 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() 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 = {} logs_by_habit = {}
stats_by_habit = {} stats_by_habit = {}
pauses_by_habit = {} pauses_by_habit = {}
for log in week_logs: for log in week_logs:
logs_by_habit.setdefault(log.habit_id, {})[log.day] = log.value logs_by_habit.setdefault(log.habit_id, {})[log.day] = log.value
for log in all_logs: for habit_id, total, logged_days, completed_days in stats_rows:
stats = stats_by_habit.setdefault(log.habit_id, {"total": 0, "completed_days": 0, "logged_days": 0}) stats_by_habit[habit_id] = {"total": total or 0, "completed_days": completed_days or 0, "logged_days": logged_days or 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 pause in pause_rows: for pause in pause_rows:
pauses_by_habit.setdefault(pause.habit_id, []).append(pause) pauses_by_habit.setdefault(pause.habit_id, []).append(pause)
output = [] output = []
+2 -1
View File
@@ -65,7 +65,8 @@ async function loadCalendar(range?: { startStr?: string; endStr?: string }) {
endExclusive.setDate(endExclusive.getDate() - 1) endExclusive.setDate(endExclusive.getDate() - 1)
const end = dateKey(endExclusive) const end = dateKey(endExclusive)
await safe(async()=>{ 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=>({ calendarTasks.value = rows.map(row=>({
id: row.recurrence_id ? `${row.task_id}:${row.occurrence_at}` : (row.id ?? row.task_id), id: row.recurrence_id ? `${row.task_id}:${row.occurrence_at}` : (row.id ?? row.task_id),
title: row.title, title: row.title,
@@ -27,15 +27,9 @@ def upgrade() -> None:
"tasks", "tasks",
["user_id", "list_id", "deleted_at", "parent_id", "created_at", "id"], ["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: 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_list_active", table_name="tasks")
op.drop_index("ix_tasks_user_due", table_name="tasks") op.drop_index("ix_tasks_user_due", table_name="tasks")
op.drop_index("ix_tasks_user_active_created", table_name="tasks") op.drop_index("ix_tasks_user_active_created", table_name="tasks")
+33
View File
@@ -64,6 +64,39 @@ def test_recurring_calendar_exceptions_and_scopes(client):
assert any(row.get("id") == normal_task["id"] for row in remaining) 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): def test_habit_logs_support_date_range_filter(client):
boot(client) boot(client)
habit = client.post("/api/v1/habits", json={"name": "跑步", "kind": "boolean", "schedule_type": "daily"}).json() habit = client.post("/api/v1/habits", json={"name": "跑步", "kind": "boolean", "schedule_type": "daily"}).json()