Add project selector in task detail, archive completed tasks

This commit is contained in:
2026-01-28 18:41:11 +00:00
parent b87cb69ae0
commit 4b753cb57a
9 changed files with 177 additions and 21 deletions

2
dist/index.html vendored
View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Todo App - Task management made simple" />
<title>Todo App</title>
<script type="module" crossorigin src="/assets/index-vdiwxbr4.js"></script>
<script type="module" crossorigin src="/assets/index-Ce_4Zv7a.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cuyvk5mt.css">
</head>
<body>

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

View File

@@ -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>

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -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 }),