This commit is contained in:
+1
-4
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user