From f87fe0f8af82eaf3e90d8b15bc0c14fc45282fba Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Tue, 27 Jan 2026 20:41:20 -0600 Subject: [PATCH] feat(US-907): Apply template to existing task --- .../telemetry/events-2026-01-28.ndjson | 3 + server/.veritas-kanban/activity.json | 267 +++++----- server/src/routes/tasks.ts | 30 ++ server/src/services/activity-service.ts | 3 +- .../components/task/ApplyTemplateDialog.tsx | 462 ++++++++++++++++++ web/src/components/task/TaskDetailPanel.tsx | 22 +- web/src/lib/api.ts | 9 + 7 files changed, 644 insertions(+), 152 deletions(-) create mode 100644 web/src/components/task/ApplyTemplateDialog.tsx diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index beac6263..85297fe2 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -19,3 +19,6 @@ {"type":"task.status_changed","taskId":"task_20260128_9Jlp1m","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_lsPTUFTmn8gW","timestamp":"2026-01-28T02:30:42.734Z"} {"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_SZlouG1WUBpJ","timestamp":"2026-01-28T02:32:53.102Z"} {"type":"task.status_changed","taskId":"task_20260128_dKzctW","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_v40ijOFUT32J","timestamp":"2026-01-28T02:32:53.111Z"} +{"type":"task.status_changed","taskId":"task_20260128_AaRWcX","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_I6-ezwZLEq7a","timestamp":"2026-01-28T02:34:13.758Z"} +{"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"todo","previousStatus":"done","id":"evt_-KkUZ32cY-cL","timestamp":"2026-01-28T02:34:56.532Z"} +{"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_Xad2QSppps2V","timestamp":"2026-01-28T02:36:31.643Z"} diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index 52b2a9ff..44e3966a 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,4 +1,121 @@ [ + { + "id": "activity_1769568075614_5u6dejgb9", + "type": "task_updated", + "taskId": "task_20260128_qcy3FJ", + "taskTitle": "US-907: Apply template to existing task", + "timestamp": "2026-01-28T02:41:15.614Z" + }, + { + "id": "activity_1769568028093_94k9mk402", + "type": "task_updated", + "taskId": "task_20260128_qcy3FJ", + "taskTitle": "US-907: Apply template to existing task", + "timestamp": "2026-01-28T02:40:28.093Z" + }, + { + "id": "activity_1769567987042_i1odnhqgj", + "type": "task_updated", + "taskId": "task_20260128_qcy3FJ", + "taskTitle": "US-907: Apply template to existing task", + "timestamp": "2026-01-28T02:39:47.042Z" + }, + { + "id": "activity_1769567916278_x73ysfm8g", + "type": "task_updated", + "taskId": "task_20260128_qcy3FJ", + "taskTitle": "US-907: Apply template to existing task", + "timestamp": "2026-01-28T02:38:36.278Z" + }, + { + "id": "activity_1769567791644_0ze9p6hxo", + "type": "status_changed", + "taskId": "task_20260128_qcy3FJ", + "taskTitle": "US-907: Apply template to existing task", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T02:36:31.644Z" + }, + { + "id": "activity_1769567696533_qfmr8x62s", + "type": "status_changed", + "taskId": "task_20260128_qcy3FJ", + "taskTitle": "US-907: Apply template to existing task", + "details": { + "from": "done", + "status": "todo" + }, + "timestamp": "2026-01-28T02:34:56.533Z" + }, + { + "id": "activity_1769567681146_fxg33ratb", + "type": "task_updated", + "taskId": "task_20260128_AaRWcX", + "taskTitle": "US-909: Enhanced template UI", + "timestamp": "2026-01-28T02:34:41.146Z" + }, + { + "id": "activity_1769567681136_tobnbsg2u", + "type": "task_updated", + "taskId": "task_20260128_dKzctW", + "taskTitle": "US-908: Template import/export", + "timestamp": "2026-01-28T02:34:41.136Z" + }, + { + "id": "activity_1769567681127_rt6kve88l", + "type": "task_updated", + "taskId": "task_20260128_9Jlp1m", + "taskTitle": "US-906: Agent & dependency defaults", + "timestamp": "2026-01-28T02:34:41.127Z" + }, + { + "id": "activity_1769567681110_p30y0xvku", + "type": "task_updated", + "taskId": "task_20260128_8ro0pA", + "taskTitle": "US-905: Multi-task blueprints", + "timestamp": "2026-01-28T02:34:41.110Z" + }, + { + "id": "activity_1769567681102_e8wwvvm0q", + "type": "task_updated", + "taskId": "task_20260128_dvaLco", + "taskTitle": "US-904: Template categories", + "timestamp": "2026-01-28T02:34:41.102Z" + }, + { + "id": "activity_1769567681092_x1p500byz", + "type": "task_updated", + "taskId": "task_20260128_nyIFMc", + "taskTitle": "US-903: Variable interpolation", + "timestamp": "2026-01-28T02:34:41.092Z" + }, + { + "id": "activity_1769567681083_7plxvn7o7", + "type": "task_updated", + "taskId": "task_20260128_z6OIb_", + "taskTitle": "US-902: Subtask templates", + "timestamp": "2026-01-28T02:34:41.083Z" + }, + { + "id": "activity_1769567681073_70343o38y", + "type": "task_updated", + "taskId": "task_20260128_fL0JN1", + "taskTitle": "US-901: Enhanced template schema", + "timestamp": "2026-01-28T02:34:41.073Z" + }, + { + "id": "activity_1769567653758_gip0r8h6y", + "type": "status_changed", + "taskId": "task_20260128_AaRWcX", + "taskTitle": "US-909: Enhanced template UI", + "details": { + "from": "todo", + "status": "done" + }, + "timestamp": "2026-01-28T02:34:13.758Z" + }, { "id": "activity_1769567573111_d3sd566d0", "type": "status_changed", @@ -861,155 +978,5 @@ "project": "Sprint 8" }, "timestamp": "2026-01-26T17:57:55.365Z" - }, - { - "id": "activity_1769450252743_xx89yq6ne", - "type": "task_created", - "taskId": "task_20260126_8SKJ8t", - "taskTitle": "US-804: Token Usage Tracking", - "details": { - "type": "code", - "priority": "medium", - "project": "Sprint 8" - }, - "timestamp": "2026-01-26T17:57:32.743Z" - }, - { - "id": "activity_1769450252689_dejjxuezs", - "type": "task_created", - "taskId": "task_20260126_aBQTa1", - "taskTitle": "US-803: Emit Run Events", - "details": { - "type": "code", - "priority": "high", - "project": "Sprint 8" - }, - "timestamp": "2026-01-26T17:57:32.689Z" - }, - { - "id": "activity_1769450252634_w6utau64q", - "type": "task_created", - "taskId": "task_20260126_kgLjM8", - "taskTitle": "US-802: Emit Task Events", - "details": { - "type": "code", - "priority": "high", - "project": "Sprint 8" - }, - "timestamp": "2026-01-26T17:57:32.634Z" - }, - { - "id": "activity_1769450237943_armw0090q", - "type": "task_created", - "taskId": "task_20260126_desyd5", - "taskTitle": "US-801: Telemetry Service", - "details": { - "type": "code", - "priority": "high", - "project": "Sprint 8" - }, - "timestamp": "2026-01-26T17:57:17.943Z" - }, - { - "id": "activity_1769449288760_hzmmgfxfi", - "type": "task_deleted", - "taskId": "task_20260126_9VIZY1", - "taskTitle": "Test task from CLI", - "timestamp": "2026-01-26T17:41:28.760Z" - }, - { - "id": "activity_1769449185820_cctph1fac", - "type": "task_deleted", - "taskId": "task_20260126_tsDVOq", - "taskTitle": "Test feature branch", - "timestamp": "2026-01-26T17:39:45.820Z" - }, - { - "id": "activity_1769449175512_6lvnf3bgl", - "type": "task_updated", - "taskId": "task_20260126_tsDVOq", - "taskTitle": "Test feature branch", - "timestamp": "2026-01-26T17:39:35.512Z" - }, - { - "id": "activity_1769448961158_7hmywa016", - "type": "status_changed", - "taskId": "task_20260126_9VIZY1", - "taskTitle": "Test task from CLI", - "details": { - "from": "archived", - "status": "done" - }, - "timestamp": "2026-01-26T17:36:01.158Z" - }, - { - "id": "activity_1769448955837_m815ex2dh", - "type": "task_archived", - "taskId": "task_20260126_9VIZY1", - "taskTitle": "Test task from CLI", - "timestamp": "2026-01-26T17:35:55.837Z" - }, - { - "id": "activity_1769448944900_dm04m4zyu", - "type": "status_changed", - "taskId": "task_20260126_9VIZY1", - "taskTitle": "Test task from CLI", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-26T17:35:44.900Z" - }, - { - "id": "activity_1769448691266_xbovau2j5", - "type": "task_updated", - "taskId": "task_20260126_tsDVOq", - "taskTitle": "Test feature branch", - "timestamp": "2026-01-26T17:31:31.266Z" - }, - { - "id": "activity_1769448671292_o56r391ij", - "type": "task_created", - "taskId": "task_20260126_tsDVOq", - "taskTitle": "Test feature branch", - "details": { - "type": "code", - "priority": "medium", - "project": "test" - }, - "timestamp": "2026-01-26T17:31:11.292Z" - }, - { - "id": "activity_1769448643552_50ghtkm2u", - "type": "status_changed", - "taskId": "task_20260126_9VIZY1", - "taskTitle": "Test task from CLI", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-26T17:30:43.552Z" - }, - { - "id": "activity_1769448480165_c8bcel8bk", - "type": "task_created", - "taskId": "task_20260126_9VIZY1", - "taskTitle": "Test task from CLI", - "details": { - "type": "code", - "priority": "high" - }, - "timestamp": "2026-01-26T17:28:00.165Z" - }, - { - "id": "activity_1769447559137_w5so806no", - "type": "status_changed", - "taskId": "task_20260126_PcoH5E", - "taskTitle": "US-711: Running indicator on task cards", - "details": { - "from": "todo", - "status": "done" - }, - "timestamp": "2026-01-26T17:12:39.137Z" } ] \ No newline at end of file diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 8252c8bc..37f50ce4 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -509,6 +509,36 @@ router.get('/:id/worktree/open', async (req, res) => { } }); +// === Template Application Routes === + +// POST /api/tasks/:id/apply-template - Apply template to existing task +router.post('/:id/apply-template', async (req, res) => { + try { + const { templateId, templateName, fieldsChanged } = req.body; + + if (!templateId) { + return res.status(400).json({ error: 'Template ID is required' }); + } + + const task = await taskService.getTask(req.params.id); + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + // Log activity for template application + await activityService.logActivity('template_applied', task.id, task.title, { + templateId, + templateName: templateName || 'Unknown', + fieldsChanged: fieldsChanged || [], + }); + + res.json({ success: true }); + } catch (error: any) { + console.error('Error logging template application:', error); + res.status(500).json({ error: error.message || 'Failed to log template application' }); + } +}); + // === Time Tracking Routes === // POST /api/tasks/:id/time/start - Start timer for a task diff --git a/server/src/services/activity-service.ts b/server/src/services/activity-service.ts index 48486141..33f76390 100644 --- a/server/src/services/activity-service.ts +++ b/server/src/services/activity-service.ts @@ -13,7 +13,8 @@ export type ActivityType = | 'task_deleted' | 'worktree_created' | 'worktree_merged' - | 'project_archived'; + | 'project_archived' + | 'template_applied'; export interface Activity { id: string; diff --git a/web/src/components/task/ApplyTemplateDialog.tsx b/web/src/components/task/ApplyTemplateDialog.tsx new file mode 100644 index 00000000..2a2c923e --- /dev/null +++ b/web/src/components/task/ApplyTemplateDialog.tsx @@ -0,0 +1,462 @@ +import { useState, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Switch } from '@/components/ui/switch'; +import { useTemplates } from '@/hooks/useTemplates'; +import { useUpdateTask } from '@/hooks/useTasks'; +import type { Task, Subtask } from '@veritas-kanban/shared'; +import { FileCode, AlertCircle, Plus, Minus } from 'lucide-react'; +import { nanoid } from 'nanoid'; +import { api } from '@/lib/api'; +import { + interpolateVariables, + extractCustomVariables, + type VariableContext +} from '@/lib/template-variables'; +import { getCategoryIcon } from '@/lib/template-categories'; + +interface ApplyTemplateDialogProps { + task: Task; + open: boolean; + onOpenChange: (open: boolean) => void; + onApplied?: () => void; +} + +interface MergedField { + field: string; + label: string; + before: string | undefined; + after: string; + willChange: boolean; +} + +interface MergePreview { + fields: MergedField[]; + subtasksAdded: number; + existingSubtasks: number; +} + +export function ApplyTemplateDialog({ task, open, onOpenChange, onApplied }: ApplyTemplateDialogProps) { + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [customVars, setCustomVars] = useState>({}); + const [requiredCustomVars, setRequiredCustomVars] = useState([]); + const [forceOverwrite, setForceOverwrite] = useState(false); + + const { data: templates } = useTemplates(); + const updateTask = useUpdateTask(); + + // Filter templates by selected category + const filteredTemplates = useMemo(() => { + if (!templates) return []; + if (categoryFilter === 'all') return templates; + return templates.filter(t => (t.category || 'custom') === categoryFilter); + }, [templates, categoryFilter]); + + // Get the selected template + const template = useMemo(() => { + if (!selectedTemplate || !templates) return null; + return templates.find(t => t.id === selectedTemplate) || null; + }, [selectedTemplate, templates]); + + // Calculate merge preview + const mergePreview = useMemo((): MergePreview | null => { + if (!template) return null; + + // Build variable context + const context: VariableContext = { + project: task.project, + author: 'Brad', // TODO: Get from user config + customVars, + }; + + const fields: MergedField[] = []; + + // Title + if (template.taskDefaults.descriptionTemplate) { + const interpolatedTitle = task.title; + const willChange = forceOverwrite || !task.title; + fields.push({ + field: 'title', + label: 'Title', + before: task.title, + after: interpolatedTitle, + willChange, + }); + } + + // Description + if (template.taskDefaults.descriptionTemplate) { + const interpolatedDescription = interpolateVariables( + template.taskDefaults.descriptionTemplate, + context + ); + const willChange = forceOverwrite || !task.description; + fields.push({ + field: 'description', + label: 'Description', + before: task.description, + after: interpolatedDescription, + willChange, + }); + } + + // Type + if (template.taskDefaults.type) { + const willChange = forceOverwrite || !task.type; + fields.push({ + field: 'type', + label: 'Type', + before: task.type, + after: template.taskDefaults.type, + willChange, + }); + } + + // Priority + if (template.taskDefaults.priority) { + const willChange = forceOverwrite || !task.priority; + fields.push({ + field: 'priority', + label: 'Priority', + before: task.priority, + after: template.taskDefaults.priority, + willChange, + }); + } + + // Project + if (template.taskDefaults.project) { + const willChange = forceOverwrite || !task.project; + fields.push({ + field: 'project', + label: 'Project', + before: task.project, + after: template.taskDefaults.project, + willChange, + }); + } + + // Tags + if (template.taskDefaults.tags && template.taskDefaults.tags.length > 0) { + const willChange = forceOverwrite || !task.tags || task.tags.length === 0; + fields.push({ + field: 'tags', + label: 'Tags', + before: task.tags?.join(', '), + after: template.taskDefaults.tags.join(', '), + willChange, + }); + } + + // Subtasks + const subtasksAdded = template.subtaskTemplates?.length || 0; + const existingSubtasks = task.subtasks?.length || 0; + + return { + fields: fields.filter(f => f.willChange), + subtasksAdded, + existingSubtasks, + }; + }, [template, task, customVars, forceOverwrite]); + + // Handle template selection + const handleTemplateSelect = (templateId: string) => { + setSelectedTemplate(templateId); + + const selected = templates?.find(t => t.id === templateId); + if (!selected) return; + + // Extract custom variables from description template and subtasks + const allTemplateText = [ + selected.taskDefaults.descriptionTemplate || '', + ...(selected.subtaskTemplates?.map(st => st.title) || []) + ].join(' '); + + const customVarNames = extractCustomVariables(allTemplateText); + setRequiredCustomVars(customVarNames); + + // Initialize custom vars + const initialCustomVars: Record = {}; + customVarNames.forEach(name => { + initialCustomVars[name] = ''; + }); + setCustomVars(initialCustomVars); + }; + + // Apply the template + const handleApply = async () => { + if (!template) return; + + // Build variable context + const context: VariableContext = { + project: task.project, + author: 'Brad', // TODO: Get from user config + customVars, + }; + + // Build update input based on merge strategy + const updates: Record = {}; + + // Description + if (template.taskDefaults.descriptionTemplate) { + const interpolated = interpolateVariables( + template.taskDefaults.descriptionTemplate, + context + ); + if (forceOverwrite || !task.description) { + updates.description = interpolated; + } + } + + // Type + if (template.taskDefaults.type && (forceOverwrite || !task.type)) { + updates.type = template.taskDefaults.type; + } + + // Priority + if (template.taskDefaults.priority && (forceOverwrite || !task.priority)) { + updates.priority = template.taskDefaults.priority; + } + + // Project + if (template.taskDefaults.project && (forceOverwrite || !task.project)) { + updates.project = template.taskDefaults.project; + } + + // Tags + if (template.taskDefaults.tags && template.taskDefaults.tags.length > 0) { + if (forceOverwrite || !task.tags || task.tags.length === 0) { + updates.tags = template.taskDefaults.tags; + } + } + + // Subtasks - APPEND to existing + if (template.subtaskTemplates && template.subtaskTemplates.length > 0) { + const now = new Date().toISOString(); + const newSubtasks: Subtask[] = template.subtaskTemplates + .sort((a, b) => a.order - b.order) + .map(st => ({ + id: nanoid(), + title: interpolateVariables(st.title, context), + completed: false, + created: now, + })); + + // Append to existing subtasks + const existingSubtasks = task.subtasks || []; + updates.subtasks = [...existingSubtasks, ...newSubtasks]; + } + + // Apply the updates + await updateTask.mutateAsync({ + id: task.id, + input: updates, + }); + + // Track which fields were changed for activity logging + const changedFields = Object.keys(updates); + + // Log activity + try { + await api.tasks.applyTemplate( + task.id, + template.id, + template.name, + changedFields + ); + } catch (error) { + console.error('Failed to log template application:', error); + // Don't fail the whole operation if logging fails + } + + // Close dialog and notify parent + onOpenChange(false); + onApplied?.(); + + // Reset state + setSelectedTemplate(null); + setCustomVars({}); + setRequiredCustomVars([]); + setForceOverwrite(false); + }; + + return ( + + + + + + Apply Template to Task + + + +
+ {/* Template selector */} +
+
+ +
+ + + All + 🐛 + ✨ + 🔄 + + + +
+ + {/* Custom variable inputs */} + {requiredCustomVars.length > 0 && ( +
+
+ + +
+ {requiredCustomVars.map((varName) => ( +
+ + setCustomVars(prev => ({ ...prev, [varName]: e.target.value }))} + placeholder={`Enter ${varName}...`} + className="h-8" + /> +
+ ))} +
+ )} + + {/* Force overwrite toggle */} + {template && ( +
+
+ +

+ Replace existing values with template values +

+
+ +
+ )} + + {/* Merge preview */} + {mergePreview && mergePreview.fields.length > 0 && ( +
+
+ + +
+ + {mergePreview.fields.map((field) => ( +
+
+ {field.label} +
+
+ + + {field.before || '(empty)'} + +
+
+ + + {field.after} + +
+
+ ))} + + {mergePreview.subtasksAdded > 0 && ( +
+
+ Subtasks +
+
+ + + Will add {mergePreview.subtasksAdded} subtasks to existing {mergePreview.existingSubtasks} + +
+
+ )} +
+ )} + + {!template && ( +
+ Select a template to see what will change +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/web/src/components/task/TaskDetailPanel.tsx b/web/src/components/task/TaskDetailPanel.tsx index 75b92dbb..027aeb99 100644 --- a/web/src/components/task/TaskDetailPanel.tsx +++ b/web/src/components/task/TaskDetailPanel.tsx @@ -34,7 +34,7 @@ import { SelectValue, } from '@/components/ui/select'; import { useUpdateTask, useDeleteTask } from '@/hooks/useTasks'; -import { Trash2, Code, Search, FileText, Zap, Calendar, Clock, GitBranch, Bot, FileDiff, ClipboardCheck, Monitor } from 'lucide-react'; +import { Trash2, Code, Search, FileText, Zap, Calendar, Clock, GitBranch, Bot, FileDiff, ClipboardCheck, Monitor, FileCode } from 'lucide-react'; import type { Task, TaskType, TaskStatus, TaskPriority, ReviewComment, ReviewState } from '@veritas-kanban/shared'; import { GitSection } from './GitSection'; import { AgentPanel } from './AgentPanel'; @@ -44,6 +44,7 @@ import { SubtasksSection } from './SubtasksSection'; import { DependenciesSection } from './DependenciesSection'; import { PreviewPanel } from './PreviewPanel'; import { TimeTrackingSection } from './TimeTrackingSection'; +import { ApplyTemplateDialog } from './ApplyTemplateDialog'; interface TaskDetailPanelProps { task: Task | null; @@ -125,6 +126,7 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro const { localTask, updateField, isDirty } = useDebouncedSave(task, updateTask); const [activeTab, setActiveTab] = useState('details'); const [previewOpen, setPreviewOpen] = useState(false); + const [applyTemplateOpen, setApplyTemplateOpen] = useState(false); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -203,6 +205,15 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro )} + {isCodeTask && localTask.git?.repo && (