refactor(ts): RoadmapPage → TypeScript (issues board + dev activity log)
This commit is contained in:
parent
14f1fedfd0
commit
b912fd74be
|
|
@ -20,10 +20,64 @@ import {
|
|||
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 = [
|
||||
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' },
|
||||
|
|
@ -33,7 +87,7 @@ const PRIORITY_LANES = [
|
|||
|
||||
/* ─── Helpers ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function priorityFromLabels(labels = []) {
|
||||
function priorityFromLabels(labels: IssueLabel[] = []): string {
|
||||
for (const l of labels) {
|
||||
if (l.name === 'priority:critical') return 'critical';
|
||||
if (l.name === 'priority:high') return 'high';
|
||||
|
|
@ -44,11 +98,11 @@ function priorityFromLabels(labels = []) {
|
|||
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();
|
||||
}
|
||||
|
||||
function stripMarkdown(text) {
|
||||
function stripMarkdown(text?: string | null): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/#{1,6}\s+[^\n]*/g, '')
|
||||
|
|
@ -64,7 +118,7 @@ function stripMarkdown(text) {
|
|||
.trim();
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
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);
|
||||
|
|
@ -76,7 +130,7 @@ function timeAgo(dateStr) {
|
|||
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 g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
|
|
@ -89,7 +143,7 @@ function labelStyle(hex) {
|
|||
|
||||
/* ─── Label chip ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function LabelChip({ label }) {
|
||||
function LabelChip({ label }: { label: IssueLabel }) {
|
||||
return (
|
||||
<span
|
||||
style={labelStyle(label.color)}
|
||||
|
|
@ -102,7 +156,7 @@ function LabelChip({ label }) {
|
|||
|
||||
/* ─── Issue card ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function IssueCard({ issue }) {
|
||||
function IssueCard({ issue }: { issue: Issue }) {
|
||||
const typeLabels = issue.labels || [];
|
||||
const title = cleanTitle(issue.title);
|
||||
const preview = stripMarkdown(issue.body);
|
||||
|
|
@ -148,7 +202,7 @@ function IssueCard({ issue }) {
|
|||
<Clock className="h-3 w-3 shrink-0" />
|
||||
{timeAgo(issue.created_at)}
|
||||
</span>
|
||||
{issue.comments > 0 && (
|
||||
{(issue.comments ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageCircle className="h-3 w-3 shrink-0" />
|
||||
{issue.comments}
|
||||
|
|
@ -163,7 +217,7 @@ function IssueCard({ issue }) {
|
|||
|
||||
/* ─── Priority lane ──────────────────────────────────────────────────────── */
|
||||
|
||||
function PriorityLane({ lane, items }) {
|
||||
function PriorityLane({ lane, items }: { lane: Lane; items: Issue[] }) {
|
||||
return (
|
||||
<section
|
||||
aria-label={`${lane.label} priority`}
|
||||
|
|
@ -189,13 +243,19 @@ function PriorityLane({ lane, items }) {
|
|||
|
||||
/* ─── Stats bar ──────────────────────────────────────────────────────────── */
|
||||
|
||||
function StatsBar({ issues, fetchedAt, stale, onRefresh, refreshing }) {
|
||||
const counts = Object.fromEntries(PRIORITY_LANES.map(l => [l.key, 0]));
|
||||
function StatsBar({ issues, fetchedAt, stale, onRefresh, refreshing }: {
|
||||
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 => {
|
||||
const p = priorityFromLabels(issue.labels);
|
||||
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 (
|
||||
<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 ──────────────────────────────────────────────────────── */
|
||||
|
||||
function DevLogEntry({ entry }) {
|
||||
function DevLogEntry({ entry }: { entry: DevLogEntryData }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -262,10 +322,10 @@ function DevLogEntry({ entry }) {
|
|||
{entry.status}
|
||||
</Badge>
|
||||
)}
|
||||
{entry.filesModified?.length > 0 && (
|
||||
{(entry.filesModified?.length ?? 0) > 0 && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-0.5">
|
||||
<FileCode className="h-3 w-3" />
|
||||
{entry.filesModified.length} files
|
||||
{entry.filesModified?.length} files
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
<div className="mt-3 space-y-3 pl-1">
|
||||
{entry.agents?.length > 0 && (
|
||||
{(entry.agents?.length ?? 0) > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-2">Agents</p>
|
||||
<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] ${
|
||||
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' :
|
||||
|
|
@ -293,11 +353,11 @@ function DevLogEntry({ entry }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{entry.filesModified?.length > 0 && (
|
||||
{(entry.filesModified?.length ?? 0) > 0 && (
|
||||
<div>
|
||||
<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">
|
||||
{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">
|
||||
{file}
|
||||
</code>
|
||||
|
|
@ -306,11 +366,11 @@ function DevLogEntry({ entry }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{entry.workCompleted?.length > 0 && (
|
||||
{(entry.workCompleted?.length ?? 0) > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1.5">Work Completed</p>
|
||||
<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">
|
||||
<span className="text-emerald-500 mt-0.5 shrink-0">✓</span>
|
||||
<span className="min-w-0 break-words">{work}</span>
|
||||
|
|
@ -330,20 +390,20 @@ function DevLogEntry({ entry }) {
|
|||
/* ─── Main page ──────────────────────────────────────────────────────────── */
|
||||
|
||||
export default function RoadmapPage() {
|
||||
const [roadmapData, setRoadmapData] = useState(null);
|
||||
const [devLogData, setDevLogData] = useState(null);
|
||||
const [roadmapData, setRoadmapData] = useState<RoadmapData | null>(null);
|
||||
const [devLogData, setDevLogData] = useState<DevLogData | null>(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);
|
||||
const [roadmapError, setRoadmapError] = useState<string | null>(null);
|
||||
const [devLogError, setDevLogError] = useState<string | null>(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'); })
|
||||
.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; };
|
||||
}, []);
|
||||
|
|
@ -351,7 +411,7 @@ export default function RoadmapPage() {
|
|||
const handleRefresh = () => {
|
||||
setRoadmapRefreshing(true);
|
||||
api.roadmap(true)
|
||||
.then(data => setRoadmapData(data))
|
||||
.then(data => setRoadmapData(data as RoadmapData))
|
||||
.catch(() => {})
|
||||
.finally(() => setRoadmapRefreshing(false));
|
||||
};
|
||||
|
|
@ -361,8 +421,8 @@ export default function RoadmapPage() {
|
|||
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'); })
|
||||
.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]);
|
||||
|
|
@ -480,7 +540,7 @@ export default function RoadmapPage() {
|
|||
</div>
|
||||
) : devLogData ? (
|
||||
<div className="pt-2">
|
||||
{devLogData.entries.map((entry, idx) => (
|
||||
{devLogData.entries?.map((entry, idx) => (
|
||||
<DevLogEntry key={entry.version || idx} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
Loading…
Reference in New Issue