mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
parent
b19a275ad0
commit
583d74b38d
23 changed files with 573 additions and 78 deletions
|
|
@ -11,7 +11,10 @@ export function registerTaskCommands(program: Command): void {
|
|||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List tasks')
|
||||
.option('-s, --status <status>', 'Filter by status (todo, in-progress, blocked, done)')
|
||||
.option(
|
||||
'-s, --status <status>',
|
||||
'Filter by status (todo, planning, in-progress, blocked, done)'
|
||||
)
|
||||
.option('-t, --type <type>', 'Filter by type (code, research, content, automation)')
|
||||
.option('-p, --project <project>', 'Filter by project')
|
||||
.option('-v, --verbose', 'Show more details')
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Task } from '../utils/types.js';
|
|||
|
||||
// Tool input schemas
|
||||
const ListTasksSchema = z.object({
|
||||
status: z.enum(['todo', 'in-progress', 'blocked', 'done']).optional(),
|
||||
status: z.enum(['todo', 'planning', 'in-progress', 'blocked', 'done']).optional(),
|
||||
type: z.enum(['code', 'research', 'content', 'automation']).optional(),
|
||||
project: z.string().optional(),
|
||||
});
|
||||
|
|
@ -22,7 +22,7 @@ const UpdateTaskSchema = z.object({
|
|||
id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['todo', 'in-progress', 'blocked', 'done']).optional(),
|
||||
status: z.enum(['todo', 'planning', 'in-progress', 'blocked', 'done']).optional(),
|
||||
type: z.enum(['code', 'research', 'content', 'automation']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
|
|
@ -41,7 +41,7 @@ export const taskTools = [
|
|||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['todo', 'in-progress', 'blocked', 'done'],
|
||||
enum: ['todo', 'planning', 'in-progress', 'blocked', 'done'],
|
||||
description: 'Filter by task status',
|
||||
},
|
||||
type: {
|
||||
|
|
@ -122,7 +122,7 @@ export const taskTools = [
|
|||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['todo', 'in-progress', 'blocked', 'done'],
|
||||
enum: ['todo', 'planning', 'in-progress', 'blocked', 'done'],
|
||||
description: 'New status',
|
||||
},
|
||||
type: {
|
||||
|
|
|
|||
|
|
@ -6,25 +6,27 @@ import express from 'express';
|
|||
import { taskRoutes } from '../routes/tasks.js';
|
||||
import { taskCommentRoutes } from '../routes/task-comments.js';
|
||||
import { taskSubtaskRoutes } from '../routes/task-subtasks.js';
|
||||
import { taskVerificationRoutes } from '../routes/task-verification.js';
|
||||
import { taskTimeRoutes } from '../routes/task-time.js';
|
||||
import { agentStatusRoutes, updateAgentStatus, getAgentStatus } from '../routes/agent-status.js';
|
||||
import { errorHandler } from '../middleware/error-handler.js';
|
||||
|
||||
export function createTestApp() {
|
||||
const app = express();
|
||||
|
||||
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
|
||||
// Mount routes
|
||||
app.use('/api/tasks', taskTimeRoutes);
|
||||
app.use('/api/tasks', taskCommentRoutes);
|
||||
app.use('/api/tasks', taskSubtaskRoutes);
|
||||
app.use('/api/tasks', taskVerificationRoutes);
|
||||
app.use('/api/tasks', taskRoutes);
|
||||
app.use('/api/agent/status', agentStatusRoutes);
|
||||
|
||||
|
||||
// Error handler
|
||||
app.use(errorHandler);
|
||||
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const options: swaggerJsdoc.Options = {
|
|||
type: { type: 'string', example: 'feature' },
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['todo', 'in-progress', 'blocked', 'done'],
|
||||
enum: ['todo', 'planning', 'in-progress', 'blocked', 'done'],
|
||||
example: 'todo',
|
||||
},
|
||||
priority: {
|
||||
|
|
@ -85,7 +85,10 @@ const options: swaggerJsdoc.Options = {
|
|||
properties: {
|
||||
id: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
status: { type: 'string', enum: ['todo', 'in-progress', 'blocked', 'done'] },
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['todo', 'planning', 'in-progress', 'blocked', 'done'],
|
||||
},
|
||||
priority: { type: 'string', enum: ['low', 'medium', 'high'] },
|
||||
type: { type: 'string' },
|
||||
project: { type: 'string' },
|
||||
|
|
@ -118,7 +121,10 @@ const options: swaggerJsdoc.Options = {
|
|||
title: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
description: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
status: { type: 'string', enum: ['todo', 'in-progress', 'blocked', 'done'] },
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['todo', 'planning', 'in-progress', 'blocked', 'done'],
|
||||
},
|
||||
priority: { type: 'string', enum: ['low', 'medium', 'high'] },
|
||||
project: { type: 'string' },
|
||||
sprint: { type: 'string' },
|
||||
|
|
|
|||
117
server/src/routes/task-verification.ts
Normal file
117
server/src/routes/task-verification.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getTaskService } from '../services/task-service.js';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
const taskService = getTaskService();
|
||||
|
||||
// Validation schemas
|
||||
const addVerificationStepSchema = z.object({
|
||||
description: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
const updateVerificationStepSchema = z.object({
|
||||
description: z.string().min(1).max(500).optional(),
|
||||
checked: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/verification - Add a verification step
|
||||
router.post(
|
||||
'/:id/verification',
|
||||
asyncHandler(async (req, res) => {
|
||||
let description: string;
|
||||
try {
|
||||
({ description } = addVerificationStepSchema.parse(req.body));
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new ValidationError('Validation failed', error.errors);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const task = await taskService.getTask(req.params.id as string);
|
||||
if (!task) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
|
||||
const step = {
|
||||
id: `vstep_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
description,
|
||||
checked: false,
|
||||
};
|
||||
|
||||
const verificationSteps = [...(task.verificationSteps || []), step];
|
||||
const updatedTask = await taskService.updateTask(req.params.id as string, {
|
||||
verificationSteps,
|
||||
});
|
||||
|
||||
res.status(201).json(updatedTask);
|
||||
})
|
||||
);
|
||||
|
||||
// PATCH /api/tasks/:id/verification/:stepId - Toggle or update a verification step
|
||||
router.patch(
|
||||
'/:id/verification/:stepId',
|
||||
asyncHandler(async (req, res) => {
|
||||
let updates;
|
||||
try {
|
||||
updates = updateVerificationStepSchema.parse(req.body);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new ValidationError('Validation failed', error.errors);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const task = await taskService.getTask(req.params.id as string);
|
||||
if (!task) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
|
||||
const verificationSteps = task.verificationSteps || [];
|
||||
const stepIndex = verificationSteps.findIndex((s) => s.id === (req.params.stepId as string));
|
||||
if (stepIndex === -1) {
|
||||
throw new NotFoundError('Verification step not found');
|
||||
}
|
||||
|
||||
const existingStep = verificationSteps[stepIndex];
|
||||
const updatedStep = { ...existingStep, ...updates };
|
||||
|
||||
// Set/clear checkedAt timestamp when checked state changes
|
||||
if (updates.checked !== undefined && updates.checked !== existingStep.checked) {
|
||||
updatedStep.checkedAt = updates.checked ? new Date().toISOString() : undefined;
|
||||
}
|
||||
|
||||
verificationSteps[stepIndex] = updatedStep;
|
||||
|
||||
const updatedTask = await taskService.updateTask(req.params.id as string, {
|
||||
verificationSteps,
|
||||
});
|
||||
|
||||
res.json(updatedTask);
|
||||
})
|
||||
);
|
||||
|
||||
// DELETE /api/tasks/:id/verification/:stepId - Remove a verification step
|
||||
router.delete(
|
||||
'/:id/verification/:stepId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const task = await taskService.getTask(req.params.id as string);
|
||||
if (!task) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
|
||||
const verificationSteps = (task.verificationSteps || []).filter(
|
||||
(s) => s.id !== (req.params.stepId as string)
|
||||
);
|
||||
const updatedTask = await taskService.updateTask(req.params.id as string, {
|
||||
verificationSteps,
|
||||
});
|
||||
|
||||
res.json(updatedTask);
|
||||
})
|
||||
);
|
||||
|
||||
export { router as taskVerificationRoutes };
|
||||
|
|
@ -109,7 +109,7 @@ const updateTaskSchema = z.object({
|
|||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
status: z.enum(['todo', 'in-progress', 'blocked', 'done']).optional(),
|
||||
status: z.enum(['todo', 'planning', 'in-progress', 'blocked', 'done']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
sprint: z.string().optional(),
|
||||
|
|
@ -123,6 +123,7 @@ const updateTaskSchema = z.object({
|
|||
autoCompleteOnSubtasks: z.boolean().optional(),
|
||||
blockedBy: z.array(z.string()).optional(),
|
||||
blockedReason: blockedReasonSchema,
|
||||
plan: z.string().optional(),
|
||||
automation: automationSchema,
|
||||
position: z.number().optional(),
|
||||
});
|
||||
|
|
@ -272,6 +273,7 @@ router.get(
|
|||
created: task.created,
|
||||
updated: task.updated,
|
||||
subtasks: task.subtasks,
|
||||
verificationSteps: task.verificationSteps,
|
||||
blockedBy: task.blockedBy,
|
||||
blockedReason: task.blockedReason,
|
||||
position: task.position,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { taskTimeRoutes } from '../task-time.js';
|
|||
import { taskRoutes } from '../tasks.js';
|
||||
import { taskCommentRoutes } from '../task-comments.js';
|
||||
import { taskSubtaskRoutes } from '../task-subtasks.js';
|
||||
import { taskVerificationRoutes } from '../task-verification.js';
|
||||
import attachmentRoutes from '../attachments.js';
|
||||
|
||||
// Feature routes
|
||||
|
|
@ -70,6 +71,7 @@ v1Router.use('/tasks', taskTimeRoutes);
|
|||
v1Router.use('/tasks', taskRoutes);
|
||||
v1Router.use('/tasks', taskCommentRoutes);
|
||||
v1Router.use('/tasks', taskSubtaskRoutes);
|
||||
v1Router.use('/tasks', taskVerificationRoutes);
|
||||
|
||||
// Attachment routes get the stricter upload rate limit (20 req/min)
|
||||
// applied BEFORE the route handler for upload (POST) requests.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export async function computeTaskMetrics(
|
|||
// Count by status
|
||||
const byStatus: Record<TaskStatus, number> = {
|
||||
todo: 0,
|
||||
planning: 0,
|
||||
'in-progress': 0,
|
||||
blocked: 0,
|
||||
done: 0,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Task Types
|
||||
|
||||
export type TaskType = string;
|
||||
export type TaskStatus = 'todo' | 'in-progress' | 'blocked' | 'done';
|
||||
export type TaskStatus = 'todo' | 'planning' | 'in-progress' | 'blocked' | 'done';
|
||||
export type TaskPriority = 'low' | 'medium' | 'high';
|
||||
/** Built-in agent types. Custom agents use any string slug. */
|
||||
export type BuiltInAgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini' | 'veritas';
|
||||
|
|
@ -38,6 +38,13 @@ export interface Subtask {
|
|||
created: string;
|
||||
}
|
||||
|
||||
export interface VerificationStep {
|
||||
id: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
checkedAt?: string; // ISO timestamp when checked
|
||||
}
|
||||
|
||||
export interface TimeEntry {
|
||||
id: string;
|
||||
startTime: string;
|
||||
|
|
@ -156,6 +163,9 @@ export interface Task {
|
|||
subtasks?: Subtask[];
|
||||
autoCompleteOnSubtasks?: boolean; // Auto-complete parent when all subtasks done
|
||||
|
||||
// Verification checklist (done criteria)
|
||||
verificationSteps?: VerificationStep[];
|
||||
|
||||
// Dependencies
|
||||
blockedBy?: string[]; // Array of task IDs that block this task
|
||||
|
||||
|
|
@ -170,6 +180,9 @@ export interface Task {
|
|||
result?: string; // Result summary from sub-agent
|
||||
};
|
||||
|
||||
// Planning phase
|
||||
plan?: string; // Markdown content for the execution plan
|
||||
|
||||
// Time tracking
|
||||
timeTracking?: TimeTracking;
|
||||
|
||||
|
|
@ -229,8 +242,10 @@ export interface UpdateTaskInput {
|
|||
review?: ReviewState;
|
||||
subtasks?: Subtask[];
|
||||
autoCompleteOnSubtasks?: boolean;
|
||||
verificationSteps?: VerificationStep[];
|
||||
blockedBy?: string[];
|
||||
blockedReason?: BlockedReason | null; // null to clear
|
||||
plan?: string;
|
||||
automation?: {
|
||||
sessionKey?: string;
|
||||
spawnedAt?: string;
|
||||
|
|
@ -266,6 +281,7 @@ export interface TaskSummary {
|
|||
created: string;
|
||||
updated: string;
|
||||
subtasks?: Subtask[];
|
||||
verificationSteps?: VerificationStep[];
|
||||
blockedBy?: string[];
|
||||
blockedReason?: BlockedReason;
|
||||
position?: number;
|
||||
|
|
|
|||
|
|
@ -9,19 +9,20 @@ export const PRIORITY_LABELS: Record<string, string> = {
|
|||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
critical: 'Critical'
|
||||
critical: 'Critical',
|
||||
};
|
||||
|
||||
/**
|
||||
* Task status labels
|
||||
*/
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
'todo': 'To Do',
|
||||
todo: 'To Do',
|
||||
planning: 'Planning',
|
||||
'in-progress': 'In Progress',
|
||||
'review': 'Review',
|
||||
'done': 'Done',
|
||||
'blocked': 'Blocked',
|
||||
'cancelled': 'Cancelled'
|
||||
review: 'Review',
|
||||
done: 'Done',
|
||||
blocked: 'Blocked',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -33,5 +34,5 @@ export const TYPE_LABELS: Record<string, string> = {
|
|||
refactor: 'Refactor',
|
||||
docs: 'Documentation',
|
||||
test: 'Test',
|
||||
chore: 'Chore'
|
||||
chore: 'Chore',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface BoardLoadingSkeletonProps {
|
|||
|
||||
export function BoardLoadingSkeleton({ columns }: BoardLoadingSkeletonProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.id}
|
||||
|
|
@ -24,10 +24,7 @@ export function BoardLoadingSkeleton({ columns }: BoardLoadingSkeletonProps) {
|
|||
</div>
|
||||
<div className="flex-1 p-2 space-y-2 min-h-[calc(100vh-200px)]">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-card border border-border rounded-md p-3 space-y-2"
|
||||
>
|
||||
<div key={i} className="bg-card border border-border rounded-md p-3 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Skeleton className="h-4 w-4 mt-0.5" />
|
||||
<div className="flex-1 space-y-1">
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ const STATUS_BUTTONS: { id: TaskStatus; label: string; color: string; activeColo
|
|||
color: 'border-slate-400 text-slate-600',
|
||||
activeColor: 'bg-slate-500 text-white border-slate-500',
|
||||
},
|
||||
{
|
||||
id: 'planning',
|
||||
label: 'Planning',
|
||||
color: 'border-violet-400 text-violet-600',
|
||||
activeColor: 'bg-violet-500 text-white border-violet-500',
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
label: 'In Progress',
|
||||
|
|
@ -63,6 +69,7 @@ export function BulkActionsBar({ tasks }: BulkActionsBarProps) {
|
|||
const taskIdsByStatus = useMemo(() => {
|
||||
const map: Record<TaskStatus, string[]> = {
|
||||
todo: [],
|
||||
planning: [],
|
||||
'in-progress': [],
|
||||
blocked: [],
|
||||
done: [],
|
||||
|
|
@ -217,6 +224,7 @@ export function BulkActionsBar({ tasks }: BulkActionsBarProps) {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="planning">Planning</SelectItem>
|
||||
<SelectItem value="in-progress">In Progress</SelectItem>
|
||||
<SelectItem value="review">Review</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const DashboardSection = lazy(() =>
|
|||
|
||||
const COLUMNS: { id: TaskStatus; title: string }[] = [
|
||||
{ id: 'todo', title: 'To Do' },
|
||||
{ id: 'planning', title: 'Planning' },
|
||||
{ id: 'in-progress', title: 'In Progress' },
|
||||
{ id: 'blocked', title: 'Blocked' },
|
||||
{ id: 'done', title: 'Done' },
|
||||
|
|
@ -196,7 +197,7 @@ export function KanbanBoard() {
|
|||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-4" role="group" aria-label="Kanban columns">
|
||||
<div className="grid grid-cols-5 gap-4" role="group" aria-label="Kanban columns">
|
||||
{COLUMNS.map((column) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
|
|
@ -216,7 +217,7 @@ export function KanbanBoard() {
|
|||
</DragOverlay>
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-4" role="group" aria-label="Kanban columns">
|
||||
<div className="grid grid-cols-5 gap-4" role="group" aria-label="Kanban columns">
|
||||
{COLUMNS.map((column) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ interface KanbanColumnProps {
|
|||
|
||||
const columnColors: Record<TaskStatus, string> = {
|
||||
todo: 'border-t-slate-500',
|
||||
planning: 'border-t-violet-500',
|
||||
'in-progress': 'border-t-blue-500',
|
||||
blocked: 'border-t-red-500',
|
||||
done: 'border-t-green-500',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useTasks } from '@/hooks/useTasks';
|
|||
import { useProjects } from '@/hooks/useProjects';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CheckCircle, Play, Ban, ListTodo } from 'lucide-react';
|
||||
import { CheckCircle, Play, Ban, ListTodo, FileEdit } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TaskStatus, Task } from '@veritas-kanban/shared';
|
||||
|
||||
|
|
@ -26,6 +26,11 @@ const statusConfig: Record<
|
|||
color: 'text-muted-foreground',
|
||||
label: 'To Do',
|
||||
},
|
||||
planning: {
|
||||
icon: <FileEdit className="h-4 w-4" />,
|
||||
color: 'text-violet-500',
|
||||
label: 'Planning',
|
||||
},
|
||||
'in-progress': {
|
||||
icon: <Play className="h-4 w-4" />,
|
||||
color: 'text-blue-500',
|
||||
|
|
@ -76,6 +81,7 @@ export function TasksDrillDown({
|
|||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<TaskStatus, number> = {
|
||||
todo: 0,
|
||||
planning: 0,
|
||||
'in-progress': 0,
|
||||
blocked: 0,
|
||||
done: 0,
|
||||
|
|
|
|||
|
|
@ -22,9 +22,10 @@ const shortcuts: { category: string; items: Shortcut[] }[] = [
|
|||
{ keys: ['c'], description: 'Create new task' },
|
||||
{ keys: ['⌘⇧C'], description: 'Open agent chat' },
|
||||
{ keys: ['1'], description: 'Move to To Do' },
|
||||
{ keys: ['2'], description: 'Move to In Progress' },
|
||||
{ keys: ['3'], description: 'Move to Review' },
|
||||
{ keys: ['4'], description: 'Move to Done' },
|
||||
{ keys: ['2'], description: 'Move to Planning' },
|
||||
{ keys: ['3'], description: 'Move to In Progress' },
|
||||
{ keys: ['4'], description: 'Move to Blocked' },
|
||||
{ keys: ['5'], description: 'Move to Done' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Loader2,
|
||||
Paperclip,
|
||||
ListChecks,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Wrench,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
Play,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
FileEdit,
|
||||
} from 'lucide-react';
|
||||
import { useBulkActions } from '@/hooks/useBulkActions';
|
||||
import { formatDuration } from '@/hooks/useTimeTracking';
|
||||
|
|
@ -103,6 +105,15 @@ function areTaskCardPropsEqual(prev: TaskCardProps, next: TaskCardProps): boolea
|
|||
for (let i = 0; i < pSubs.length; i++) {
|
||||
if (pSubs[i].completed !== nSubs[i].completed) return false;
|
||||
}
|
||||
// Plan field — presence matters for the indicator
|
||||
if ((pt.plan || '') !== (nt.plan || '')) return false;
|
||||
// Verification steps — compare count and checked state
|
||||
const pVSteps = pt.verificationSteps || [];
|
||||
const nVSteps = nt.verificationSteps || [];
|
||||
if (pVSteps.length !== nVSteps.length) return false;
|
||||
for (let i = 0; i < pVSteps.length; i++) {
|
||||
if (pVSteps[i].checked !== nVSteps[i].checked) return false;
|
||||
}
|
||||
// Attachments — only count matters for the badge
|
||||
if ((pt.attachments?.length || 0) !== (nt.attachments?.length || 0)) return false;
|
||||
}
|
||||
|
|
@ -226,6 +237,18 @@ export const TaskCard = memo(function TaskCard({
|
|||
};
|
||||
}, [task.subtasks]);
|
||||
|
||||
// Memoize verification progress
|
||||
const { verificationTotal, verificationChecked, allVerificationDone } = useMemo(() => {
|
||||
const steps = task.verificationSteps || [];
|
||||
const total = steps.length;
|
||||
const checked = steps.filter((s) => s.checked).length;
|
||||
return {
|
||||
verificationTotal: total,
|
||||
verificationChecked: checked,
|
||||
allVerificationDone: total > 0 && checked === total,
|
||||
};
|
||||
}, [task.verificationSteps]);
|
||||
|
||||
// Suppress the outer card tooltip entirely during any drag operation
|
||||
const suppressCardTooltip = isDragActive || isDragging || isCurrentlyDragging;
|
||||
|
||||
|
|
@ -383,6 +406,19 @@ export const TaskCard = memo(function TaskCard({
|
|||
{task.attachments.length}
|
||||
</span>
|
||||
)}
|
||||
{/* Plan indicator */}
|
||||
{task.plan && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-violet-500/20 text-violet-400 flex items-center gap-1">
|
||||
<FileEdit className="h-3 w-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">Has execution plan</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Right side: subtask count + time tracking */}
|
||||
{subtaskTotal > 0 && (
|
||||
<Tooltip>
|
||||
|
|
@ -407,12 +443,37 @@ export const TaskCard = memo(function TaskCard({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Verification progress indicator */}
|
||||
{verificationTotal > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded flex items-center gap-1',
|
||||
!subtaskTotal && 'ml-auto',
|
||||
allVerificationDone
|
||||
? 'bg-green-500/20 text-green-500'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
{verificationChecked}/{verificationTotal}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">Done Criteria</p>
|
||||
<p className="text-sm">
|
||||
{verificationChecked} of {verificationTotal} verified
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Time tracking indicator */}
|
||||
{(task.timeTracking?.totalSeconds || task.timeTracking?.isRunning) && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded flex items-center gap-1',
|
||||
!subtaskTotal && !cardMetrics && 'ml-auto',
|
||||
!subtaskTotal && !verificationTotal && !cardMetrics && 'ml-auto',
|
||||
task.timeTracking?.isRunning
|
||||
? 'bg-green-500/20 text-green-500'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
|
|
|
|||
164
web/src/components/task/VerificationSection.tsx
Normal file
164
web/src/components/task/VerificationSection.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { useState } from 'react';
|
||||
import { Plus, Trash2, ShieldCheck, Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
useAddVerificationStep,
|
||||
useUpdateVerificationStep,
|
||||
useDeleteVerificationStep,
|
||||
} from '@/hooks/useTasks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Task, VerificationStep } from '@veritas-kanban/shared';
|
||||
|
||||
interface VerificationSectionProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
export function VerificationSection({ task }: VerificationSectionProps) {
|
||||
const [newDescription, setNewDescription] = useState('');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const addStep = useAddVerificationStep();
|
||||
const updateStep = useUpdateVerificationStep();
|
||||
const deleteStep = useDeleteVerificationStep();
|
||||
|
||||
const steps = task.verificationSteps || [];
|
||||
const checkedCount = steps.filter((s) => s.checked).length;
|
||||
const totalCount = steps.length;
|
||||
const progress = totalCount > 0 ? (checkedCount / totalCount) * 100 : 0;
|
||||
|
||||
const handleAddStep = async () => {
|
||||
if (!newDescription.trim()) return;
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
await addStep.mutateAsync({ taskId: task.id, description: newDescription.trim() });
|
||||
setNewDescription('');
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStep = async (step: VerificationStep) => {
|
||||
await updateStep.mutateAsync({
|
||||
taskId: task.id,
|
||||
stepId: step.id,
|
||||
updates: { checked: !step.checked },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteStep = async (stepId: string) => {
|
||||
await deleteStep.mutateAsync({ taskId: task.id, stepId });
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleAddStep();
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (iso: string) => {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="text-muted-foreground">Done Criteria</Label>
|
||||
</div>
|
||||
{totalCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{checkedCount}/{totalCount} verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{totalCount > 0 && (
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-300',
|
||||
checkedCount === totalCount ? 'bg-green-500' : 'bg-primary'
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verification step list */}
|
||||
<div className="space-y-1">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
'flex items-start gap-2 p-2 rounded-md group hover:bg-muted/50 transition-colors',
|
||||
step.checked && 'opacity-70'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={step.checked}
|
||||
onCheckedChange={() => handleToggleStep(step)}
|
||||
className={cn(
|
||||
'flex-shrink-0 mt-0.5',
|
||||
step.checked &&
|
||||
'data-[state=checked]:bg-green-600 data-[state=checked]:border-green-600'
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={cn('text-sm', step.checked && 'line-through text-muted-foreground')}>
|
||||
{step.description}
|
||||
</span>
|
||||
{step.checked && step.checkedAt && (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
<span className="text-xs text-green-500/80">
|
||||
{formatTimestamp(step.checkedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
onClick={() => handleDeleteStep(step.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add verification step input */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add verification step..."
|
||||
className="text-sm"
|
||||
disabled={isAdding}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleAddStep}
|
||||
disabled={!newDescription.trim() || isAdding}
|
||||
className="h-9 w-9 shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '@/components/ui/alert-dialog';
|
||||
import { TaskMetadataSection } from './TaskMetadataSection';
|
||||
import { SubtasksSection } from '../SubtasksSection';
|
||||
import { VerificationSection } from '../VerificationSection';
|
||||
import { DependenciesSection } from '../DependenciesSection';
|
||||
import { TimeTrackingSection } from '../TimeTrackingSection';
|
||||
import { CommentsSection } from '../CommentsSection';
|
||||
|
|
@ -32,10 +33,10 @@ interface TaskDetailsTabProps {
|
|||
onRestore?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function TaskDetailsTab({
|
||||
task,
|
||||
onUpdate,
|
||||
onClose,
|
||||
export function TaskDetailsTab({
|
||||
task,
|
||||
onUpdate,
|
||||
onClose,
|
||||
readOnly = false,
|
||||
onRestore,
|
||||
}: TaskDetailsTabProps) {
|
||||
|
|
@ -78,19 +79,39 @@ export function TaskDetailsTab({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution Plan (visible when status is planning or plan has content) */}
|
||||
{(task.status === 'planning' || task.plan) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground flex items-center gap-1.5">
|
||||
📋 Execution Plan
|
||||
</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm whitespace-pre-wrap text-foreground/80 bg-violet-500/5 border border-violet-500/20 rounded-md p-3 min-h-[60px]">
|
||||
{sanitizeText(task.plan || '') || 'No plan defined'}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={task.plan || ''}
|
||||
onChange={(e) => onUpdate('plan', e.target.value)}
|
||||
placeholder="Define the approach, steps, and considerations..."
|
||||
rows={6}
|
||||
className="resize-none bg-violet-500/5 border-violet-500/20 focus:border-violet-500/40"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata Section */}
|
||||
<TaskMetadataSection
|
||||
task={task}
|
||||
onUpdate={onUpdate}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<TaskMetadataSection task={task} onUpdate={onUpdate} readOnly={readOnly} />
|
||||
|
||||
{/* Blocked Reason (shown when status is blocked) */}
|
||||
{task.status === 'blocked' && (
|
||||
<div className="border-t pt-4">
|
||||
<BlockedReasonSection
|
||||
task={task}
|
||||
onUpdate={(blockedReason: BlockedReason | undefined) => onUpdate('blockedReason', blockedReason)}
|
||||
onUpdate={(blockedReason: BlockedReason | undefined) =>
|
||||
onUpdate('blockedReason', blockedReason)
|
||||
}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -104,6 +125,11 @@ export function TaskDetailsTab({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Verification / Done Criteria */}
|
||||
<div className="border-t pt-4">
|
||||
<VerificationSection task={task} />
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
{taskSettings.enableDependencies && (
|
||||
<div className="border-t pt-4">
|
||||
|
|
@ -144,40 +170,38 @@ export function TaskDetailsTab({
|
|||
{/* Delete/Restore Button */}
|
||||
<div className="border-t pt-4">
|
||||
{readOnly && onRestore ? (
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={() => onRestore(task.id)}
|
||||
>
|
||||
<Button variant="default" className="w-full" onClick={() => onRestore(task.id)}>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Restore to Board
|
||||
</Button>
|
||||
) : !readOnly && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Task
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this task?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{task.title}".
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
!readOnly && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Task
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this task?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{task.title}".
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ interface TaskMetadataSectionProps {
|
|||
|
||||
const statusLabels: Record<TaskStatus, string> = {
|
||||
todo: 'To Do',
|
||||
planning: 'Planning',
|
||||
'in-progress': 'In Progress',
|
||||
blocked: 'Blocked',
|
||||
done: 'Done',
|
||||
|
|
|
|||
|
|
@ -40,9 +40,10 @@ const KeyboardContext = createContext<KeyboardContextValue | null>(null);
|
|||
|
||||
const STATUS_MAP: Record<string, TaskStatus> = {
|
||||
'1': 'todo',
|
||||
'2': 'in-progress',
|
||||
'3': 'blocked',
|
||||
'4': 'done',
|
||||
'2': 'planning',
|
||||
'3': 'in-progress',
|
||||
'4': 'blocked',
|
||||
'5': 'done',
|
||||
};
|
||||
|
||||
export function KeyboardProvider({ children }: { children: ReactNode }) {
|
||||
|
|
@ -90,7 +91,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
// Get flat list of tasks sorted by column then position
|
||||
const getTaskList = useCallback(() => {
|
||||
const statusOrder: TaskStatus[] = ['todo', 'in-progress', 'blocked', 'done'];
|
||||
const statusOrder: TaskStatus[] = ['todo', 'planning', 'in-progress', 'blocked', 'done'];
|
||||
return [...tasks].sort((a, b) => {
|
||||
const aIndex = statusOrder.indexOf(a.status);
|
||||
const bIndex = statusOrder.indexOf(b.status);
|
||||
|
|
|
|||
|
|
@ -217,6 +217,52 @@ export function useDeleteSubtask() {
|
|||
});
|
||||
}
|
||||
|
||||
export function useAddVerificationStep() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ taskId, description }: { taskId: string; description: string }) =>
|
||||
api.tasks.addVerificationStep(taskId, description),
|
||||
onSuccess: (task) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.setQueryData(['tasks', task.id], task);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateVerificationStep() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
stepId,
|
||||
updates,
|
||||
}: {
|
||||
taskId: string;
|
||||
stepId: string;
|
||||
updates: { description?: string; checked?: boolean };
|
||||
}) => api.tasks.updateVerificationStep(taskId, stepId, updates),
|
||||
onSuccess: (task) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.setQueryData(['tasks', task.id], task);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteVerificationStep() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ taskId, stepId }: { taskId: string; stepId: string }) =>
|
||||
api.tasks.deleteVerificationStep(taskId, stepId),
|
||||
onSuccess: (task) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.setQueryData(['tasks', task.id], task);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddComment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -288,6 +334,7 @@ export function useTasksByStatus(tasks: Task[] | undefined) {
|
|||
if (!tasks) {
|
||||
return {
|
||||
todo: [],
|
||||
planning: [],
|
||||
'in-progress': [],
|
||||
blocked: [],
|
||||
done: [],
|
||||
|
|
@ -296,6 +343,7 @@ export function useTasksByStatus(tasks: Task[] | undefined) {
|
|||
|
||||
return {
|
||||
todo: sortByPosition(tasks.filter((t) => t.status === 'todo')),
|
||||
planning: sortByPosition(tasks.filter((t) => t.status === 'planning')),
|
||||
'in-progress': sortByPosition(tasks.filter((t) => t.status === 'in-progress')),
|
||||
blocked: sortByPosition(tasks.filter((t) => t.status === 'blocked')),
|
||||
done: sortByPosition(tasks.filter((t) => t.status === 'done')),
|
||||
|
|
|
|||
|
|
@ -119,6 +119,38 @@ export const tasksApi = {
|
|||
return handleResponse<Task>(response);
|
||||
},
|
||||
|
||||
addVerificationStep: async (taskId: string, description: string): Promise<Task> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/verification`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ description }),
|
||||
});
|
||||
return handleResponse<Task>(response);
|
||||
},
|
||||
|
||||
updateVerificationStep: async (
|
||||
taskId: string,
|
||||
stepId: string,
|
||||
updates: { description?: string; checked?: boolean }
|
||||
): Promise<Task> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/verification/${stepId}`, {
|
||||
credentials: 'include',
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
return handleResponse<Task>(response);
|
||||
},
|
||||
|
||||
deleteVerificationStep: async (taskId: string, stepId: string): Promise<Task> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/verification/${stepId}`, {
|
||||
credentials: 'include',
|
||||
method: 'DELETE',
|
||||
});
|
||||
return handleResponse<Task>(response);
|
||||
},
|
||||
|
||||
addComment: async (taskId: string, author: string, text: string): Promise<Task> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/comments`, {
|
||||
credentials: 'include',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue