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