feat: initial project scaffolding

- Dev container with Node.js 22
- pnpm workspace monorepo structure
- Express + WebSocket server
- React + Vite + shadcn/ui frontend
- Shared TypeScript types package
- Kanban board with drag-and-drop
- Task CRUD with file-based persistence
- Dark mode styling

Sprint 1 - US-101: Project scaffolding with dev container
This commit is contained in:
Brad Groux 2026-01-26 02:34:54 -06:00
commit a489c5358f
43 changed files with 1961 additions and 0 deletions

View file

@ -0,0 +1,32 @@
{
"name": "Veritas Kanban",
"image": "mcr.microsoft.com/devcontainers/typescript-node:22",
"features": {
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"formulahendry.auto-rename-tag",
"christian-kohler.path-intellisense",
"ms-vscode.vscode-typescript-next"
],
"settings": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.updateImportsOnFileMove.enabled": "always"
}
}
},
"forwardPorts": [3000, 3001],
"postCreateCommand": "corepack enable && pnpm install",
"remoteUser": "node"
}

37
.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
# Dependencies
node_modules/
# Build outputs
dist/
build/
.next/
# IDE
.idea/
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Env
.env
.env.local
.env.*.local
# Task data (track structure, not content)
tasks/active/*.md
tasks/archive/*.md
.veritas-kanban/logs/
.veritas-kanban/config.json
# Test
coverage/
.nyc_output/

7
.prettierrc Normal file
View file

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

0
.veritas-kanban/.gitkeep Normal file
View file

101
README.md Normal file
View file

@ -0,0 +1,101 @@
# Veritas Kanban
A local-first task management and AI agent orchestration platform built for Brad Groux and Veritas.
## Features
- 📋 **Kanban Board** - Visual task management with drag-and-drop
- 🤖 **AI Agent Orchestration** - Spawn Claude Code, Amp, Copilot, Gemini
- 🌳 **Git Worktrees** - Isolated branches for each coding task
- 📝 **Markdown Storage** - Human-readable task files with frontmatter
- 🔍 **Code Review** - Diff viewing and inline comments
- 🌙 **Dark Mode** - Easy on the eyes
## Quick Start
```bash
# Clone the repo
git clone https://github.com/dm-bradgroux/veritas-kanban.git
cd veritas-kanban
# Install dependencies
pnpm install
# Start development
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
## Tech Stack
| Layer | Technology |
|-------|------------|
| Runtime | Node.js 22+ |
| Language | TypeScript (strict) |
| Server | Express + WebSocket |
| Frontend | React 19 + Vite + shadcn/ui |
| Persistence | Markdown files (gray-matter) |
| Git | simple-git |
## Project Structure
```
veritas-kanban/
├── .devcontainer/ # Dev container config
├── server/ # Express API + WebSocket
├── web/ # React frontend
├── shared/ # Shared TypeScript types
├── tasks/ # Task storage (markdown files)
│ ├── active/ # Current tasks
│ └── archive/ # Completed tasks
└── .veritas-kanban/ # Config and runtime data
```
## Repositories
- **Work**: https://github.com/dm-bradgroux/veritas-kanban
- **Personal**: https://github.com/BradGroux/veritas-kanban
## Development
### Prerequisites
- Node.js 22+
- pnpm 9+
### Commands
```bash
pnpm dev # Start dev server (frontend + backend)
pnpm build # Build for production
pnpm typecheck # Run TypeScript checks
pnpm lint # Run ESLint
```
### Dev Container
This project includes a VS Code Dev Container configuration. Open in VS Code and select "Reopen in Container" for a consistent development environment.
## Task File Format
Tasks are stored as markdown files with YAML frontmatter:
```markdown
---
id: "task_20260126_abc123"
title: "Implement feature X"
type: "code"
status: "in-progress"
priority: "high"
project: "rubicon"
---
## Description
Details about the task...
```
## License
MIT

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "veritas-kanban",
"version": "0.1.0",
"private": true,
"description": "Local-first task management and AI agent orchestration platform",
"author": "Brad Groux <brad@digitalmeld.io>",
"license": "MIT",
"type": "module",
"engines": {
"node": ">=22.0.0",
"pnpm": ">=9.0.0"
},
"packageManager": "pnpm@9.15.4",
"scripts": {
"dev": "concurrently -n server,web -c blue,green \"pnpm --filter server dev\" \"pnpm --filter web dev\"",
"build": "pnpm --filter server build && pnpm --filter web build",
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test",
"clean": "pnpm -r clean && rm -rf node_modules"
},
"devDependencies": {
"@types/node": "^22.10.0",
"concurrently": "^9.1.0",
"prettier": "^3.4.0",
"typescript": "^5.7.0"
}
}

4
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,4 @@
packages:
- "server"
- "web"
- "shared"

34
server/package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "@veritas-kanban/server",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts",
"clean": "rm -rf dist"
},
"dependencies": {
"@veritas-kanban/shared": "workspace:*",
"cors": "^2.8.5",
"express": "^4.21.0",
"gray-matter": "^4.0.3",
"nanoid": "^5.0.9",
"simple-git": "^3.27.0",
"slugify": "^1.6.6",
"ws": "^8.18.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/ws": "^8.5.13",
"eslint": "^9.17.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

50
server/src/index.ts Normal file
View file

@ -0,0 +1,50 @@
import express from 'express';
import cors from 'cors';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import { taskRoutes } from './routes/tasks.js';
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/tasks', taskRoutes);
// Create HTTP server
const server = createServer(app);
// WebSocket server for real-time updates
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => {
console.log('WebSocket client connected');
ws.on('close', () => {
console.log('WebSocket client disconnected');
});
});
// Export for use in other modules
export { wss };
// Start server
server.listen(PORT, () => {
console.log(`
Veritas Kanban Server
API: http://localhost:${PORT} ║
WebSocket: ws://localhost:${PORT}/ws ║
Health: http://localhost:${PORT}/health ║
`);
});

115
server/src/routes/tasks.ts Normal file
View file

@ -0,0 +1,115 @@
import { Router } from 'express';
import { z } from 'zod';
import { TaskService } from '../services/task-service.js';
import type { CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared';
const router = Router();
const taskService = new TaskService();
// Validation schemas
const createTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional().default(''),
type: z.enum(['code', 'research', 'content', 'automation']).optional().default('code'),
priority: z.enum(['low', 'medium', 'high']).optional().default('medium'),
project: z.string().optional(),
tags: z.array(z.string()).optional(),
});
const updateTaskSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().optional(),
type: z.enum(['code', 'research', 'content', 'automation']).optional(),
status: z.enum(['todo', 'in-progress', 'review', 'done']).optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
project: z.string().optional(),
tags: z.array(z.string()).optional(),
});
// GET /api/tasks - List all tasks
router.get('/', async (_req, res) => {
try {
const tasks = await taskService.listTasks();
res.json(tasks);
} catch (error) {
console.error('Error listing tasks:', error);
res.status(500).json({ error: 'Failed to list tasks' });
}
});
// GET /api/tasks/:id - Get single task
router.get('/:id', async (req, res) => {
try {
const task = await taskService.getTask(req.params.id);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
res.json(task);
} catch (error) {
console.error('Error getting task:', error);
res.status(500).json({ error: 'Failed to get task' });
}
});
// POST /api/tasks - Create task
router.post('/', async (req, res) => {
try {
const input = createTaskSchema.parse(req.body) as CreateTaskInput;
const task = await taskService.createTask(input);
res.status(201).json(task);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation failed', details: error.errors });
}
console.error('Error creating task:', error);
res.status(500).json({ error: 'Failed to create task' });
}
});
// PATCH /api/tasks/:id - Update task
router.patch('/:id', async (req, res) => {
try {
const input = updateTaskSchema.parse(req.body) as UpdateTaskInput;
const task = await taskService.updateTask(req.params.id, input);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
res.json(task);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation failed', details: error.errors });
}
console.error('Error updating task:', error);
res.status(500).json({ error: 'Failed to update task' });
}
});
// DELETE /api/tasks/:id - Delete task (move to archive)
router.delete('/:id', async (req, res) => {
try {
const success = await taskService.deleteTask(req.params.id);
if (!success) {
return res.status(404).json({ error: 'Task not found' });
}
res.status(204).send();
} catch (error) {
console.error('Error deleting task:', error);
res.status(500).json({ error: 'Failed to delete task' });
}
});
// POST /api/tasks/:id/archive - Archive task
router.post('/:id/archive', async (req, res) => {
try {
const success = await taskService.archiveTask(req.params.id);
if (!success) {
return res.status(404).json({ error: 'Task not found' });
}
res.json({ archived: true });
} catch (error) {
console.error('Error archiving task:', error);
res.status(500).json({ error: 'Failed to archive task' });
}
});
export { router as taskRoutes };

View file

@ -0,0 +1,175 @@
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
import { nanoid } from 'nanoid';
import slugify from 'slugify';
import type { Task, CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared';
const TASKS_DIR = path.join(process.cwd(), 'tasks', 'active');
const ARCHIVE_DIR = path.join(process.cwd(), 'tasks', 'archive');
export class TaskService {
constructor() {
this.ensureDirectories();
}
private async ensureDirectories(): Promise<void> {
await fs.mkdir(TASKS_DIR, { recursive: true });
await fs.mkdir(ARCHIVE_DIR, { recursive: true });
}
private generateId(): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
return `task_${date}_${nanoid(6)}`;
}
private taskToFilename(task: Task): string {
const slug = slugify(task.title, { lower: true, strict: true }).slice(0, 50);
return `${task.id}-${slug}.md`;
}
private taskToMarkdown(task: Task): string {
const { description, reviewComments, ...frontmatter } = task;
const content = matter.stringify(description || '', frontmatter);
// Add review comments section if present
if (reviewComments && reviewComments.length > 0) {
const commentsSection = reviewComments
.map(c => `- **${c.file}:${c.line}** - ${c.content}`)
.join('\n');
return content + '\n\n## Review Comments\n\n' + commentsSection;
}
return content;
}
private parseTaskFile(content: string, filename: string): Task {
const { data, content: description } = matter(content);
// Extract review comments from description if present
let cleanDescription = description;
const reviewComments: Task['reviewComments'] = [];
const reviewSection = description.indexOf('## Review Comments');
if (reviewSection !== -1) {
cleanDescription = description.slice(0, reviewSection).trim();
}
return {
id: data.id || filename.split('-')[0],
title: data.title || 'Untitled',
description: cleanDescription.trim(),
type: data.type || 'code',
status: data.status || 'todo',
priority: data.priority || 'medium',
project: data.project,
tags: data.tags,
created: data.created || new Date().toISOString(),
updated: data.updated || new Date().toISOString(),
git: data.git,
attempt: data.attempt,
attempts: data.attempts,
reviewComments,
};
}
async listTasks(): Promise<Task[]> {
await this.ensureDirectories();
const files = await fs.readdir(TASKS_DIR);
const mdFiles = files.filter(f => f.endsWith('.md'));
const tasks = await Promise.all(
mdFiles.map(async (filename) => {
const filepath = path.join(TASKS_DIR, filename);
const content = await fs.readFile(filepath, 'utf-8');
return this.parseTaskFile(content, filename);
})
);
// Sort by updated date, newest first
return tasks.sort((a, b) =>
new Date(b.updated).getTime() - new Date(a.updated).getTime()
);
}
async getTask(id: string): Promise<Task | null> {
const tasks = await this.listTasks();
return tasks.find(t => t.id === id) || null;
}
async createTask(input: CreateTaskInput): Promise<Task> {
const now = new Date().toISOString();
const task: Task = {
id: this.generateId(),
title: input.title,
description: input.description || '',
type: input.type || 'code',
status: 'todo',
priority: input.priority || 'medium',
project: input.project,
tags: input.tags,
created: now,
updated: now,
};
const filename = this.taskToFilename(task);
const filepath = path.join(TASKS_DIR, filename);
const content = this.taskToMarkdown(task);
await fs.writeFile(filepath, content, 'utf-8');
return task;
}
async updateTask(id: string, input: UpdateTaskInput): Promise<Task | null> {
const task = await this.getTask(id);
if (!task) return null;
const updatedTask: Task = {
...task,
...input,
updated: new Date().toISOString(),
};
// Remove old file if title changed (filename changes)
const oldFilename = this.taskToFilename(task);
const newFilename = this.taskToFilename(updatedTask);
if (oldFilename !== newFilename) {
await fs.unlink(path.join(TASKS_DIR, oldFilename)).catch(() => {});
}
const filepath = path.join(TASKS_DIR, newFilename);
const content = this.taskToMarkdown(updatedTask);
await fs.writeFile(filepath, content, 'utf-8');
return updatedTask;
}
async deleteTask(id: string): Promise<boolean> {
const task = await this.getTask(id);
if (!task) return false;
const filename = this.taskToFilename(task);
await fs.unlink(path.join(TASKS_DIR, filename));
return true;
}
async archiveTask(id: string): Promise<boolean> {
const task = await this.getTask(id);
if (!task) return false;
const filename = this.taskToFilename(task);
const sourcePath = path.join(TASKS_DIR, filename);
const destPath = path.join(ARCHIVE_DIR, filename);
await fs.rename(sourcePath, destPath);
return true;
}
}

19
server/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

22
shared/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "@veritas-kanban/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}

3
shared/src/index.ts Normal file
View file

@ -0,0 +1,3 @@
// Veritas Kanban - Shared Types
export * from './types.js';

139
shared/src/types.ts Normal file
View file

@ -0,0 +1,139 @@
// Task Types
export type TaskType = 'code' | 'research' | 'content' | 'automation';
export type TaskStatus = 'todo' | 'in-progress' | 'review' | 'done';
export type TaskPriority = 'low' | 'medium' | 'high';
export type AgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini';
export type AttemptStatus = 'pending' | 'running' | 'complete' | 'failed';
export interface TaskGit {
repo: string;
branch: string;
baseBranch: string;
worktreePath?: string;
}
export interface TaskAttempt {
id: string;
agent: AgentType;
status: AttemptStatus;
started?: string;
ended?: string;
}
export interface Task {
id: string;
title: string;
description: string;
type: TaskType;
status: TaskStatus;
priority: TaskPriority;
project?: string;
tags?: string[];
created: string;
updated: string;
// Code task specific
git?: TaskGit;
// Current attempt
attempt?: TaskAttempt;
// Attempt history
attempts?: TaskAttempt[];
// Review comments (for code tasks)
reviewComments?: ReviewComment[];
}
export interface ReviewComment {
id: string;
file: string;
line: number;
content: string;
created: string;
}
// API Types
export interface CreateTaskInput {
title: string;
description?: string;
type?: TaskType;
priority?: TaskPriority;
project?: string;
tags?: string[];
}
export interface UpdateTaskInput {
title?: string;
description?: string;
type?: TaskType;
status?: TaskStatus;
priority?: TaskPriority;
project?: string;
tags?: string[];
git?: Partial<TaskGit>;
}
export interface TaskFilters {
status?: TaskStatus | TaskStatus[];
type?: TaskType | TaskType[];
project?: string;
search?: string;
}
// Config Types
export interface RepoConfig {
name: string;
path: string;
defaultBranch: string;
}
export interface AgentConfig {
type: AgentType;
name: string;
command: string;
args: string[];
enabled: boolean;
}
export interface AppConfig {
repos: RepoConfig[];
agents: AgentConfig[];
defaultAgent: AgentType;
}
// WebSocket Message Types
export type WSMessageType =
| 'agent:output'
| 'agent:status'
| 'agent:complete'
| 'task:updated'
| 'error';
export interface WSMessage {
type: WSMessageType;
taskId?: string;
attemptId?: string;
data: unknown;
timestamp: string;
}
export interface AgentOutputMessage extends WSMessage {
type: 'agent:output';
data: {
stream: 'stdout' | 'stderr';
content: string;
};
}
export interface AgentStatusMessage extends WSMessage {
type: 'agent:status';
data: {
status: AttemptStatus;
exitCode?: number;
};
}

19
shared/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

0
tasks/active/.gitkeep Normal file
View file

0
tasks/archive/.gitkeep Normal file
View file

13
web/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Veritas Kanban</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

45
web/package.json Normal file
View file

@ -0,0 +1,45 @@
{
"name": "@veritas-kanban/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"clean": "rm -rf dist"
},
"dependencies": {
"@veritas-kanban/shared": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-toast": "^1.2.4",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}

6
web/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

17
web/src/App.tsx Normal file
View file

@ -0,0 +1,17 @@
import { KanbanBoard } from './components/board/KanbanBoard';
import { Header } from './components/layout/Header';
import { Toaster } from './components/ui/toaster';
function App() {
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-6">
<KanbanBoard />
</main>
<Toaster />
</div>
);
}
export default App;

View file

@ -0,0 +1,107 @@
import { useTasks, useTasksByStatus, useUpdateTask } from '@/hooks/useTasks';
import { KanbanColumn } from './KanbanColumn';
import type { TaskStatus } from '@veritas-kanban/shared';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { useState } from 'react';
import { TaskCard } from '@/components/task/TaskCard';
import type { Task } from '@veritas-kanban/shared';
const COLUMNS: { id: TaskStatus; title: string }[] = [
{ id: 'todo', title: 'To Do' },
{ id: 'in-progress', title: 'In Progress' },
{ id: 'review', title: 'Review' },
{ id: 'done', title: 'Done' },
];
export function KanbanBoard() {
const { data: tasks, isLoading, error } = useTasks();
const tasksByStatus = useTasksByStatus(tasks);
const updateTask = useUpdateTask();
const [activeTask, setActiveTask] = useState<Task | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
const handleDragStart = (event: DragStartEvent) => {
const task = tasks?.find(t => t.id === event.active.id);
if (task) {
setActiveTask(task);
}
};
const handleDragEnd = (event: DragEndEvent) => {
setActiveTask(null);
const { active, over } = event;
if (!over) return;
const taskId = active.id as string;
const newStatus = over.id as TaskStatus;
const task = tasks?.find(t => t.id === taskId);
if (task && task.status !== newStatus) {
updateTask.mutate({
id: taskId,
input: { status: newStatus },
});
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-muted-foreground">Loading tasks...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-destructive">
Error loading tasks: {error.message}
</div>
</div>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-4 gap-4">
{COLUMNS.map(column => (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
tasks={tasksByStatus[column.id]}
/>
))}
</div>
<DragOverlay>
{activeTask ? (
<TaskCard task={activeTask} isDragging />
) : null}
</DragOverlay>
</DndContext>
);
}

View file

@ -0,0 +1,53 @@
import { useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { TaskCard } from '@/components/task/TaskCard';
import type { Task, TaskStatus } from '@veritas-kanban/shared';
interface KanbanColumnProps {
id: TaskStatus;
title: string;
tasks: Task[];
}
const columnColors: Record<TaskStatus, string> = {
'todo': 'border-t-slate-500',
'in-progress': 'border-t-blue-500',
'review': 'border-t-amber-500',
'done': 'border-t-green-500',
};
export function KanbanColumn({ id, title, tasks }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
className={cn(
'flex flex-col rounded-lg bg-muted/50 border-t-2',
columnColors[id],
isOver && 'ring-2 ring-primary/50'
)}
>
<div className="flex items-center justify-between px-3 py-2">
<h2 className="text-sm font-medium text-muted-foreground">
{title}
</h2>
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{tasks.length}
</span>
</div>
<div className="flex-1 p-2 space-y-2 min-h-[calc(100vh-200px)] overflow-y-auto">
{tasks.length === 0 ? (
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
No tasks
</div>
) : (
tasks.map(task => (
<TaskCard key={task.id} task={task} />
))
)}
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
import { Plus, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CreateTaskDialog } from '@/components/task/CreateTaskDialog';
import { useState } from 'react';
export function Header() {
const [createOpen, setCreateOpen] = useState(false);
return (
<header className="border-b border-border bg-card">
<div className="container mx-auto px-4">
<div className="flex h-14 items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xl"></span>
<h1 className="text-lg font-semibold">Veritas Kanban</h1>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4 mr-1" />
New Task
</Button>
<Button variant="ghost" size="icon">
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<CreateTaskDialog open={createOpen} onOpenChange={setCreateOpen} />
</header>
);
}

View file

@ -0,0 +1,144 @@
import { useState } 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 { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCreateTask } from '@/hooks/useTasks';
import type { TaskType, TaskPriority } from '@veritas-kanban/shared';
interface CreateTaskDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [type, setType] = useState<TaskType>('code');
const [priority, setPriority] = useState<TaskPriority>('medium');
const [project, setProject] = useState('');
const createTask = useCreateTask();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
await createTask.mutateAsync({
title: title.trim(),
description: description.trim(),
type,
priority,
project: project.trim() || undefined,
});
// Reset form
setTitle('');
setDescription('');
setType('code');
setPriority('medium');
setProject('');
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Create New Task</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter task title..."
autoFocus
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe the task..."
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="type">Type</Label>
<Select value={type} onValueChange={(v) => setType(v as TaskType)}>
<SelectTrigger>
<SelectValue />
</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 htmlFor="priority">Priority</Label>
<Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="project">Project (optional)</Label>
<Input
id="project"
value={project}
onChange={(e) => setProject(e.target.value)}
placeholder="e.g., rubicon"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!title.trim() || createTask.isPending}>
{createTask.isPending ? 'Creating...' : 'Create Task'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,87 @@
import { useDraggable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import type { Task, TaskType, TaskPriority } from '@veritas-kanban/shared';
import { Code, Search, FileText, Zap } from 'lucide-react';
interface TaskCardProps {
task: Task;
isDragging?: boolean;
}
const typeIcons: Record<TaskType, React.ReactNode> = {
code: <Code className="h-3.5 w-3.5" />,
research: <Search className="h-3.5 w-3.5" />,
content: <FileText className="h-3.5 w-3.5" />,
automation: <Zap className="h-3.5 w-3.5" />,
};
const typeColors: Record<TaskType, string> = {
code: 'border-l-violet-500',
research: 'border-l-cyan-500',
content: 'border-l-orange-500',
automation: 'border-l-emerald-500',
};
const priorityColors: Record<TaskPriority, string> = {
high: 'bg-red-500/20 text-red-400',
medium: 'bg-amber-500/20 text-amber-400',
low: 'bg-slate-500/20 text-slate-400',
};
export function TaskCard({ task, isDragging }: TaskCardProps) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: task.id,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={cn(
'group bg-card border border-border rounded-md p-3 cursor-grab active:cursor-grabbing',
'hover:border-muted-foreground/50 transition-colors',
'border-l-2',
typeColors[task.type],
isDragging && 'opacity-50 shadow-lg'
)}
>
<div className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5">
{typeIcons[task.type]}
</span>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium leading-tight truncate">
{task.title}
</h3>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{task.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-2">
{task.project && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{task.project}
</span>
)}
<span className={cn(
'text-xs px-1.5 py-0.5 rounded capitalize',
priorityColors[task.priority]
)}>
{task.priority}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,52 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View file

@ -0,0 +1,107 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View file

@ -0,0 +1,23 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View file

@ -0,0 +1,23 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View file

@ -0,0 +1,89 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
};

View file

@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';
export { Textarea };

View file

@ -0,0 +1,4 @@
// Placeholder toaster - we'll implement this properly later
export function Toaster() {
return null;
}

76
web/src/globals.css Normal file
View file

@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
}
/* Custom scrollbar for dark mode */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}

