feat(US-603): task templates

- Add TemplateService with markdown file storage
- API endpoints for template CRUD
- Template selector in create task dialog
- Template management in settings
- Templates pre-fill type, priority, project, description
This commit is contained in:
Brad Groux 2026-01-26 04:53:51 -06:00
parent bf11dec7d9
commit ac91ccb83d
8 changed files with 659 additions and 4 deletions

View file

@ -13,7 +13,7 @@
|----|-------|--------|-------|
| US-601 | Keyboard shortcuts | ✅ Complete | c, j/k, Enter, Esc, 1-4, ? |
| US-602 | Filter and search | ✅ Complete | Project/type dropdowns, text search |
| US-603 | Task templates | ⏳ Todo | New from Template option |
| US-603 | Task templates | ✅ Complete | New from Template option |
| US-604 | Bulk actions | ⏳ Todo | Multi-select, bulk status/archive/delete |
| US-605 | Activity log | ⏳ Todo | Toggleable sidebar |
@ -44,3 +44,12 @@
- Active filter count badge with "Clear all" button
- URL persistence (filters sync to query params)
- Added Badge UI component
**US-603: Task templates** ✅
- Created TemplateService for server-side template management
- Templates stored as markdown files in `.veritas-kanban/templates/`
- API endpoints: GET/POST/PATCH/DELETE /api/templates
- Template fields: name, description, taskDefaults (type, priority, project, descriptionTemplate)
- Template selector in CreateTaskDialog
- Template management UI in Settings dialog
- Added api.templates methods to api.ts

View file

@ -9,6 +9,7 @@ import { diffRoutes } from './routes/diff.js';
import { automationRoutes } from './routes/automation.js';
import { summaryRoutes } from './routes/summary.js';
import { notificationRoutes } from './routes/notifications.js';
import templateRoutes from './routes/templates.js';
import type { AgentOutput } from './services/agent-service.js';
const app = express();
@ -31,6 +32,7 @@ app.use('/api/diff', diffRoutes);
app.use('/api/automation', automationRoutes);
app.use('/api/summary', summaryRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/templates', templateRoutes);
// Create HTTP server
const server = createServer(app);

View file

@ -0,0 +1,103 @@
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { TemplateService } from '../services/template-service.js';
const router: RouterType = Router();
const templateService = new TemplateService();
// Validation schemas
const createTemplateSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
taskDefaults: z.object({
type: z.enum(['code', 'research', 'content', 'automation']).optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
project: z.string().optional(),
descriptionTemplate: z.string().optional(),
}),
});
const updateTemplateSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional(),
taskDefaults: z.object({
type: z.enum(['code', 'research', 'content', 'automation']).optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
project: z.string().optional(),
descriptionTemplate: z.string().optional(),
}).optional(),
});
// GET /api/templates - List all templates
router.get('/', async (_req, res) => {
try {
const templates = await templateService.getTemplates();
res.json(templates);
} catch (error) {
console.error('Error listing templates:', error);
res.status(500).json({ error: 'Failed to list templates' });
}
});
// GET /api/templates/:id - Get single template
router.get('/:id', async (req, res) => {
try {
const template = await templateService.getTemplate(req.params.id);
if (!template) {
return res.status(404).json({ error: 'Template not found' });
}
res.json(template);
} catch (error) {
console.error('Error getting template:', error);
res.status(500).json({ error: 'Failed to get template' });
}
});
// POST /api/templates - Create template
router.post('/', async (req, res) => {
try {
const input = createTemplateSchema.parse(req.body);
const template = await templateService.createTemplate(input);
res.status(201).json(template);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation failed', details: error.errors });
}
console.error('Error creating template:', error);
res.status(500).json({ error: 'Failed to create template' });
}
});
// PATCH /api/templates/:id - Update template
router.patch('/:id', async (req, res) => {
try {
const input = updateTemplateSchema.parse(req.body);
const template = await templateService.updateTemplate(req.params.id, input);
if (!template) {
return res.status(404).json({ error: 'Template not found' });
}
res.json(template);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation failed', details: error.errors });
}
console.error('Error updating template:', error);
res.status(500).json({ error: 'Failed to update template' });
}
});
// DELETE /api/templates/:id - Delete template
router.delete('/:id', async (req, res) => {
try {
const deleted = await templateService.deleteTemplate(req.params.id);
if (!deleted) {
return res.status(404).json({ error: 'Template not found' });
}
res.status(204).send();
} catch (error) {
console.error('Error deleting template:', error);
res.status(500).json({ error: 'Failed to delete template' });
}
});
export default router;

