import React, { useCallback, useEffect, useState } from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; import { AlertCircle, ChevronDown, CircleDot, Clock, ExternalLink, FileCode, FileText, Loader2, MessageCircle, RefreshCw, } from 'lucide-react'; 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 ─────────────────────────────────────────────────────── */ 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: '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: 'low', emoji: '🔵', label: 'LOW', borderColor: 'border-t-blue-500', textColor: 'text-blue-500', badgeClass: 'bg-blue-500/15 text-blue-500 border-blue-500/20' }, { key: 'niceToHave', emoji: '💭', label: 'NICE TO HAVE', borderColor: 'border-t-border', textColor: 'text-muted-foreground', badgeClass: 'bg-muted/50 text-muted-foreground border-border/50' }, ]; /* ─── Helpers ────────────────────────────────────────────────────────────── */ function priorityFromLabels(labels: IssueLabel[] = []): string { for (const l of labels) { if (l.name === 'priority:critical') return 'critical'; if (l.name === 'priority:high') return 'high'; if (l.name === 'priority:medium') return 'medium'; if (l.name === 'priority:low') return 'low'; if (l.name === 'priority:nice-to-have') return 'niceToHave'; } return 'low'; } function cleanTitle(title: string): string { return title.replace(/^(CRITICAL|HIGH|MEDIUM|LOW|NICE[\s-]TO[\s-]HAVE)\s*:\s*/i, '').trim(); } function stripMarkdown(text?: string | null): string { if (!text) return ''; return text .replace(/#{1,6}\s+[^\n]*/g, '') .replace(/```[\s\S]*?```/g, '') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/^[-*+]\s+/gm, '') .replace(/\n\n+/g, ' ') .replace(/\n/g, ' ') .replace(/\s{2,}/g, ' ') .trim(); } function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; if (hours < 24) return `${hours}h ago`; if (days < 30) return `${days}d ago`; return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } function labelStyle(hex: string): React.CSSProperties { const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); return { backgroundColor: `rgba(${r},${g},${b},0.12)`, color: `#${hex}`, border: `1px solid rgba(${r},${g},${b},0.28)`, }; } /* ─── Label chip ─────────────────────────────────────────────────────────── */ function LabelChip({ label }: { label: IssueLabel }) { return ( {label.name} ); } /* ─── Issue card ─────────────────────────────────────────────────────────── */ function IssueCard({ issue }: { issue: Issue }) { const typeLabels = issue.labels || []; const title = cleanTitle(issue.title); const preview = stripMarkdown(issue.body); return ( {/* Title row */}

{title}