71
web/src/hooks/useTasks.ts Normal file
View file

@ -0,0 +1,71 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { Task, CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared';
export function useTasks() {
return useQuery({
queryKey: ['tasks'],
queryFn: api.tasks.list,
});
}
export function useTask(id: string) {
return useQuery({
queryKey: ['tasks', id],
queryFn: () => api.tasks.get(id),
enabled: !!id,
});
}
export function useCreateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateTaskInput) => api.tasks.create(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useUpdateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateTaskInput }) =>
api.tasks.update(id, input),
onSuccess: (task) => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.setQueryData(['tasks', task.id], task);
},
});
}
export function useDeleteTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.tasks.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useTasksByStatus(tasks: Task[] | undefined) {
if (!tasks) {
return {
todo: [],
'in-progress': [],
review: [],
done: [],
};
}
return {
todo: tasks.filter(t => t.status === 'todo'),
'in-progress': tasks.filter(t => t.status === 'in-progress'),
review: tasks.filter(t => t.status === 'review'),
done: tasks.filter(t => t.status === 'done'),
};
}

60
web/src/lib/api.ts Normal file
View file

@ -0,0 +1,60 @@
import type { Task, CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared';
const API_BASE = '/api';
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || `HTTP ${response.status}`);
}
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
export const api = {
tasks: {
list: async (): Promise<Task[]> => {
const response = await fetch(`${API_BASE}/tasks`);
return handleResponse<Task[]>(response);
},
get: async (id: string): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${id}`);
return handleResponse<Task>(response);
},
create: async (input: CreateTaskInput): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return handleResponse<Task>(response);
},
update: async (id: string, input: UpdateTaskInput): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return handleResponse<Task>(response);
},
delete: async (id: string): Promise<void> => {
const response = await fetch(`${API_BASE}/tasks/${id}`, {
method: 'DELETE',
});
return handleResponse<void>(response);
},
archive: async (id: string): Promise<void> => {
const response = await fetch(`${API_BASE}/tasks/${id}/archive`, {
method: 'POST',
});
return handleResponse<void>(response);
},
},
};

6
web/src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

22
web/src/main.tsx Normal file
View file

@ -0,0 +1,22 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './globals.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
refetchOnWindowFocus: true,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

49
web/tailwind.config.js Normal file
View file

@ -0,0 +1,49 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [require('tailwindcss-animate')],
};

25
web/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

25
web/vite.config.ts Normal file
View file

@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3001',
ws: true,
},
},
},
});