View file

@ -0,0 +1,157 @@
import { readdir, readFile, writeFile, unlink, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import matter from 'gray-matter';
import type { TaskType, TaskPriority } from '@veritas-kanban/shared';
export interface TaskTemplate {
id: string;
name: string;
description?: string;
taskDefaults: {
type?: TaskType;
priority?: TaskPriority;
project?: string;
descriptionTemplate?: string;
};
created: string;
updated: string;
}
export interface CreateTemplateInput {
name: string;
description?: string;
taskDefaults: {
type?: TaskType;
priority?: TaskPriority;
project?: string;
descriptionTemplate?: string;
};
}
export interface UpdateTemplateInput {
name?: string;
description?: string;
taskDefaults?: {
type?: TaskType;
priority?: TaskPriority;
project?: string;
descriptionTemplate?: string;
};
}
export class TemplateService {
private templatesDir: string;
constructor() {
this.templatesDir = join(process.cwd(), '.veritas-kanban', 'templates');
this.ensureDir();
}
private async ensureDir() {
if (!existsSync(this.templatesDir)) {
await mkdir(this.templatesDir, { recursive: true });
}
}
private slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
private templatePath(id: string): string {
return join(this.templatesDir, `${id}.md`);
}
async getTemplates(): Promise<TaskTemplate[]> {
await this.ensureDir();
const files = await readdir(this.templatesDir);
const templates: TaskTemplate[] = [];
for (const file of files) {
if (!file.endsWith('.md')) continue;
try {
const content = await readFile(join(this.templatesDir, file), 'utf-8');
const { data } = matter(content);
templates.push(data as TaskTemplate);
} catch (err) {
console.error(`Error reading template ${file}:`, err);
}
}
return templates.sort((a, b) => a.name.localeCompare(b.name));
}
async getTemplate(id: string): Promise<TaskTemplate | null> {
const path = this.templatePath(id);
if (!existsSync(path)) {
return null;
}
try {
const content = await readFile(path, 'utf-8');
const { data } = matter(content);
return data as TaskTemplate;
} catch (err) {
console.error(`Error reading template ${id}:`, err);
return null;
}
}
async createTemplate(input: CreateTemplateInput): Promise<TaskTemplate> {
await this.ensureDir();
const id = `template_${this.slugify(input.name)}_${Date.now()}`;
const now = new Date().toISOString();
const template: TaskTemplate = {
id,
name: input.name,
description: input.description,
taskDefaults: input.taskDefaults,
created: now,
updated: now,
};
const content = matter.stringify('', template);
await writeFile(this.templatePath(id), content, 'utf-8');
return template;
}
async updateTemplate(id: string, input: UpdateTemplateInput): Promise<TaskTemplate | null> {
const existing = await this.getTemplate(id);
if (!existing) return null;
const updated: TaskTemplate = {
...existing,
...input,
taskDefaults: {
...existing.taskDefaults,
...input.taskDefaults,
},
updated: new Date().toISOString(),
};
const content = matter.stringify('', updated);
await writeFile(this.templatePath(id), content, 'utf-8');
return updated;
}
async deleteTemplate(id: string): Promise<boolean> {
const path = this.templatePath(id);
if (!existsSync(path)) {
return false;
}
await unlink(path);
return true;
}
}

View file

@ -8,6 +8,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
@ -34,8 +35,14 @@ import {
useUpdateAgents,
useSetDefaultAgent,
} from '@/hooks/useConfig';
import { Plus, Trash2, Check, X, Loader2, FolderGit2, Bot, Star } from 'lucide-react';
import type { RepoConfig, AgentConfig, AgentType } from '@veritas-kanban/shared';
import {
useTemplates,
useCreateTemplate,
useDeleteTemplate,
type TaskTemplate,
} from '@/hooks/useTemplates';
import { Plus, Trash2, Check, X, Loader2, FolderGit2, Bot, Star, FileText } from 'lucide-react';
import type { RepoConfig, AgentConfig, AgentType, TaskType, TaskPriority } from '@veritas-kanban/shared';
import { cn } from '@/lib/utils';
interface SettingsDialogProps {
@ -219,6 +226,170 @@ function RepoItem({ repo }: { repo: RepoConfig }) {
);
}
// Template Components
function AddTemplateForm({ onClose }: { onClose: () => void }) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [type, setType] = useState<TaskType | ''>('');
const [priority, setPriority] = useState<TaskPriority | ''>('');
const [project, setProject] = useState('');
const [descriptionTemplate, setDescriptionTemplate] = useState('');
const createTemplate = useCreateTemplate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
await createTemplate.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
taskDefaults: {
type: type || undefined,
priority: priority || undefined,
project: project.trim() || undefined,
descriptionTemplate: descriptionTemplate.trim() || undefined,
},
});
onClose();
};
return (
<form onSubmit={handleSubmit} className="space-y-4 border rounded-lg p-4 bg-muted/30">
<div className="flex items-center gap-2 text-sm font-medium">
<FileText className="h-4 w-4" />
Add Template
</div>
<div className="grid gap-3">
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label htmlFor="template-name">Name *</Label>
<Input
id="template-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Bug Fix"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="template-desc">Description</Label>
<Input
id="template-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g., Template for bug fixes"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="grid gap-2">
<Label>Default Type</Label>
<Select value={type} onValueChange={(v) => setType(v as TaskType)}>
<SelectTrigger>
<SelectValue placeholder="Any" />
</SelectTrigger>
<SelectContent>
<SelectItem value="code">Code</SelectItem>
<SelectItem value="research">Research</SelectItem>
<SelectItem value="content">Content</SelectItem>
<SelectItem value="automation">Automation</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Default Priority</Label>
<Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}>
<SelectTrigger>
<SelectValue placeholder="Any" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Default Project</Label>
<Input
value={project}
onChange={(e) => setProject(e.target.value)}
placeholder="e.g., rubicon"
/>
</div>
</div>
<div className="grid gap-2">
<Label>Description Template</Label>
<Textarea
value={descriptionTemplate}
onChange={(e) => setDescriptionTemplate(e.target.value)}
placeholder="Pre-filled description text..."
rows={2}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim() || createTemplate.isPending}>
{createTemplate.isPending ? 'Creating...' : 'Create Template'}
</Button>
</div>
</form>
);
}
function TemplateItem({ template }: { template: TaskTemplate }) {
const deleteTemplate = useDeleteTemplate();
return (
<div className="flex items-center justify-between py-2 px-3 rounded-md border bg-card">
<div className="flex items-center gap-3">
<FileText className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">{template.name}</div>
<div className="text-xs text-muted-foreground">
{[
template.taskDefaults.type,
template.taskDefaults.priority,
template.taskDefaults.project,
].filter(Boolean).join(' • ') || 'No defaults'}
</div>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete template?</AlertDialogTitle>
<AlertDialogDescription>
This will delete the "{template.name}" template.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteTemplate.mutate(template.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function AgentItem({
agent,
isDefault,
@ -279,7 +450,9 @@ function AgentItem({
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
const { data: config, isLoading } = useConfig();
const { data: templates, isLoading: templatesLoading } = useTemplates();
const [showAddForm, setShowAddForm] = useState(false);
const [showAddTemplateForm, setShowAddTemplateForm] = useState(false);
const updateAgents = useUpdateAgents();
const setDefaultAgent = useSetDefaultAgent();
@ -363,6 +536,41 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
</div>
)}
</div>
{/* Templates Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Task Templates</h3>
{!showAddTemplateForm && (
<Button
variant="outline"
size="sm"
onClick={() => setShowAddTemplateForm(true)}
>
<Plus className="h-4 w-4 mr-1" />
Add Template
</Button>
)}
</div>
{showAddTemplateForm && (
<AddTemplateForm onClose={() => setShowAddTemplateForm(false)} />
)}
{templatesLoading ? (
<div className="text-sm text-muted-foreground">Loading...</div>
) : !templates || templates.length === 0 ? (
<div className="text-sm text-muted-foreground py-4 text-center border rounded-md border-dashed">
No templates created. Templates pre-fill task fields when creating new tasks.
</div>
) : (
<div className="space-y-2">
{templates.map((template) => (
<TemplateItem key={template.id} template={template} />
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>

View file

@ -18,7 +18,9 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useCreateTask } from '@/hooks/useTasks';
import { useTemplates, type TaskTemplate } from '@/hooks/useTemplates';
import type { TaskType, TaskPriority } from '@veritas-kanban/shared';
import { FileText } from 'lucide-react';
interface CreateTaskDialogProps {
open: boolean;
@ -31,8 +33,22 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
const [type, setType] = useState<TaskType>('code');
const [priority, setPriority] = useState<TaskPriority>('medium');
const [project, setProject] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const createTask = useCreateTask();
const { data: templates } = useTemplates();
const applyTemplate = (template: TaskTemplate) => {
setSelectedTemplate(template.id);
if (template.taskDefaults.type) setType(template.taskDefaults.type);
if (template.taskDefaults.priority) setPriority(template.taskDefaults.priority);
if (template.taskDefaults.project) setProject(template.taskDefaults.project);
if (template.taskDefaults.descriptionTemplate) setDescription(template.taskDefaults.descriptionTemplate);
};
const clearTemplate = () => {
setSelectedTemplate(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -53,6 +69,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
setType('code');
setPriority('medium');
setProject('');
setSelectedTemplate(null);
onOpenChange(false);
};
@ -64,6 +81,41 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
<DialogTitle>Create New Task</DialogTitle>
</DialogHeader>
{/* Template selector */}
{templates && templates.length > 0 && (
<div className="flex items-center gap-2 py-2 border-b">
<FileText className="h-4 w-4 text-muted-foreground" />
<Select
value={selectedTemplate || 'none'}
onValueChange={(value) => {
if (value === 'none') {
clearTemplate();
} else {
const template = templates.find(t => t.id === value);
if (template) applyTemplate(template);
}
}}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Use a template..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No template</SelectItem>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
{template.description && (
<span className="text-muted-foreground ml-2">
— {template.description}
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Title</Label>

View file

@ -0,0 +1,50 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, type TaskTemplate, type CreateTemplateInput, type UpdateTemplateInput } from '@/lib/api';
export type { TaskTemplate, CreateTemplateInput, UpdateTemplateInput };
export function useTemplates() {
return useQuery({
queryKey: ['templates'],
queryFn: api.templates.list,
});
}
export function useTemplate(id: string) {
return useQuery({
queryKey: ['templates', id],
queryFn: () => api.templates.get(id),
enabled: !!id,
});
}
export function useCreateTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.templates.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates'] });
},
});
}
export function useUpdateTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateTemplateInput }) =>
api.templates.update(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates'] });
},
});
}
export function useDeleteTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.templates.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates'] });
},
});
}

