refactor(ts): RoadmapPage → TypeScript (issues board + dev activity log)
This commit is contained in:
parent
14f1fedfd0
commit
b912fd74be
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue