feat(web): project detail views — missions, tasks, PRD viewer (#122) (#140)
Some checks failed
ci/woodpecker/push/ci Pipeline failed

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #140.
This commit is contained in:
2026-03-15 18:28:14 +00:00
committed by jason.woltje
parent d42cd68ea4
commit f208f72dc0
7 changed files with 883 additions and 3 deletions

View File

@@ -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<MissionStatus, { dot: string; label: string; badge: string }> = {
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 (
<div className="py-6 text-center">
<p className="text-sm text-text-muted">No missions for this project</p>
</div>
);
}
const sorted = sortMissions(missions);
return (
<ol className="relative space-y-0">
{sorted.map((mission, idx) => {
const styles = statusStyles[mission.status] ?? statusStyles.planning;
const isLast = idx === sorted.length - 1;
return (
<li key={mission.id} className="relative flex gap-4 pb-6 last:pb-0">
{/* Vertical line */}
{!isLast && <div className="absolute left-2.5 top-5 h-full w-px bg-surface-border" />}
{/* Status dot */}
<div
className={cn('mt-1 h-5 w-5 shrink-0 rounded-full border-2 border-bg', styles.dot)}
/>
<div className="flex-1 rounded-lg border border-surface-border bg-surface-card p-3">
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium text-text-primary">{mission.name}</span>
<span className={cn('shrink-0 rounded-full px-2 py-0.5 text-xs', styles.badge)}>
{mission.status}
</span>
</div>
{mission.description && (
<p className="mt-1 text-xs text-text-muted">{mission.description}</p>
)}
<p className="mt-2 text-xs text-text-muted">
Created {new Date(mission.createdAt).toLocaleDateString()}
</p>
</div>
</li>
);
})}
</ol>
);
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Code blocks (must run before inline code)
html = html.replace(/```[\w]*\n?([\s\S]*?)```/g, (_match, code: string) => {
return `<pre class="overflow-x-auto rounded-lg bg-surface-elevated p-4 my-4"><code class="text-xs text-text-secondary whitespace-pre">${code.trim()}</code></pre>`;
});
// Headings
html = html.replace(
/^#### (.+)$/gm,
'<h4 class="text-sm font-semibold text-text-primary mt-4 mb-1">$1</h4>',
);
html = html.replace(
/^### (.+)$/gm,
'<h3 class="text-base font-semibold text-text-primary mt-6 mb-2">$1</h3>',
);
html = html.replace(
/^## (.+)$/gm,
'<h2 class="text-lg font-semibold text-text-primary mt-8 mb-3">$1</h2>',
);
html = html.replace(
/^# (.+)$/gm,
'<h1 class="text-xl font-bold text-text-primary mt-8 mb-4">$1</h1>',
);
// Horizontal rule
html = html.replace(/^---$/gm, '<hr class="my-6 border-surface-border" />');
// Unordered list items
html = html.replace(
/^[-*] (.+)$/gm,
'<li class="ml-4 text-sm text-text-secondary list-disc">$1</li>',
);
// Ordered list items
html = html.replace(
/^\d+\. (.+)$/gm,
'<li class="ml-4 text-sm text-text-secondary list-decimal">$1</li>',
);
// Bold + italic
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
// Bold
html = html.replace(
/\*\*(.+?)\*\*/g,
'<strong class="font-semibold text-text-primary">$1</strong>',
);
// Italic
html = html.replace(/\*(.+?)\*/g, '<em class="italic text-text-secondary">$1</em>');
// Inline code
html = html.replace(
/`([^`]+)`/g,
'<code class="rounded bg-surface-elevated px-1 py-0.5 text-xs text-text-secondary">$1</code>',
);
// 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('<h') ||
trimmed.startsWith('<pre') ||
trimmed.startsWith('<li') ||
trimmed.startsWith('<hr')
) {
result.push(trimmed);
} else {
result.push(`<p class="text-sm text-text-secondary leading-relaxed my-2">${trimmed}</p>`);
}
}
return result.join('\n');
}
export function PrdViewer({ content }: PrdViewerProps): React.ReactElement {
const html = renderMarkdown(content);
return <div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: html }} />;
}

View File

@@ -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<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',
};
function DetailRow({
label,
children,
}: {
label: string;
children: ReactNode;
}): React.ReactElement {
return (
<div className="flex gap-2 py-2 text-sm">
<span className="w-24 shrink-0 text-text-muted">{label}</span>
<span className="text-text-primary">{children}</span>
</div>
);
}
export function TaskDetailModal({ task, onClose }: TaskDetailModalProps): React.ReactElement {
const dialogRef = useRef<HTMLDivElement>(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 */
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="task-detail-title"
tabIndex={-1}
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-surface-border bg-surface-card outline-none"
>
{/* Header */}
<div className="flex items-start justify-between gap-3 border-b border-surface-border p-4">
<h2 id="task-detail-title" className="text-base font-semibold text-text-primary">
{task.title}
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close task details"
className="shrink-0 rounded p-1 text-text-muted hover:text-text-primary"
>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4">
{/* Badges */}
<div className="mb-4 flex flex-wrap 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>
<span className={cn('text-xs', priorityColors[task.priority])}>
{task.priority} priority
</span>
</div>
{/* Description */}
{task.description && (
<div className="mb-4 rounded-lg bg-surface-elevated p-3">
<p className="text-sm text-text-secondary">{task.description}</p>
</div>
)}
{/* Details */}
<div className="divide-y divide-surface-border rounded-lg border border-surface-border px-3">
{task.assignee ? <DetailRow label="Assignee">{task.assignee}</DetailRow> : null}
{task.dueDate ? (
<DetailRow label="Due date">
{new Date(task.dueDate).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</DetailRow>
) : null}
<DetailRow label="Created">
{new Date(task.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</DetailRow>
<DetailRow label="Updated">
{new Date(task.updatedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</DetailRow>
</div>
{/* Tags */}
{task.tags != null && task.tags.length > 0 ? (
<div className="mt-4">
<p className="mb-2 text-xs text-text-muted">Tags</p>
<div className="flex flex-wrap gap-1">
{task.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-surface-elevated px-2 py-0.5 text-xs text-text-secondary"
>
{tag}
</span>
))}
</div>
</div>
) : null}
{/* PR Links */}
{prLinks.length > 0 ? (
<div className="mt-4">
<p className="mb-2 text-xs text-text-muted">Pull Requests</p>
<ul className="space-y-1">
{prLinks.map((link) => (
<li key={link.url}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 underline hover:text-blue-300"
>
{link.label ?? link.url}
</a>
</li>
))}
</ul>
</div>
) : null}
{/* Notes from metadata */}
<NotesSection notes={getNotesString(task.metadata)} />
</div>
</div>
</div>
);
}
interface PrLink {
url: string;
label?: string;
}
function extractPrLinks(metadata: Record<string, unknown> | 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<string, unknown> | 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 (
<div className="mt-4">
<p className="mb-2 text-xs text-text-muted">Notes</p>
<div className="rounded-lg bg-surface-elevated p-3">
<p className="whitespace-pre-wrap text-xs text-text-secondary">{notes}</p>
</div>
</div>
);
}

View File

@@ -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<TaskStatus | 'all', number> = {
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 (
<div className="flex flex-wrap gap-2">
{statusConfig.map((config) => {
const count = counts[config.id];
const isActive = activeFilter === config.id;
return (
<button
key={config.id}
type="button"
onClick={() => onFilterChange(config.id)}
className={cn(
'flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors',
isActive
? cn('border-transparent', config.activeColor)
: 'border-surface-border text-text-muted hover:border-gray-500',
)}
>
<span>{config.label}</span>
<span
className={cn(
'rounded-full px-1.5 py-0.5 text-xs font-medium',
isActive ? 'bg-black/20' : 'bg-surface-elevated',
)}
>
{count}
</span>
</button>
);
})}
</div>
);
}