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'; /* ─── Priority lanes ─────────────────────────────────────────────────────── */ const PRIORITY_LANES = [ { 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 = []) { 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) { return title.replace(/^(CRITICAL|HIGH|MEDIUM|LOW|NICE[\s-]TO[\s-]HAVE)\s*:\s*/i, '').trim(); } function stripMarkdown(text) { 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) { 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) { 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 }) { return ( {label.name} ); } /* ─── Issue card ─────────────────────────────────────────────────────────── */ function IssueCard({ 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 && ( {issue.comments} )}
); } /* ─── Priority lane ──────────────────────────────────────────────────────── */ function PriorityLane({ lane, items }) { return (

{lane.label}

{items.length}
{items.map(issue => ( ))}
); } /* ─── Stats bar ──────────────────────────────────────────────────────────── */ function StatsBar({ issues, fetchedAt, stale, onRefresh, refreshing }) { const counts = 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); 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 }) { const [open, setOpen] = useState(false); return (
{entry.agents?.length > 0 && (

Agents

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

Files Modified

{entry.filesModified.map((file, idx) => ( {file} ))}
)} {entry.workCompleted?.length > 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); }) .catch(err => { if (!cancelled) setRoadmapError(err.message || 'Failed to load issues'); }) .finally(() => { if (!cancelled) setRoadmapLoading(false); }); return () => { cancelled = true; }; }, []); const handleRefresh = () => { setRoadmapRefreshing(true); api.roadmap(true) .then(data => setRoadmapData(data)) .catch(() => {}) .finally(() => setRoadmapRefreshing(false)); }; const fetchDevLog = useCallback(() => { if (devLogData || devLogLoading) return; let cancelled = false; setDevLogLoading(true); api.devLog() .then(data => { if (!cancelled) setDevLogData(data); }) .catch(err => { if (!cancelled) setDevLogError(err.message || '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}
); }