feat: migrate schema to DATE type, add test infrastructure
- Migrate due_date/next_occurrence columns from TIMESTAMPTZ to DATE - Update serializeRow() to distinguish DATE vs TIMESTAMPTZ serialization - Simplify frontend date parsing (no more timezone workarounds) - Add Vitest + Testing Library test infrastructure - Add initial date parsing/formatting unit tests - Update package.json with dev dependencies (vitest, testing-library, jsdom)
This commit is contained in:
143
src/components/AppProvider.tsx
Normal file
143
src/components/AppProvider.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
export type ViewType = 'list' | 'kanban' | 'calendar';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
projectId: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
dueDate: string | null;
|
||||
status: 'todo' | 'in_progress' | 'done';
|
||||
parentTaskId: string | null;
|
||||
recurrenceRule: 'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly';
|
||||
recurrenceInterval: number;
|
||||
nextOccurrence: string | null;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
view: ViewType;
|
||||
selectedProject: string | null;
|
||||
selectedTask: Task | null;
|
||||
searchQuery: string;
|
||||
sidebarOpen: boolean;
|
||||
showNewTaskModal: boolean;
|
||||
showEditTaskModal: boolean;
|
||||
showNewProjectModal: boolean;
|
||||
showRecurrenceModal: boolean;
|
||||
editingTask: Task | null;
|
||||
filterStatus: string;
|
||||
filterPriority: string;
|
||||
filterDueBefore: string;
|
||||
filterDueAfter: string;
|
||||
filterCompleted: string;
|
||||
tasks: Task[];
|
||||
projects: Project[];
|
||||
setView: (view: ViewType) => void;
|
||||
setSelectedProject: (project: string | null) => void;
|
||||
setSelectedTask: (task: Task | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setShowNewTaskModal: (show: boolean) => void;
|
||||
setShowEditTaskModal: (show: boolean) => void;
|
||||
setShowNewProjectModal: (show: boolean) => void;
|
||||
setShowRecurrenceModal: (show: boolean) => void;
|
||||
setEditingTask: (task: Task | null) => void;
|
||||
setFilterStatus: (status: string) => void;
|
||||
setFilterPriority: (priority: string) => void;
|
||||
setFilterDueBefore: (date: string) => void;
|
||||
setFilterDueAfter: (date: string) => void;
|
||||
setFilterCompleted: (completed: string) => void;
|
||||
setTasks: Dispatch<SetStateAction<Task[]>>;
|
||||
setProjects: Dispatch<SetStateAction<Project[]>>;
|
||||
refreshTasks: () => Promise<void>;
|
||||
refreshProjects: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppState | null>(null);
|
||||
|
||||
export function useApp() {
|
||||
const context = useContext(AppContext);
|
||||
if (!context) throw new Error('useApp must be used within AppProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
export default function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [view, setView] = useState<ViewType>('list');
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [showNewTaskModal, setShowNewTaskModal] = useState(false);
|
||||
const [showEditTaskModal, setShowEditTaskModal] = useState(false);
|
||||
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
|
||||
const [showRecurrenceModal, setShowRecurrenceModal] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterPriority, setFilterPriority] = useState('');
|
||||
const [filterDueBefore, setFilterDueBefore] = useState('');
|
||||
const [filterDueAfter, setFilterDueAfter] = useState('');
|
||||
const [filterCompleted, setFilterCompleted] = useState('');
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedProject) params.set('project', selectedProject);
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
if (filterStatus) params.set('status', filterStatus);
|
||||
if (filterPriority) params.set('priority', filterPriority);
|
||||
if (filterDueBefore) params.set('due_before', filterDueBefore);
|
||||
if (filterDueAfter) params.set('due_after', filterDueAfter);
|
||||
if (filterCompleted) params.set('completed', filterCompleted);
|
||||
|
||||
const res = await fetch(`/api/tasks?${params}`);
|
||||
const data = await res.json();
|
||||
setTasks(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh tasks:', error);
|
||||
}
|
||||
}, [selectedProject, searchQuery, filterStatus, filterPriority, filterDueBefore, filterDueAfter, filterCompleted]);
|
||||
|
||||
const refreshProjects = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
const data = await res.json();
|
||||
setProjects(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh projects:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value: AppState = {
|
||||
view, selectedProject, selectedTask, searchQuery, sidebarOpen,
|
||||
showNewTaskModal, showEditTaskModal, showNewProjectModal, showRecurrenceModal,
|
||||
editingTask, filterStatus, filterPriority, filterDueBefore, filterDueAfter, filterCompleted,
|
||||
tasks, projects,
|
||||
setView, setSelectedProject, setSelectedTask, setSearchQuery, setSidebarOpen,
|
||||
setShowNewTaskModal, setShowEditTaskModal, setShowNewProjectModal, setShowRecurrenceModal,
|
||||
setEditingTask, setFilterStatus, setFilterPriority, setFilterDueBefore, setFilterDueAfter,
|
||||
setFilterCompleted, setTasks, setProjects,
|
||||
refreshTasks, refreshProjects,
|
||||
};
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
160
src/components/CalendarView.tsx
Normal file
160
src/components/CalendarView.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useApp, type Task } from './AppProvider';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek, addMonths, subMonths,
|
||||
format, isSameDay, isToday, isPast, isFuture,
|
||||
eachDayOfInterval,
|
||||
} from 'date-fns';
|
||||
|
||||
export default function CalendarView() {
|
||||
const { tasks, projects, setSelectedTask, setShowEditTaskModal, setEditingTask } = useApp();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const calendarStart = startOfWeek(monthStart);
|
||||
const calendarEnd = endOfWeek(monthEnd);
|
||||
|
||||
const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
|
||||
|
||||
const daysInMonth = days.filter(d => d.getMonth() === month);
|
||||
|
||||
// Group tasks by date
|
||||
const tasksByDate = useMemo(() => {
|
||||
const map = new Map<string, Task[]>();
|
||||
tasks.forEach(task => {
|
||||
if (!task.dueDate) return;
|
||||
// DATE type returns "YYYY-MM-DD" directly
|
||||
if (!map.has(task.dueDate)) map.set(task.dueDate, []);
|
||||
map.get(task.dueDate)!.push(task);
|
||||
});
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
const priorityColors = {
|
||||
urgent: 'bg-[var(--priority-urgent)]',
|
||||
high: 'bg-[var(--priority-high)]',
|
||||
medium: 'bg-[var(--priority-medium)]',
|
||||
low: 'bg-[var(--priority-low)]',
|
||||
};
|
||||
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
const monthName = format(currentDate, 'MMMM yyyy');
|
||||
|
||||
const handleDayClick = (date: Date) => {
|
||||
// Could open a "new task" modal pre-filled with this date
|
||||
console.log('Day clicked:', format(date, 'yyyy-MM-dd'));
|
||||
};
|
||||
|
||||
const handleTaskClick = (task: Task) => {
|
||||
setSelectedTask(task);
|
||||
setEditingTask(task);
|
||||
setShowEditTaskModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
|
||||
className="p-2 hover:bg-content rounded-lg transition-colors"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<h2 className="text-xl font-bold">{monthName}</h2>
|
||||
<button
|
||||
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
|
||||
className="p-2 hover:bg-content rounded-lg transition-colors"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||
{/* Week day headers */}
|
||||
<div className="grid grid-cols-7 border-b border-border">
|
||||
{weekDays.map(day => (
|
||||
<div key={day} className="p-2 text-center text-xs font-semibold text-text-secondary bg-content">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days */}
|
||||
<div className="grid grid-cols-7">
|
||||
{days.map((day, index) => {
|
||||
const dateStr = format(day, 'yyyy-MM-dd');
|
||||
const isCurrentMonth = day.getMonth() === month;
|
||||
const isTodayDate = isToday(day);
|
||||
const dayTasks = tasksByDate.get(dateStr) || [];
|
||||
const incompleteTasks = dayTasks.filter(t => !t.completed);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dateStr}
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={`min-h-[80px] p-1.5 border-b border-r border-border cursor-pointer hover:bg-content/50 transition-colors ${
|
||||
!isCurrentMonth ? 'opacity-40' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Date number */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`text-xs font-medium ${
|
||||
isTodayDate
|
||||
? 'bg-accent text-white w-6 h-6 rounded-full flex items-center justify-center'
|
||||
: isPast(day) && !isTodayDate
|
||||
? 'text-text-secondary'
|
||||
: 'text-text-primary'
|
||||
}`}>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
{incompleteTasks.length > 0 && (
|
||||
<span className="text-xs text-accent font-medium">{incompleteTasks.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task indicators */}
|
||||
<div className="space-y-0.5">
|
||||
{dayTasks.slice(0, 3).map(task => (
|
||||
<button
|
||||
key={task.id}
|
||||
onClick={(e) => { e.stopPropagation(); handleTaskClick(task); }}
|
||||
className={`w-full text-left text-xs px-1 py-0.5 rounded truncate ${priorityColors[task.priority]} bg-opacity-20 text-text-primary hover:bg-opacity-30 transition-colors`}
|
||||
title={task.title}
|
||||
>
|
||||
{task.title}
|
||||
</button>
|
||||
))}
|
||||
{dayTasks.length > 3 && (
|
||||
<span className="text-xs text-text-secondary pl-1">
|
||||
+{dayTasks.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 flex items-center gap-4 text-xs text-text-secondary">
|
||||
<span className="font-medium">Priority:</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[var(--priority-urgent)]" /> Urgent</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[var(--priority-high)]" /> High</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[var(--priority-medium)]" /> Medium</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[var(--priority-low)]" /> Low</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
340
src/components/EditTaskModal.tsx
Normal file
340
src/components/EditTaskModal.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
'use client';
|
||||
|
||||
import { useApp, type Task } from './AppProvider';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export default function EditTaskModal() {
|
||||
const {
|
||||
showEditTaskModal, setShowEditTaskModal,
|
||||
selectedTask, editingTask, setEditingTask,
|
||||
projects, refreshTasks,
|
||||
} = useApp();
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [status, setStatus] = useState<'todo' | 'in_progress' | 'done'>('todo');
|
||||
const [recurrenceRule, setRecurrenceRule] = useState<'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly'>('none');
|
||||
const [recurrenceInterval, setRecurrenceInterval] = useState(1);
|
||||
const [subtasks, setSubtasks] = useState<Task[]>([]);
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (editingTask) {
|
||||
setTitle(editingTask.title);
|
||||
setDescription(editingTask.description);
|
||||
setProjectId(editingTask.projectId || '');
|
||||
setPriority(editingTask.priority);
|
||||
setDueDate(editingTask.dueDate || '');
|
||||
setStatus(editingTask.status);
|
||||
setRecurrenceRule(editingTask.recurrenceRule);
|
||||
setRecurrenceInterval(editingTask.recurrenceInterval);
|
||||
}
|
||||
}, [editingTask]);
|
||||
|
||||
// Fetch subtasks when modal opens
|
||||
useEffect(() => {
|
||||
if (showEditTaskModal && editingTask) {
|
||||
fetch(`/api/tasks/${editingTask.id}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setSubtasks(data.subtasks || []);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [showEditTaskModal, editingTask]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingTask || !title.trim()) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/tasks/${editingTask.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
projectId: projectId || null,
|
||||
priority,
|
||||
dueDate: dueDate || null,
|
||||
status,
|
||||
recurrenceRule,
|
||||
recurrenceInterval,
|
||||
}),
|
||||
});
|
||||
|
||||
setShowEditTaskModal(false);
|
||||
refreshTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to update task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!editingTask) return;
|
||||
if (!confirm('Delete this task and all its subtasks?')) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/tasks/${editingTask.id}`, { method: 'DELETE' });
|
||||
setShowEditTaskModal(false);
|
||||
refreshTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSubtask = async (subtask: Task) => {
|
||||
try {
|
||||
await fetch(`/api/tasks/${subtask.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'toggle-done' }),
|
||||
});
|
||||
// Refresh subtasks
|
||||
const res = await fetch(`/api/tasks/${editingTask?.id}`);
|
||||
const data = await res.json();
|
||||
setSubtasks(data.subtasks || []);
|
||||
refreshTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle subtask:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSubtask = async () => {
|
||||
if (!newSubtaskTitle.trim() || !editingTask) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: newSubtaskTitle.trim(),
|
||||
projectId: editingTask.projectId,
|
||||
priority: editingTask.priority,
|
||||
parentTaskId: editingTask.id,
|
||||
}),
|
||||
});
|
||||
|
||||
setNewSubtaskTitle('');
|
||||
// Refresh subtasks
|
||||
const res = await fetch(`/api/tasks/${editingTask.id}`);
|
||||
const data = await res.json();
|
||||
setSubtasks(data.subtasks || []);
|
||||
refreshTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to add subtask:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowEditTaskModal(false);
|
||||
}
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
if (!showEditTaskModal || !editingTask) return null;
|
||||
|
||||
const priorityConfig = {
|
||||
urgent: { label: 'Urgent', icon: '🔴', bg: 'bg-[var(--priority-urgent)]/10', text: 'text-[var(--priority-urgent)]' },
|
||||
high: { label: 'High', icon: '🟠', bg: 'bg-[var(--priority-high)]/10', text: 'text-[var(--priority-high)]' },
|
||||
medium: { label: 'Medium', icon: '🟡', bg: 'bg-[var(--priority-medium)]/10', text: 'text-[var(--priority-medium)]' },
|
||||
low: { label: 'Low', icon: '🟢', bg: 'bg-[var(--priority-low)]/10', text: 'text-[var(--priority-low)]' },
|
||||
};
|
||||
|
||||
const prio = priorityConfig[priority];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 animate-fade-in"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowEditTaskModal(false); }}
|
||||
>
|
||||
<div
|
||||
className="bg-card rounded-xl w-full max-w-lg mx-4 shadow-xl animate-slide-in max-h-[90vh] overflow-y-auto"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border sticky top-0 bg-card z-10">
|
||||
<h2 className="text-lg font-bold">Edit Task</h2>
|
||||
<button
|
||||
onClick={() => setShowEditTaskModal(false)}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors text-xl"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Title */}
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full bg-content border border-border rounded-lg px-3 py-2.5 text-sm font-medium focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description"
|
||||
rows={3}
|
||||
className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors resize-none"
|
||||
/>
|
||||
|
||||
{/* Grid: Project, Priority, Status, Due Date */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">No Project</option>
|
||||
{projects.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as any)}
|
||||
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="todo">📋 To Do</option>
|
||||
<option value="in_progress">🔄 In Progress</option>
|
||||
<option value="done">✅ Done</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as any)}
|
||||
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="low">🟢 Low</option>
|
||||
<option value="medium">🟡 Medium</option>
|
||||
<option value="high">🟠 High</option>
|
||||
<option value="urgent">🔴 Urgent</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recurrence */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Recurrence</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={recurrenceRule}
|
||||
onChange={(e) => setRecurrenceRule(e.target.value as any)}
|
||||
className="flex-1 bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="none">Not recurring</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Bi-weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
{recurrenceRule !== 'none' && (
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={recurrenceInterval}
|
||||
onChange={(e) => setRecurrenceInterval(Number(e.target.value))}
|
||||
className="w-16 bg-content border border-border rounded-lg px-3 py-2 text-sm text-center focus:outline-none focus:border-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{recurrenceRule !== 'none' && (
|
||||
<p className="text-xs text-text-secondary">
|
||||
Repeats every {recurrenceInterval} {recurrenceRule === 'daily' ? 'day' : recurrenceRule === 'weekly' ? 'week' : recurrenceRule === 'biweekly' ? 'weeks' : recurrenceRule === 'monthly' ? 'month' : recurrenceRule === 'yearly' ? 'year' : ''}
|
||||
{recurrenceInterval > 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtasks */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text-secondary">Subtasks ({subtasks.filter(s => !s.completed).length}/{subtasks.length})</label>
|
||||
<div className="mt-2 space-y-1">
|
||||
{subtasks.map(subtask => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-colors ${
|
||||
subtask.completed ? 'opacity-60' : ''
|
||||
}`}
|
||||
onClick={() => handleToggleSubtask(subtask)}
|
||||
>
|
||||
<span className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 ${
|
||||
subtask.completed ? 'bg-accent border-accent' : 'border-text-secondary'
|
||||
}`}>
|
||||
{subtask.completed && <span className="text-white text-xs">✓</span>}
|
||||
</span>
|
||||
<span className={`text-sm flex-1 ${subtask.completed ? 'line-through text-text-secondary' : ''}`}>
|
||||
{subtask.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add subtask */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newSubtaskTitle}
|
||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAddSubtask(); }}
|
||||
placeholder="Add a subtask..."
|
||||
className="flex-1 bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddSubtask}
|
||||
disabled={!newSubtaskTitle.trim()}
|
||||
className="px-3 py-2 bg-accent/10 text-accent rounded-lg text-sm font-medium hover:bg-accent/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-border sticky bottom-0 bg-card">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-sm text-red-500 hover:text-red-600 transition-colors"
|
||||
>
|
||||
Delete Task
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowEditTaskModal(false)}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!title.trim()}
|
||||
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/components/FilterPanel.tsx
Normal file
112
src/components/FilterPanel.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useApp } from './AppProvider';
|
||||
|
||||
export default function FilterPanel() {
|
||||
const {
|
||||
filterStatus, setFilterStatus, filterPriority, setFilterPriority,
|
||||
filterDueBefore, setFilterDueBefore, filterDueAfter, setFilterDueAfter,
|
||||
filterCompleted, setFilterCompleted, refreshTasks,
|
||||
} = useApp();
|
||||
|
||||
const handleApply = () => {
|
||||
refreshTasks();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilterStatus('');
|
||||
setFilterPriority('');
|
||||
setFilterDueBefore('');
|
||||
setFilterDueAfter('');
|
||||
setFilterCompleted('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card border-b border-border px-4 py-3 animate-slide-in">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-xs text-text-secondary mb-1">Status</label>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="bg-content border border-border rounded px-2 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="todo">Todo</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="block text-xs text-text-secondary mb-1">Priority</label>
|
||||
<select
|
||||
value={filterPriority}
|
||||
onChange={(e) => setFilterPriority(e.target.value)}
|
||||
className="bg-content border border-border rounded px-2 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="urgent">🔴 Urgent</option>
|
||||
<option value="high">🟠 High</option>
|
||||
<option value="medium">🟡 Medium</option>
|
||||
<option value="low">🟢 Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Due before */}
|
||||
<div>
|
||||
<label className="block text-xs text-text-secondary mb-1">Due before</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filterDueBefore}
|
||||
onChange={(e) => setFilterDueBefore(e.target.value)}
|
||||
className="bg-content border border-border rounded px-2 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Due after */}
|
||||
<div>
|
||||
<label className="block text-xs text-text-secondary mb-1">Due after</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filterDueAfter}
|
||||
onChange={(e) => setFilterDueAfter(e.target.value)}
|
||||
className="bg-content border border-border rounded px-2 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Completed */}
|
||||
<div>
|
||||
<label className="block text-xs text-text-secondary mb-1">Completed</label>
|
||||
<select
|
||||
value={filterCompleted}
|
||||
onChange={(e) => setFilterCompleted(e.target.value)}
|
||||
className="bg-content border border-border rounded px-2 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="true">Completed</option>
|
||||
<option value="false">Not completed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="px-4 py-1.5 bg-accent hover:bg-accent-hover text-white rounded text-sm transition-colors"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/components/Header.tsx
Normal file
111
src/components/Header.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useApp } from './AppProvider';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export default function Header() {
|
||||
const {
|
||||
view, selectedProject, projects, searchQuery, setSearchQuery,
|
||||
showNewTaskModal, setShowNewTaskModal,
|
||||
filterStatus, setFilterStatus, filterPriority, setFilterPriority,
|
||||
filterDueBefore, setFilterDueBefore, filterDueAfter, setFilterDueAfter,
|
||||
filterCompleted, setFilterCompleted,
|
||||
refreshTasks,
|
||||
} = useApp();
|
||||
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
if (e.key === 'n' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setShowNewTaskModal(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [setShowNewTaskModal]);
|
||||
|
||||
const selectedProjectName = projects.find(p => p.id === selectedProject)?.name || 'All Projects';
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
refreshTasks();
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilterStatus('');
|
||||
setFilterPriority('');
|
||||
setFilterDueBefore('');
|
||||
setFilterDueAfter('');
|
||||
setFilterCompleted('');
|
||||
setSearchQuery('');
|
||||
refreshTasks();
|
||||
};
|
||||
|
||||
const hasActiveFilters = filterStatus || filterPriority || filterDueBefore || filterDueAfter || filterCompleted || searchQuery;
|
||||
|
||||
return (
|
||||
<header className="bg-card border-b border-border px-4 py-3 flex items-center gap-4">
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">🔍</span>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search tasks... (⌘K)"
|
||||
className="w-full bg-content border border-border rounded-lg pl-9 pr-4 py-2 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Project name */}
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>📋</span>
|
||||
<span className="font-medium">{selectedProjectName}</span>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
showFilters || hasActiveFilters
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-text-secondary hover:bg-content'
|
||||
}`}
|
||||
>
|
||||
<span>⚙</span>
|
||||
Filters
|
||||
{hasActiveFilters && (
|
||||
<span className="w-2 h-2 bg-accent rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
className="text-xs text-text-secondary hover:text-red-500 transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* New task button (mobile) */}
|
||||
<button
|
||||
onClick={() => setShowNewTaskModal(true)}
|
||||
className="bg-accent hover:bg-accent-hover text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
+ New Task
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
173
src/components/KanbanBoard.tsx
Normal file
173
src/components/KanbanBoard.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { useApp, type Task } from './AppProvider';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { format, isToday, isTomorrow, isPast } from 'date-fns';
|
||||
|
||||
interface KanbanTask extends Task {
|
||||
projectColor?: string;
|
||||
}
|
||||
|
||||
export default function KanbanBoard() {
|
||||
const { tasks, projects, setSelectedTask, setShowEditTaskModal, setEditingTask, refreshTasks } = useApp();
|
||||
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
||||
|
||||
// Add project info
|
||||
const kanbanTasks: KanbanTask[] = tasks.map(task => {
|
||||
const project = projects.find(p => p.id === task.projectId);
|
||||
return { ...task, projectColor: project?.color };
|
||||
});
|
||||
|
||||
const todoTasks = kanbanTasks.filter(t => t.status === 'todo');
|
||||
const inProgressTasks = kanbanTasks.filter(t => t.status === 'in_progress');
|
||||
const doneTasks = kanbanTasks.filter(t => t.status === 'done');
|
||||
|
||||
const columns: { key: string; title: string; tasks: KanbanTask[]; icon: string; color: string }[] = [
|
||||
{ key: 'todo', title: 'To Do', tasks: todoTasks, icon: '📋', color: 'border-gray-300' },
|
||||
{ key: 'in_progress', title: 'In Progress', tasks: inProgressTasks, icon: '🔄', color: 'border-blue-300' },
|
||||
{ key: 'done', title: 'Done', tasks: doneTasks, icon: '✅', color: 'border-green-300' },
|
||||
];
|
||||
|
||||
const handleDragStart = (taskId: string) => {
|
||||
setDraggedTask(taskId);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = async (status: string, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!draggedTask) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/tasks/${draggedTask}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
refreshTasks();
|
||||
// Refresh kanban data
|
||||
const res = await fetch(`/api/kanban`);
|
||||
const data = await res.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to update task status:', error);
|
||||
}
|
||||
setDraggedTask(null);
|
||||
};
|
||||
|
||||
const handleTaskClick = (task: KanbanTask) => {
|
||||
setSelectedTask(task);
|
||||
setEditingTask(task);
|
||||
setShowEditTaskModal(true);
|
||||
};
|
||||
|
||||
const getDueDateInfo = (dueDate: string | null) => {
|
||||
if (!dueDate) return null;
|
||||
// DATE type returns "YYYY-MM-DD" - parse as local date directly
|
||||
const [year, month, day] = dueDate.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (isToday(date)) return { label: 'Today', className: 'text-[var(--priority-urgent)] font-medium' };
|
||||
if (isTomorrow(date)) return { label: 'Tomorrow', className: 'text-[var(--priority-high)]' };
|
||||
if (isPast(date)) return { label: format(date, 'MMM d'), className: 'text-[var(--priority-urgent)] font-medium' };
|
||||
return { label: format(date, 'MMM d'), className: 'text-text-secondary' };
|
||||
};
|
||||
|
||||
const priorityConfig = {
|
||||
urgent: { bg: 'bg-[var(--priority-urgent)]/10', text: 'text-[var(--priority-urgent)]' },
|
||||
high: { bg: 'bg-[var(--priority-high)]/10', text: 'text-[var(--priority-high)]' },
|
||||
medium: { bg: 'bg-[var(--priority-medium)]/10', text: 'text-[var(--priority-medium)]' },
|
||||
low: { bg: 'bg-[var(--priority-low)]/10', text: 'text-[var(--priority-low)]' },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-x-auto overflow-y-hidden p-4">
|
||||
<div className="flex gap-4 h-full min-w-[900px]">
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className="flex-1 flex flex-col bg-content rounded-xl border border-border"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(column.key, e)}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className={`p-3 border-b-2 ${column.color} rounded-t-xl`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{column.icon}</span>
|
||||
<span className="font-semibold text-sm">{column.title}</span>
|
||||
<span className="text-xs text-text-secondary bg-content px-2 py-0.5 rounded-full">
|
||||
{column.tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{column.tasks.map((task) => {
|
||||
const dueInfo = getDueDateInfo(task.dueDate);
|
||||
const prio = priorityConfig[task.priority];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(task.id)}
|
||||
onClick={() => handleTaskClick(task)}
|
||||
className={`p-3 bg-card border border-border rounded-lg cursor-grab hover:shadow-md transition-all ${
|
||||
draggedTask === task.id ? 'opacity-50 scale-95' : ''
|
||||
} ${task.completed ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{/* Project indicator */}
|
||||
{task.projectColor && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: task.projectColor }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<p className={`text-sm font-medium mb-1 ${task.completed ? 'line-through text-text-secondary' : ''}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
|
||||
{/* Description preview */}
|
||||
{task.description && (
|
||||
<p className="text-xs text-text-secondary truncate mb-2">{task.description}</p>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${prio.bg} ${prio.text}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
|
||||
{dueInfo && (
|
||||
<span className={`text-xs ${dueInfo.className}`}>
|
||||
{dueInfo.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{task.recurrenceRule !== 'none' && (
|
||||
<span title="Recurring">🔄</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{column.tasks.length === 0 && (
|
||||
<div className="text-center py-8 text-text-secondary text-sm">
|
||||
Drop tasks here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/components/ListView.tsx
Normal file
69
src/components/ListView.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useApp } from './AppProvider';
|
||||
import TaskCard from './TaskCard';
|
||||
|
||||
export default function ListView() {
|
||||
const { tasks, projects, filterStatus, filterPriority, filterCompleted } = useApp();
|
||||
|
||||
// Add project info to each task
|
||||
const tasksWithProject = tasks.map(task => {
|
||||
const project = projects.find(p => p.id === task.projectId);
|
||||
return { ...task, projectColor: project?.color, projectName: project?.name };
|
||||
});
|
||||
|
||||
// Apply client-side filters for status, priority, completed
|
||||
const filteredTasks = tasksWithProject.filter(task => {
|
||||
if (filterStatus && task.status !== filterStatus) return false;
|
||||
if (filterPriority && task.priority !== filterPriority) return false;
|
||||
if (filterCompleted === 'true' && !task.completed) return false;
|
||||
if (filterCompleted === 'false' && task.completed) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Group by status
|
||||
const todoTasks = filteredTasks.filter(t => t.status === 'todo');
|
||||
const inProgressTasks = filteredTasks.filter(t => t.status === 'in_progress');
|
||||
const doneTasks = filteredTasks.filter(t => t.status === 'done');
|
||||
|
||||
const renderSection = (title: string, tasks: typeof filteredTasks, icon: string) => {
|
||||
if (tasks.length === 0 && filterStatus) return null;
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-text-secondary mb-2 flex items-center gap-2">
|
||||
<span>{icon}</span>
|
||||
{title}
|
||||
<span className="text-xs text-text-secondary font-normal">({tasks.length})</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{tasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
projectColor={task.projectColor}
|
||||
projectName={task.projectName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-secondary">
|
||||
<span className="text-4xl mb-4">📝</span>
|
||||
<p className="text-lg font-medium">No tasks yet</p>
|
||||
<p className="text-sm mt-1">Create your first task to get started!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{renderSection('To Do', todoTasks, '📋')}
|
||||
{renderSection('In Progress', inProgressTasks, '🔄')}
|
||||
{renderSection('Done', doneTasks, '✅')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
src/components/NewTaskModal.tsx
Normal file
234
src/components/NewTaskModal.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { useApp } from './AppProvider';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export default function NewTaskModal() {
|
||||
const {
|
||||
showNewTaskModal, setShowNewTaskModal,
|
||||
projects, selectedProject, refreshTasks,
|
||||
} = useApp();
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [projectId, setProjectId] = useState(selectedProject || '');
|
||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [status, setStatus] = useState<'todo' | 'in_progress'>('todo');
|
||||
const [showRecurrence, setShowRecurrence] = useState(false);
|
||||
const [recurrenceRule, setRecurrenceRule] = useState<'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly'>('none');
|
||||
const [recurrenceInterval, setRecurrenceInterval] = useState(1);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showNewTaskModal && titleInputRef.current) {
|
||||
titleInputRef.current.focus();
|
||||
}
|
||||
}, [showNewTaskModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showNewTaskModal) {
|
||||
setProjectId(selectedProject || '');
|
||||
}
|
||||
}, [showNewTaskModal, selectedProject]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
projectId: projectId || null,
|
||||
priority,
|
||||
dueDate: dueDate || null,
|
||||
status,
|
||||
recurrenceRule,
|
||||
recurrenceInterval,
|
||||
}),
|
||||
});
|
||||
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setPriority('medium');
|
||||
setDueDate('');
|
||||
setStatus('todo');
|
||||
setShowRecurrence(false);
|
||||
setRecurrenceRule('none');
|
||||
setRecurrenceInterval(1);
|
||||
setShowNewTaskModal(false);
|
||||
refreshTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to create task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewTaskModal(false);
|
||||
}
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleCreate();
|
||||
}
|
||||
};
|
||||
|
||||
if (!showNewTaskModal) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 animate-fade-in"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowNewTaskModal(false); }}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="bg-card rounded-xl w-full max-w-md mx-4 shadow-xl animate-slide-in"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-bold">New Task</h2>
|
||||
<button
|
||||
onClick={() => setShowNewTaskModal(false)}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors text-xl"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Title */}
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Task title"
|
||||
className="w-full bg-content border border-border rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
rows={2}
|
||||
className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors resize-none"
|
||||
/>
|
||||
|
||||
{/* Project */}
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">No Project</option>
|
||||
{projects.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Priority & Due Date row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as any)}
|
||||
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="low">🟢 Low</option>
|
||||
<option value="medium">🟡 Medium</option>
|
||||
<option value="high">🟠 High</option>
|
||||
<option value="urgent">🔴 Urgent</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as any)}
|
||||
className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="todo">To Do</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
</select>
|
||||
|
||||
{/* Recurrence toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setShowRecurrence(!showRecurrence)}
|
||||
className={`text-sm flex items-center gap-2 ${showRecurrence ? 'text-accent' : 'text-text-secondary'}`}
|
||||
>
|
||||
🔄 Recurring task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recurrence options */}
|
||||
{showRecurrence && (
|
||||
<div className="space-y-2 animate-slide-in">
|
||||
<select
|
||||
value={recurrenceRule}
|
||||
onChange={(e) => setRecurrenceRule(e.target.value as any)}
|
||||
className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="none">Not recurring</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Bi-weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
{recurrenceRule !== 'none' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-text-secondary">Every</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={recurrenceInterval}
|
||||
onChange={(e) => setRecurrenceInterval(Number(e.target.value))}
|
||||
className="w-20 bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{recurrenceInterval === 1 ? '' : recurrenceInterval + ' '}
|
||||
{recurrenceRule === 'daily' ? 'day' : recurrenceRule === 'weekly' ? 'week' : recurrenceRule === 'biweekly' ? 'weeks' : recurrenceRule === 'monthly' ? 'month' : recurrenceRule === 'yearly' ? 'year' : ''}
|
||||
{recurrenceInterval > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => setShowNewTaskModal(false)}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!title.trim()}
|
||||
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
src/components/Sidebar.tsx
Normal file
221
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useApp } from './AppProvider';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Sidebar() {
|
||||
const {
|
||||
view, setView, selectedProject, setSelectedProject,
|
||||
sidebarOpen, setSidebarOpen, showNewProjectModal, setShowNewProjectModal,
|
||||
showNewTaskModal, setShowNewTaskModal, projects, setProjects, refreshTasks, refreshProjects,
|
||||
} = useApp();
|
||||
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [projectDescription, setProjectDescription] = useState('');
|
||||
const [projectColor, setProjectColor] = useState('#3b82f6');
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
|
||||
const views: { key: typeof view; label: string; icon: string }[] = [
|
||||
{ key: 'list', label: 'List View', icon: '☰' },
|
||||
{ key: 'kanban', label: 'Kanban Board', icon: '▦' },
|
||||
{ key: 'calendar', label: 'Calendar', icon: '📅' },
|
||||
];
|
||||
|
||||
const handleNewProject = async () => {
|
||||
if (!projectName.trim()) return;
|
||||
try {
|
||||
await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: projectName.trim(),
|
||||
description: projectDescription.trim(),
|
||||
color: projectColor,
|
||||
}),
|
||||
});
|
||||
setProjectName('');
|
||||
setProjectDescription('');
|
||||
setProjectColor('#3b82f6');
|
||||
setCreatingProject(false);
|
||||
refreshProjects();
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async (id: string) => {
|
||||
if (!confirm('Delete this project and all its tasks?')) return;
|
||||
try {
|
||||
await fetch(`/api/projects/${id}`, { method: 'DELETE' });
|
||||
if (selectedProject === id) setSelectedProject(null);
|
||||
refreshProjects();
|
||||
refreshTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete project:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full bg-sidebar text-white transition-all duration-300 ${sidebarOpen ? 'w-64' : 'w-16'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
{sidebarOpen && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold">VixTix</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="p-1.5 rounded-lg hover:bg-sidebar-hover transition-colors"
|
||||
>
|
||||
<span className="text-lg">{sidebarOpen ? '◀' : '▶'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New Task Button */}
|
||||
<div className="p-3">
|
||||
<button
|
||||
onClick={() => setShowNewTaskModal(true)}
|
||||
className="w-full flex items-center justify-center gap-2 bg-accent hover:bg-accent-hover text-white py-2.5 px-4 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
<span className="text-lg">+</span>
|
||||
{sidebarOpen && <span>New Task</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Views */}
|
||||
{sidebarOpen && (
|
||||
<div className="px-3 mb-4">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wider mb-2 px-2">Views</p>
|
||||
<div className="space-y-1">
|
||||
{views.map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
onClick={() => setView(v.key)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm ${
|
||||
view === v.key
|
||||
? 'bg-sidebar-active text-white'
|
||||
: 'text-gray-300 hover:bg-sidebar-hover hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{v.icon}</span>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Projects */}
|
||||
<div className="flex-1 overflow-y-auto px-3">
|
||||
{sidebarOpen && (
|
||||
<div className="flex items-center justify-between mb-2 px-2">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wider">Projects</p>
|
||||
<button
|
||||
onClick={() => setCreatingProject(!creatingProject)}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
title="New Project"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New project form */}
|
||||
{creatingProject && sidebarOpen && (
|
||||
<div className="mb-3 p-3 bg-sidebar-hover rounded-lg animate-slide-in">
|
||||
<input
|
||||
type="text"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
className="w-full bg-transparent border border-white/20 rounded px-2 py-1.5 text-sm text-white placeholder-gray-500 mb-2 focus:outline-none focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={projectDescription}
|
||||
onChange={(e) => setProjectDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="w-full bg-transparent border border-white/20 rounded px-2 py-1.5 text-sm text-white placeholder-gray-500 mb-2 focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400">Color:</span>
|
||||
<input
|
||||
type="color"
|
||||
value={projectColor}
|
||||
onChange={(e) => setProjectColor(e.target.value)}
|
||||
className="w-8 h-8 rounded cursor-pointer border-0 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleNewProject}
|
||||
className="flex-1 bg-accent hover:bg-accent-hover text-white py-1.5 rounded text-sm transition-colors"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setCreatingProject(false); setProjectName(''); }}
|
||||
className="flex-1 bg-gray-600 hover:bg-gray-500 text-white py-1.5 rounded text-sm transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project list */}
|
||||
<div className="space-y-0.5">
|
||||
{/* All Projects option */}
|
||||
<button
|
||||
onClick={() => setSelectedProject(null)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm ${
|
||||
!selectedProject
|
||||
? 'bg-sidebar-active text-white'
|
||||
: 'text-gray-300 hover:bg-sidebar-hover hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">📋</span>
|
||||
{sidebarOpen && <span className="truncate">All Projects</span>}
|
||||
</button>
|
||||
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={`group flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm ${
|
||||
selectedProject === project.id
|
||||
? 'bg-sidebar-active text-white'
|
||||
: 'text-gray-300 hover:bg-sidebar-hover hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: project.color }}
|
||||
/>
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<span className="truncate flex-1 text-left">{project.name}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteProject(project.id); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-400 transition-all"
|
||||
title="Delete project"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{sidebarOpen && (
|
||||
<div className="p-3 border-t border-white/10 text-xs text-gray-500 text-center">
|
||||
VixTix v1.0
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
src/components/TaskCard.tsx
Normal file
153
src/components/TaskCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useApp, type Task } from './AppProvider';
|
||||
import { format, isToday, isTomorrow, isPast } from 'date-fns';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
projectColor?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
const priorityConfig = {
|
||||
urgent: { color: 'text-[var(--priority-urgent)]', bg: 'bg-[var(--priority-urgent)]/10', label: 'Urgent', icon: '🔴' },
|
||||
high: { color: 'text-[var(--priority-high)]', bg: 'bg-[var(--priority-high)]/10', label: 'High', icon: '🟠' },
|
||||
medium: { color: 'text-[var(--priority-medium)]', bg: 'bg-[var(--priority-medium)]/10', label: 'Medium', icon: '🟡' },
|
||||
low: { color: 'text-[var(--priority-low)]', bg: 'bg-[var(--priority-low)]/10', label: 'Low', icon: '🟢' },
|
||||
};
|
||||
|
||||
export default function TaskCard({ task, projectColor, projectName }: TaskCardProps) {
|
||||
const { setSelectedTask, setShowEditTaskModal, setEditingTask, refreshTasks } = useApp();
|
||||
|
||||
const handleToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'toggle-done' }),
|
||||
});
|
||||
if (res.ok) {
|
||||
refreshTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm('Delete this task?')) return;
|
||||
try {
|
||||
await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' });
|
||||
refreshTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
setSelectedTask(task);
|
||||
setEditingTask(task);
|
||||
setShowEditTaskModal(true);
|
||||
};
|
||||
|
||||
const priority = priorityConfig[task.priority];
|
||||
// DATE type returns "YYYY-MM-DD" - parse as local date directly
|
||||
let dueDate: Date | null = null;
|
||||
if (task.dueDate) {
|
||||
const [year, month, day] = task.dueDate.split('-').map(Number);
|
||||
dueDate = new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
let dueDateLabel = '';
|
||||
let dueDateClass = '';
|
||||
if (dueDate) {
|
||||
if (isToday(dueDate)) {
|
||||
dueDateLabel = 'Today';
|
||||
dueDateClass = 'text-[var(--priority-urgent)] font-medium';
|
||||
} else if (isTomorrow(dueDate)) {
|
||||
dueDateLabel = 'Tomorrow';
|
||||
dueDateClass = 'text-[var(--priority-high)]';
|
||||
} else if (isPast(dueDate) && !task.completed) {
|
||||
dueDateLabel = format(dueDate, 'MMM d');
|
||||
dueDateClass = 'text-[var(--priority-urgent)] font-medium';
|
||||
} else {
|
||||
dueDateLabel = format(dueDate, 'MMM d');
|
||||
dueDateClass = 'text-text-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
const hasRecurrence = task.recurrenceRule !== 'none';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={`group flex items-center gap-3 p-3 bg-card border border-border rounded-lg hover:shadow-sm transition-all cursor-pointer ${
|
||||
task.completed ? 'opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
task.completed
|
||||
? 'bg-accent border-accent'
|
||||
: 'border-text-secondary hover:border-accent'
|
||||
}`}
|
||||
>
|
||||
{task.completed && <span className="text-white text-xs">✓</span>}
|
||||
</button>
|
||||
|
||||
{/* Project indicator */}
|
||||
{projectColor && (
|
||||
<span
|
||||
className="w-2 h-8 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: projectColor }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium truncate ${task.completed ? 'line-through text-text-secondary' : ''}`}>
|
||||
{task.title}
|
||||
</span>
|
||||
{hasRecurrence && <span title="Recurring">🔄</span>}
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="text-xs text-text-secondary truncate mt-0.5">{task.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priority badge */}
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priority.bg} ${priority.color} flex-shrink-0`}>
|
||||
{priority.icon} {priority.label}
|
||||
</span>
|
||||
|
||||
{/* Due date */}
|
||||
{dueDate && (
|
||||
<span className={`text-xs flex-shrink-0 ${dueDateClass}`}>
|
||||
{dueDateLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
{task.status !== 'done' && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded flex-shrink-0 ${
|
||||
task.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{task.status === 'in_progress' ? 'In Progress' : 'Todo'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all flex-shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user