feat(web): task management with list view and kanban board (#86)

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #86.
This commit is contained in:
2026-03-13 13:28:17 +00:00
committed by jason.woltje
parent f0d1d4bafa
commit a1a1976b38
6 changed files with 283 additions and 2 deletions

View File

@@ -0,0 +1,72 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
import { KanbanBoard } from '@/components/tasks/kanban-board';
import { TaskListView } from '@/components/tasks/task-list-view';
type ViewMode = 'list' | 'kanban';
export default function TasksPage(): React.ReactElement {
const [tasks, setTasks] = useState<Task[]>([]);
const [view, setView] = useState<ViewMode>('kanban');
const [loading, setLoading] = useState(true);
useEffect(() => {
api<Task[]>('/api/tasks')
.then(setTasks)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleTaskClick = useCallback((task: Task) => {
// Task detail view will be added in future iteration
console.log('Task clicked:', task.id);
}, []);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold">Tasks</h1>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-surface-border">
<button
type="button"
onClick={() => setView('list')}
className={cn(
'px-3 py-1.5 text-xs transition-colors',
view === 'list'
? 'bg-surface-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary',
)}
>
List
</button>
<button
type="button"
onClick={() => setView('kanban')}
className={cn(
'px-3 py-1.5 text-xs transition-colors',
view === 'kanban'
? 'bg-surface-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary',
)}
>
Kanban
</button>
</div>
</div>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
) : view === 'kanban' ? (
<KanbanBoard tasks={tasks} onTaskClick={handleTaskClick} />
) : (
<TaskListView tasks={tasks} onTaskClick={handleTaskClick} />
)}
</div>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import type { Task, TaskStatus } from '@/lib/types';
import { TaskCard } from './task-card';
interface KanbanBoardProps {
tasks: Task[];
onTaskClick: (task: Task) => void;
}
const columns: { id: TaskStatus; label: string }[] = [
{ id: 'not-started', label: 'Not Started' },
{ id: 'in-progress', label: 'In Progress' },
{ id: 'blocked', label: 'Blocked' },
{ id: 'done', label: 'Done' },
];
export function KanbanBoard({ tasks, onTaskClick }: KanbanBoardProps): React.ReactElement {
return (
<div className="flex gap-4 overflow-x-auto pb-4">
{columns.map((col) => {
const columnTasks = tasks.filter((t) => t.status === col.id);
return (
<div
key={col.id}
className="flex w-72 shrink-0 flex-col rounded-lg border border-surface-border bg-surface-elevated"
>
<div className="flex items-center justify-between border-b border-surface-border px-3 py-2">
<h3 className="text-sm font-medium text-text-secondary">{col.label}</h3>
<span className="rounded-full bg-surface-card px-2 py-0.5 text-xs text-text-muted">
{columnTasks.length}
</span>
</div>
<div className="flex-1 space-y-2 p-2">
{columnTasks.length === 0 && (
<p className="py-4 text-center text-xs text-text-muted">No tasks</p>
)}
{columnTasks.map((task) => (
<TaskCard key={task.id} task={task} onClick={onTaskClick} />
))}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
interface TaskCardProps {
task: Task;
onClick: (task: Task) => void;
}
const priorityColors: Record<string, string> = {
critical: 'text-error',
high: 'text-warning',
medium: 'text-blue-400',
low: 'text-text-muted',
};
const statusBadgeColors: Record<string, string> = {
'not-started': 'bg-gray-600/20 text-gray-300',
'in-progress': 'bg-blue-600/20 text-blue-400',
blocked: 'bg-error/20 text-error',
done: 'bg-success/20 text-success',
cancelled: 'bg-gray-600/20 text-gray-500',
};
export function TaskCard({ task, onClick }: TaskCardProps): React.ReactElement {
return (
<button
type="button"
onClick={() => onClick(task)}
className="w-full rounded-lg border border-surface-border bg-surface-card p-3 text-left transition-colors hover:border-gray-500"
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium text-text-primary">{task.title}</span>
<span className={cn('text-xs', priorityColors[task.priority])}>{task.priority}</span>
</div>
{task.description && (
<p className="mt-1 line-clamp-2 text-xs text-text-muted">{task.description}</p>
)}
<div className="mt-2 flex items-center gap-2">
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
statusBadgeColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{task.status}
</span>
{task.dueDate && (
<span className="text-xs text-text-muted">
{new Date(task.dueDate).toLocaleDateString()}
</span>
)}
</div>
</button>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
interface TaskListViewProps {
tasks: Task[];
onTaskClick: (task: Task) => void;
}
const priorityColors: Record<string, string> = {
critical: 'text-error',
high: 'text-warning',
medium: 'text-blue-400',
low: 'text-text-muted',
};
const statusColors: Record<string, string> = {
'not-started': 'text-gray-400',
'in-progress': 'text-blue-400',
blocked: 'text-error',
done: 'text-success',
cancelled: 'text-gray-500',
};
export function TaskListView({ tasks, onTaskClick }: TaskListViewProps): React.ReactElement {
if (tasks.length === 0) {
return <p className="py-8 text-center text-sm text-text-muted">No tasks found</p>;
}
return (
<div className="overflow-hidden rounded-lg border border-surface-border">
<table className="w-full">
<thead>
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
<th className="px-4 py-2 font-medium">Title</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Priority</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Due</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => (
<tr
key={task.id}
onClick={() => onTaskClick(task)}
className="cursor-pointer border-b border-surface-border transition-colors last:border-b-0 hover:bg-surface-elevated"
>
<td className="px-4 py-3 text-sm text-text-primary">{task.title}</td>
<td className="px-4 py-3">
<span className={cn('text-xs', statusColors[task.status])}>{task.status}</span>
</td>
<td className="px-4 py-3">
<span className={cn('text-xs', priorityColors[task.priority])}>
{task.priority}
</span>
</td>
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -17,3 +17,41 @@ export interface Message {
metadata?: Record<string, unknown>;
createdAt: string;
}
/** Task statuses. */
export type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
/** Task priorities. */
export type TaskPriority = 'critical' | 'high' | 'medium' | 'low';
/** Task returned by the gateway API. */
export interface Task {
id: string;
title: string;
description: string | null;
status: TaskStatus;
priority: TaskPriority;
projectId: string | null;
missionId: string | null;
assignee: string | null;
tags: string[] | null;
dueDate: string | null;
metadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
/** Project statuses. */
export type ProjectStatus = 'active' | 'paused' | 'completed' | 'archived';
/** Project returned by the gateway API. */
export interface Project {
id: string;
name: string;
description: string | null;
status: ProjectStatus;
userId: string;
metadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}