View file

@ -1,4 +1,41 @@
import type { Task, CreateTaskInput, UpdateTaskInput, AppConfig, RepoConfig, AgentConfig, AgentType } from '@veritas-kanban/shared';
import type { Task, CreateTaskInput, UpdateTaskInput, AppConfig, RepoConfig, AgentConfig, AgentType, TaskType, TaskPriority } from '@veritas-kanban/shared';
// Template types
export interface TaskTemplate {
id: string;
name: string;
description?: string;
taskDefaults: {
type?: TaskType;
priority?: TaskPriority;
project?: string;
descriptionTemplate?: string;
};
created: string;
updated: string;
}
export interface CreateTemplateInput {
name: string;
description?: string;
taskDefaults: {
type?: TaskType;
priority?: TaskPriority;
project?: string;
descriptionTemplate?: string;
};
}
export interface UpdateTemplateInput {
name?: string;
description?: string;
taskDefaults?: {
type?: TaskType;
priority?: TaskPriority;
project?: string;
descriptionTemplate?: string;
};
}
const API_BASE = '/api';
@ -232,6 +269,43 @@ export const api = {
return handleResponse<FileDiff[]>(response);
},
},
templates: {
list: async (): Promise<TaskTemplate[]> => {
const response = await fetch(`${API_BASE}/templates`);
return handleResponse<TaskTemplate[]>(response);
},
get: async (id: string): Promise<TaskTemplate> => {
const response = await fetch(`${API_BASE}/templates/${id}`);
return handleResponse<TaskTemplate>(response);
},
create: async (input: CreateTemplateInput): Promise<TaskTemplate> => {
const response = await fetch(`${API_BASE}/templates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return handleResponse<TaskTemplate>(response);
},
update: async (id: string, input: UpdateTemplateInput): Promise<TaskTemplate> => {
const response = await fetch(`${API_BASE}/templates/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return handleResponse<TaskTemplate>(response);
},
delete: async (id: string): Promise<void> => {
const response = await fetch(`${API_BASE}/templates/${id}`, {
method: 'DELETE',
});
return handleResponse<void>(response);
},
},
};
// Types for diff