Kyle Pope 46d4c5e28b Implement todo recurrence logic with auto-reset scheduling
Backend:
- Add reset_at (datetime) and next_due_date (date) columns to todos
- Toggle endpoint calculates reset schedule when completing recurring todos:
  daily resets next day, weekly resets start of next week (respects
  first_day_of_week setting), monthly resets 1st of next month
- GET /todos auto-reactivates recurring todos whose reset_at has passed,
  updating due_date to next_due_date and clearing completion state
- Alembic migration 014

Frontend:
- Add reset_at and next_due_date to Todo type
- TodoItem shows recurrence badge (Daily/Weekly/Monthly) in purple
- Completed recurring todos display reset info:
  "Resets Mon 02/03/26 · Next due 06/03/26"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:04:12 +08:00

257 lines
7.6 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from typing import Optional, List
from datetime import datetime, date, timedelta
import calendar
from app.database import get_db
from app.models.todo import Todo
from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse
from app.routers.auth import get_current_session
from app.models.settings import Settings
router = APIRouter()
def _calculate_recurrence(
recurrence_rule: str,
current_due_date: date | None,
first_day_of_week: int = 0,
) -> tuple[datetime | None, date | None]:
"""Calculate reset_at and next_due_date for a recurring todo.
Args:
recurrence_rule: "daily", "weekly", or "monthly"
current_due_date: The todo's current due date (may be None)
first_day_of_week: 0=Sunday, 1=Monday
Returns:
(reset_at, next_due_date) or (None, None) if rule is invalid
"""
today = date.today()
if recurrence_rule == "daily":
reset_date = today + timedelta(days=1)
next_due = reset_date
elif recurrence_rule == "weekly":
# Find the start of the next week based on first_day_of_week setting.
# Python weekday(): Monday=0 ... Sunday=6
# Setting: 0=Sunday, 1=Monday
target_weekday = 6 if first_day_of_week == 0 else 0 # Python weekday for start
days_ahead = (target_weekday - today.weekday()) % 7
if days_ahead == 0:
days_ahead = 7 # Always push to *next* week
reset_date = today + timedelta(days=days_ahead)
if current_due_date:
# Preserve the day-of-week: place it in the reset week
dow_offset = (current_due_date.weekday() - target_weekday) % 7
next_due = reset_date + timedelta(days=dow_offset)
else:
next_due = reset_date
elif recurrence_rule == "monthly":
# First day of next month
if today.month == 12:
reset_date = date(today.year + 1, 1, 1)
else:
reset_date = date(today.year, today.month + 1, 1)
if current_due_date:
# Same day-of-month, clamped to month length
max_day = calendar.monthrange(reset_date.year, reset_date.month)[1]
day = min(current_due_date.day, max_day)
next_due = date(reset_date.year, reset_date.month, day)
else:
next_due = reset_date
else:
return None, None
reset_at = datetime(reset_date.year, reset_date.month, reset_date.day, 0, 0, 0)
return reset_at, next_due
async def _reactivate_recurring_todos(db: AsyncSession) -> None:
"""Auto-reactivate recurring todos whose reset_at has passed."""
now = datetime.now()
query = select(Todo).where(
and_(
Todo.completed == True,
Todo.recurrence_rule.isnot(None),
Todo.reset_at.isnot(None),
Todo.reset_at <= now,
)
)
result = await db.execute(query)
todos = result.scalars().all()
for todo in todos:
todo.completed = False
todo.completed_at = None
if todo.next_due_date:
todo.due_date = todo.next_due_date
todo.reset_at = None
todo.next_due_date = None
if todos:
await db.commit()
@router.get("/", response_model=List[TodoResponse])
async def get_todos(
completed: Optional[bool] = Query(None),
priority: Optional[str] = Query(None),
category: Optional[str] = Query(None),
search: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get all todos with optional filters."""
# Reactivate any recurring todos whose reset time has passed
await _reactivate_recurring_todos(db)
query = select(Todo)
if completed is not None:
query = query.where(Todo.completed == completed)
if priority:
query = query.where(Todo.priority == priority)
if category:
query = query.where(Todo.category == category)
if search:
query = query.where(Todo.title.ilike(f"%{search}%"))
query = query.order_by(Todo.created_at.desc())
result = await db.execute(query)
todos = result.scalars().all()
return todos
@router.post("/", response_model=TodoResponse, status_code=201)
async def create_todo(
todo: TodoCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Create a new todo."""
new_todo = Todo(**todo.model_dump())
db.add(new_todo)
await db.commit()
await db.refresh(new_todo)
return new_todo
@router.get("/{todo_id}", response_model=TodoResponse)
async def get_todo(
todo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get a specific todo by ID."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
return todo
@router.put("/{todo_id}", response_model=TodoResponse)
async def update_todo(
todo_id: int,
todo_update: TodoUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Update a todo."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
update_data = todo_update.model_dump(exclude_unset=True)
# Handle completion timestamp
if "completed" in update_data:
if update_data["completed"] and not todo.completed:
update_data["completed_at"] = datetime.now()
elif not update_data["completed"]:
update_data["completed_at"] = None
update_data["reset_at"] = None
update_data["next_due_date"] = None
for key, value in update_data.items():
setattr(todo, key, value)
await db.commit()
await db.refresh(todo)
return todo
@router.delete("/{todo_id}", status_code=204)
async def delete_todo(
todo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Delete a todo."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
await db.delete(todo)
await db.commit()
return None
@router.patch("/{todo_id}/toggle", response_model=TodoResponse)
async def toggle_todo(
todo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Toggle todo completion status. For recurring todos, calculates reset schedule."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
todo.completed = not todo.completed
if todo.completed:
todo.completed_at = datetime.now()
# If recurring, schedule the reset
if todo.recurrence_rule:
reset_at, next_due = _calculate_recurrence(
todo.recurrence_rule,
todo.due_date,
current_user.first_day_of_week,
)
todo.reset_at = reset_at
todo.next_due_date = next_due
else:
# Manual uncomplete — clear recurrence scheduling
todo.completed_at = None
todo.reset_at = None
todo.next_due_date = None
await db.commit()
await db.refresh(todo)
return todo