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>
This commit is contained in:
Kyle 2026-02-23 17:04:12 +08:00
parent aa6502b47b
commit 46d4c5e28b
6 changed files with 191 additions and 25 deletions

View File

@ -0,0 +1,26 @@
"""Add reset_at and next_due_date to todos for recurrence
Revision ID: 014
Revises: 013
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "014"
down_revision = "013"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("todos", sa.Column("reset_at", sa.DateTime(), nullable=True))
op.add_column("todos", sa.Column("next_due_date", sa.Date(), nullable=True))
def downgrade() -> None:
op.drop_column("todos", "next_due_date")
op.drop_column("todos", "reset_at")

View File

@ -17,6 +17,8 @@ class Todo(Base):
completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
reset_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
next_due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
project_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("projects.id"), nullable=True) project_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("projects.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -1,8 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, and_
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime, date, timedelta
import calendar
from app.database import get_db from app.database import get_db
from app.models.todo import Todo from app.models.todo import Todo
@ -13,6 +14,90 @@ from app.models.settings import Settings
router = APIRouter() 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]) @router.get("/", response_model=List[TodoResponse])
async def get_todos( async def get_todos(
completed: Optional[bool] = Query(None), completed: Optional[bool] = Query(None),
@ -23,6 +108,9 @@ async def get_todos(
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Get all todos with optional filters.""" """Get all todos with optional filters."""
# Reactivate any recurring todos whose reset time has passed
await _reactivate_recurring_todos(db)
query = select(Todo) query = select(Todo)
if completed is not None: if completed is not None:
@ -98,6 +186,8 @@ async def update_todo(
update_data["completed_at"] = datetime.now() update_data["completed_at"] = datetime.now()
elif not update_data["completed"]: elif not update_data["completed"]:
update_data["completed_at"] = None update_data["completed_at"] = None
update_data["reset_at"] = None
update_data["next_due_date"] = None
for key, value in update_data.items(): for key, value in update_data.items():
setattr(todo, key, value) setattr(todo, key, value)
@ -133,7 +223,7 @@ async def toggle_todo(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Toggle todo completion status.""" """Toggle todo completion status. For recurring todos, calculates reset schedule."""
result = await db.execute(select(Todo).where(Todo.id == todo_id)) result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none() todo = result.scalar_one_or_none()
@ -141,7 +231,24 @@ async def toggle_todo(
raise HTTPException(status_code=404, detail="Todo not found") raise HTTPException(status_code=404, detail="Todo not found")
todo.completed = not todo.completed todo.completed = not todo.completed
todo.completed_at = datetime.now() if todo.completed else None
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.commit()
await db.refresh(todo) await db.refresh(todo)

View File

@ -36,6 +36,8 @@ class TodoResponse(BaseModel):
completed_at: Optional[datetime] completed_at: Optional[datetime]
category: Optional[str] category: Optional[str]
recurrence_rule: Optional[str] recurrence_rule: Optional[str]
reset_at: Optional[datetime]
next_due_date: Optional[date]
project_id: Optional[int] project_id: Optional[int]
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Trash2, Pencil, Calendar, AlertCircle } from 'lucide-react'; import { Trash2, Pencil, Calendar, AlertCircle, RefreshCw } from 'lucide-react';
import { format, isToday, isPast, parseISO, startOfDay } from 'date-fns'; import { format, isToday, isPast, parseISO, startOfDay } from 'date-fns';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Todo } from '@/types'; import type { Todo } from '@/types';
@ -20,6 +20,12 @@ const priorityStyles: Record<string, string> = {
high: 'bg-red-500/20 text-red-400', high: 'bg-red-500/20 text-red-400',
}; };
const recurrenceLabels: Record<string, string> = {
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
};
export default function TodoItem({ todo, onEdit }: TodoItemProps) { export default function TodoItem({ todo, onEdit }: TodoItemProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -58,6 +64,10 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
const isDueToday = dueDate ? isToday(dueDate) : false; const isDueToday = dueDate ? isToday(dueDate) : false;
const isOverdue = dueDate && !todo.completed ? isPast(startOfDay(dueDate)) && !isDueToday : false; const isOverdue = dueDate && !todo.completed ? isPast(startOfDay(dueDate)) && !isDueToday : false;
const resetDate = todo.reset_at ? parseISO(todo.reset_at) : null;
const nextDueDate = todo.next_due_date ? parseISO(todo.next_due_date) : null;
const showResetInfo = todo.completed && todo.recurrence_rule && resetDate;
return ( return (
<div <div
className={cn( className={cn(
@ -95,32 +105,49 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
{todo.category} {todo.category}
</span> </span>
)} )}
{todo.recurrence_rule && (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-purple-500/15 text-purple-400 shrink-0">
{recurrenceLabels[todo.recurrence_rule] || todo.recurrence_rule}
</span>
)}
</div> </div>
{todo.description && ( {todo.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">{todo.description}</p> <p className="text-sm text-muted-foreground mt-1 line-clamp-1">{todo.description}</p>
)} )}
{dueDate && ( <div className="flex items-center gap-3 mt-1.5">
<div {dueDate && (
className={cn( <div
'flex items-center gap-1 mt-1.5 text-xs', className={cn(
isOverdue 'flex items-center gap-1 text-xs',
? 'text-red-400' isOverdue
: isDueToday ? 'text-red-400'
? 'text-yellow-400' : isDueToday
: 'text-muted-foreground' ? 'text-yellow-400'
)} : 'text-muted-foreground'
> )}
{isOverdue ? ( >
<AlertCircle className="h-3 w-3" /> {isOverdue ? (
) : ( <AlertCircle className="h-3 w-3" />
<Calendar className="h-3 w-3" /> ) : (
)} <Calendar className="h-3 w-3" />
{isOverdue ? 'Overdue — ' : isDueToday ? 'Today — ' : ''} )}
{format(dueDate, 'MMM d, yyyy')} {isOverdue ? 'Overdue — ' : isDueToday ? 'Today — ' : ''}
</div> {format(dueDate, 'MMM d, yyyy')}
)} </div>
)}
{showResetInfo && (
<div className="flex items-center gap-1 text-xs text-purple-400">
<RefreshCw className="h-3 w-3" />
<span>
Resets {format(resetDate, 'EEE dd/MM/yy')}
{nextDueDate && <> · Next due {format(nextDueDate, 'dd/MM/yy')}</>}
</span>
</div>
)}
</div>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">

View File

@ -29,6 +29,8 @@ export interface Todo {
due_date?: string; due_date?: string;
category?: string; category?: string;
recurrence_rule?: string; recurrence_rule?: string;
reset_at?: string;
next_due_date?: string;
project_id?: number; project_id?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;