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:
Victor
2026-05-03 03:16:54 +00:00
commit 6daa8f7f59
43 changed files with 9786 additions and 0 deletions

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
);
}