From f208f72dc0f3d340c25d1f1bc1a547441a94d421 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 18:28:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20project=20detail=20views=20?= =?UTF-8?q?=E2=80=94=20missions,=20tasks,=20PRD=20viewer=20(#122)=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../app/(dashboard)/projects/[id]/page.tsx | 338 ++++++++++++++++++ .../web/src/app/(dashboard)/projects/page.tsx | 11 +- .../components/projects/mission-timeline.tsx | 92 +++++ .../src/components/projects/prd-viewer.tsx | 99 +++++ .../components/tasks/task-detail-modal.tsx | 231 ++++++++++++ .../components/tasks/task-status-summary.tsx | 99 +++++ apps/web/src/lib/types.ts | 16 + 7 files changed, 883 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/projects/[id]/page.tsx create mode 100644 apps/web/src/components/projects/mission-timeline.tsx create mode 100644 apps/web/src/components/projects/prd-viewer.tsx create mode 100644 apps/web/src/components/tasks/task-detail-modal.tsx create mode 100644 apps/web/src/components/tasks/task-status-summary.tsx diff --git a/apps/web/src/app/(dashboard)/projects/[id]/page.tsx b/apps/web/src/app/(dashboard)/projects/[id]/page.tsx new file mode 100644 index 0000000..7db4bb9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/projects/[id]/page.tsx @@ -0,0 +1,338 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/cn'; +import type { Mission, Project, Task, TaskStatus } from '@/lib/types'; +import { MissionTimeline } from '@/components/projects/mission-timeline'; +import { PrdViewer } from '@/components/projects/prd-viewer'; +import { TaskDetailModal } from '@/components/tasks/task-detail-modal'; +import { TaskListView } from '@/components/tasks/task-list-view'; +import { TaskStatusSummary } from '@/components/tasks/task-status-summary'; + +type Tab = 'overview' | 'tasks' | 'missions' | 'prd'; + +const statusColors: Record = { + active: 'bg-success/20 text-success', + paused: 'bg-warning/20 text-warning', + completed: 'bg-blue-600/20 text-blue-400', + archived: 'bg-gray-600/20 text-gray-400', +}; + +interface TabButtonProps { + id: Tab; + label: string; + activeTab: Tab; + onClick: (tab: Tab) => void; +} + +function TabButton({ id, label, activeTab, onClick }: TabButtonProps): React.ReactElement { + return ( + + ); +} + +export default function ProjectDetailPage(): React.ReactElement { + const params = useParams(); + const router = useRouter(); + const id = typeof params['id'] === 'string' ? params['id'] : ''; + + const [project, setProject] = useState(null); + const [missions, setMissions] = useState([]); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [activeTab, setActiveTab] = useState('overview'); + const [taskFilter, setTaskFilter] = useState('all'); + const [selectedTask, setSelectedTask] = useState(null); + + useEffect(() => { + if (!id) return; + + setLoading(true); + setError(null); + + Promise.all([ + api(`/api/projects/${id}`), + api('/api/missions').catch(() => [] as Mission[]), + api(`/api/tasks?projectId=${id}`).catch(() => [] as Task[]), + ]) + .then(([proj, allMissions, tks]) => { + setProject(proj); + setMissions(allMissions.filter((m) => m.projectId === id)); + setTasks(tks); + }) + .catch((err: Error) => { + setError(err.message ?? 'Failed to load project'); + }) + .finally(() => setLoading(false)); + }, [id]); + + const handleTaskClick = useCallback((task: Task) => { + setSelectedTask(task); + }, []); + + const handleCloseTaskModal = useCallback(() => { + setSelectedTask(null); + }, []); + + if (loading) { + return ( +
+

Loading project...

+
+ ); + } + + if (error || !project) { + return ( +
+

{error ?? 'Project not found'}

+ +
+ ); + } + + const filteredTasks = taskFilter === 'all' ? tasks : tasks.filter((t) => t.status === taskFilter); + + const prdContent = getPrdContent(project); + const hasPrd = Boolean(prdContent); + + const tabs: { id: Tab; label: string }[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'tasks', label: `Tasks (${tasks.length})` }, + { id: 'missions', label: `Missions (${missions.length})` }, + ...(hasPrd ? [{ id: 'prd' as Tab, label: 'PRD' }] : []), + ]; + + return ( +
+ {/* Breadcrumb */} + + + {/* Project header */} +
+
+
+

{project.name}

+ + {project.status} + +
+ {project.description && ( +

{project.description}

+ )} +

+ Created {new Date(project.createdAt).toLocaleDateString()} ยท Updated{' '} + {new Date(project.updatedAt).toLocaleDateString()} +

+
+
+ + {/* Stats bar */} +
+ + t.status === 'done').length)} + valueClass="text-success" + /> + t.status === 'in-progress').length)} + valueClass="text-blue-400" + /> + t.status === 'blocked').length)} + valueClass={tasks.some((t) => t.status === 'blocked') ? 'text-error' : undefined} + /> +
+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Tab content */} + {activeTab === 'overview' && ( + + )} + + {activeTab === 'tasks' && ( +
+
+ +
+ +
+ )} + + {activeTab === 'missions' && } + + {activeTab === 'prd' && prdContent && ( +
+ +
+ )} + + {/* Task detail modal */} + {selectedTask && } +
+ ); +} + +interface OverviewTabProps { + project: Project; + missions: Mission[]; + tasks: Task[]; +} + +function OverviewTab({ project, missions, tasks }: OverviewTabProps): React.ReactElement { + const recentTasks = [...tasks] + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) + .slice(0, 5); + + return ( +
+ {/* Recent tasks */} +
+

Recent Tasks

+ {recentTasks.length === 0 ? ( +
+

No tasks yet

+
+ ) : ( +
+ {recentTasks.map((task) => ( + + ))} +
+ )} +
+ + {/* Mission summary */} +
+

Missions

+ {missions.length === 0 ? ( +
+

No missions yet

+
+ ) : ( + + )} +
+ + {/* Metadata */} + {project.metadata && Object.keys(project.metadata).length > 0 && ( +
+

Project Metadata

+
+
+              {JSON.stringify(project.metadata, null, 2)}
+            
+
+
+ )} +
+ ); +} + +const taskStatusColors: Record = { + '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', +}; + +function TaskSummaryRow({ task }: { task: Task }): React.ReactElement { + return ( +
+ {task.title} + + {task.status} + +
+ ); +} + +function StatCard({ + label, + value, + valueClass, +}: { + label: string; + value: string; + valueClass?: string; +}): React.ReactElement { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function getPrdContent(project: Project): string | null { + if (!project.metadata) return null; + + const prd = project.metadata['prd']; + if (typeof prd === 'string' && prd.trim().length > 0) return prd; + + const prdContent = project.metadata['prdContent']; + if (typeof prdContent === 'string' && prdContent.trim().length > 0) return prdContent; + + return null; +} diff --git a/apps/web/src/app/(dashboard)/projects/page.tsx b/apps/web/src/app/(dashboard)/projects/page.tsx index a5b3510..bd0d662 100644 --- a/apps/web/src/app/(dashboard)/projects/page.tsx +++ b/apps/web/src/app/(dashboard)/projects/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { api } from '@/lib/api'; import type { Project } from '@/lib/types'; import { ProjectCard } from '@/components/projects/project-card'; @@ -8,6 +9,7 @@ import { ProjectCard } from '@/components/projects/project-card'; export default function ProjectsPage(): React.ReactElement { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); + const router = useRouter(); useEffect(() => { api('/api/projects') @@ -16,9 +18,12 @@ export default function ProjectsPage(): React.ReactElement { .finally(() => setLoading(false)); }, []); - const handleProjectClick = useCallback((project: Project) => { - console.log('Project clicked:', project.id); - }, []); + const handleProjectClick = useCallback( + (project: Project) => { + router.push(`/projects/${project.id}`); + }, + [router], + ); return (
diff --git a/apps/web/src/components/projects/mission-timeline.tsx b/apps/web/src/components/projects/mission-timeline.tsx new file mode 100644 index 0000000..e844fdb --- /dev/null +++ b/apps/web/src/components/projects/mission-timeline.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { cn } from '@/lib/cn'; +import type { Mission, MissionStatus } from '@/lib/types'; + +interface MissionTimelineProps { + missions: Mission[]; +} + +const statusOrder: MissionStatus[] = ['planning', 'active', 'paused', 'completed', 'failed']; + +const statusStyles: Record = { + planning: { + dot: 'bg-gray-400', + label: 'text-gray-400', + badge: 'bg-gray-600/20 text-gray-300', + }, + active: { + dot: 'bg-blue-400', + label: 'text-blue-400', + badge: 'bg-blue-600/20 text-blue-400', + }, + paused: { + dot: 'bg-warning', + label: 'text-warning', + badge: 'bg-warning/20 text-warning', + }, + completed: { + dot: 'bg-success', + label: 'text-success', + badge: 'bg-success/20 text-success', + }, + failed: { + dot: 'bg-error', + label: 'text-error', + badge: 'bg-error/20 text-error', + }, +}; + +function sortMissions(missions: Mission[]): Mission[] { + return [...missions].sort((a, b) => { + const aIdx = statusOrder.indexOf(a.status); + const bIdx = statusOrder.indexOf(b.status); + if (aIdx !== bIdx) return aIdx - bIdx; + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + }); +} + +export function MissionTimeline({ missions }: MissionTimelineProps): React.ReactElement { + if (missions.length === 0) { + return ( +
+

No missions for this project

+
+ ); + } + + const sorted = sortMissions(missions); + + return ( +
    + {sorted.map((mission, idx) => { + const styles = statusStyles[mission.status] ?? statusStyles.planning; + const isLast = idx === sorted.length - 1; + return ( +
  1. + {/* Vertical line */} + {!isLast &&
    } + {/* Status dot */} +
    +
    +
    + {mission.name} + + {mission.status} + +
    + {mission.description && ( +

    {mission.description}

    + )} +

    + Created {new Date(mission.createdAt).toLocaleDateString()} +

    +
    +
  2. + ); + })} +
+ ); +} diff --git a/apps/web/src/components/projects/prd-viewer.tsx b/apps/web/src/components/projects/prd-viewer.tsx new file mode 100644 index 0000000..46c49bf --- /dev/null +++ b/apps/web/src/components/projects/prd-viewer.tsx @@ -0,0 +1,99 @@ +'use client'; + +interface PrdViewerProps { + content: string; +} + +/** + * Lightweight markdown-to-HTML renderer for PRD content. + * Supports headings, bold, italic, inline code, code blocks, and lists. + * No external dependency โ€” keeps bundle size minimal. + */ +function renderMarkdown(md: string): string { + let html = md + // Escape HTML entities first to prevent XSS + .replace(/&/g, '&') + .replace(//g, '>'); + + // Code blocks (must run before inline code) + html = html.replace(/```[\w]*\n?([\s\S]*?)```/g, (_match, code: string) => { + return `
${code.trim()}
`; + }); + + // Headings + html = html.replace( + /^#### (.+)$/gm, + '

$1

', + ); + html = html.replace( + /^### (.+)$/gm, + '

$1

', + ); + html = html.replace( + /^## (.+)$/gm, + '

$1

', + ); + html = html.replace( + /^# (.+)$/gm, + '

$1

', + ); + + // Horizontal rule + html = html.replace(/^---$/gm, '
'); + + // Unordered list items + html = html.replace( + /^[-*] (.+)$/gm, + '
  • $1
  • ', + ); + + // Ordered list items + html = html.replace( + /^\d+\. (.+)$/gm, + '
  • $1
  • ', + ); + + // Bold + italic + html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); + // Bold + html = html.replace( + /\*\*(.+?)\*\*/g, + '$1', + ); + // Italic + html = html.replace(/\*(.+?)\*/g, '$1'); + + // Inline code + html = html.replace( + /`([^`]+)`/g, + '$1', + ); + + // Paragraphs โ€” wrap lines that aren't already wrapped in a block element + const lines = html.split('\n'); + const result: string[] = []; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '') { + result.push(''); + } else if ( + trimmed.startsWith('${trimmed}

    `); + } + } + + return result.join('\n'); +} + +export function PrdViewer({ content }: PrdViewerProps): React.ReactElement { + const html = renderMarkdown(content); + + return
    ; +} diff --git a/apps/web/src/components/tasks/task-detail-modal.tsx b/apps/web/src/components/tasks/task-detail-modal.tsx new file mode 100644 index 0000000..52fa661 --- /dev/null +++ b/apps/web/src/components/tasks/task-detail-modal.tsx @@ -0,0 +1,231 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import type { ReactNode } from 'react'; +import { cn } from '@/lib/cn'; +import type { Task } from '@/lib/types'; + +interface TaskDetailModalProps { + task: Task; + onClose: () => void; +} + +const priorityColors: Record = { + critical: 'text-error', + high: 'text-warning', + medium: 'text-blue-400', + low: 'text-text-muted', +}; + +const statusBadgeColors: Record = { + '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', +}; + +function DetailRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}): React.ReactElement { + return ( +
    + {label} + {children} +
    + ); +} + +export function TaskDetailModal({ task, onClose }: TaskDetailModalProps): React.ReactElement { + const dialogRef = useRef(null); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent): void { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // Trap focus on mount + useEffect(() => { + dialogRef.current?.focus(); + }, []); + + const prLinks = extractPrLinks(task.metadata); + + return ( + /* backdrop */ +
    { + if (e.target === e.currentTarget) onClose(); + }} + role="presentation" + > +
    + {/* Header */} +
    +

    + {task.title} +

    + +
    + + {/* Body */} +
    + {/* Badges */} +
    + + {task.status} + + + {task.priority} priority + +
    + + {/* Description */} + {task.description && ( +
    +

    {task.description}

    +
    + )} + + {/* Details */} +
    + {task.assignee ? {task.assignee} : null} + {task.dueDate ? ( + + {new Date(task.dueDate).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + ) : null} + + {new Date(task.createdAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + + {new Date(task.updatedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
    + + {/* Tags */} + {task.tags != null && task.tags.length > 0 ? ( +
    +

    Tags

    +
    + {task.tags.map((tag) => ( + + {tag} + + ))} +
    +
    + ) : null} + + {/* PR Links */} + {prLinks.length > 0 ? ( +
    +

    Pull Requests

    + +
    + ) : null} + + {/* Notes from metadata */} + +
    +
    +
    + ); +} + +interface PrLink { + url: string; + label?: string; +} + +function extractPrLinks(metadata: Record | null): PrLink[] { + if (!metadata) return []; + + const raw = metadata['pr_links'] ?? metadata['prLinks']; + if (!Array.isArray(raw)) return []; + + return raw + .filter((item): item is PrLink => { + if (typeof item === 'string') return true; + if (typeof item === 'object' && item !== null && 'url' in item) return true; + return false; + }) + .map((item): PrLink => { + if (typeof item === 'string') return { url: item }; + return item as PrLink; + }); +} + +function getNotesString(metadata: Record | null): string | null { + if (!metadata) return null; + const notes = metadata['notes']; + if (typeof notes === 'string' && notes.trim().length > 0) return notes; + return null; +} + +function NotesSection({ notes }: { notes: string | null }): React.ReactElement | null { + if (!notes) return null; + return ( +
    +

    Notes

    +
    +

    {notes}

    +
    +
    + ); +} diff --git a/apps/web/src/components/tasks/task-status-summary.tsx b/apps/web/src/components/tasks/task-status-summary.tsx new file mode 100644 index 0000000..9262173 --- /dev/null +++ b/apps/web/src/components/tasks/task-status-summary.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { cn } from '@/lib/cn'; +import type { Task, TaskStatus } from '@/lib/types'; + +interface TaskStatusSummaryProps { + tasks: Task[]; + activeFilter: TaskStatus | 'all'; + onFilterChange: (filter: TaskStatus | 'all') => void; +} + +const statusConfig: { + id: TaskStatus | 'all'; + label: string; + color: string; + activeColor: string; +}[] = [ + { + id: 'all', + label: 'All', + color: 'text-text-muted', + activeColor: 'bg-surface-elevated text-text-primary', + }, + { + id: 'not-started', + label: 'Not Started', + color: 'text-gray-400', + activeColor: 'bg-gray-600/20 text-gray-300', + }, + { + id: 'in-progress', + label: 'In Progress', + color: 'text-blue-400', + activeColor: 'bg-blue-600/20 text-blue-400', + }, + { + id: 'blocked', + label: 'Blocked', + color: 'text-error', + activeColor: 'bg-error/20 text-error', + }, + { + id: 'done', + label: 'Done', + color: 'text-success', + activeColor: 'bg-success/20 text-success', + }, +]; + +export function TaskStatusSummary({ + tasks, + activeFilter, + onFilterChange, +}: TaskStatusSummaryProps): React.ReactElement { + const counts: Record = { + all: tasks.length, + 'not-started': 0, + 'in-progress': 0, + blocked: 0, + done: 0, + cancelled: 0, + }; + + for (const task of tasks) { + counts[task.status] = (counts[task.status] ?? 0) + 1; + } + + return ( +
    + {statusConfig.map((config) => { + const count = counts[config.id]; + const isActive = activeFilter === config.id; + return ( + + ); + })} +
    + ); +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 233c98a..88184fe 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -52,6 +52,22 @@ export interface Project { description: string | null; status: ProjectStatus; userId: string; + ownerId?: string; + metadata: Record | null; + createdAt: string; + updatedAt: string; +} + +/** Mission statuses. */ +export type MissionStatus = 'planning' | 'active' | 'paused' | 'completed' | 'failed'; + +/** Mission returned by the gateway API. */ +export interface Mission { + id: string; + name: string; + description: string | null; + status: MissionStatus; + projectId: string | null; metadata: Record | null; createdAt: string; updatedAt: string;