feat(US-702): Restore from archive

- Added restoreTask() to TaskService
- Added POST /api/tasks/:id/restore endpoint
- Added useRestoreTask() hook
- Added restore button (hover) to ArchiveSidebar items
- Tasks restore to Done column with all data preserved
This commit is contained in:
Brad Groux 2026-01-26 05:46:27 -06:00
parent db802ed56d
commit cf725c5eff
7 changed files with 151 additions and 5 deletions

View file

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

View file

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

View file

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

View file

@ -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<Task | null> {
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;
}
}

View file

@ -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 (
<div
className={cn(
"flex items-start gap-3 py-3 px-2 rounded-md transition-colors",
"flex items-start gap-3 py-3 px-2 rounded-md transition-colors group",
onClick ? "hover:bg-muted/50 cursor-pointer" : ""
)}
onClick={onClick}
@ -93,6 +103,21 @@ function ArchivedTaskItem({ task, onClick }: { task: Task; onClick?: () => void
</div>
)}
</div>
{onRestore && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
onRestore();
}}
disabled={isRestoring}
title="Restore to board"
>
<RotateCcw className={cn("h-4 w-4", isRestoring && "animate-spin")} />
</Button>
)}
</div>
);
}
@ -101,8 +126,21 @@ export function ArchiveSidebar({ open, onOpenChange, onTaskClick }: ArchiveSideb
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [projectFilter, setProjectFilter] = useState<string>('all');
const [restoringId, setRestoringId] = useState<string | null>(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}
/>
))}
</div>

View file

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

View file

@ -98,6 +98,13 @@ export const api = {
});
return handleResponse<void>(response);
},
restore: async (id: string): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${id}/restore`, {
method: 'POST',
});
return handleResponse<Task>(response);
},
},
config: {