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 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)
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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,6 +115,18 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="due_date">Due Date</Label>
|
<Label htmlFor="due_date">Due Date</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -113,18 +136,17 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
|
|||||||
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="due_time">Due Time</Label>
|
||||||
<Input
|
<Input
|
||||||
id="category"
|
id="due_time"
|
||||||
value={formData.category}
|
type="time"
|
||||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
value={formData.due_time}
|
||||||
placeholder="e.g., Work, Personal"
|
onChange={(e) => setFormData({ ...formData, due_time: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="recurrence">Recurrence</Label>
|
<Label htmlFor="recurrence">Recurrence</Label>
|
||||||
@ -140,7 +162,6 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user