From 46d4c5e28b252474c6c6edf16e692da3707836a1 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 23 Feb 2026 17:04:12 +0800 Subject: [PATCH] Implement todo recurrence logic with auto-reset scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../014_add_todo_recurrence_fields.py | 26 ++++ backend/app/models/todo.py | 2 + backend/app/routers/todos.py | 115 +++++++++++++++++- backend/app/schemas/todo.py | 2 + frontend/src/components/todos/TodoItem.tsx | 69 +++++++---- frontend/src/types/index.ts | 2 + 6 files changed, 191 insertions(+), 25 deletions(-) create mode 100644 backend/alembic/versions/014_add_todo_recurrence_fields.py diff --git a/backend/alembic/versions/014_add_todo_recurrence_fields.py b/backend/alembic/versions/014_add_todo_recurrence_fields.py new file mode 100644 index 0000000..c4c1581 --- /dev/null +++ b/backend/alembic/versions/014_add_todo_recurrence_fields.py @@ -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") diff --git a/backend/app/models/todo.py b/backend/app/models/todo.py index 1673151..2a828ad 100644 --- a/backend/app/models/todo.py +++ b/backend/app/models/todo.py @@ -17,6 +17,8 @@ class Todo(Base): completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) category: Mapped[Optional[str]] = mapped_column(String(100), 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) created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/todos.py b/backend/app/routers/todos.py index fc52fc4..99c3cf5 100644 --- a/backend/app/routers/todos.py +++ b/backend/app/routers/todos.py @@ -1,8 +1,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, and_ 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.models.todo import Todo @@ -13,6 +14,90 @@ 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), @@ -23,6 +108,9 @@ async def get_todos( 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: @@ -98,6 +186,8 @@ async def update_todo( 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) @@ -133,7 +223,7 @@ async def toggle_todo( db: AsyncSession = Depends(get_db), 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)) todo = result.scalar_one_or_none() @@ -141,7 +231,24 @@ async def toggle_todo( raise HTTPException(status_code=404, detail="Todo not found") 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.refresh(todo) diff --git a/backend/app/schemas/todo.py b/backend/app/schemas/todo.py index 147eb87..47ac7fa 100644 --- a/backend/app/schemas/todo.py +++ b/backend/app/schemas/todo.py @@ -36,6 +36,8 @@ class TodoResponse(BaseModel): completed_at: Optional[datetime] category: Optional[str] recurrence_rule: Optional[str] + reset_at: Optional[datetime] + next_due_date: Optional[date] project_id: Optional[int] created_at: datetime updated_at: datetime diff --git a/frontend/src/components/todos/TodoItem.tsx b/frontend/src/components/todos/TodoItem.tsx index 2c06443..4246374 100644 --- a/frontend/src/components/todos/TodoItem.tsx +++ b/frontend/src/components/todos/TodoItem.tsx @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; 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 api from '@/lib/api'; import type { Todo } from '@/types'; @@ -20,6 +20,12 @@ const priorityStyles: Record = { high: 'bg-red-500/20 text-red-400', }; +const recurrenceLabels: Record = { + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', +}; + export default function TodoItem({ todo, onEdit }: TodoItemProps) { const queryClient = useQueryClient(); @@ -58,6 +64,10 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) { const isDueToday = dueDate ? isToday(dueDate) : 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 (
)} + {todo.recurrence_rule && ( + + {recurrenceLabels[todo.recurrence_rule] || todo.recurrence_rule} + + )}
{todo.description && (

{todo.description}

)} - {dueDate && ( -
- {isOverdue ? ( - - ) : ( - - )} - {isOverdue ? 'Overdue — ' : isDueToday ? 'Today — ' : ''} - {format(dueDate, 'MMM d, yyyy')} -
- )} +
+ {dueDate && ( +
+ {isOverdue ? ( + + ) : ( + + )} + {isOverdue ? 'Overdue — ' : isDueToday ? 'Today — ' : ''} + {format(dueDate, 'MMM d, yyyy')} +
+ )} + + {showResetInfo && ( +
+ + + Resets {format(resetDate, 'EEE dd/MM/yy')} + {nextDueDate && <> · Next due {format(nextDueDate, 'dd/MM/yy')}} + +
+ )} +
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9f88aee..9da2b6d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -29,6 +29,8 @@ export interface Todo { due_date?: string; category?: string; recurrence_rule?: string; + reset_at?: string; + next_due_date?: string; project_id?: number; created_at: string; updated_at: string;