feat(v1.4): planning status + verification checklists (#40 #38)

This commit is contained in:
Brad Groux 2026-02-01 02:45:57 -06:00
parent b19a275ad0
commit 583d74b38d
23 changed files with 573 additions and 78 deletions

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -23,6 +23,7 @@ interface TaskMetadataSectionProps {
const statusLabels: Record<TaskStatus, string> = {
todo: 'To Do',
planning: 'Planning',
'in-progress': 'In Progress',
blocked: 'Blocked',
done: 'Done',

View file

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

View file

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

View file

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