diff --git a/docs/sprint-7.md b/docs/sprint-7.md index ceec6180..bb814060 100644 --- a/docs/sprint-7.md +++ b/docs/sprint-7.md @@ -12,7 +12,7 @@ | ID | Title | Status | Dependencies | Notes | |----|-------|--------|--------------|-------| | US-701 | Archive sidebar | ✅ Done | None | Slide-out viewer with search/filter for archived tasks | -| US-702 | Restore from archive | ⏳ Todo | US-701 | Unarchive tasks back to board | +| US-702 | Restore from archive | ✅ Done | US-701 | Unarchive tasks back to board | | US-703 | Subtasks | ⏳ Todo | None | Nested tasks with parent completion logic | | US-704 | Task dependencies | ⏳ Todo | US-703 | Block tasks until dependencies complete | | US-705 | Multiple task attempts | ⏳ Todo | None | Retry with different agent, preserve history | diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index b032fe54..eab8649c 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,4 +1,45 @@ [ + { + "id": "activity_1769427982787_zlmx7wl1j", + "type": "status_changed", + "taskId": "task_20260126_UBz41o", + "taskTitle": "US-702: Restore from archive", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-26T11:46:22.788Z" + }, + { + "id": "activity_1769427885455_irs3lpfay", + "type": "status_changed", + "taskId": "task_20260126_UBz41o", + "taskTitle": "US-702: Restore from archive", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-26T11:44:45.455Z" + }, + { + "id": "activity_1769427882031_qsa7fjdlb", + "type": "task_created", + "taskId": "task_20260126_UBz41o", + "taskTitle": "US-702: Restore from archive", + "details": { + "type": "code", + "priority": "medium", + "project": "veritas-kanban" + }, + "timestamp": "2026-01-26T11:44:42.031Z" + }, + { + "id": "activity_1769427809793_r57vixvwa", + "type": "task_updated", + "taskId": "task_20260126_wzj1d0", + "taskTitle": "US-701: Archive sidebar", + "timestamp": "2026-01-26T11:43:29.793Z" + }, { "id": "activity_1769427062484_c9s4q6kiq", "type": "task_created", diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 1270474f..116e9455 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -201,6 +201,27 @@ router.post('/:id/archive', async (req, res) => { } }); +// POST /api/tasks/:id/restore - Restore task from archive +router.post('/:id/restore', async (req, res) => { + try { + const task = await taskService.restoreTask(req.params.id); + if (!task) { + return res.status(404).json({ error: 'Archived task not found' }); + } + + // Log activity + await activityService.logActivity('status_changed', task.id, task.title, { + from: 'archived', + status: 'done', + }); + + res.json(task); + } catch (error) { + console.error('Error restoring task:', error); + res.status(500).json({ error: 'Failed to restore task' }); + } +}); + // === Worktree Routes === // POST /api/tasks/:id/worktree - Create worktree diff --git a/server/src/services/task-service.ts b/server/src/services/task-service.ts index 6667d9ed..65841b8c 100644 --- a/server/src/services/task-service.ts +++ b/server/src/services/task-service.ts @@ -228,4 +228,28 @@ export class TaskService { const tasks = await this.listArchivedTasks(); return tasks.find(t => t.id === id) || null; } + + async restoreTask(id: string): Promise { + const task = await this.getArchivedTask(id); + if (!task) return null; + + const filename = this.taskToFilename(task); + const sourcePath = path.join(this.archiveDir, filename); + const destPath = path.join(this.tasksDir, filename); + + // Move back to active and set status to done + await fs.rename(sourcePath, destPath); + + // Update status to done + const restoredTask: Task = { + ...task, + status: 'done', + updated: new Date().toISOString(), + }; + + const content = this.taskToMarkdown(restoredTask); + await fs.writeFile(destPath, content, 'utf-8'); + + return restoredTask; + } } diff --git a/web/src/components/layout/ArchiveSidebar.tsx b/web/src/components/layout/ArchiveSidebar.tsx index 509a5b79..f34b88e5 100644 --- a/web/src/components/layout/ArchiveSidebar.tsx +++ b/web/src/components/layout/ArchiveSidebar.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react'; -import { Archive, RefreshCw, Search, Calendar, FolderOpen } from 'lucide-react'; +import { Archive, RefreshCw, Search, Calendar, FolderOpen, RotateCcw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -17,7 +17,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { useArchivedTasks } from '@/hooks/useTasks'; +import { useArchivedTasks, useRestoreTask } from '@/hooks/useTasks'; import { cn } from '@/lib/utils'; import type { Task, TaskType } from '@veritas-kanban/shared'; @@ -50,11 +50,21 @@ function formatDate(dateString: string): string { }); } -function ArchivedTaskItem({ task, onClick }: { task: Task; onClick?: () => void }) { +function ArchivedTaskItem({ + task, + onClick, + onRestore, + isRestoring, +}: { + task: Task; + onClick?: () => void; + onRestore?: () => void; + isRestoring?: boolean; +}) { return (
void
)} + {onRestore && ( + + )} ); } @@ -101,8 +126,21 @@ export function ArchiveSidebar({ open, onOpenChange, onTaskClick }: ArchiveSideb const [search, setSearch] = useState(''); const [typeFilter, setTypeFilter] = useState('all'); const [projectFilter, setProjectFilter] = useState('all'); + const [restoringId, setRestoringId] = useState(null); const { data: archivedTasks, isLoading, refetch, isRefetching } = useArchivedTasks(); + const restoreTask = useRestoreTask(); + + const handleRestore = async (task: Task) => { + setRestoringId(task.id); + try { + await restoreTask.mutateAsync(task.id); + } catch (error) { + console.error('Failed to restore task:', error); + } finally { + setRestoringId(null); + } + }; // Get unique projects for filter dropdown const projects = useMemo(() => { @@ -224,6 +262,8 @@ export function ArchiveSidebar({ open, onOpenChange, onTaskClick }: ArchiveSideb key={task.id} task={task} onClick={onTaskClick ? () => onTaskClick(task) : undefined} + onRestore={() => handleRestore(task)} + isRestoring={restoringId === task.id} /> ))} diff --git a/web/src/hooks/useTasks.ts b/web/src/hooks/useTasks.ts index a23dd7f5..509cf9e3 100644 --- a/web/src/hooks/useTasks.ts +++ b/web/src/hooks/useTasks.ts @@ -66,6 +66,19 @@ export function useArchiveTask() { mutationFn: (id: string) => api.tasks.archive(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['tasks', 'archived'] }); + }, + }); +} + +export function useRestoreTask() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => api.tasks.restore(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['tasks', 'archived'] }); }, }); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 60aa36fc..90312ebd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -98,6 +98,13 @@ export const api = { }); return handleResponse(response); }, + + restore: async (id: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${id}/restore`, { + method: 'POST', + }); + return handleResponse(response); + }, }, config: {