Global enhancements: none priority, optional remind_at, required labels, textarea flex, remove color picker

- Add "none" priority (grey) to task/todo schemas, types, and all priority color maps
- Make remind_at optional on reminders (schema, model, migration 010)
- Add required prop to Label component with red asterisk indicator
- Add invalid:ring-red-500 to Input, Select, Textarea base classes
- Mark mandatory fields with required labels across all forms
- Replace fixed textarea rows with min-h + flex-1 for auto-expand
- Remove color picker from ProjectForm
- Align TaskRow metadata into fixed-width columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-22 11:58:19 +08:00
parent bfe97fd749
commit 4169c245c2
19 changed files with 76 additions and 57 deletions

View File

@ -0,0 +1,23 @@
"""make remind_at nullable
Revision ID: 010
Revises: 009
Create Date: 2026-02-22
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "010"
down_revision = "009"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column("reminders", "remind_at", existing_type=sa.DateTime(), nullable=True)
def downgrade() -> None:
op.alter_column("reminders", "remind_at", existing_type=sa.DateTime(), nullable=False)

View File

@ -11,7 +11,7 @@ class Reminder(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
remind_at: Mapped[datetime] = mapped_column(nullable=False) remind_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_dismissed: Mapped[bool] = mapped_column(Boolean, default=False) is_dismissed: Mapped[bool] = mapped_column(Boolean, default=False)
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)

View File

@ -4,7 +4,7 @@ from typing import Optional, List, Literal
from app.schemas.task_comment import TaskCommentResponse from app.schemas.task_comment import TaskCommentResponse
TaskStatus = Literal["pending", "in_progress", "completed"] TaskStatus = Literal["pending", "in_progress", "completed"]
TaskPriority = Literal["low", "medium", "high"] TaskPriority = Literal["none", "low", "medium", "high"]
class ProjectTaskCreate(BaseModel): class ProjectTaskCreate(BaseModel):

View File

@ -6,7 +6,7 @@ from typing import Optional
class ReminderCreate(BaseModel): class ReminderCreate(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
remind_at: datetime remind_at: Optional[datetime] = None
is_active: bool = True is_active: bool = True
recurrence_rule: Optional[str] = None recurrence_rule: Optional[str] = None
@ -24,7 +24,7 @@ class ReminderResponse(BaseModel):
id: int id: int
title: str title: str
description: Optional[str] description: Optional[str]
remind_at: datetime remind_at: Optional[datetime]
is_active: bool is_active: bool
is_dismissed: bool is_dismissed: bool
recurrence_rule: Optional[str] recurrence_rule: Optional[str]

View File

@ -2,7 +2,7 @@ from pydantic import BaseModel, ConfigDict
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, Literal from typing import Optional, Literal
TodoPriority = Literal["low", "medium", "high"] TodoPriority = Literal["none", "low", "medium", "high"]
class TodoCreate(BaseModel): class TodoCreate(BaseModel):

View File

@ -214,7 +214,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="px-6 py-5 space-y-4 flex-1"> <div className="px-6 py-5 space-y-4 flex-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Title</Label> <Label htmlFor="title" required>Title</Label>
<Input <Input
id="title" id="title"
value={formData.title} value={formData.title}
@ -229,7 +229,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4} className="min-h-[80px] flex-1"
/> />
</div> </div>
@ -252,7 +252,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="start">Start</Label> <Label htmlFor="start" required>Start</Label>
<Input <Input
id="start" id="start"
type={formData.all_day ? 'date' : 'datetime-local'} type={formData.all_day ? 'date' : 'datetime-local'}

View File

@ -19,6 +19,7 @@ const COLUMNS: { id: string; label: string; color: string }[] = [
]; ];
const priorityColors: Record<string, string> = { const priorityColors: Record<string, string> = {
none: 'bg-gray-500/20 text-gray-400',
low: 'bg-green-500/20 text-green-400', low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400', medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-red-500/20 text-red-400', high: 'bg-red-500/20 text-red-400',

View File

@ -53,7 +53,7 @@ const statusLabels: Record<string, string> = {
type SortMode = 'manual' | 'priority' | 'due_date'; type SortMode = 'manual' | 'priority' | 'due_date';
type ViewMode = 'list' | 'kanban'; type ViewMode = 'list' | 'kanban';
const PRIORITY_ORDER: Record<string, number> = { high: 0, medium: 1, low: 2 }; const PRIORITY_ORDER: Record<string, number> = { high: 0, medium: 1, low: 2, none: 3 };
function SortableTaskRow({ function SortableTaskRow({
task, task,

View File

@ -28,7 +28,6 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
name: project?.name || '', name: project?.name || '',
description: project?.description || '', description: project?.description || '',
status: project?.status || 'not_started', status: project?.status || 'not_started',
color: project?.color || '',
due_date: project?.due_date ? project.due_date.slice(0, 10) : '', due_date: project?.due_date ? project.due_date.slice(0, 10) : '',
}); });
@ -84,7 +83,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4"> <div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Name</Label> <Label htmlFor="name" required>Name</Label>
<Input <Input
id="name" id="name"
value={formData.name} value={formData.name}
@ -99,7 +98,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4} className="min-h-[80px] flex-1"
/> />
</div> </div>
@ -128,15 +127,6 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
</div> </div>
</div> </div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Input
id="color"
type="color"
value={formData.color || '#3b82f6'}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
/>
</div>
</div> </div>
<SheetFooter> <SheetFooter>

View File

@ -26,6 +26,7 @@ const taskStatusLabels: Record<string, string> = {
}; };
const priorityColors: Record<string, string> = { const priorityColors: Record<string, string> = {
none: 'bg-gray-500/20 text-gray-400',
low: 'bg-green-500/20 text-green-400', low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400', medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-red-500/20 text-red-400', high: 'bg-red-500/20 text-red-400',

View File

@ -99,7 +99,7 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4"> <div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Title</Label> <Label htmlFor="title" required>Title</Label>
<Input <Input
id="title" id="title"
value={formData.title} value={formData.title}
@ -114,7 +114,7 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3} className="min-h-[80px] flex-1"
/> />
</div> </div>
@ -139,6 +139,7 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas
value={formData.priority} value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as ProjectTask['priority'] })} onChange={(e) => setFormData({ ...formData, priority: e.target.value as ProjectTask['priority'] })}
> >
<option value="none">None</option>
<option value="low">Low</option> <option value="low">Low</option>
<option value="medium">Medium</option> <option value="medium">Medium</option>
<option value="high">High</option> <option value="high">High</option>

View File

@ -11,6 +11,7 @@ const taskStatusColors: Record<string, string> = {
}; };
const priorityColors: Record<string, string> = { const priorityColors: Record<string, string> = {
none: 'bg-gray-500/20 text-gray-400',
low: 'bg-green-500/20 text-green-400', low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400', medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-red-500/20 text-red-400', high: 'bg-red-500/20 text-red-400',
@ -103,35 +104,32 @@ export default function TaskRow({
{task.title} {task.title}
</span> </span>
{/* Status badge */} {/* Metadata columns */}
<Badge className={`text-[9px] px-1.5 py-0.5 shrink-0 ${taskStatusColors[task.status]}`}> <Badge className={`text-[9px] px-1.5 py-0.5 shrink-0 w-16 text-center ${taskStatusColors[task.status]}`}>
{task.status.replace('_', ' ')} {task.status.replace('_', ' ')}
</Badge> </Badge>
{/* Priority pill */}
<Badge <Badge
className={`text-[9px] px-1.5 py-0.5 rounded-full shrink-0 ${priorityColors[task.priority]}`} className={`text-[9px] px-1.5 py-0.5 rounded-full shrink-0 w-14 text-center ${priorityColors[task.priority]}`}
> >
{task.priority} {task.priority}
</Badge> </Badge>
{/* Due date */} <span
{task.due_date && ( className={`text-[11px] shrink-0 tabular-nums w-12 text-right ${
<span task.due_date
className={`text-[11px] shrink-0 tabular-nums ${ ? isOverdue ? 'text-red-400' : 'text-muted-foreground'
isOverdue ? 'text-red-400' : 'text-muted-foreground' : 'text-transparent'
}`} }`}
> >
{format(parseISO(task.due_date), 'MMM d')} {task.due_date ? format(parseISO(task.due_date), 'MMM d') : '—'}
</span> </span>
)}
{/* Subtask count */} <span className={`text-[11px] shrink-0 tabular-nums w-8 text-right ${
{hasSubtasks && ( hasSubtasks ? 'text-muted-foreground' : 'text-transparent'
<span className="text-[11px] text-muted-foreground shrink-0 tabular-nums"> }`}>
{completedSubtasks}/{task.subtasks.length} {hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'}
</span> </span>
)}
</div> </div>
); );
} }

View File

@ -68,7 +68,7 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="px-6 py-5 space-y-4 flex-1"> <div className="px-6 py-5 space-y-4 flex-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Title</Label> <Label htmlFor="title" required>Title</Label>
<Input <Input
id="title" id="title"
value={formData.title} value={formData.title}
@ -83,7 +83,7 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4} className="min-h-[80px] flex-1"
/> />
</div> </div>
@ -95,7 +95,6 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
type="datetime-local" type="datetime-local"
value={formData.remind_at} value={formData.remind_at}
onChange={(e) => setFormData({ ...formData, remind_at: e.target.value })} onChange={(e) => setFormData({ ...formData, remind_at: e.target.value })}
required
/> />
</div> </div>

View File

@ -70,7 +70,7 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="px-6 py-5 space-y-4 flex-1"> <div className="px-6 py-5 space-y-4 flex-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Title</Label> <Label htmlFor="title" required>Title</Label>
<Input <Input
id="title" id="title"
value={formData.title} value={formData.title}
@ -85,7 +85,7 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4} className="min-h-[80px] flex-1"
/> />
</div> </div>
@ -97,6 +97,7 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
value={formData.priority} value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as any })} onChange={(e) => setFormData({ ...formData, priority: e.target.value as any })}
> >
<option value="none">None</option>
<option value="low">Low</option> <option value="low">Low</option>
<option value="medium">Medium</option> <option value="medium">Medium</option>
<option value="high">High</option> <option value="high">High</option>

View File

@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 invalid:ring-red-500 invalid:border-red-500',
className className
)} )}
ref={ref} ref={ref}

View File

@ -1,10 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {} export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
required?: boolean;
}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>( const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => ( ({ className, required, children, ...props }, ref) => (
<label <label
ref={ref} ref={ref}
className={cn( className={cn(
@ -12,7 +14,10 @@ const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
className className
)} )}
{...props} {...props}
/> >
{children}
{required && <span className="text-red-400 ml-0.5">*</span>}
</label>
) )
); );
Label.displayName = 'Label'; Label.displayName = 'Label';

View File

@ -10,7 +10,7 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
<div className="relative"> <div className="relative">
<select <select
className={cn( className={cn(
'flex h-10 w-full appearance-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 'flex h-10 w-full appearance-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 invalid:ring-red-500 invalid:border-red-500',
className className
)} )}
ref={ref} ref={ref}

View File

@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return ( return (
<textarea <textarea
className={cn( className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 invalid:ring-red-500 invalid:border-red-500',
className className
)} )}
ref={ref} ref={ref}

View File

@ -25,7 +25,7 @@ export interface Todo {
description?: string; description?: string;
completed: boolean; completed: boolean;
completed_at?: string; completed_at?: string;
priority: 'low' | 'medium' | 'high'; priority: 'none' | 'low' | 'medium' | 'high';
due_date?: string; due_date?: string;
category?: string; category?: string;
recurrence_rule?: string; recurrence_rule?: string;
@ -79,7 +79,7 @@ export interface Reminder {
id: number; id: number;
title: string; title: string;
description?: string; description?: string;
remind_at: string; remind_at?: string;
is_active: boolean; is_active: boolean;
is_dismissed: boolean; is_dismissed: boolean;
recurrence_rule?: string; recurrence_rule?: string;
@ -113,7 +113,7 @@ export interface ProjectTask {
title: string; title: string;
description?: string; description?: string;
status: 'pending' | 'in_progress' | 'completed'; status: 'pending' | 'in_progress' | 'completed';
priority: 'low' | 'medium' | 'high'; priority: 'none' | 'low' | 'medium' | 'high';
due_date?: string; due_date?: string;
person_id?: number; person_id?: number;
sort_order: number; sort_order: number;