Add optional due time to todos, fix date not being optional

Backend:
- Add due_time (TIME, nullable) column to todos model + migration 015
- Add due_time to Create/Update/Response schemas

Frontend:
- Add due_time to Todo type
- TodoForm: add time input, convert empty strings to null before
  sending (fixes date appearing required — Pydantic rejected '' as date)
- TodoItem: display clock icon + time when due_time is set

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-23 19:59:38 +08:00
parent 46d4c5e28b
commit 8e0af3ce86
6 changed files with 86 additions and 29 deletions

View File

@ -0,0 +1,24 @@
"""Add due_time column to todos
Revision ID: 015
Revises: 014
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "015"
down_revision = "014"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("todos", sa.Column("due_time", sa.Time(), nullable=True))
def downgrade() -> None:
op.drop_column("todos", "due_time")

View File

@ -1,6 +1,6 @@
from sqlalchemy import String, Text, Boolean, Date, Integer, ForeignKey, func
from sqlalchemy import String, Text, Boolean, Date, Time, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, date
from datetime import datetime, date, time
from typing import Optional
from app.database import Base
@ -13,6 +13,7 @@ class Todo(Base):
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
priority: Mapped[str] = mapped_column(String(20), default="medium")
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
due_time: Mapped[Optional[time]] = mapped_column(Time, nullable=True)
completed: Mapped[bool] = mapped_column(Boolean, default=False)
completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)

View File

@ -1,5 +1,5 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime, date
from datetime import datetime, date, time
from typing import Optional, Literal
TodoPriority = Literal["none", "low", "medium", "high"]
@ -10,6 +10,7 @@ class TodoCreate(BaseModel):
description: Optional[str] = None
priority: TodoPriority = "medium"
due_date: Optional[date] = None
due_time: Optional[time] = None
category: Optional[str] = None
recurrence_rule: Optional[str] = None
project_id: Optional[int] = None
@ -20,6 +21,7 @@ class TodoUpdate(BaseModel):
description: Optional[str] = None
priority: Optional[TodoPriority] = None
due_date: Optional[date] = None
due_time: Optional[time] = None
completed: Optional[bool] = None
category: Optional[str] = None
recurrence_rule: Optional[str] = None
@ -32,6 +34,7 @@ class TodoResponse(BaseModel):
description: Optional[str]
priority: str
due_date: Optional[date]
due_time: Optional[time]
completed: bool
completed_at: Optional[datetime]
category: Optional[str]

View File

@ -29,17 +29,28 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
description: todo?.description || '',
priority: todo?.priority || 'medium',
due_date: todo?.due_date || '',
due_time: todo?.due_time ? todo.due_time.slice(0, 5) : '',
category: todo?.category || '',
recurrence_rule: todo?.recurrence_rule || '',
});
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
// Convert empty strings to null for optional fields
const payload = {
title: data.title,
description: data.description || null,
priority: data.priority,
due_date: data.due_date || null,
due_time: data.due_time || null,
category: data.category || null,
recurrence_rule: data.recurrence_rule || null,
};
if (todo) {
const response = await api.put(`/todos/${todo.id}`, data);
const response = await api.put(`/todos/${todo.id}`, payload);
return response.data;
} else {
const response = await api.post('/todos', data);
const response = await api.post('/todos', payload);
return response.data;
}
},
@ -104,6 +115,18 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Input
id="category"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
placeholder="e.g., Work, Personal"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
@ -113,18 +136,17 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Label htmlFor="due_time">Due Time</Label>
<Input
id="category"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
placeholder="e.g., Work, Personal"
id="due_time"
type="time"
value={formData.due_time}
onChange={(e) => setFormData({ ...formData, due_time: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="recurrence">Recurrence</Label>
@ -140,7 +162,6 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
</Select>
</div>
</div>
</div>
<SheetFooter>
<Button type="button" variant="outline" onClick={onClose}>

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Trash2, Pencil, Calendar, AlertCircle, RefreshCw } from 'lucide-react';
import { Trash2, Pencil, Calendar, Clock, AlertCircle, RefreshCw } from 'lucide-react';
import { format, isToday, isPast, parseISO, startOfDay } from 'date-fns';
import api from '@/lib/api';
import type { Todo } from '@/types';
@ -138,6 +138,13 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
</div>
)}
{todo.due_time && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{todo.due_time.slice(0, 5)}
</div>
)}
{showResetInfo && (
<div className="flex items-center gap-1 text-xs text-purple-400">
<RefreshCw className="h-3 w-3" />

View File

@ -27,6 +27,7 @@ export interface Todo {
completed_at?: string;
priority: 'none' | 'low' | 'medium' | 'high';
due_date?: string;
due_time?: string;
category?: string;
recurrence_rule?: string;
reset_at?: string;