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:
parent
46d4c5e28b
commit
8e0af3ce86
24
backend/alembic/versions/015_add_todo_due_time.py
Normal file
24
backend/alembic/versions/015_add_todo_due_time.py
Normal 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")
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user