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:
parent
aa6502b47b
commit
46d4c5e28b
26
backend/alembic/versions/014_add_todo_recurrence_fields.py
Normal file
26
backend/alembic/versions/014_add_todo_recurrence_fields.py
Normal 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")
|
||||||
@ -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())
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,16 +105,22 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-1.5">
|
||||||
{dueDate && (
|
{dueDate && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1 mt-1.5 text-xs',
|
'flex items-center gap-1 text-xs',
|
||||||
isOverdue
|
isOverdue
|
||||||
? 'text-red-400'
|
? 'text-red-400'
|
||||||
: isDueToday
|
: isDueToday
|
||||||
@ -121,6 +137,17 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
|
|||||||
{format(dueDate, 'MMM d, yyyy')}
|
{format(dueDate, 'MMM d, yyyy')}
|
||||||
</div>
|
</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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user