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 sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, date from datetime import datetime, date, time
from typing import Optional from typing import Optional
from app.database import Base from app.database import Base
@ -13,6 +13,7 @@ class Todo(Base):
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
priority: Mapped[str] = mapped_column(String(20), default="medium") priority: Mapped[str] = mapped_column(String(20), default="medium")
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) 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: Mapped[bool] = mapped_column(Boolean, default=False)
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)

View File

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

View File

@ -29,17 +29,28 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
description: todo?.description || '', description: todo?.description || '',
priority: todo?.priority || 'medium', priority: todo?.priority || 'medium',
due_date: todo?.due_date || '', due_date: todo?.due_date || '',
due_time: todo?.due_time ? todo.due_time.slice(0, 5) : '',
category: todo?.category || '', category: todo?.category || '',
recurrence_rule: todo?.recurrence_rule || '', recurrence_rule: todo?.recurrence_rule || '',
}); });
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (data: typeof formData) => { 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) { if (todo) {
const response = await api.put(`/todos/${todo.id}`, data); const response = await api.put(`/todos/${todo.id}`, payload);
return response.data; return response.data;
} else { } else {
const response = await api.post('/todos', data); const response = await api.post('/todos', payload);
return response.data; return response.data;
} }
}, },
@ -104,18 +115,6 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
</Select> </Select>
</div> </div>
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="category">Category</Label> <Label htmlFor="category">Category</Label>
<Input <Input
@ -125,21 +124,43 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
placeholder="e.g., Work, Personal" placeholder="e.g., Work, Personal"
/> />
</div> </div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="recurrence">Recurrence</Label> <Label htmlFor="due_time">Due Time</Label>
<Select <Input
id="recurrence" id="due_time"
value={formData.recurrence_rule} type="time"
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })} value={formData.due_time}
> onChange={(e) => setFormData({ ...formData, due_time: e.target.value })}
<option value="">None</option> />
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div> </div>
</div> </div>
<div className="space-y-2">
<Label htmlFor="recurrence">Recurrence</Label>
<Select
id="recurrence"
value={formData.recurrence_rule}
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })}
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
</div> </div>
<SheetFooter> <SheetFooter>

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, RefreshCw } from 'lucide-react'; import { Trash2, Pencil, Calendar, Clock, 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';
@ -138,6 +138,13 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
</div> </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 && ( {showResetInfo && (
<div className="flex items-center gap-1 text-xs text-purple-400"> <div className="flex items-center gap-1 text-xs text-purple-400">
<RefreshCw className="h-3 w-3" /> <RefreshCw className="h-3 w-3" />

View File

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