refactor(ts): RoadmapPage → TypeScript (issues board + dev activity log)

This commit is contained in:
null 2026-07-04 22:25:35 -05:00
parent 14f1fedfd0
commit b912fd74be
1 changed files with 92 additions and 32 deletions

View File

@ -20,10 +20,64 @@ import {
RefreshCw, RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { errMessage } from '@/lib/utils';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface IssueLabel {
id: number | string;
name: string;
color: string;
}
interface Issue {
id: number | string;
number: number;
title: string;
body?: string | null;
html_url: string;
created_at: string;
comments?: number;
labels?: IssueLabel[];
}
interface Lane {
key: string;
emoji: string;
label: string;
borderColor: string;
textColor: string;
badgeClass: string;
}
interface Agent {
name?: string;
status?: string;
time?: string;
}
interface DevLogEntryData {
version?: string;
date?: string;
status?: string;
filesModified?: string[];
agents?: Agent[];
workCompleted?: string[];
}
interface RoadmapData {
issues?: Issue[];
fetchedAt?: string | null;
stale?: boolean;
}
interface DevLogData {
entries?: DevLogEntryData[];
}
/* ─── Priority lanes ─────────────────────────────────────────────────────── */ /* ─── Priority lanes ─────────────────────────────────────────────────────── */
const PRIORITY_LANES = [ const PRIORITY_LANES: Lane[] = [
{ key: 'critical', emoji: '🔴', label: 'CRITICAL', borderColor: 'border-t-red-500', textColor: 'text-red-500', badgeClass: 'bg-red-500/15 text-red-500 border-red-500/20' }, { key: 'critical', emoji: '🔴', label: 'CRITICAL', borderColor: 'border-t-red-500', textColor: 'text-red-500', badgeClass: 'bg-red-500/15 text-red-500 border-red-500/20' },
{ key: 'high', emoji: '🟠', label: 'HIGH', borderColor: 'border-t-orange-500', textColor: 'text-orange-500', badgeClass: 'bg-orange-500/15 text-orange-500 border-orange-500/20' }, { key: 'high', emoji: '🟠', label: 'HIGH', borderColor: 'border-t-orange-500', textColor: 'text-orange-500', badgeClass: 'bg-orange-500/15 text-orange-500 border-orange-500/20' },
{ key: 'medium', emoji: '🟡', label: 'MEDIUM', borderColor: 'border-t-yellow-500', textColor: 'text-yellow-500', badgeClass: 'bg-yellow-500/15 text-yellow-500 border-yellow-500/20' }, { key: 'medium', emoji: '🟡', label: 'MEDIUM', borderColor: 'border-t-yellow-500', textColor: 'text-yellow-500', badgeClass: 'bg-yellow-500/15 text-yellow-500 border-yellow-500/20' },
@ -33,7 +87,7 @@ const PRIORITY_LANES = [
/* ─── Helpers ────────────────────────────────────────────────────────────── */ /* ─── Helpers ────────────────────────────────────────────────────────────── */
function priorityFromLabels(labels = []) { function priorityFromLabels(labels: IssueLabel[] = []): string {
for (const l of labels) { for (const l of labels) {
if (l.name === 'priority:critical') return 'critical'; if (l.name === 'priority:critical') return 'critical';
if (l.name === 'priority:high') return 'high'; if (l.name === 'priority:high') return 'high';
@ -44,11 +98,11 @@ function priorityFromLabels(labels = []) {
return 'low'; return 'low';
} }
function cleanTitle(title) { function cleanTitle(title: string): string {
return title.replace(/^(CRITICAL|HIGH|MEDIUM|LOW|NICE[\s-]TO[\s-]HAVE)\s*:\s*/i, '').trim(); return title.replace(/^(CRITICAL|HIGH|MEDIUM|LOW|NICE[\s-]TO[\s-]HAVE)\s*:\s*/i, '').trim();
} }
function stripMarkdown(text) { function stripMarkdown(text?: string | null): string {
if (!text) return ''; if (!text) return '';
return text return text
.replace(/#{1,6}\s+[^\n]*/g, '') .replace(/#{1,6}\s+[^\n]*/g, '')
@ -64,7 +118,7 @@ function stripMarkdown(text) {
.trim(); .trim();
} }
function timeAgo(dateStr) { function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime(); const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000); const mins = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000); const hours = Math.floor(diff / 3600000);
@ -76,7 +130,7 @@ function timeAgo(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} }
function labelStyle(hex) { function labelStyle(hex: string): React.CSSProperties {
const r = parseInt(hex.slice(0, 2), 16); const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16); const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16); const b = parseInt(hex.slice(4, 6), 16);
@ -89,7 +143,7 @@ function labelStyle(hex) {
/* ─── Label chip ─────────────────────────────────────────────────────────── */ /* ─── Label chip ─────────────────────────────────────────────────────────── */
function LabelChip({ label }) { function LabelChip({ label }: { label: IssueLabel }) {
return ( return (
<span <span
style={labelStyle(label.color)} style={labelStyle(label.color)}
@ -102,7 +156,7 @@ function LabelChip({ label }) {
/* ─── Issue card ─────────────────────────────────────────────────────────── */ /* ─── Issue card ─────────────────────────────────────────────────────────── */
function IssueCard({ issue }) { function IssueCard({ issue }: { issue: Issue }) {
const typeLabels = issue.labels || []; const typeLabels = issue.labels || [];
const title = cleanTitle(issue.title); const title = cleanTitle(issue.title);
const preview = stripMarkdown(issue.body); const preview = stripMarkdown(issue.body);
@ -148,7 +202,7 @@ function IssueCard({ issue }) {
<Clock className="h-3 w-3 shrink-0" /> <Clock className="h-3 w-3 shrink-0" />
{timeAgo(issue.created_at)} {timeAgo(issue.created_at)}
</span> </span>
{issue.comments > 0 && ( {(issue.comments ?? 0) > 0 && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<MessageCircle className="h-3 w-3 shrink-0" /> <MessageCircle className="h-3 w-3 shrink-0" />
{issue.comments} {issue.comments}
@ -163,7 +217,7 @@ function IssueCard({ issue }) {
/* ─── Priority lane ──────────────────────────────────────────────────────── */ /* ─── Priority lane ──────────────────────────────────────────────────────── */
function PriorityLane({ lane, items }) { function PriorityLane({ lane, items }: { lane: Lane; items: Issue[] }) {
return ( return (
<section <section
aria-label={`${lane.label} priority`} aria-label={`${lane.label} priority`}
@ -189,13 +243,19 @@ function PriorityLane({ lane, items }) {
/* ─── Stats bar ──────────────────────────────────────────────────────────── */ /* ─── Stats bar ──────────────────────────────────────────────────────────── */
function StatsBar({ issues, fetchedAt, stale, onRefresh, refreshing }) { function StatsBar({ issues, fetchedAt, stale, onRefresh, refreshing }: {
const counts = Object.fromEntries(PRIORITY_LANES.map(l => [l.key, 0])); issues: Issue[];
fetchedAt?: string | null;
stale?: boolean;
onRefresh: () => void;
refreshing: boolean;
}) {
const counts: Record<string, number> = Object.fromEntries(PRIORITY_LANES.map(l => [l.key, 0]));
issues.forEach(issue => { issues.forEach(issue => {
const p = priorityFromLabels(issue.labels); const p = priorityFromLabels(issue.labels);
counts[p] = (counts[p] || 0) + 1; counts[p] = (counts[p] || 0) + 1;
}); });
const nonEmpty = PRIORITY_LANES.filter(l => counts[l.key] > 0); const nonEmpty = PRIORITY_LANES.filter(l => (counts[l.key] ?? 0) > 0);
return ( return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl border border-border/60 bg-muted/30 px-4 py-3 text-[12px]"> <div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl border border-border/60 bg-muted/30 px-4 py-3 text-[12px]">
@ -236,7 +296,7 @@ function StatsBar({ issues, fetchedAt, stale, onRefresh, refreshing }) {
/* ─── Dev log entry ──────────────────────────────────────────────────────── */ /* ─── Dev log entry ──────────────────────────────────────────────────────── */
function DevLogEntry({ entry }) { function DevLogEntry({ entry }: { entry: DevLogEntryData }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@ -262,10 +322,10 @@ function DevLogEntry({ entry }) {
{entry.status} {entry.status}
</Badge> </Badge>
)} )}
{entry.filesModified?.length > 0 && ( {(entry.filesModified?.length ?? 0) > 0 && (
<span className="text-xs text-muted-foreground flex items-center gap-0.5"> <span className="text-xs text-muted-foreground flex items-center gap-0.5">
<FileCode className="h-3 w-3" /> <FileCode className="h-3 w-3" />
{entry.filesModified.length} files {entry.filesModified?.length} files
</span> </span>
)} )}
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-auto transition-transform duration-200 group-data-[state=open]:rotate-180" /> <ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-auto transition-transform duration-200 group-data-[state=open]:rotate-180" />
@ -275,11 +335,11 @@ function DevLogEntry({ entry }) {
<CollapsibleContent> <CollapsibleContent>
<div className="mt-3 space-y-3 pl-1"> <div className="mt-3 space-y-3 pl-1">
{entry.agents?.length > 0 && ( {(entry.agents?.length ?? 0) > 0 && (
<div> <div>
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-2">Agents</p> <p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-2">Agents</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{entry.agents.map((agent, idx) => ( {entry.agents?.map((agent, idx) => (
<Badge key={idx} variant="outline" className={`text-[11px] ${ <Badge key={idx} variant="outline" className={`text-[11px] ${
agent.status === 'COMPLETED' ? 'bg-emerald-500/15 text-emerald-500 border-emerald-500/20' : agent.status === 'COMPLETED' ? 'bg-emerald-500/15 text-emerald-500 border-emerald-500/20' :
agent.status === 'IN PROGRESS' ? 'bg-yellow-500/15 text-yellow-500 border-yellow-500/20' : agent.status === 'IN PROGRESS' ? 'bg-yellow-500/15 text-yellow-500 border-yellow-500/20' :
@ -293,11 +353,11 @@ function DevLogEntry({ entry }) {
</div> </div>
)} )}
{entry.filesModified?.length > 0 && ( {(entry.filesModified?.length ?? 0) > 0 && (
<div> <div>
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1.5">Files Modified</p> <p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1.5">Files Modified</p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{entry.filesModified.map((file, idx) => ( {entry.filesModified?.map((file, idx) => (
<code key={idx} className="max-w-full break-all rounded border border-border/50 bg-muted/50 px-1.5 py-0.5 text-[11px] text-muted-foreground"> <code key={idx} className="max-w-full break-all rounded border border-border/50 bg-muted/50 px-1.5 py-0.5 text-[11px] text-muted-foreground">
{file} {file}
</code> </code>
@ -306,11 +366,11 @@ function DevLogEntry({ entry }) {
</div> </div>
)} )}
{entry.workCompleted?.length > 0 && ( {(entry.workCompleted?.length ?? 0) > 0 && (
<div> <div>
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1.5">Work Completed</p> <p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1.5">Work Completed</p>
<ul className="space-y-1"> <ul className="space-y-1">
{entry.workCompleted.map((work, idx) => ( {entry.workCompleted?.map((work, idx) => (
<li key={idx} className="text-sm text-muted-foreground flex items-start gap-1.5"> <li key={idx} className="text-sm text-muted-foreground flex items-start gap-1.5">
<span className="text-emerald-500 mt-0.5 shrink-0"></span> <span className="text-emerald-500 mt-0.5 shrink-0"></span>
<span className="min-w-0 break-words">{work}</span> <span className="min-w-0 break-words">{work}</span>
@ -330,20 +390,20 @@ function DevLogEntry({ entry }) {
/* ─── Main page ──────────────────────────────────────────────────────────── */ /* ─── Main page ──────────────────────────────────────────────────────────── */
export default function RoadmapPage() { export default function RoadmapPage() {
const [roadmapData, setRoadmapData] = useState(null); const [roadmapData, setRoadmapData] = useState<RoadmapData | null>(null);
const [devLogData, setDevLogData] = useState(null); const [devLogData, setDevLogData] = useState<DevLogData | null>(null);
const [roadmapLoading, setRoadmapLoading] = useState(true); const [roadmapLoading, setRoadmapLoading] = useState(true);
const [roadmapRefreshing,setRoadmapRefreshing]= useState(false); const [roadmapRefreshing,setRoadmapRefreshing]= useState(false);
const [devLogLoading, setDevLogLoading] = useState(false); const [devLogLoading, setDevLogLoading] = useState(false);
const [roadmapError, setRoadmapError] = useState(null); const [roadmapError, setRoadmapError] = useState<string | null>(null);
const [devLogError, setDevLogError] = useState(null); const [devLogError, setDevLogError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setRoadmapLoading(true); setRoadmapLoading(true);
api.roadmap() api.roadmap()
.then(data => { if (!cancelled) setRoadmapData(data); }) .then(data => { if (!cancelled) setRoadmapData(data as RoadmapData); })
.catch(err => { if (!cancelled) setRoadmapError(err.message || 'Failed to load issues'); }) .catch(err => { if (!cancelled) setRoadmapError(errMessage(err, 'Failed to load issues')); })
.finally(() => { if (!cancelled) setRoadmapLoading(false); }); .finally(() => { if (!cancelled) setRoadmapLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
@ -351,7 +411,7 @@ export default function RoadmapPage() {
const handleRefresh = () => { const handleRefresh = () => {
setRoadmapRefreshing(true); setRoadmapRefreshing(true);
api.roadmap(true) api.roadmap(true)
.then(data => setRoadmapData(data)) .then(data => setRoadmapData(data as RoadmapData))
.catch(() => {}) .catch(() => {})
.finally(() => setRoadmapRefreshing(false)); .finally(() => setRoadmapRefreshing(false));
}; };
@ -361,8 +421,8 @@ export default function RoadmapPage() {
let cancelled = false; let cancelled = false;
setDevLogLoading(true); setDevLogLoading(true);
api.devLog() api.devLog()
.then(data => { if (!cancelled) setDevLogData(data); }) .then(data => { if (!cancelled) setDevLogData(data as DevLogData); })
.catch(err => { if (!cancelled) setDevLogError(err.message || 'Failed to load activity log'); }) .catch(err => { if (!cancelled) setDevLogError(errMessage(err, 'Failed to load activity log')); })
.finally(() => { if (!cancelled) setDevLogLoading(false); }); .finally(() => { if (!cancelled) setDevLogLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [devLogData, devLogLoading]); }, [devLogData, devLogLoading]);
@ -480,7 +540,7 @@ export default function RoadmapPage() {
</div> </div>
) : devLogData ? ( ) : devLogData ? (
<div className="pt-2"> <div className="pt-2">
{devLogData.entries.map((entry, idx) => ( {devLogData.entries?.map((entry, idx) => (
<DevLogEntry key={entry.version || idx} entry={entry} /> <DevLogEntry key={entry.version || idx} entry={entry} />
))} ))}
</div> </div>