Add project selector in task detail, archive completed tasks
This commit is contained in:
66
src/components/CompletedSection.tsx
Normal file
66
src/components/CompletedSection.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, ChevronDown, Check } from 'lucide-react';
|
||||
import type { Task } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTasksStore } from '@/stores/tasks';
|
||||
|
||||
interface CompletedSectionProps {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export function CompletedSection({ tasks }: CompletedSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { toggleComplete, setSelectedTask } = useTasksStore();
|
||||
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-gray-100 pt-4">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
Completed ({tasks.length})
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 cursor-pointer group opacity-60"
|
||||
onClick={() => setSelectedTask(task)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleComplete(task.id);
|
||||
}}
|
||||
className="flex-shrink-0 w-5 h-5 rounded-full bg-gray-400 border-2 border-gray-400 flex items-center justify-center"
|
||||
>
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-500 line-through">
|
||||
{task.title}
|
||||
</span>
|
||||
{task.project && (
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<span
|
||||
className="w-2 h-2 rounded"
|
||||
style={{ backgroundColor: task.project.color }}
|
||||
/>
|
||||
{task.project.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export function TaskDetail({ task, onClose }: TaskDetailProps) {
|
||||
const [dueDate, setDueDate] = useState(task.dueDate || '');
|
||||
const [priority, setPriority] = useState<Priority>(task.priority);
|
||||
const [assigneeId, setAssigneeId] = useState(task.assigneeId || '');
|
||||
const [projectId, setProjectId] = useState(task.projectId || '');
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -32,6 +33,7 @@ export function TaskDetail({ task, onClose }: TaskDetailProps) {
|
||||
setDueDate(task.dueDate || '');
|
||||
setPriority(task.priority);
|
||||
setAssigneeId(task.assigneeId || '');
|
||||
setProjectId(task.projectId || '');
|
||||
}, [task]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,6 +74,11 @@ export function TaskDetail({ task, onClose }: TaskDetailProps) {
|
||||
updateTask(task.id, { assigneeId: value || undefined });
|
||||
};
|
||||
|
||||
const handleProjectChange = (value: string) => {
|
||||
setProjectId(value);
|
||||
updateTask(task.id, { projectId: value || undefined });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
@@ -85,6 +92,10 @@ export function TaskDetail({ task, onClose }: TaskDetailProps) {
|
||||
|
||||
const handleComplete = async () => {
|
||||
await toggleComplete(task.id);
|
||||
if (!task.isCompleted) {
|
||||
// Task was just completed — close detail panel after brief delay
|
||||
setTimeout(() => onClose(), 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Close on Escape
|
||||
@@ -176,14 +187,26 @@ export function TaskDetail({ task, onClose }: TaskDetailProps) {
|
||||
<span className="text-sm text-gray-500 w-20">Project</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{taskProject && (
|
||||
<>
|
||||
<span
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: taskProject.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{taskProject.name}</span>
|
||||
</>
|
||||
<span
|
||||
className="w-3 h-3 rounded flex-shrink-0"
|
||||
style={{ backgroundColor: taskProject.color }}
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e) => handleProjectChange(e.target.value)}
|
||||
className={cn(
|
||||
'text-sm border border-gray-200 rounded px-2 py-1 outline-none focus:border-blue-400 bg-white cursor-pointer',
|
||||
projectId ? 'text-gray-900' : 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
<option value="">No project</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,15 +3,17 @@ import { Inbox as InboxIcon } from 'lucide-react';
|
||||
import { useTasksStore } from '@/stores/tasks';
|
||||
import { TaskItem } from '@/components/TaskItem';
|
||||
import { AddTask } from '@/components/AddTask';
|
||||
import { CompletedSection } from '@/components/CompletedSection';
|
||||
|
||||
export function InboxPage() {
|
||||
const { tasks, projects, isLoading, fetchTasks, setSelectedTask } = useTasksStore();
|
||||
const { tasks, completedTasks, projects, isLoading, fetchTasks, fetchCompletedTasks, setSelectedTask } = useTasksStore();
|
||||
|
||||
const inbox = projects.find(p => p.isInbox);
|
||||
|
||||
useEffect(() => {
|
||||
if (inbox) {
|
||||
fetchTasks({ projectId: inbox.id, completed: false });
|
||||
fetchCompletedTasks({ projectId: inbox.id });
|
||||
}
|
||||
}, [inbox?.id]);
|
||||
|
||||
@@ -50,6 +52,9 @@ export function InboxPage() {
|
||||
<div className="mt-4">
|
||||
<AddTask projectId={inbox?.id} />
|
||||
</div>
|
||||
|
||||
{/* Completed tasks */}
|
||||
<CompletedSection tasks={completedTasks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useTasksStore } from '@/stores/tasks';
|
||||
import { TaskItem } from '@/components/TaskItem';
|
||||
import { AddTask } from '@/components/AddTask';
|
||||
import { CompletedSection } from '@/components/CompletedSection';
|
||||
import { BoardView } from '@/pages/Board';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Project as ProjectType, Section } from '@/types';
|
||||
@@ -13,7 +14,7 @@ export function ProjectPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { tasks, isLoading, fetchTasks, setSelectedTask } = useTasksStore();
|
||||
const { tasks, completedTasks, isLoading, fetchTasks, fetchCompletedTasks, setSelectedTask } = useTasksStore();
|
||||
const [project, setProject] = useState<ProjectType | null>(null);
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
|
||||
@@ -22,6 +23,7 @@ export function ProjectPage() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
fetchTasks({ projectId: id, completed: false });
|
||||
fetchCompletedTasks({ projectId: id });
|
||||
api.getProject(id).then((p) => {
|
||||
setProject(p);
|
||||
setSections(p.sections || []);
|
||||
@@ -138,6 +140,9 @@ export function ProjectPage() {
|
||||
<AddTask projectId={id} sectionId={section.id} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Completed tasks */}
|
||||
<CompletedSection tasks={completedTasks} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,14 @@ import { Calendar } from 'lucide-react';
|
||||
import { useTasksStore } from '@/stores/tasks';
|
||||
import { TaskItem } from '@/components/TaskItem';
|
||||
import { AddTask } from '@/components/AddTask';
|
||||
import { CompletedSection } from '@/components/CompletedSection';
|
||||
|
||||
export function TodayPage() {
|
||||
const { tasks, isLoading, fetchTasks, setSelectedTask } = useTasksStore();
|
||||
const { tasks, completedTasks, isLoading, fetchTasks, fetchCompletedTasks, setSelectedTask } = useTasksStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks({ today: true, completed: false });
|
||||
fetchCompletedTasks({ today: true });
|
||||
}, []);
|
||||
|
||||
const today = new Date();
|
||||
@@ -104,6 +106,9 @@ export function TodayPage() {
|
||||
<div className="mt-4">
|
||||
<AddTask />
|
||||
</div>
|
||||
|
||||
{/* Completed tasks */}
|
||||
<CompletedSection tasks={completedTasks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ import { CalendarDays } from 'lucide-react';
|
||||
import { useTasksStore } from '@/stores/tasks';
|
||||
import { TaskItem } from '@/components/TaskItem';
|
||||
import { AddTask } from '@/components/AddTask';
|
||||
import { CompletedSection } from '@/components/CompletedSection';
|
||||
import { format, addDays, startOfDay, isSameDay } from 'date-fns';
|
||||
|
||||
export function UpcomingPage() {
|
||||
const { tasks, isLoading, fetchTasks, setSelectedTask } = useTasksStore();
|
||||
const { tasks, completedTasks, isLoading, fetchTasks, fetchCompletedTasks, setSelectedTask } = useTasksStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks({ upcoming: true, completed: false });
|
||||
fetchCompletedTasks({ upcoming: true });
|
||||
}, []);
|
||||
|
||||
// Group tasks by date
|
||||
@@ -112,6 +114,9 @@ export function UpcomingPage() {
|
||||
<div className="mt-6">
|
||||
<AddTask />
|
||||
</div>
|
||||
|
||||
{/* Completed tasks */}
|
||||
<CompletedSection tasks={completedTasks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('useTasksStore', () => {
|
||||
vi.clearAllMocks();
|
||||
useTasksStore.setState({
|
||||
tasks: [],
|
||||
completedTasks: [],
|
||||
projects: [],
|
||||
labels: [],
|
||||
users: [],
|
||||
@@ -75,6 +76,7 @@ describe('useTasksStore', () => {
|
||||
it('has correct initial state', () => {
|
||||
const state = useTasksStore.getState();
|
||||
expect(state.tasks).toEqual([]);
|
||||
expect(state.completedTasks).toEqual([]);
|
||||
expect(state.projects).toEqual([]);
|
||||
expect(state.labels).toEqual([]);
|
||||
expect(state.users).toEqual([]);
|
||||
@@ -157,14 +159,37 @@ describe('useTasksStore', () => {
|
||||
});
|
||||
|
||||
describe('toggleComplete', () => {
|
||||
it('flips isCompleted', async () => {
|
||||
it('moves task to completedTasks when completing', async () => {
|
||||
const task = makeTask({ id: '1', isCompleted: false });
|
||||
useTasksStore.setState({ tasks: [task] });
|
||||
useTasksStore.setState({ tasks: [task], completedTasks: [] });
|
||||
mockApi.updateTask.mockResolvedValueOnce({ ...task, isCompleted: true, completedAt: '2025-06-15' });
|
||||
|
||||
await useTasksStore.getState().toggleComplete('1');
|
||||
|
||||
expect(useTasksStore.getState().tasks[0].isCompleted).toBe(true);
|
||||
expect(useTasksStore.getState().tasks).toHaveLength(0);
|
||||
expect(useTasksStore.getState().completedTasks[0].isCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it('moves task back to tasks when uncompleting', async () => {
|
||||
const task = makeTask({ id: '1', isCompleted: true, completedAt: '2025-06-15' });
|
||||
useTasksStore.setState({ tasks: [], completedTasks: [task] });
|
||||
mockApi.updateTask.mockResolvedValueOnce({ ...task, isCompleted: false, completedAt: undefined });
|
||||
|
||||
await useTasksStore.getState().toggleComplete('1');
|
||||
|
||||
expect(useTasksStore.getState().completedTasks).toHaveLength(0);
|
||||
expect(useTasksStore.getState().tasks[0].isCompleted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchCompletedTasks', () => {
|
||||
it('populates completedTasks', async () => {
|
||||
const tasks = [makeTask({ id: '1', isCompleted: true })];
|
||||
mockApi.getTasks.mockResolvedValueOnce(tasks);
|
||||
|
||||
await useTasksStore.getState().fetchCompletedTasks();
|
||||
|
||||
expect(useTasksStore.getState().completedTasks).toEqual(tasks);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from '@/lib/api';
|
||||
|
||||
interface TasksState {
|
||||
tasks: Task[];
|
||||
completedTasks: Task[];
|
||||
projects: Project[];
|
||||
labels: Label[];
|
||||
users: User[];
|
||||
@@ -16,6 +17,7 @@ interface TasksState {
|
||||
|
||||
// Actions
|
||||
fetchTasks: (params?: Parameters<typeof api.getTasks>[0]) => Promise<void>;
|
||||
fetchCompletedTasks: (params?: Parameters<typeof api.getTasks>[0]) => Promise<void>;
|
||||
fetchProjects: () => Promise<void>;
|
||||
fetchLabels: () => Promise<void>;
|
||||
fetchUsers: () => Promise<void>;
|
||||
@@ -33,6 +35,7 @@ interface TasksState {
|
||||
|
||||
export const useTasksStore = create<TasksState>((set, get) => ({
|
||||
tasks: [],
|
||||
completedTasks: [],
|
||||
projects: [],
|
||||
labels: [],
|
||||
users: [],
|
||||
@@ -52,6 +55,15 @@ export const useTasksStore = create<TasksState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
fetchCompletedTasks: async (params) => {
|
||||
try {
|
||||
const completedTasks = await api.getTasks({ ...params, completed: true });
|
||||
set({ completedTasks });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch completed tasks:', error);
|
||||
}
|
||||
},
|
||||
|
||||
fetchProjects: async () => {
|
||||
try {
|
||||
const projects = await api.getProjects();
|
||||
@@ -110,15 +122,25 @@ export const useTasksStore = create<TasksState>((set, get) => ({
|
||||
},
|
||||
|
||||
toggleComplete: async (id) => {
|
||||
const task = get().tasks.find((t) => t.id === id);
|
||||
const task = get().tasks.find((t) => t.id === id) || get().completedTasks.find((t) => t.id === id);
|
||||
if (!task) return;
|
||||
|
||||
const updated = await api.updateTask(id, { isCompleted: !task.isCompleted });
|
||||
set((state) => ({
|
||||
tasks: state.tasks.map((t) =>
|
||||
t.id === id ? { ...t, isCompleted: !task.isCompleted, completedAt: updated.completedAt } : t
|
||||
),
|
||||
}));
|
||||
const updatedTask = { ...task, isCompleted: !task.isCompleted, completedAt: updated.completedAt };
|
||||
|
||||
if (updatedTask.isCompleted) {
|
||||
// Move from tasks to completedTasks
|
||||
set((state) => ({
|
||||
tasks: state.tasks.filter((t) => t.id !== id),
|
||||
completedTasks: [updatedTask, ...state.completedTasks],
|
||||
}));
|
||||
} else {
|
||||
// Move from completedTasks to tasks
|
||||
set((state) => ({
|
||||
tasks: [updatedTask, ...state.tasks],
|
||||
completedTasks: state.completedTasks.filter((t) => t.id !== id),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedTask: (task) => set({ selectedTask: task }),
|
||||
|
||||
Reference in New Issue
Block a user