#{issue.number}
{/* Body preview */} {preview && (

{preview}

)} {/* Type labels */} {typeLabels.length > 0 && (
{typeLabels.map(label => ( ))}
)} {/* Footer */}
{timeAgo(issue.created_at)} {(issue.comments ?? 0) > 0 && ( {issue.comments} )}
); } /* ─── Priority lane ──────────────────────────────────────────────────────── */ function PriorityLane({ lane, items }: { lane: Lane; items: Issue[] }) { return (

{lane.label}

{items.length}
{items.map(issue => ( ))}
); } /* ─── Stats bar ──────────────────────────────────────────────────────────── */ function StatsBar({ issues, fetchedAt, stale, onRefresh, refreshing }: { issues: Issue[]; fetchedAt?: string | null; stale?: boolean; onRefresh: () => void; refreshing: boolean; }) { const counts: Record = Object.fromEntries(PRIORITY_LANES.map(l => [l.key, 0])); issues.forEach(issue => { const p = priorityFromLabels(issue.labels); counts[p] = (counts[p] || 0) + 1; }); const nonEmpty = PRIORITY_LANES.filter(l => (counts[l.key] ?? 0) > 0); return (
{issues.length} open {nonEmpty.map(lane => ( · {lane.emoji} {counts[lane.key]} {lane.label} ))}
{fetchedAt && ( {stale && ⚠ stale ·} {timeAgo(fetchedAt)} )}
); } /* ─── Dev log entry ──────────────────────────────────────────────────────── */ function DevLogEntry({ entry }: { entry: DevLogEntryData }) { const [open, setOpen] = useState(false); return (
{(entry.agents?.length ?? 0) > 0 && (

Agents

{entry.agents?.map((agent, idx) => ( {agent.status === 'COMPLETED' ? '✅' : agent.status === 'IN PROGRESS' ? '⏳' : '❓'}{' '} {agent.name}{agent.time ? ` · ${agent.time}` : ''} ))}
)} {(entry.filesModified?.length ?? 0) > 0 && (

Files Modified

{entry.filesModified?.map((file, idx) => ( {file} ))}
)} {(entry.workCompleted?.length ?? 0) > 0 && (

Work Completed

    {entry.workCompleted?.map((work, idx) => (
  • {work}
  • ))}
)}
); } /* ─── Main page ──────────────────────────────────────────────────────────── */ export default function RoadmapPage() { const [roadmapData, setRoadmapData] = useState(null); const [devLogData, setDevLogData] = useState(null); const [roadmapLoading, setRoadmapLoading] = useState(true); const [roadmapRefreshing,setRoadmapRefreshing]= useState(false); const [devLogLoading, setDevLogLoading] = useState(false); const [roadmapError, setRoadmapError] = useState(null); const [devLogError, setDevLogError] = useState(null); useEffect(() => { let cancelled = false; setRoadmapLoading(true); api.roadmap() .then(data => { if (!cancelled) setRoadmapData(data as RoadmapData); }) .catch(err => { if (!cancelled) setRoadmapError(errMessage(err, 'Failed to load issues')); }) .finally(() => { if (!cancelled) setRoadmapLoading(false); }); return () => { cancelled = true; }; }, []); const handleRefresh = () => { setRoadmapRefreshing(true); api.roadmap(true) .then(data => setRoadmapData(data as RoadmapData)) .catch(() => {}) .finally(() => setRoadmapRefreshing(false)); }; const fetchDevLog = useCallback(() => { if (devLogData || devLogLoading) return; let cancelled = false; setDevLogLoading(true); api.devLog() .then(data => { if (!cancelled) setDevLogData(data as DevLogData); }) .catch(err => { if (!cancelled) setDevLogError(errMessage(err, 'Failed to load activity log')); }) .finally(() => { if (!cancelled) setDevLogLoading(false); }); return () => { cancelled = true; }; }, [devLogData, devLogLoading]); const issues = roadmapData?.issues || []; const grouped = PRIORITY_LANES.map(lane => ({ ...lane, items: issues.filter(issue => priorityFromLabels(issue.labels) === lane.key), })); const visibleLanes = grouped.filter(lane => lane.items.length > 0); const cols = visibleLanes.length; const laneGridClass = cols <= 1 ? 'grid-cols-1' : cols === 2 ? 'grid-cols-1 lg:grid-cols-2' : cols === 3 ? 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-3' : cols === 4 ? 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 min-[1400px]:grid-cols-5'; return (
{/* Header */}

Issues & Roadmap

Open issues ·{' '} BillTracker ↗

{issues.length > 0 && ( {issues.length} open )}
{/* Tabs */} { if (v === 'activity') fetchDevLog(); }} className="min-w-0"> Issues Activity Log {/* ── Issues tab ── */} {roadmapLoading ? (
Loading issues…
) : roadmapError ? (
Failed to load issues

{roadmapError}

) : issues.length === 0 ? (
No open issues.
) : (
{visibleLanes.map(lane => ( ))}
)}
{/* ── Activity log tab ── */} {devLogLoading ? (
Loading activity log…
) : devLogError ? (

Failed to load activity log

{devLogError}

) : devLogData && devLogData.entries?.length === 0 ? (
No activity log entries found.
) : devLogData ? (
{devLogData.entries?.map((entry, idx) => ( ))}
) : null}
); }