+ {/* Sidebar-width Container - Desktop only */}
+
+ {/* Burger Menu */}
+ {(isHomePage || true) && (
+
+ )}
+
+ {/* Logo */}
+ {!(isHomePage && homeMenuOpen) && (
+
+
+
+
+ )}
+
+
+ {/* Mobile Container - Hamburger + Logo (left-aligned) */}
+
+ {/* Mobile Burger Menu */}
+ {(isHomePage || true) && (
+
+ )}
+
+ {/* Mobile Logo - Left-aligned next to hamburger */}
+ {!(isHomePage && homeMenuOpen) && (
+
+
+
+
+ )}
+
+
+ {/* Search Bar - Desktop, starts after sidebar */}
+
+
+
+
+ {/* Action Icons - Right */}
+
+ {/* Documentation */}
+
+
+ {/* Language Switcher */}
+ {/*
*/}
+
+
+
+
+ {/* Theme Toggle */}
+
+
+ {/* TODO: Notifications Bell - hidden until backend is built */}
+
+ {/* Create Menu */}
+
+
+ {/* User Activity Sidebar (Sign In / Profile) - Always visible */}
+
+
+
+
+
+
+ {/* Mobile Search - Below header on small screens */}
+
+
+
+
+ );
+}
diff --git a/app-next/src/components/layout/main-content.tsx b/app-next/src/components/layout/main-content.tsx
new file mode 100644
index 00000000..004fcbf6
--- /dev/null
+++ b/app-next/src/components/layout/main-content.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { usePathname } from "@/config/routing";
+import { useSidebar } from "@/contexts/sidebar-context";
+import { cn } from "@/lib/utils";
+
+export function MainContent({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ const isHomePage = pathname === "/";
+ const { isCollapsed } = useSidebar();
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app-next/src/components/layout/section-container.tsx b/app-next/src/components/layout/section-container.tsx
new file mode 100644
index 00000000..bab6f141
--- /dev/null
+++ b/app-next/src/components/layout/section-container.tsx
@@ -0,0 +1,25 @@
+import { ReactNode, CSSProperties } from "react";
+
+/**
+ * Reusable Section Container - Server Component
+ * Provides consistent spacing and max-width for homepage sections
+ */
+interface SectionContainerProps {
+ children: ReactNode;
+ className?: string;
+ id?: string;
+ style?: CSSProperties;
+}
+
+export function SectionContainer({
+ children,
+ className = "",
+ id,
+ style,
+}: SectionContainerProps) {
+ return (
+
+ );
+}
diff --git a/app-next/src/components/layout/sidebar.tsx b/app-next/src/components/layout/sidebar.tsx
new file mode 100644
index 00000000..c0d44e97
--- /dev/null
+++ b/app-next/src/components/layout/sidebar.tsx
@@ -0,0 +1,553 @@
+"use client";
+
+import { usePathname } from "@/config/routing"; // Use localized usePathname
+import { Link } from "@/config/routing"; // Use localized Link
+import NextLink from "next/link"; // For routes outside localized config
+import { useTranslations } from "next-intl";
+import { cn, abbreviateNumber } from "@/lib/utils";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ ChevronDown,
+ ChevronRight,
+ ArrowRightFromLine,
+ ArrowLeftFromLine,
+ ExternalLink,
+ User as UserIcon,
+ Settings,
+ LogOut,
+} from "lucide-react";
+import { useSession, signOut } from "next-auth/react";
+import { useState, useEffect } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { navItems, type NavItem } from "@/constants";
+import Image from "next/image";
+import { useSidebar } from "@/contexts/sidebar-context";
+
+// Helper to check if a string is a valid URL
+const isValidImageSrc = (url: string | undefined | null): boolean => {
+ if (!url || typeof url !== "string" || url.trim() === "") return false;
+ // Accept relative paths (local dev avatars) and absolute URLs (Vercel Blob, OAuth)
+ if (url.startsWith("/")) return true;
+ try {
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+export function Sidebar() {
+ const pathname = usePathname();
+ const isHomePage = pathname === "/";
+ const { isCollapsed, setIsCollapsed, homeMenuOpen, setHomeMenuOpen } =
+ useSidebar();
+ const { data: session, status } = useSession();
+ const [user, setUser] = useState<{
+ name: string;
+ email: string;
+ avatar: string;
+ initials: string;
+ } | null>(null);
+ const t = useTranslations("sidebar");
+
+ // Load user from NextAuth session
+ useEffect(() => {
+ if (status === "authenticated" && session?.user) {
+ const firstName =
+ (session.user as { firstName?: string }).firstName || "";
+ const lastName = (session.user as { lastName?: string }).lastName || "";
+ const name =
+ session.user.name ||
+ `${firstName} ${lastName}`.trim() ||
+ session.user.username ||
+ session.user.email?.split("@")[0] ||
+ "User";
+ const email = session.user.email || "";
+ const avatar = session.user.image || "";
+
+ // Calculate initials safely
+ let initials = "OP";
+ if (firstName && lastName) {
+ initials = `${firstName[0]}${lastName[0]}`.toUpperCase();
+ } else if (name && name.length > 0) {
+ const nameParts = name.split(" ").filter((n: string) => n.length > 0);
+ if (nameParts.length >= 2) {
+ initials = `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
+ } else if (nameParts.length === 1 && nameParts[0].length >= 2) {
+ initials = nameParts[0].substring(0, 2).toUpperCase();
+ } else if (nameParts.length === 1 && nameParts[0].length === 1) {
+ initials = nameParts[0][0].toUpperCase();
+ }
+ }
+ // Use queueMicrotask to avoid cascading render warning
+ queueMicrotask(() => {
+ setUser({ name, email, avatar, initials });
+ });
+ }
+ }, [status, session]);
+
+ // Fetch entity counts with React Query (deduplicates requests, caches results)
+ const { data: counts = {} } = useQuery
>({
+ queryKey: ["entity-counts"],
+ queryFn: async () => {
+ const response = await fetch("/api/count");
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ const data = await response.json();
+ if (!Array.isArray(data)) {
+ console.warn(
+ "⚠️ API returned non-array data, counts unavailable:",
+ data,
+ );
+ return {};
+ }
+ return data.reduce(
+ (
+ acc: Record,
+ item: { index: string; count: number },
+ ) => {
+ acc[item.index] = item.count;
+ return acc;
+ },
+ {},
+ );
+ },
+ staleTime: 5 * 60 * 1000, // Cache counts for 5 minutes
+ retry: 1,
+ });
+
+ // Render mobile/tablet unified menu (< 1024px for all pages, all sizes for homepage)
+ const renderUnifiedMenu = () => (
+ <>
+ {/* Overlay when menu is open */}
+ {homeMenuOpen && (
+ setHomeMenuOpen(false)}
+ style={{
+ filter: "contrast(1.75)",
+ }}
+ />
+ )}
+
+ {/* Unified Sidebar - Mobile/Tablet */}
+
+ {/* Logo Header - Only show when menu is open */}
+ {homeMenuOpen && (
+
+ setHomeMenuOpen(false)}
+ >
+
+
+
+ )}
+
+ {/* Navigation - Only show when menu is open */}
+ {homeMenuOpen && (
+
+
+ {/* User Profile Section - Mobile Only */}
+
+ {user && (
+
+
+ {isValidImageSrc(user.avatar) ? (
+
+ ) : (
+
+
+ {user.initials}
+
+
+ )}
+
+
+
+ {user.name}
+
+
+ {user.email}
+
+
+
+ )}
+
+
+ {navItems.map((section) => (
+
+
+ {t(section.titleKey)}
+
+
+ {section.items.map((item) => (
+ setHomeMenuOpen(false)}
+ />
+ ))}
+
+
+ ))}
+
+ {/* User Menu Items - Mobile Only */}
+ {user && (
+
+
+ Account
+
+
+ setHomeMenuOpen(false)}
+ className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-gray-300 transition-colors hover:bg-slate-700/50 hover:text-white"
+ >
+
+ My Profile
+
+ setHomeMenuOpen(false)}
+ className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-gray-300 transition-colors hover:bg-slate-700/50 hover:text-white"
+ >
+
+ Settings
+
+
+
+
+ )}
+
+
+ )}
+
+ >
+ );
+
+ // Homepage: Always show unified menu
+ if (isHomePage) {
+ return renderUnifiedMenu();
+ }
+
+ // Other pages: Show unified menu on mobile (< 1024px) + desktop sidebar
+ return (
+ <>
+ {/* Mobile/Tablet Unified Menu (< 1024px) */}
+
{renderUnifiedMenu()}
+
+ {/* Desktop Sidebar (>= 1024px) */}
+
+ {/* Logo Header */}
+
+ {!isCollapsed && (
+
+
+
+ )}
+
+ {/* Collapse Button */}
+
+
+
+ {/* Navigation */}
+
+
+ {navItems.map((section) => (
+
+ {!isCollapsed && (
+
+ {t(section.titleKey)}
+
+ )}
+
+ {section.items.map((item) => (
+
+ ))}
+
+
+ ))}
+
+
+
+ >
+ );
+}
+
+function SidebarItem({
+ item,
+ pathname,
+ counts,
+ t,
+ iconOnly = false,
+ onItemClick,
+}: {
+ item: NavItem;
+ pathname: string;
+ counts?: Record
;
+ t: (key: string) => string;
+ iconOnly?: boolean;
+ onItemClick?: () => void;
+}) {
+ const [isOpen, setIsOpen] = useState(false);
+ const isActive =
+ pathname === item.href || pathname.startsWith(item.href + "/");
+ const hasChildren = item.children && item.children.length > 0;
+ const count = item.index && counts ? counts[item.index] : null;
+ const countText = count ? abbreviateNumber(count) : "";
+ const isExternal =
+ item.href.startsWith("http://") || item.href.startsWith("https://");
+
+ if (iconOnly) {
+ // Icon-only mode
+ if (isExternal) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ if (hasChildren) {
+ return (
+
+
+ {isOpen && (
+
+ {item.children?.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ if (isExternal) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/app-next/src/components/layout/user-activity-sidebar.tsx b/app-next/src/components/layout/user-activity-sidebar.tsx
new file mode 100644
index 00000000..754a821e
--- /dev/null
+++ b/app-next/src/components/layout/user-activity-sidebar.tsx
@@ -0,0 +1,443 @@
+"use client";
+
+import * as React from "react";
+import { useSession, signOut } from "next-auth/react";
+import { useTheme } from "next-themes";
+import {
+ ChevronRight,
+ X,
+ User as UserIcon,
+ FileText,
+ LogOut,
+ Bell,
+ MoreHorizontal,
+} from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { Badge } from "@/components/ui/badge";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+
+interface UserActivityItem {
+ id: number;
+ title: string;
+ description: string;
+ time: string;
+ isNew?: boolean;
+}
+
+interface UserActivitySidebarProps {
+ className?: string;
+}
+
+// User Activity Sidebar - kggl-inspired collapsible sidebar
+export function UserActivitySidebar({ className }: UserActivitySidebarProps) {
+ const { data: session, status } = useSession();
+ const { resolvedTheme } = useTheme();
+ const [isOpen, setIsOpen] = React.useState(false);
+ const sidebarRef = React.useRef(null);
+ const [user, setUser] = React.useState<{
+ name: string;
+ email: string;
+ avatar: string;
+ initials: string;
+ } | null>(null);
+
+ // Determine background color based on theme
+ const bgColor = resolvedTheme === "dark" ? "#0f172a" : "#ffffff"; // slate-900 or white
+
+ // Close sidebar when clicking outside
+ React.useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (
+ isOpen &&
+ sidebarRef.current &&
+ !sidebarRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ }
+
+ // Close on escape key
+ function handleEscapeKey(event: KeyboardEvent) {
+ if (event.key === "Escape" && isOpen) {
+ setIsOpen(false);
+ }
+ }
+
+ document.addEventListener("mousedown", handleClickOutside);
+ document.addEventListener("keydown", handleEscapeKey);
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ document.removeEventListener("keydown", handleEscapeKey);
+ };
+ }, [isOpen]);
+
+ // Load user from NextAuth session or localStorage fallback
+ React.useEffect(() => {
+ // console.log("🔍 [UserActivitySidebar] Session status:", status);
+ // console.log("🔍 [UserActivitySidebar] Session:", session);
+
+ if (status === "authenticated" && session?.user) {
+ const firstName = session.user.firstName || "";
+ const lastName = session.user.lastName || "";
+ // console.log(
+ // "👤 [UserActivitySidebar] firstName:",
+ // firstName,
+ // "lastName:",
+ // lastName,
+ // );
+
+ const name =
+ session.user.name ||
+ `${firstName} ${lastName}`.trim() ||
+ session.user.username ||
+ session.user.email?.split("@")[0] ||
+ "User";
+ const email = session.user.email || "";
+
+ // Check localStorage for updated avatar (in case of upload)
+ let avatar = session.user.image || "";
+ const storedUser = localStorage.getItem("user");
+ if (storedUser) {
+ try {
+ const userData = JSON.parse(storedUser);
+ if (userData.image) {
+ avatar = userData.image;
+ }
+ } catch (_e) {
+ // Ignore parse errors
+ }
+ }
+
+ // Calculate initials from firstName/lastName first, then fallback to name
+ let initials = "OP";
+ if (firstName && lastName) {
+ initials = `${firstName[0]}${lastName[0]}`.toUpperCase();
+ } else if (name && name.length > 0) {
+ const nameParts = name.split(" ").filter((n: string) => n.length > 0);
+ if (nameParts.length >= 2) {
+ initials = `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
+ } else if (nameParts.length === 1 && nameParts[0].length >= 2) {
+ initials = nameParts[0].substring(0, 2).toUpperCase();
+ } else if (nameParts.length === 1 && nameParts[0].length === 1) {
+ initials = nameParts[0][0].toUpperCase();
+ }
+ }
+
+ // console.log(
+ // "✅ [UserActivitySidebar] Setting user with initials:",
+ // initials,
+ // );
+ setUser({
+ name,
+ email,
+ avatar,
+ initials,
+ });
+ } else {
+ // Fallback to localStorage for backward compatibility
+ const storedUser = localStorage.getItem("user");
+ if (storedUser) {
+ try {
+ const userData = JSON.parse(storedUser);
+ const firstName = userData.firstName || "";
+ const lastName = userData.lastName || "";
+ const userName =
+ `${firstName} ${lastName}`.trim() || userData.username || "User";
+
+ // Calculate initials safely
+ let initials = "OP";
+ if (firstName && lastName) {
+ initials = `${firstName[0]}${lastName[0]}`.toUpperCase();
+ } else if (userName && userName.length > 0) {
+ const nameParts = userName
+ .split(" ")
+ .filter((n: string) => n.length > 0);
+ if (nameParts.length >= 2) {
+ initials = `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
+ } else if (nameParts.length === 1 && nameParts[0].length >= 2) {
+ initials = nameParts[0].substring(0, 2).toUpperCase();
+ } else if (nameParts.length === 1 && nameParts[0].length === 1) {
+ initials = nameParts[0][0].toUpperCase();
+ }
+ }
+
+ setUser({
+ name: userName,
+ email: userData.email || "",
+ avatar: userData.image || "",
+ initials: initials,
+ });
+ } catch (error) {
+ console.error("Error parsing user data:", error);
+ }
+ }
+ }
+ }, [session, status]);
+
+ // Poll localStorage for avatar updates (e.g., after upload)
+ React.useEffect(() => {
+ const checkForAvatarUpdate = () => {
+ const storedUser = localStorage.getItem("user");
+ if (storedUser && user) {
+ try {
+ const userData = JSON.parse(storedUser);
+ if (userData.image && userData.image !== user.avatar) {
+ // Avatar changed - update state
+ setUser({ ...user, avatar: userData.image });
+ }
+ } catch (_e) {
+ // Ignore parse errors
+ }
+ }
+ };
+
+ // Check every 2 seconds for avatar updates
+ const interval = setInterval(checkForAvatarUpdate, 2000);
+ return () => clearInterval(interval);
+ }, [user]);
+
+ const [notifications] = React.useState([
+ {
+ id: 1,
+ title: "New Badge Received",
+ description: "Congratulations, you're a member of opemML Community",
+ time: "2d",
+ isNew: true,
+ },
+ ]);
+
+ const handleSignOut = () => {
+ // Clear localStorage for backward compatibility
+ localStorage.removeItem("user");
+ localStorage.removeItem("openml_token");
+ setUser(null);
+ setIsOpen(false);
+ // Use NextAuth signOut
+ signOut({ callbackUrl: "/auth/sign-in" });
+ };
+
+ const menuItems = [
+ {
+ label: "Your Work",
+ href: "/dashboard",
+ icon: ,
+ },
+ {
+ label: "Your Profile",
+ href: "/auth/profile",
+ icon: ,
+ },
+ ];
+
+ return (
+ <>
+ {/* Avatar Button */}
+ {!user ? (
+ // Not logged in - show 'Sign In' button with Google-inspired design
+
+ ) : (
+ // Logged in - show user image if available, otherwise User icon with gradient background
+
+ )}
+
+ {/* Overlay */}
+ {isOpen && (
+ setIsOpen(false)}
+ />
+ )}
+
+
+
+ {/* Close Button - Top Right - Ensure it's clickable and visible */}
+
+
+ {/* Header with Avatar and Name */}
+
+
+ {user?.avatar &&
+ (user.avatar.startsWith("/") ||
+ user.avatar.startsWith("http://") ||
+ user.avatar.startsWith("https://")) ? (
+
+ ) : (
+
+
+ {user?.initials}
+
+
+ )}
+
+
+
+ {user?.name}
+
+ {user?.email && (
+
+ {user.email}
+
+ )}
+
+
+
+ {/* Menu Items */}
+
+
+ {/* Notifications Section */}
+
+
+
+
+
+ Your notifications
+
+
+
+
+
+
+
+ {notifications.map((notification) => (
+
+
+
+
+
+ {notification.title}
+
+ {notification.isNew && (
+
+ {notification.time}
+
+ )}
+
+
+ {notification.description}
+
+
+
+ ))}
+
+
+
+
+
+ >
+ );
+}
diff --git a/app-next/src/components/measure/index.ts b/app-next/src/components/measure/index.ts
new file mode 100644
index 00000000..1ddfc208
--- /dev/null
+++ b/app-next/src/components/measure/index.ts
@@ -0,0 +1,7 @@
+export { MeasureList } from "./measure-list";
+export { MeasureSearchContainer } from "./measure-search-container";
+export { MeasureHeader } from "./measure-header";
+export { MeasureDescriptionSection } from "./measure-description-section";
+export { MeasureNavigationMenu } from "./measure-navigation-menu";
+export { MeasureStatsCard } from "./measure-stats-card";
+export { MeasureAnalysisSection } from "./measure-analysis-section";
diff --git a/app-next/src/components/measure/measure-analysis-section.tsx b/app-next/src/components/measure/measure-analysis-section.tsx
new file mode 100644
index 00000000..614c93fb
--- /dev/null
+++ b/app-next/src/components/measure/measure-analysis-section.tsx
@@ -0,0 +1,280 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { useState, useEffect, useMemo } from "react";
+import { Loader2, TrendingUp, TrendingDown } from "lucide-react";
+import { Card, CardContent } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { usePlotlyTheme } from "@/hooks/usePlotlyTheme";
+import { entityColors } from "@/constants/entityColors";
+import type { Measure } from "@/types/measure";
+
+// Dynamic import for Plotly (required for SSR compatibility)
+const Plot = dynamic(() => import("react-plotly.js"), {
+ ssr: false,
+ loading: () => (
+
+
+
+ ),
+});
+
+interface MeasureAnalysisSectionProps {
+ measure: Measure;
+}
+
+export function MeasureAnalysisSection({
+ measure,
+}: MeasureAnalysisSectionProps) {
+ const [relatedMeasures, setRelatedMeasures] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const plotTheme = usePlotlyTheme();
+
+ useEffect(() => {
+ const fetchRelatedMeasures = async () => {
+ try {
+ // Fetch all measures of the same type for comparison
+ const esQuery = {
+ query: {
+ bool: {
+ filter: [{ term: { measure_type: measure.measure_type } }],
+ },
+ },
+ size: 500,
+ sort: [{ date: { order: "asc" } }],
+ };
+
+ const res = await fetch("/api/search", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ indexName: "measure",
+ esQuery,
+ }),
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ const measures =
+ data.hits?.hits?.map((hit: { _source: Measure }) => hit._source) || [];
+ setRelatedMeasures(measures);
+ }
+ } catch (error) {
+ console.error("[MeasureAnalysisSection] Error:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchRelatedMeasures();
+ }, [measure.measure_type]);
+
+ // Compute timeline data
+ const timelineData = useMemo(() => {
+ if (relatedMeasures.length === 0) return null;
+
+ // Group by year
+ const yearCounts: Record = {};
+ relatedMeasures.forEach((m) => {
+ if (m.date) {
+ const year = new Date(m.date).getFullYear().toString();
+ yearCounts[year] = (yearCounts[year] || 0) + 1;
+ }
+ });
+
+ const years = Object.keys(yearCounts).sort();
+ const counts = years.map((y) => yearCounts[y]);
+
+ return { years, counts };
+ }, [relatedMeasures]);
+
+ // Compute stats
+ const stats = useMemo(() => {
+ if (measure.measure_type === "evaluation_measure") {
+ const higher = relatedMeasures.filter(
+ (m) => m.higherIsBetter === true,
+ ).length;
+ const lower = relatedMeasures.filter(
+ (m) => m.higherIsBetter === false,
+ ).length;
+
+ return {
+ current: measure.higherIsBetter
+ ? "Higher is better"
+ : "Lower is better",
+ higherCount: higher,
+ lowerCount: lower,
+ };
+ }
+
+ if (measure.measure_type === "estimation_procedure") {
+ const stratifiedCount = relatedMeasures.filter(
+ (m) => m.stratified_sampling === "true",
+ ).length;
+
+ return {
+ current:
+ measure.stratified_sampling === "true"
+ ? "Stratified"
+ : "Non-stratified",
+ stratifiedCount,
+ nonStratifiedCount: relatedMeasures.length - stratifiedCount,
+ };
+ }
+
+ return null;
+ }, [measure, relatedMeasures]);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Stats Cards */}
+ {stats && (
+
+ {measure.measure_type === "evaluation_measure" && (
+ <>
+
+
+
+
+
+
+
+
{stats.higherCount}
+
+ Higher is Better
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{stats.lowerCount}
+
+ Lower is Better
+
+
+
+
+
+ >
+ )}
+
+ {measure.measure_type === "estimation_procedure" && (
+ <>
+
+
+
+
+ {stats.stratifiedCount}
+
+
+ Stratified Procedures
+
+
+
+
+
+
+
+
+
+ {stats.nonStratifiedCount}
+
+
+ Non-Stratified Procedures
+
+
+
+
+ >
+ )}
+
+ )}
+
+ {/* Timeline Chart */}
+ {timelineData && timelineData.years.length > 1 && (
+
+
+
+ Measures Added Over Time
+
+ %{x}
Measures Added: %{y}",
+ },
+ ]}
+ layout={
+ {
+ font: plotTheme.font,
+ xaxis: {
+ title: { text: "Year" },
+ tickfont: plotTheme.font,
+ gridcolor: plotTheme.gridcolor,
+ linecolor: plotTheme.gridcolor,
+ },
+ yaxis: {
+ title: { text: "Count" },
+ tickfont: plotTheme.font,
+ gridcolor: plotTheme.gridcolor,
+ linecolor: plotTheme.gridcolor,
+ },
+ hovermode: "closest",
+ hoverlabel: plotTheme.hoverlabel,
+ height: 300,
+ margin: { l: 50, r: 20, t: 20, b: 50 },
+ plot_bgcolor: plotTheme.plot_bgcolor,
+ paper_bgcolor: plotTheme.paper_bgcolor,
+ } as object
+ }
+ config={{
+ responsive: true,
+ displayModeBar: false,
+ }}
+ style={{ width: "100%", height: "300px" }}
+ />
+
+
+ )}
+
+ {/* Current Measure Properties */}
+ {(measure.min !== undefined || measure.max !== undefined) && (
+
+
+
+ Range:
+
+ {measure.min ?? "−∞"} to {measure.max ?? "+∞"}
+ {measure.unit && ` ${measure.unit}`}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app-next/src/components/measure/measure-description-section.tsx b/app-next/src/components/measure/measure-description-section.tsx
new file mode 100644
index 00000000..710e0eb3
--- /dev/null
+++ b/app-next/src/components/measure/measure-description-section.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { ArrowUp, ArrowDown, Ruler, Tag } from "lucide-react";
+import { Card, CardContent } from "@/components/ui/card";
+import type { Measure } from "@/types/measure";
+
+const MEASURE_TYPE_LABELS: Record = {
+ data_quality: "Data Quality",
+ evaluation_measure: "Evaluation Measure",
+ estimation_procedure: "Estimation Procedure",
+};
+
+interface MeasureDescriptionSectionProps {
+ measure: Measure;
+}
+
+export function MeasureDescriptionSection({
+ measure,
+}: MeasureDescriptionSectionProps) {
+ const typeLabel =
+ MEASURE_TYPE_LABELS[measure.measure_type] || measure.measure_type;
+
+ const hasProperties =
+ measure.higherIsBetter !== undefined ||
+ measure.min !== undefined ||
+ measure.max !== undefined ||
+ measure.unit;
+
+ return (
+
+ {/* Description */}
+ {measure.description && (
+
+
{measure.description}
+
+ )}
+
+ {/* Properties Grid */}
+ {hasProperties && (
+
+
+
+ Properties
+
+
+ {/* Type */}
+
+
+ {/* Direction */}
+ {measure.higherIsBetter !== undefined && (
+
+ {measure.higherIsBetter ? (
+
+ ) : (
+
+ )}
+
+
Direction
+
+ {measure.higherIsBetter
+ ? "Higher is better"
+ : "Lower is better"}
+
+
+
+ )}
+
+ {/* Range */}
+ {(measure.min !== undefined || measure.max !== undefined) && (
+
+
+
+
Range
+
+ {measure.min !== undefined ? measure.min : "–"} to{" "}
+ {measure.max !== undefined ? measure.max : "–"}
+
+
+
+ )}
+
+ {/* Unit */}
+ {measure.unit && (
+
+
+
+
Unit
+
{measure.unit}
+
+
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/app-next/src/components/measure/measure-header.tsx b/app-next/src/components/measure/measure-header.tsx
new file mode 100644
index 00000000..770ea722
--- /dev/null
+++ b/app-next/src/components/measure/measure-header.tsx
@@ -0,0 +1,122 @@
+import {
+ Calendar,
+ Hash,
+ ArrowUp,
+ ArrowDown,
+ Ruler,
+} from "lucide-react";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ENTITY_ICONS } from "@/constants/entityIcons";
+import { Badge } from "@/components/ui/badge";
+import type { Measure } from "@/types/measure";
+import { entityColors } from "@/constants/entityColors";
+
+const MEASURE_TYPE_LABELS: Record = {
+ data_quality: "Data Quality",
+ evaluation_measure: "Evaluation Measure",
+ estimation_procedure: "Estimation Procedure",
+};
+
+interface MeasureHeaderProps {
+ measure: Measure;
+}
+
+export function MeasureHeader({ measure }: MeasureHeaderProps) {
+ const measureId =
+ measure.eval_id || measure.proc_id || measure.quality_id || measure.measure_id;
+ const typeLabel =
+ MEASURE_TYPE_LABELS[measure.measure_type] || measure.measure_type;
+
+ const uploadDate = measure.date
+ ? new Date(measure.date).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })
+ : null;
+
+ return (
+
+ {/* LINE 1: Icon + Title */}
+
+
+
+
+
+
+
+ {measure.name}
+
+
+ {/* LINE 2: ID badge, type, date */}
+
+ {measureId !== undefined && (
+
+
+ {measureId}
+
+ )}
+
+
+ {typeLabel}
+
+
+ {uploadDate && (
+
+
+ {uploadDate}
+
+ )}
+
+
+ {/* LINE 3: Properties */}
+
+ {measure.higherIsBetter !== undefined && (
+
+ {measure.higherIsBetter ? (
+
+ ) : (
+
+ )}
+
+ {measure.higherIsBetter
+ ? "Higher is better"
+ : "Lower is better"}
+
+
+ )}
+
+ {measure.min !== undefined && (
+
+
+ Min: {measure.min}
+
+ )}
+
+ {measure.max !== undefined && (
+
+
+ Max: {measure.max}
+
+ )}
+
+ {measure.unit && (
+
Unit: {measure.unit}
+ )}
+
+
+
+
+ );
+}
diff --git a/app-next/src/components/measure/measure-list.tsx b/app-next/src/components/measure/measure-list.tsx
new file mode 100644
index 00000000..77416c00
--- /dev/null
+++ b/app-next/src/components/measure/measure-list.tsx
@@ -0,0 +1,121 @@
+import { getElasticsearchUrl } from "@/lib/elasticsearch";
+import { Card, CardContent } from "@/components/ui/card";
+import { ArrowUp, ArrowDown } from "lucide-react";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ENTITY_ICONS, entityColors } from "@/constants";
+
+interface Measure {
+ quality_id?: number;
+ proc_id?: number;
+ eval_id?: number;
+ name: string;
+ description?: string;
+ date?: string;
+ min?: number;
+ max?: number;
+ unit?: string;
+ higherIsBetter?: boolean;
+}
+
+interface MeasureListProps {
+ measureType: "evaluation_measure" | "estimation_procedure" | "data_quality";
+}
+
+async function fetchMeasures(measureType: string): Promise {
+ const url = getElasticsearchUrl("measure/_search");
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ query: {
+ bool: {
+ filter: [{ term: { measure_type: measureType } }],
+ },
+ },
+ size: 200,
+ }),
+ next: { revalidate: 3600 },
+ });
+
+ if (!res.ok) return [];
+
+ const data = await res.json();
+ const measures = (data.hits?.hits || []).map(
+ (hit: { _source: Measure }) => hit._source,
+ );
+ return measures.sort((a: Measure, b: Measure) =>
+ a.name.localeCompare(b.name),
+ );
+}
+
+export async function MeasureList({ measureType }: MeasureListProps) {
+ const measures = await fetchMeasures(measureType);
+
+ if (measures.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {measures.map((measure, index) => {
+ const id = measure.eval_id || measure.proc_id || measure.quality_id;
+
+ return (
+
+
+
+
+
+
+
{measure.name}
+ {measure.description && (
+
+ {measure.description}
+
+ )}
+
+ {measure.higherIsBetter !== undefined && (
+
+ {measure.higherIsBetter ? (
+
+ ) : (
+
+ )}
+ {measure.higherIsBetter
+ ? "Higher is better"
+ : "Lower is better"}
+
+ )}
+ {measure.min !== undefined && (
+
Min: {measure.min}
+ )}
+ {measure.max !== undefined && (
+
Max: {measure.max}
+ )}
+ {measure.unit &&
Unit: {measure.unit}}
+
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/app-next/src/components/measure/measure-navigation-menu.tsx b/app-next/src/components/measure/measure-navigation-menu.tsx
new file mode 100644
index 00000000..009d9c34
--- /dev/null
+++ b/app-next/src/components/measure/measure-navigation-menu.tsx
@@ -0,0 +1,226 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import {
+ ArrowLeft,
+ Grid3x3,
+ Menu,
+ X,
+ ChevronRight,
+ ChevronLeft,
+ FileText,
+ Flag,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { entityColors } from "@/constants/entityColors";
+
+interface MeasureNavigationMenuProps {
+ relatedTaskCount: number;
+ measureType: string;
+}
+
+const MEASURE_TYPE_ROUTES: Record = {
+ evaluation_measure: { href: "/measures/evaluation", label: "Evaluation Measures" },
+ data_quality: { href: "/measures/data", label: "Data Quality Measures" },
+ estimation_procedure: { href: "/measures/procedures", label: "Estimation Procedures" },
+};
+
+export function MeasureNavigationMenu({
+ relatedTaskCount,
+ measureType,
+}: MeasureNavigationMenuProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [isCollapsed, setIsCollapsed] = useState(false);
+
+ const backRoute = MEASURE_TYPE_ROUTES[measureType] || {
+ href: "/measures/evaluation",
+ label: "All Measures",
+ };
+
+ const pageNavItems = [
+ { id: "description", label: "Description", icon: FileText },
+ {
+ id: "related-tasks",
+ label: "Related Tasks",
+ icon: Flag,
+ count: relatedTaskCount,
+ },
+ ];
+
+ const violet = entityColors.measures;
+
+ return (
+ <>
+ {/* Mobile: Floating Menu Button */}
+
+
+
+
+ {/* Mobile: Slide-out Panel */}
+ {isOpen && (
+ <>
+ setIsOpen(false)}
+ />
+
+
+
+
Navigation
+
+
+
+
+
+
+ Quick Links
+
+
+
+
+
+
+ >
+ )}
+
+ {/* Desktop: Fixed Navigation */}
+
+ >
+ );
+}
diff --git a/app-next/src/components/measure/measure-search-container.tsx b/app-next/src/components/measure/measure-search-container.tsx
new file mode 100644
index 00000000..091d4c6c
--- /dev/null
+++ b/app-next/src/components/measure/measure-search-container.tsx
@@ -0,0 +1,653 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import {
+ Search,
+ Loader2,
+ List,
+ Table2,
+ Grid3x3,
+ ArrowUp,
+ ArrowDown,
+ Hash,
+ ChevronLeft,
+ ChevronRight,
+} from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { entityColors, ENTITY_ICONS } from "@/constants";
+import { Card, CardContent } from "@/components/ui/card";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+interface Measure {
+ quality_id?: number;
+ proc_id?: number;
+ eval_id?: number;
+ name: string;
+ description?: string;
+ date?: string;
+ min?: number;
+ max?: number;
+ unit?: string;
+ higherIsBetter?: boolean;
+ measure_type?: string;
+}
+
+interface MeasureSearchContainerProps {
+ measureType: "evaluation_measure" | "estimation_procedure" | "data_quality";
+}
+
+const SORT_OPTIONS = [
+ { id: "date_desc", label: "Most Recent", field: "date", dir: "desc" },
+ { id: "date_asc", label: "Oldest First", field: "date", dir: "asc" },
+];
+
+export function MeasureSearchContainer({
+ measureType,
+}: MeasureSearchContainerProps) {
+ const searchParams = useSearchParams();
+ const urlQuery = searchParams.get("q") || "";
+
+ const [measures, setMeasures] = useState
([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [searchInput, setSearchInput] = useState(urlQuery);
+ const [searchQuery, setSearchQuery] = useState(urlQuery);
+ const [view, setView] = useState("list");
+ const [sortId, setSortId] = useState("date_desc");
+ const [currentPage, setCurrentPage] = useState(1);
+ const [resultsPerPage] = useState(20);
+
+ // Sync URL ?q= changes into local search state (e.g. from the header search bar)
+ useEffect(() => {
+ setSearchInput(urlQuery);
+ setSearchQuery(urlQuery);
+ setCurrentPage(1);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [urlQuery]);
+
+ const fetchMeasures = useCallback(async () => {
+ setLoading(true);
+ try {
+ const sortOpt =
+ SORT_OPTIONS.find((s) => s.id === sortId) || SORT_OPTIONS[0];
+
+ // Build ES query matching the original MeasureList pattern
+ const esQuery: {
+ query: {
+ bool: {
+ filter: Array<{ term: { [key: string]: string } }>;
+ must?: {
+ multi_match: {
+ query: string;
+ fields: string[];
+ type: string;
+ };
+ };
+ };
+ };
+ size: number;
+ from: number;
+ sort?: Array<{ [key: string]: { order: string } }>;
+ } = {
+ query: {
+ bool: {
+ // Use exact same format as original MeasureList (without .keyword)
+ filter: [{ term: { measure_type: measureType } }],
+ },
+ },
+ size: resultsPerPage,
+ from: (currentPage - 1) * resultsPerPage,
+ };
+
+ // Add search if provided
+ if (searchQuery) {
+ esQuery.query.bool.must = {
+ multi_match: {
+ query: searchQuery,
+ fields: ["name^2", "description"],
+ type: "best_fields",
+ },
+ };
+ }
+
+ // Add sort (measure index only supports date sorting)
+ if (sortOpt.field && sortOpt.dir) {
+ esQuery.sort = [{ [sortOpt.field]: { order: sortOpt.dir } }];
+ }
+
+ // Use the /api/search proxy (now uses fetch instead of axios)
+ const res = await fetch("/api/search", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ indexName: "measure",
+ esQuery,
+ }),
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+
+ const measures = (data.hits?.hits || []).map(
+ (hit: { _source: Record; _id: string }) => ({
+ ...hit._source,
+ _id: hit._id,
+ }),
+ );
+
+ setMeasures(measures);
+
+ // Set total count for pagination
+ const totalValue =
+ typeof data.hits?.total === "object"
+ ? data.hits.total.value
+ : data.hits?.total || 0;
+ setTotal(totalValue);
+ } else {
+ const responseText = await res.text();
+ console.error(
+ "[MeasureSearchContainer] Error response status:",
+ res.status,
+ );
+ console.error(
+ "[MeasureSearchContainer] Error response text:",
+ responseText,
+ );
+
+ try {
+ const errorData = JSON.parse(responseText);
+ console.error(
+ "[MeasureSearchContainer] Error data parsed:",
+ errorData,
+ );
+ } catch {
+ console.error(
+ "[MeasureSearchContainer] Could not parse error as JSON",
+ );
+ }
+ }
+ } catch (err) {
+ console.error("[MeasureSearchContainer] Fetch error:", err);
+ } finally {
+ setLoading(false);
+ }
+ }, [measureType, searchQuery, sortId, currentPage, resultsPerPage]);
+
+ useEffect(() => {
+ fetchMeasures();
+ }, [fetchMeasures]);
+
+ const handleSearch = () => {
+ setCurrentPage(1); // Reset to page 1 on new search
+ setSearchQuery(searchInput);
+ };
+
+ const handleSortChange = (newSortId: string) => {
+ setCurrentPage(1); // Reset to page 1 on sort change
+ setSortId(newSortId);
+ };
+
+ const getMeasureId = (measure: Measure) => {
+ return measure.eval_id || measure.proc_id || measure.quality_id;
+ };
+
+ const renderListView = () => (
+
+ {measures.map((measure, index) => {
+ const id = getMeasureId(measure);
+ return (
+
+
+
+
+
+
+
+
{measure.name}
+ {measure.description && (
+
+ {measure.description}
+
+ )}
+
+ {measure.higherIsBetter !== undefined && (
+
+ {measure.higherIsBetter ? (
+
+ ) : (
+
+ )}
+ {measure.higherIsBetter
+ ? "Higher is better"
+ : "Lower is better"}
+
+ )}
+ {measure.min !== undefined && (
+
Min: {measure.min}
+ )}
+ {measure.max !== undefined && (
+
Max: {measure.max}
+ )}
+ {measure.unit &&
Unit: {measure.unit}}
+
+
+
+
+
+
+ );
+ })}
+
+ );
+
+ const renderTableView = () => (
+
+
+
+
+ | Name |
+ Description |
+ Direction |
+ Range |
+ ID |
+
+
+
+ {measures.map((measure, index) => {
+ const id = getMeasureId(measure);
+ return (
+
+ |
+
+ {measure.name}
+
+ |
+
+ {measure.description
+ ? measure.description.slice(0, 80) +
+ (measure.description.length > 80 ? "..." : "")
+ : "—"}
+ |
+
+ {measure.higherIsBetter !== undefined ? (
+
+ {measure.higherIsBetter ? (
+
+ ) : (
+
+ )}
+ {measure.higherIsBetter ? "Higher" : "Lower"}
+
+ ) : (
+ —
+ )}
+ |
+
+ {measure.min !== undefined || measure.max !== undefined ? (
+ <>
+ {measure.min ?? "−∞"} – {measure.max ?? "+∞"}
+ {measure.unit && ` ${measure.unit}`}
+ >
+ ) : (
+ "—"
+ )}
+ |
+
+
+
+ {id}
+
+ |
+
+ );
+ })}
+
+
+
+ );
+
+ const renderGridView = () => (
+
+ {measures.map((measure, index) => {
+ const id = getMeasureId(measure);
+ return (
+
+
+
+
+
+
+
+
+ {id}
+
+
+
+ {measure.name}
+
+ {measure.description && (
+
+ {measure.description}
+
+ )}
+
+ {measure.higherIsBetter !== undefined && (
+
+ {measure.higherIsBetter ? (
+
+ ) : (
+
+ )}
+ {measure.higherIsBetter ? "Higher" : "Lower"}
+
+ )}
+ {(measure.min !== undefined || measure.max !== undefined) && (
+
+ {measure.min ?? "−∞"} – {measure.max ?? "+∞"}
+
+ )}
+
+
+
+
+ );
+ })}
+
+ );
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (measures.length === 0 && !searchQuery) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Controls Bar */}
+
+ {/* Left: Sort */}
+
+ Sort:
+
+
+
+ {/* Center: View Toggle */}
+
v && setView(v)}
+ className="border"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {/* Right: Search */}
+
+ setSearchInput(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleSearch()}
+ className="h-8 w-[200px] text-sm"
+ />
+
+
+ {total.toLocaleString()} {total === 1 ? "result" : "results"}
+
+
+
+
+ {/* Empty State */}
+ {measures.length === 0 && searchQuery && (
+
+
+ No measures match "{searchQuery}"
+
+
+
+ )}
+
+ {/* Results */}
+ {measures.length > 0 && (
+ <>
+ {view === "list" && renderListView()}
+ {view === "table" && renderTableView()}
+ {view === "grid" && renderGridView()}
+ >
+ )}
+
+ {/* Pagination */}
+ {total > resultsPerPage && (
+
+
+
+
+ {(() => {
+ const totalPages = Math.ceil(total / resultsPerPage);
+ const pages = [];
+ const maxVisible = 5;
+
+ let startPage = Math.max(
+ 1,
+ currentPage - Math.floor(maxVisible / 2),
+ );
+ const endPage = Math.min(totalPages, startPage + maxVisible - 1);
+
+ if (endPage - startPage < maxVisible - 1) {
+ startPage = Math.max(1, endPage - maxVisible + 1);
+ }
+
+ if (startPage > 1) {
+ pages.push(
+ ,
+ );
+ if (startPage > 2) {
+ pages.push(
+
+ ...
+ ,
+ );
+ }
+ }
+
+ for (let i = startPage; i <= endPage; i++) {
+ if (i !== 1 && i !== totalPages) {
+ pages.push(
+ ,
+ );
+ }
+ }
+
+ if (endPage < totalPages) {
+ if (endPage < totalPages - 1) {
+ pages.push(
+
+ ...
+ ,
+ );
+ }
+ pages.push(
+ ,
+ );
+ }
+
+ return pages;
+ })()}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app-next/src/components/measure/measure-stats-card.tsx b/app-next/src/components/measure/measure-stats-card.tsx
new file mode 100644
index 00000000..d61eced7
--- /dev/null
+++ b/app-next/src/components/measure/measure-stats-card.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Card, CardContent } from "@/components/ui/card";
+import { Loader2, TrendingUp, Calendar, Hash } from "lucide-react";
+import { entityColors } from "@/constants/entityColors";
+
+interface MeasureStatsCardProps {
+ measureType: "evaluation_measure" | "estimation_procedure" | "data_quality";
+}
+
+interface Stats {
+ total: number;
+ dateRange: { first: string; last: string } | null;
+ typeSpecific: Record;
+}
+
+export function MeasureStatsCard({ measureType }: MeasureStatsCardProps) {
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchStats = async () => {
+ try {
+ // Fetch all measures of this type to compute stats
+ const esQuery = {
+ query: {
+ bool: {
+ filter: [{ term: { measure_type: measureType } }],
+ },
+ },
+ size: 500,
+ sort: [{ date: { order: "asc" } }],
+ };
+
+ const res = await fetch("/api/search", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ indexName: "measure",
+ esQuery,
+ }),
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ type MeasureDoc = { date?: string; higherIsBetter?: string | number; stratified_sampling?: string };
+ const measures: MeasureDoc[] = data.hits?.hits?.map((hit: { _source: MeasureDoc }) => hit._source) || [];
+
+ // Compute stats
+ const total = measures.length;
+ const dates = measures
+ .map((m) => m.date)
+ .filter(Boolean)
+ .sort() as string[];
+ const dateRange =
+ dates.length > 0
+ ? { first: dates[0], last: dates[dates.length - 1] }
+ : null;
+
+ // Type-specific stats
+ let typeSpecific: Record = {};
+
+ if (measureType === "evaluation_measure") {
+ const higher = measures.filter(
+ (m) => m.higherIsBetter === "1" || m.higherIsBetter === 1
+ ).length;
+ const lower = measures.filter(
+ (m) => m.higherIsBetter === "0" || m.higherIsBetter === 0
+ ).length;
+ typeSpecific = {
+ higherIsBetter: higher,
+ lowerIsBetter: lower,
+ };
+ } else if (measureType === "estimation_procedure") {
+ const stratified = measures.filter(
+ (m) => m.stratified_sampling === "true"
+ ).length;
+ typeSpecific = {
+ stratified,
+ nonStratified: total - stratified,
+ };
+ }
+
+ setStats({ total, dateRange, typeSpecific });
+ }
+ } catch (error) {
+ console.error("[MeasureStatsCard] Error fetching stats:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchStats();
+ }, [measureType]);
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (!stats) return null;
+
+ return (
+
+
+
+ {/* Total Count */}
+
+
+
+
{stats.total}
+
Total Measures
+
+
+
+ {/* Date Range */}
+ {stats.dateRange && (
+
+
+
+
+ {new Date(stats.dateRange.first).getFullYear()} -{" "}
+ {new Date(stats.dateRange.last).getFullYear()}
+
+
Date Range
+
+
+ )}
+
+ {/* Type-specific stats */}
+ {measureType === "evaluation_measure" && (
+ <>
+
+
+
+
+ {stats.typeSpecific.higherIsBetter}
+
+
Higher is Better
+
+
+
+
+
+
+ {stats.typeSpecific.lowerIsBetter}
+
+
Lower is Better
+
+
+ >
+ )}
+
+ {measureType === "estimation_procedure" && (
+
+
+
+
+ {stats.typeSpecific.stratified}
+
+
Stratified
+
+
+ )}
+
+
+
+ );
+}
diff --git a/app-next/src/components/meet-us/flickr-gallert.tsx b/app-next/src/components/meet-us/flickr-gallert.tsx
new file mode 100644
index 00000000..50297e3e
--- /dev/null
+++ b/app-next/src/components/meet-us/flickr-gallert.tsx
@@ -0,0 +1,39 @@
+import Image from "next/image";
+
+async function getFlickrPhotos() {
+ const API_KEY = process.env.FLICKR_API_KEY;
+ const USER_ID = "159879889@N02"; // Your ID from the URL provided
+
+ // Method to get a user's public photos
+ const res = await fetch(
+ `https://www.flickr.com/services/rest/?method=flickr.people.getPhotos&api_key=${API_KEY}&user_id=${USER_ID}&format=json&nojsoncallback=1`,
+ { next: { revalidate: 3600 } }, // Cache for 1 hour
+ );
+
+ const data = await res.json();
+ return data.photos.photo;
+}
+
+export default async function GalleryPage() {
+ const photos = await getFlickrPhotos();
+
+ return (
+
+ {photos.map((photo: { id: string; server: string; secret: string; title: string }) => {
+ // Construct Flickr Image URL: https://farm{farm}.staticflickr.com/{server}/{id}_{secret}_{size}.jpg
+ const src = `https://live.staticflickr.com/${photo.server}/${photo.id}_${photo.secret}_z.jpg`;
+
+ return (
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/app-next/src/components/performance-monitor.tsx b/app-next/src/components/performance-monitor.tsx
new file mode 100644
index 00000000..6dfa326b
--- /dev/null
+++ b/app-next/src/components/performance-monitor.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { useReportWebVitals } from "next/web-vitals";
+
+// Helper to rate the metric value based on Core Web Vitals thresholds
+function getRating(name: string, value: number) {
+ switch (name) {
+ case "FCP":
+ return value <= 1800
+ ? "🟢 Good"
+ : value <= 3000
+ ? "jm Needs Improvement"
+ : "🔴 Poor";
+ case "LCP":
+ return value <= 2500
+ ? "🟢 Good"
+ : value <= 4000
+ ? "🟡 Needs Improvement"
+ : "🔴 Poor";
+ case "CLS":
+ return value <= 0.1
+ ? "🟢 Good"
+ : value <= 0.25
+ ? "🟡 Needs Improvement"
+ : "🔴 Poor";
+ case "FID":
+ return value <= 100
+ ? "🟢 Good"
+ : value <= 300
+ ? "🟡 Needs Improvement"
+ : "🔴 Poor";
+ case "INP":
+ return value <= 200
+ ? "🟢 Good"
+ : value <= 500
+ ? "🟡 Needs Improvement"
+ : "🔴 Poor";
+ case "TTFB":
+ return value <= 800
+ ? "🟢 Good"
+ : value <= 1800
+ ? "🟡 Needs Improvement"
+ : "🔴 Poor";
+ default:
+ return "";
+ }
+}
+
+export function PerformanceMonitor() {
+ useReportWebVitals((metric) => {
+ // Only log in development
+ if (process.env.NODE_ENV === "development") {
+ const rating = getRating(metric.name, metric.value);
+ const logMsg = `[Performance] ${metric.name}: ${Math.round(metric.value)}${metric.name === "CLS" ? "" : "ms"} ${rating}`;
+
+ switch (metric.name) {
+ case "FCP":
+ console.log(
+ `${logMsg} (First Contentful Paint - Time until first text/image appears)`,
+ );
+ break;
+ case "LCP":
+ console.log(
+ `${logMsg} (Largest Contentful Paint - Time until main content appears)`,
+ );
+ break;
+ case "CLS":
+ console.log(
+ `${logMsg} (Cumulative Layout Shift - How much the page jumps around)`,
+ );
+ break;
+ case "FID":
+ console.log(
+ `${logMsg} (First Input Delay - Speed of first interaction)`,
+ );
+ break;
+ case "INP":
+ console.log(
+ `${logMsg} (Interaction to Next Paint - Responsiveness to clicks/typing)`,
+ );
+ break;
+ case "TTFB":
+ console.log(`${logMsg} (Time to First Byte - Server response time)`);
+ break;
+ }
+ }
+ });
+
+ return null;
+}
diff --git a/app-next/src/components/providers.tsx b/app-next/src/components/providers.tsx
new file mode 100644
index 00000000..4d3ae56b
--- /dev/null
+++ b/app-next/src/components/providers.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { SessionProvider } from "next-auth/react";
+import { useState, type ReactNode } from "react";
+
+/**
+ * React Query Provider wrapper
+ * This component wraps the app with React Query context for data fetching
+ */
+export function QueryProvider({ children }: { children: ReactNode }) {
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 60 * 1000, // 1 minute
+ refetchOnWindowFocus: false,
+ retry: 1,
+ },
+ },
+ }),
+ );
+
+ return (
+ {children}
+ );
+}
+
+/**
+ * Auth Provider wrapper
+ * This component wraps the app with NextAuth SessionProvider
+ */
+export function AuthProvider({ children }: { children: ReactNode }) {
+ return (
+ {children}
+ );
+}
diff --git a/app-next/src/components/run/metric-item.tsx b/app-next/src/components/run/metric-item.tsx
new file mode 100644
index 00000000..25839522
--- /dev/null
+++ b/app-next/src/components/run/metric-item.tsx
@@ -0,0 +1,349 @@
+"use client";
+
+import { useState, useMemo, useCallback } from "react";
+import { ChevronRight, Copy, Check } from "lucide-react";
+
+export interface Evaluation {
+ name: string;
+ value: string | number;
+ stdev?: string | number;
+ array_data?: Record;
+ per_fold?: Array;
+}
+
+// Colors for bar charts
+const COLORS = [
+ "#3b82f6", // blue
+ "#22c55e", // green
+ "#f59e0b", // amber
+ "#ef4444", // red
+ "#8b5cf6", // violet
+ "#ec4899", // pink
+ "#14b8a6", // teal
+ "#f97316", // orange
+];
+
+// Format metric names
+const formatMetricName = (name: string) => {
+ const mapping: Record = {
+ predictive_accuracy: "Predictive Accuracy",
+ area_under_roc_curve: "Area Under ROC Curve (AUC)",
+ root_mean_squared_error: "Root Mean Squared Error (RMSE)",
+ mean_absolute_error: "Mean Absolute Error (MAE)",
+ f_measure: "F-Measure",
+ precision: "Precision",
+ recall: "Recall",
+ kappa: "Kappa",
+ kb_relative_information_score: "KB Relative Information Score",
+ weighted_recall: "Weighted Recall",
+ };
+ return (
+ mapping[name] ||
+ name.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())
+ );
+};
+
+// ─── Pure-CSS horizontal bar (for Per Class) ───────────────────────────
+function HorizontalBarChart({
+ data,
+}: {
+ data: { name: string; value: number; fill: string; pct: number }[];
+}) {
+ const [hovered, setHovered] = useState(null);
+
+ return (
+
+ {data.map((entry, idx) => (
+
setHovered(idx)}
+ onMouseLeave={() => setHovered(null)}
+ >
+
+ {entry.name}
+
+
+
+ {entry.value.toFixed(4)}
+
+
+ ))}
+
+ );
+}
+
+// ─── Pure-CSS vertical bar (for Per Fold) ──────────────────────────────
+function VerticalBarChart({
+ data,
+}: {
+ data: { name: string; value: number; pct: number }[];
+}) {
+ const [hovered, setHovered] = useState(null);
+
+ return (
+
+ {/* Bars */}
+
+ {data.map((entry, idx) => (
+
setHovered(idx)}
+ onMouseLeave={() => setHovered(null)}
+ >
+ {/* Tooltip on hover */}
+ {hovered === idx && (
+
+ {entry.value.toFixed(4)}
+
+ )}
+
+
+ ))}
+
+ {/* Labels */}
+
+ {data.map((entry) => (
+
+ {entry.name}
+
+ ))}
+
+
+ );
+}
+
+// ─── Data processing ───────────────────────────────────────────────────
+const getPerClassChartData = (arrayData: Record) => {
+ const entries = Object.entries(arrayData)
+ .map(([key, val], idx) => {
+ const numeric = typeof val === "number" ? val : parseFloat(String(val));
+ return { name: key, value: numeric, fill: COLORS[idx % COLORS.length] };
+ })
+ .filter((d) => !isNaN(d.value));
+ if (entries.length === 0) return [];
+ const sorted = [...entries].sort((a, b) => b.value - a.value);
+ const maxVal = sorted[0]?.value || 1;
+ return sorted.map((entry) => ({
+ ...entry,
+ pct: maxVal > 0 ? (entry.value / maxVal) * 100 : 0,
+ }));
+};
+
+const getPerFoldChartData = (perFold: Array) => {
+ const flatValues = perFold.flat().filter((v) => !isNaN(v));
+ if (flatValues.length === 0) return [];
+ const maxVal = Math.max(...flatValues);
+ return flatValues.map((val, idx) => ({
+ name: `Fold ${idx + 1}`,
+ value: val,
+ pct: maxVal > 0 ? (val / maxVal) * 100 : 0,
+ }));
+};
+
+// ─── MetricItem ────────────────────────────────────────────────────────
+export function MetricItemWithCharts({
+ evaluation,
+ isCollapsed,
+ onToggle,
+}: {
+ evaluation: Evaluation;
+ isCollapsed: boolean;
+ onToggle: (name: string) => void;
+}) {
+ return (
+
+ );
+}
+
+export default function MetricItem({
+ evaluation,
+ isCollapsed,
+ onToggle,
+}: {
+ evaluation: Evaluation;
+ isCollapsed: boolean;
+ onToggle: (name: string) => void;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const copyValue = useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+ const v =
+ typeof evaluation.value === "string"
+ ? parseFloat(evaluation.value)
+ : evaluation.value;
+ const s = evaluation.stdev
+ ? typeof evaluation.stdev === "string"
+ ? parseFloat(evaluation.stdev)
+ : evaluation.stdev
+ : null;
+ const text =
+ s !== null && !isNaN(s)
+ ? `${evaluation.name}: ${v.toFixed(4)} ± ${s.toFixed(4)}`
+ : `${evaluation.name}: ${v.toFixed(4)}`;
+ navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ },
+ [evaluation.name, evaluation.value, evaluation.stdev],
+ );
+
+ const chartData = useMemo(() => {
+ const arrayData = evaluation.array_data;
+ const perFold = evaluation.per_fold;
+ const perClassData = arrayData ? getPerClassChartData(arrayData) : null;
+ const perFoldData = perFold ? getPerFoldChartData(perFold) : null;
+ return { perClassData, perFoldData, arrayData };
+ }, [evaluation.array_data, evaluation.per_fold]);
+
+ const { perClassData, perFoldData, arrayData } = chartData;
+ const value =
+ typeof evaluation.value === "string"
+ ? parseFloat(evaluation.value)
+ : evaluation.value;
+ const stdev = evaluation.stdev
+ ? typeof evaluation.stdev === "string"
+ ? parseFloat(evaluation.stdev)
+ : evaluation.stdev
+ : null;
+
+ const hasCharts =
+ (perClassData && perClassData.length > 0) ||
+ (perFoldData && perFoldData.length > 0);
+ const bothCharts =
+ perClassData &&
+ perClassData.length > 0 &&
+ perFoldData &&
+ perFoldData.length > 0;
+
+ return (
+
+ {/* Header row */}
+
onToggle(evaluation.name) : undefined}
+ >
+
+ {hasCharts ? (
+
+ ) : (
+
+ )}
+
+ {formatMetricName(evaluation.name)}
+
+
+
+
+
+
+ {!isNaN(value) ? value.toFixed(4) : evaluation.value}
+
+ {stdev !== null && !isNaN(stdev) && (
+
+ ±{stdev.toFixed(4)}
+
+ )}
+
+
+
+
+ {/* Charts — hidden when collapsed */}
+ {!isCollapsed && (
+ <>
+
+ {perClassData && perClassData.length > 0 && (
+
+
+ Per Class
+
+
+
+ )}
+
+ {perFoldData && perFoldData.length > 0 && (
+
+
+ Per Fold
+
+
+
+ )}
+
+
+ {/* Fallback text display if no charts */}
+ {!perClassData && !perFoldData && arrayData && (
+
+ {Object.entries(arrayData).map(
+ ([key, val]: [string, string | number]) => (
+
+ {key}:
+
+ {typeof val === "number"
+ ? val.toFixed(4)
+ : parseFloat(val as string).toFixed(4)}
+
+
+ ),
+ )}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/app-next/src/components/run/run-analyses-section.tsx b/app-next/src/components/run/run-analyses-section.tsx
new file mode 100644
index 00000000..7bbadb49
--- /dev/null
+++ b/app-next/src/components/run/run-analyses-section.tsx
@@ -0,0 +1,504 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import dynamic from "next/dynamic";
+import { useTheme } from "next-themes";
+import { Loader2, Download, Table2, BarChart3, X } from "lucide-react";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Button } from "@/components/ui/button";
+import { APP_CONFIG } from "@/lib/config";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { usePlotlyTheme } from "@/hooks/usePlotlyTheme";
+
+const Plot = dynamic(() => import("react-plotly.js"), {
+ ssr: false,
+ loading: () => ,
+});
+
+interface PredictionRow {
+ row_id: number;
+ fold: number;
+ repeat: number;
+ prediction: string | number;
+ correct: string | number;
+ confidence?: Record;
+}
+
+interface ConfusionMatrixData {
+ actual: string;
+ predicted: string;
+ count: number;
+}
+
+interface RunAnalysesSectionProps {
+ runId: number;
+ taskId?: number;
+}
+
+export function RunAnalysesSection({ runId }: RunAnalysesSectionProps) {
+ const plotTheme = usePlotlyTheme();
+ const { resolvedTheme } = useTheme();
+ const isDark = resolvedTheme === "dark";
+
+ const [predictions, setPredictions] = useState([]);
+ const [confusionMatrix, setConfusionMatrix] = useState([]);
+ const [classes, setClasses] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [activeTab, setActiveTab] = useState("confusion");
+ const [selectedCell, setSelectedCell] = useState<{ actual: string; predicted: string } | null>(null);
+
+ useEffect(() => {
+ async function fetchPredictions() {
+ try {
+ setLoading(true);
+ const apiUrl = APP_CONFIG.urlApi || "https://www.openml.org/api/v1";
+
+ const response = await fetch(`${apiUrl}/json/run/${runId}`, {
+ headers: { Accept: "application/json" },
+ });
+
+ if (!response.ok) throw new Error("Failed to fetch run data");
+
+ const data = await response.json();
+ const run = data.run;
+
+ if (run?.output_data?.file) {
+ const predictionFile = run.output_data.file.find(
+ (f: { name: string }) => f.name === "predictions",
+ );
+
+ if (predictionFile?.url) {
+ const proxyUrl = `/api/proxy-file?url=${encodeURIComponent(predictionFile.url)}`;
+ const predResponse = await fetch(proxyUrl);
+ if (predResponse.ok) {
+ const predText = await predResponse.text();
+ const parsedPredictions = parsePredictions(predText);
+ setPredictions(parsedPredictions);
+
+ const { matrix, uniqueClasses } = buildConfusionMatrix(parsedPredictions);
+ setConfusionMatrix(matrix);
+ setClasses(uniqueClasses);
+ }
+ }
+ }
+
+ if (predictions.length === 0 && run?.output_data?.evaluation) {
+ const confMatrixEval = run.output_data.evaluation.find(
+ (e: { name: string }) => e.name === "confusion_matrix",
+ );
+ if (confMatrixEval?.array_data) {
+ const parsed = parseConfusionMatrixFromEval(confMatrixEval.array_data);
+ setConfusionMatrix(parsed.matrix);
+ setClasses(parsed.classes);
+ }
+ }
+
+ setLoading(false);
+ } catch (err) {
+ console.error("Failed to fetch predictions:", err);
+ setError("Could not load predictions data");
+ setLoading(false);
+ }
+ }
+
+ fetchPredictions();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [runId]);
+
+ function parsePredictions(text: string): PredictionRow[] {
+ const lines = text.split("\n");
+ const predictions: PredictionRow[] = [];
+ let inData = false;
+
+ for (const line of lines) {
+ if (line.trim().toLowerCase() === "@data") { inData = true; continue; }
+ if (!inData || !line.trim() || line.startsWith("%")) continue;
+
+ const parts = line.split(",");
+ if (parts.length >= 4) {
+ predictions.push({
+ row_id: parseInt(parts[0]) || 0,
+ fold: parseInt(parts[1]) || 0,
+ repeat: parseInt(parts[2]) || 0,
+ prediction: parts[3]?.replace(/'/g, "").trim(),
+ correct: parts[4]?.replace(/'/g, "").trim(),
+ });
+ }
+ }
+ return predictions;
+ }
+
+ function buildConfusionMatrix(preds: PredictionRow[]): {
+ matrix: ConfusionMatrixData[];
+ uniqueClasses: string[];
+ } {
+ const counts: Record> = {};
+ const classSet = new Set();
+
+ preds.forEach((p) => {
+ const actual = String(p.correct);
+ const predicted = String(p.prediction);
+ classSet.add(actual);
+ classSet.add(predicted);
+ if (!counts[actual]) counts[actual] = {};
+ counts[actual][predicted] = (counts[actual][predicted] || 0) + 1;
+ });
+
+ const uniqueClasses = Array.from(classSet).sort();
+
+ // Guard: regression tasks (decimal class labels) or too many unique values
+ const isRegression = uniqueClasses.some((c) => {
+ const n = parseFloat(c);
+ return !isNaN(n) && !Number.isInteger(n);
+ });
+ if (uniqueClasses.length > 50 || isRegression) return { matrix: [], uniqueClasses: [] };
+
+ const matrix: ConfusionMatrixData[] = [];
+ uniqueClasses.forEach((actual) => {
+ uniqueClasses.forEach((predicted) => {
+ matrix.push({ actual, predicted, count: counts[actual]?.[predicted] || 0 });
+ });
+ });
+
+ return { matrix, uniqueClasses };
+ }
+
+ function parseConfusionMatrixFromEval(arrayData: Record): {
+ matrix: ConfusionMatrixData[];
+ classes: string[];
+ } {
+ const matrix: ConfusionMatrixData[] = [];
+ const classSet = new Set();
+
+ Object.entries(arrayData).forEach(([key, value]) => {
+ const parts = key.split(",");
+ if (parts.length === 2) {
+ const [actual, predicted] = parts;
+ classSet.add(actual);
+ classSet.add(predicted);
+ matrix.push({
+ actual: actual.trim(),
+ predicted: predicted.trim(),
+ count: typeof value === "number" ? value : parseInt(String(value)) || 0,
+ });
+ }
+ });
+
+ return { matrix, classes: Array.from(classSet).sort() };
+ }
+
+ if (loading) {
+ return (
+
+
+ Loading analyses...
+
+ );
+ }
+
+ if (error || (predictions.length === 0 && confusionMatrix.length === 0)) {
+ return (
+
+
No prediction data available for this run.
+
+ Predictions are only available for runs that include output files.
+
+
+ );
+ }
+
+ // ── Confusion matrix: build z-matrix (rows = actual, cols = predicted) ──
+ const heatmapZ = classes.map((actual) =>
+ classes.map((predicted) => {
+ const cell = confusionMatrix.find(
+ (m) => m.actual === actual && m.predicted === predicted,
+ );
+ return cell?.count || 0;
+ }),
+ );
+
+ const annotationColor = isDark ? "white" : "#166534";
+ const heatmapAnnotations = classes.flatMap((actual, i) =>
+ classes.map((predicted, j) => ({
+ x: predicted,
+ y: actual,
+ // Only annotate non-zero cells to avoid clutter on empty light-mode cells
+ text: heatmapZ[i][j] > 0 ? String(heatmapZ[i][j]) : "",
+ showarrow: false,
+ font: {
+ color: annotationColor,
+ size: classes.length > 20 ? 8 : 11,
+ },
+ })),
+ );
+
+ // Filtered predictions when a confusion matrix cell is selected
+ const displayedPredictions = selectedCell
+ ? predictions.filter(
+ (p) =>
+ String(p.correct) === selectedCell.actual &&
+ String(p.prediction) === selectedCell.predicted,
+ )
+ : predictions;
+
+ // ── Class distribution for stacked bar chart ──
+ const distMap: Record = {};
+ predictions.forEach((p) => {
+ const actual = String(p.correct);
+ if (!distMap[actual]) distMap[actual] = { correct: 0, incorrect: 0 };
+ if (p.prediction === p.correct) distMap[actual].correct++;
+ else distMap[actual].incorrect++;
+ });
+ const distClasses = Object.keys(distMap).sort();
+
+ const totalCorrect = distClasses.reduce((s, c) => s + distMap[c].correct, 0);
+ const totalPredictions = predictions.length;
+ const overallAccuracy = totalPredictions > 0
+ ? ((totalCorrect / totalPredictions) * 100).toFixed(1)
+ : null;
+
+ return (
+
+
+
+
+
+ Confusion Matrix
+
+
+
+ Class Distribution
+
+
+
+ Predictions
+
+
+
+ {/* ── Confusion Matrix Tab ── */}
+
+ {confusionMatrix.length > 0 && classes.length > 0 ? (
+
+
Predicted: %{x}
Count: %{z}
Click to filter predictions",
+ } as object,
+ ]}
+ layout={
+ {
+ height: Math.min(600, Math.max(300, classes.length * 40 + 100)),
+ margin: { l: 100, r: 20, t: 30, b: 80 },
+ font: plotTheme.font,
+ xaxis: {
+ title: { text: "Predicted" },
+ tickfont: plotTheme.font,
+ side: "bottom",
+ gridcolor: plotTheme.gridcolor,
+ },
+ yaxis: {
+ title: { text: "Actual" },
+ tickfont: plotTheme.font,
+ autorange: "reversed",
+ gridcolor: plotTheme.gridcolor,
+ },
+ annotations: heatmapAnnotations,
+ paper_bgcolor: plotTheme.paper_bgcolor,
+ plot_bgcolor: plotTheme.plot_bgcolor,
+ hoverlabel: plotTheme.hoverlabel,
+ } as object
+ }
+ config={{ responsive: true, displayModeBar: false }}
+ style={{ width: "100%", cursor: "pointer" }}
+ onClick={(event) => {
+ if (event.points?.length) {
+ const pt = event.points[0];
+ setSelectedCell({
+ actual: String(pt.y),
+ predicted: String(pt.x),
+ });
+ setActiveTab("predictions");
+ }
+ }}
+ />
+
+ Darker = more predictions. Diagonal = correct. Click a cell to filter predictions.
+
+
+ ) : (
+
+ No confusion matrix data available for this run (regression tasks or too many classes are not shown).
+
+ )}
+
+
+ {/* ── Class Distribution Tab ── */}
+
+ {distClasses.length > 0 ? (
+
+ {/* Overall accuracy summary */}
+ {overallAccuracy !== null && (
+
+ Overall accuracy:
+ {overallAccuracy}%
+
+ ({totalCorrect.toLocaleString()} / {totalPredictions.toLocaleString()} correct)
+
+
+ )}
+
distMap[c].correct),
+ marker: { color: "#22c55e" },
+ customdata: distClasses.map((c) => {
+ const total = distMap[c].correct + distMap[c].incorrect;
+ return total > 0
+ ? ((distMap[c].correct / total) * 100).toFixed(1)
+ : "0.0";
+ }),
+ hovertemplate:
+ "%{y}
Correct: %{x} (%{customdata}% of class)",
+ } as object,
+ {
+ type: "bar",
+ orientation: "h",
+ name: "Incorrect",
+ y: distClasses,
+ x: distClasses.map((c) => distMap[c].incorrect),
+ marker: { color: "#ef4444" },
+ customdata: distClasses.map((c) => {
+ const total = distMap[c].correct + distMap[c].incorrect;
+ return total > 0
+ ? ((distMap[c].incorrect / total) * 100).toFixed(1)
+ : "0.0";
+ }),
+ hovertemplate:
+ "%{y}
Incorrect: %{x} (%{customdata}% of class)",
+ } as object,
+ ]}
+ layout={
+ {
+ barmode: "stack",
+ height: Math.min(600, Math.max(300, distClasses.length * 30 + 100)),
+ margin: { l: 100, r: 20, t: 20, b: 50 },
+ font: plotTheme.font,
+ xaxis: {
+ title: { text: "Count" },
+ tickfont: plotTheme.font,
+ gridcolor: plotTheme.gridcolor,
+ },
+ yaxis: {
+ tickfont: plotTheme.font,
+ gridcolor: plotTheme.gridcolor,
+ automargin: true,
+ },
+ legend: { orientation: "h", y: -0.15 },
+ paper_bgcolor: plotTheme.paper_bgcolor,
+ plot_bgcolor: plotTheme.plot_bgcolor,
+ hoverlabel: plotTheme.hoverlabel,
+ } as object
+ }
+ config={{
+ responsive: true,
+ displayModeBar: true,
+ modeBarButtonsToRemove: ["lasso2d", "select2d"],
+ displaylogo: false,
+ }}
+ style={{ width: "100%" }}
+ />
+
+ ) : (
+
+ No class distribution data available
+
+ )}
+
+
+ {/* ── Predictions Table Tab ── */}
+
+ {predictions.length > 0 ? (
+
+ {/* Active cell filter banner */}
+ {selectedCell && (
+
+
+ Showing predictions where actual =
+ {selectedCell.actual} and predicted =
+ {selectedCell.predicted}
+ ({displayedPredictions.length} rows)
+
+
+
+ )}
+
+
+
+
+ | Row |
+ Fold |
+ Actual |
+ Predicted |
+ Correct |
+
+
+
+ {displayedPredictions.slice(0, 200).map((p, idx) => {
+ const isCorrect = p.prediction === p.correct;
+ return (
+
+ | {p.row_id} |
+ {p.fold} |
+ {p.correct} |
+ {p.prediction} |
+
+ {isCorrect ? (
+ ✓
+ ) : (
+ ✗
+ )}
+ |
+
+ );
+ })}
+
+
+ {displayedPredictions.length > 200 && (
+
+ Showing first 200 of {displayedPredictions.length.toLocaleString()} rows
+
+ )}
+
+
+ ) : (
+
+ No individual predictions available
+
+ )}
+
+
+
+ );
+}
diff --git a/app-next/src/components/run/run-comparison-chart.tsx b/app-next/src/components/run/run-comparison-chart.tsx
new file mode 100644
index 00000000..b0a8a8c4
--- /dev/null
+++ b/app-next/src/components/run/run-comparison-chart.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { RunDetail } from "@/lib/api/run";
+
+// ─── Colors (same as comparison table) ──────────────────────────────────
+const RUN_COLORS = [
+ "#3b82f6",
+ "#22c55e",
+ "#f59e0b",
+ "#ef4444",
+ "#8b5cf6",
+ "#ec4899",
+ "#14b8a6",
+ "#f97316",
+];
+
+/** Metrics where lower is better */
+const LOWER_IS_BETTER = new Set([
+ "root_mean_squared_error",
+ "mean_absolute_error",
+ "mean_prior_absolute_error",
+ "root_mean_prior_squared_error",
+ "relative_absolute_error",
+ "root_relative_squared_error",
+ "average_cost",
+ "total_cost",
+]);
+
+const METRIC_NAMES: Record = {
+ predictive_accuracy: "Predictive Accuracy",
+ area_under_roc_curve: "AUC",
+ root_mean_squared_error: "RMSE",
+ mean_absolute_error: "MAE",
+ f_measure: "F-Measure",
+ precision: "Precision",
+ recall: "Recall",
+ kappa: "Kappa",
+};
+
+function formatMetricName(name: string): string {
+ return (
+ METRIC_NAMES[name] ||
+ name.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════
+// Component
+// ═══════════════════════════════════════════════════════════════════════
+interface RunComparisonChartProps {
+ runs: RunDetail[];
+}
+
+export function RunComparisonChart({ runs }: RunComparisonChartProps) {
+ // Collect all metric names with summary values
+ const metrics = useMemo(() => {
+ const nameSet = new Set();
+ for (const run of runs) {
+ for (const ev of run.output_data?.evaluation ?? []) {
+ if (ev.value != null) nameSet.add(ev.name);
+ }
+ }
+ // Sort: common first, alphabetical
+ return Array.from(nameSet).sort((a, b) =>
+ formatMetricName(a).localeCompare(formatMetricName(b)),
+ );
+ }, [runs]);
+
+ const [selectedMetric, setSelectedMetric] = useState(
+ () => metrics.find((m) => m === "predictive_accuracy") ?? metrics[0] ?? "",
+ );
+
+ const chartData = useMemo(() => {
+ if (!selectedMetric) return [];
+ return runs.map((run, idx) => {
+ const ev = run.output_data?.evaluation?.find(
+ (e) => e.name === selectedMetric,
+ );
+ const val =
+ ev?.value != null
+ ? typeof ev.value === "number"
+ ? ev.value
+ : parseFloat(String(ev.value))
+ : null;
+ const stdev =
+ ev?.stdev != null
+ ? typeof ev.stdev === "number"
+ ? ev.stdev
+ : parseFloat(String(ev.stdev))
+ : null;
+ return {
+ runId: run.run_id,
+ flowName: run.flow_name ?? `Flow #${run.flow_id}`,
+ value: val != null && !isNaN(val) ? val : null,
+ stdev: stdev != null && !isNaN(stdev) ? stdev : null,
+ color: RUN_COLORS[idx % RUN_COLORS.length],
+ };
+ });
+ }, [runs, selectedMetric]);
+
+ const lowerBetter = LOWER_IS_BETTER.has(selectedMetric);
+ const validValues = chartData
+ .map((d) => d.value)
+ .filter((v): v is number => v !== null);
+ const maxVal = validValues.length > 0 ? Math.max(...validValues) : 1;
+ const minVal = validValues.length > 0 ? Math.min(...validValues) : 0;
+
+ // For bar scaling — use 0 as baseline unless all values very high
+ const baseline = minVal > maxVal * 0.5 ? minVal * 0.9 : 0;
+ const range = maxVal - baseline || 1;
+
+ // Determine best
+ const bestVal = lowerBetter
+ ? Math.min(...validValues)
+ : Math.max(...validValues);
+ const unique = new Set(validValues.map((v) => v.toFixed(6)));
+ const hasBest = unique.size > 1;
+
+ if (metrics.length === 0) {
+ return (
+
+ No evaluation metrics to chart.
+
+ );
+ }
+
+ return (
+
+ {/* Metric picker */}
+
+ {metrics.map((m) => (
+
+ ))}
+
+
+ {/* Lower-is-better indicator */}
+ {lowerBetter && (
+
+ ↓ Lower is better for this metric
+
+ )}
+
+ {/* Bar chart */}
+
+ {chartData.map((d) => {
+ const pct =
+ d.value !== null ? ((d.value - baseline) / range) * 100 : 0;
+ const isBest = hasBest && d.value !== null && d.value === bestVal;
+ return (
+
+ {/* Label */}
+
+
Run #{d.runId}
+
+ {d.flowName}
+
+
+ {/* Bar */}
+
+ {d.value !== null && (
+
+
+ {d.value.toFixed(4)}
+ {d.stdev !== null && (
+
+ ±{d.stdev.toFixed(4)}
+
+ )}
+
+
+ )}
+ {d.value === null && (
+
+ No data
+
+ )}
+
+ {isBest && (
+
+ Best
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/app-next/src/components/run/run-comparison-client.tsx b/app-next/src/components/run/run-comparison-client.tsx
new file mode 100644
index 00000000..df71d972
--- /dev/null
+++ b/app-next/src/components/run/run-comparison-client.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { useState } from "react";
+import { Table2, BarChart3 } from "lucide-react";
+import { RunComparisonTable } from "./run-comparison-table";
+import { RunComparisonChart } from "./run-comparison-chart";
+import type { RunDetail } from "@/lib/api/run";
+
+type Tab = "table" | "chart";
+
+interface RunComparisonClientProps {
+ runs: RunDetail[];
+}
+
+export function RunComparisonClient({ runs }: RunComparisonClientProps) {
+ const [tab, setTab] = useState("table");
+
+ return (
+
+ {/* Tab bar */}
+
+
+
+
+
+ {/* Content */}
+ {tab === "table" &&
}
+ {tab === "chart" &&
}
+
+ );
+}
diff --git a/app-next/src/components/run/run-comparison-table.tsx b/app-next/src/components/run/run-comparison-table.tsx
new file mode 100644
index 00000000..93a2e555
--- /dev/null
+++ b/app-next/src/components/run/run-comparison-table.tsx
@@ -0,0 +1,619 @@
+"use client";
+
+import { useState, useMemo, useCallback } from "react";
+import {
+ ArrowUpDown,
+ ArrowUp,
+ ArrowDown,
+ Copy,
+ Check,
+ Download,
+ ChevronRight,
+} from "lucide-react";
+import type { RunDetail } from "@/lib/api/run";
+
+// ─── Types ──────────────────────────────────────────────────────────────
+type SortField = "metric" | `run_${number}`;
+type SortDir = "asc" | "desc";
+
+interface MetricRow {
+ name: string;
+ displayName: string;
+ values: (number | null)[];
+ stdevs: (number | null)[];
+ foldCounts: number[];
+ /** Index of the best (highest) value */
+ bestIdx: number | null;
+}
+
+interface ParamRow {
+ name: string;
+ values: (string | null)[];
+ allSame: boolean;
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────────
+const METRIC_NAMES: Record = {
+ predictive_accuracy: "Predictive Accuracy",
+ area_under_roc_curve: "AUC",
+ root_mean_squared_error: "RMSE",
+ mean_absolute_error: "MAE",
+ f_measure: "F-Measure",
+ precision: "Precision",
+ recall: "Recall",
+ kappa: "Kappa",
+ weighted_recall: "Weighted Recall",
+};
+
+/** Metrics where lower is better */
+const LOWER_IS_BETTER = new Set([
+ "root_mean_squared_error",
+ "mean_absolute_error",
+ "mean_prior_absolute_error",
+ "root_mean_prior_squared_error",
+ "relative_absolute_error",
+ "root_relative_squared_error",
+ "average_cost",
+ "total_cost",
+]);
+
+function fmt(n: string | number): string {
+ const v = typeof n === "number" ? n : parseFloat(String(n));
+ return isNaN(v) ? String(n) : v.toFixed(4);
+}
+
+function parseNum(v: string | number | undefined): number {
+ if (v == null) return NaN;
+ return typeof v === "number" ? v : parseFloat(String(v));
+}
+
+function formatMetricName(name: string): string {
+ return (
+ METRIC_NAMES[name] ||
+ name.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())
+ );
+}
+
+// ─── Sort icon (module-level for React 19) ──────────────────────────────
+function SortIcon({
+ field,
+ sortField,
+ sortDir,
+}: {
+ field: SortField;
+ sortField: SortField;
+ sortDir: SortDir;
+}) {
+ if (sortField !== field)
+ return ;
+ return sortDir === "asc" ? (
+
+ ) : (
+
+ );
+}
+
+// ─── Run colors ─────────────────────────────────────────────────────────
+const RUN_COLORS = [
+ "#3b82f6", // blue
+ "#22c55e", // green
+ "#f59e0b", // amber
+ "#ef4444", // red
+ "#8b5cf6", // violet
+ "#ec4899", // pink
+ "#14b8a6", // teal
+ "#f97316", // orange
+];
+
+// ═══════════════════════════════════════════════════════════════════════
+// Component
+// ═══════════════════════════════════════════════════════════════════════
+interface RunComparisonTableProps {
+ runs: RunDetail[];
+ showParams?: boolean;
+}
+
+export function RunComparisonTable({
+ runs,
+ showParams = true,
+}: RunComparisonTableProps) {
+ const [sortField, setSortField] = useState("metric");
+ const [sortDir, setSortDir] = useState("asc");
+ const [copiedCell, setCopiedCell] = useState(null);
+ const [expandedMetric, setExpandedMetric] = useState(null);
+
+ // ── Build metric rows ─────────────────────────────────────────────────
+ const metricRows = useMemo(() => {
+ // Collect all metric names across runs
+ const allNames = new Set();
+ for (const run of runs) {
+ for (const ev of run.output_data?.evaluation ?? []) {
+ allNames.add(ev.name);
+ }
+ }
+
+ const rows: MetricRow[] = [];
+ for (const name of allNames) {
+ const values: (number | null)[] = [];
+ const stdevs: (number | null)[] = [];
+ const foldCounts: number[] = [];
+
+ for (const run of runs) {
+ const ev = run.output_data?.evaluation?.find((e) => e.name === name);
+ if (ev) {
+ const v = parseNum(ev.value);
+ values.push(isNaN(v) ? null : v);
+ const s = parseNum(ev.stdev);
+ stdevs.push(isNaN(s) ? null : s);
+ foldCounts.push(ev.per_fold?.flat().length ?? 0);
+ } else {
+ values.push(null);
+ stdevs.push(null);
+ foldCounts.push(0);
+ }
+ }
+
+ // Determine best value
+ const lowerBetter = LOWER_IS_BETTER.has(name);
+ let bestIdx: number | null = null;
+ let bestVal = lowerBetter ? Infinity : -Infinity;
+ for (let i = 0; i < values.length; i++) {
+ const v = values[i];
+ if (v === null) continue;
+ if (lowerBetter ? v < bestVal : v > bestVal) {
+ bestVal = v;
+ bestIdx = i;
+ }
+ }
+ // Only highlight if there's a real difference
+ const unique = new Set(
+ values.filter((v) => v !== null).map((v) => v!.toFixed(6)),
+ );
+ if (unique.size <= 1) bestIdx = null;
+
+ rows.push({
+ name,
+ displayName: formatMetricName(name),
+ values,
+ stdevs,
+ foldCounts,
+ bestIdx,
+ });
+ }
+ return rows;
+ }, [runs]);
+
+ // ── Build parameter rows ──────────────────────────────────────────────
+ const paramRows = useMemo(() => {
+ if (!showParams) return [];
+ const allNames = new Set();
+ for (const run of runs) {
+ for (const p of run.parameter_setting ?? []) {
+ allNames.add(p.name);
+ }
+ }
+
+ const rows: ParamRow[] = [];
+ for (const name of allNames) {
+ const values: (string | null)[] = [];
+ for (const run of runs) {
+ const p = run.parameter_setting?.find((x) => x.name === name);
+ values.push(p ? String(p.value) : null);
+ }
+ const nonNull = values.filter((v) => v !== null);
+ const allSame = nonNull.length > 0 && new Set(nonNull).size === 1;
+ rows.push({ name, values, allSame });
+ }
+ // Sort: different values first
+ rows.sort((a, b) => {
+ if (a.allSame !== b.allSame) return a.allSame ? 1 : -1;
+ return a.name.localeCompare(b.name);
+ });
+ return rows;
+ }, [runs, showParams]);
+
+ // ── Sort metric rows ──────────────────────────────────────────────────
+ const sortedMetrics = useMemo(() => {
+ const arr = [...metricRows];
+ arr.sort((a, b) => {
+ let cmp = 0;
+ if (sortField === "metric") {
+ cmp = a.displayName.localeCompare(b.displayName);
+ } else {
+ const runIdx = parseInt(sortField.replace("run_", ""), 10);
+ const va = a.values[runIdx] ?? -Infinity;
+ const vb = b.values[runIdx] ?? -Infinity;
+ cmp = va - vb;
+ }
+ return sortDir === "asc" ? cmp : -cmp;
+ });
+ return arr;
+ }, [metricRows, sortField, sortDir]);
+
+ const toggleSort = (field: SortField) => {
+ if (sortField === field) {
+ setSortDir((d) => (d === "asc" ? "desc" : "asc"));
+ } else {
+ setSortField(field);
+ setSortDir(field === "metric" ? "asc" : "desc");
+ }
+ };
+
+ const copyCell = useCallback((text: string, key: string) => {
+ navigator.clipboard.writeText(text);
+ setCopiedCell(key);
+ setTimeout(() => setCopiedCell(null), 1200);
+ }, []);
+
+ // ── Export ────────────────────────────────────────────────────────────
+ const exportCSV = useCallback(() => {
+ const header = ["Metric", ...runs.map((r) => `Run #${r.run_id}`)].join(",");
+ const rows = sortedMetrics.map((row) =>
+ [
+ row.name,
+ ...row.values.map((v) => (v !== null ? v.toFixed(4) : "")),
+ ].join(","),
+ );
+ const csv = [header, ...rows].join("\n");
+ const blob = new Blob([csv], { type: "text/csv" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `comparison_${runs.map((r) => r.run_id).join("_")}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }, [runs, sortedMetrics]);
+
+ // ── Diff params: only show rows where values differ ───────────────────
+ const [showAllParams, setShowAllParams] = useState(false);
+ const visibleParams = showAllParams
+ ? paramRows
+ : paramRows.filter((r) => !r.allSame);
+
+ return (
+
+ {/* Toolbar */}
+
+
+ {sortedMetrics.length} metrics × {runs.length} runs
+
+
+
+
+ {/* ── Metric Comparison Table ────────────────────────────────────── */}
+
+
+
+
+ |
+ toggleSort("metric")}
+ >
+ Metric
+
+ |
+ {runs.map((run, i) => (
+ toggleSort(`run_${i}`)}
+ >
+
+ Run #{run.run_id}
+
+ |
+ ))}
+
+
+
+ {sortedMetrics.map((row) => {
+ const isExpanded = expandedMetric === row.name;
+ const hasDetail = row.foldCounts.some((c) => c > 0);
+ return (
+
+ setExpandedMetric(isExpanded ? null : row.name)
+ }
+ onCopy={copyCell}
+ />
+ );
+ })}
+
+
+
+
+ {/* ── Parameter Diff Table ───────────────────────────────────────── */}
+ {showParams && paramRows.length > 0 && (
+
+
+
+ Parameters
+ {!showAllParams && visibleParams.length < paramRows.length && (
+
+ ({visibleParams.length} differing of {paramRows.length})
+
+ )}
+
+ {paramRows.some((r) => r.allSame) && (
+
+ )}
+
+
+
+
+
+ |
+ Parameter
+ |
+ {runs.map((run, i) => (
+
+
+ Run #{run.run_id}
+ |
+ ))}
+
+
+
+ {visibleParams.map((row) => (
+
+ | {row.name} |
+ {row.values.map((val, i) => (
+
+ {val ?? "—"}
+ |
+ ))}
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
+
+// ─── Metric Row ─────────────────────────────────────────────────────────
+function MetricCompareRow({
+ row,
+ runs,
+ isExpanded,
+ hasDetail,
+ copiedCell,
+ onToggle,
+ onCopy,
+}: {
+ row: MetricRow;
+ runs: RunDetail[];
+ isExpanded: boolean;
+ hasDetail: boolean;
+ copiedCell: string | null;
+ onToggle: () => void;
+ onCopy: (text: string, key: string) => void;
+}) {
+ return (
+ <>
+
+ |
+ {hasDetail && (
+
+ )}
+ |
+ {row.displayName} |
+ {row.values.map((v, i) => {
+ const key = `${row.name}_${i}`;
+ const isBest = row.bestIdx === i;
+ return (
+
+ {v !== null ? (
+
+ {v.toFixed(4)}
+ {row.stdevs[i] !== null && (
+
+ ±{row.stdevs[i]!.toFixed(4)}
+
+ )}
+
+ ) : (
+ —
+ )}
+ {v !== null && (
+
+ )}
+ |
+ );
+ })}
+
+
+ {/* Expanded per-fold detail */}
+ {isExpanded && (
+
+ |
+
+
+ |
+
+ )}
+ >
+ );
+}
+
+// ─── Per-fold side-by-side bars ─────────────────────────────────────────
+function PerFoldCompare({
+ metricName,
+ runs,
+}: {
+ metricName: string;
+ runs: RunDetail[];
+}) {
+ const foldData = useMemo(() => {
+ return runs.map((run, idx) => {
+ const ev = run.output_data?.evaluation?.find(
+ (e) => e.name === metricName,
+ );
+ const folds = ev?.per_fold?.flat().filter((v) => !isNaN(v)) ?? [];
+ return {
+ runId: run.run_id,
+ color: RUN_COLORS[idx % RUN_COLORS.length],
+ folds,
+ };
+ });
+ }, [runs, metricName]);
+
+ const maxFolds = Math.max(...foldData.map((d) => d.folds.length));
+ const allValues = foldData.flatMap((d) => d.folds);
+ const maxVal = allValues.length > 0 ? Math.max(...allValues) : 1;
+
+ if (maxFolds === 0) {
+ return (
+
+ No per-fold data available for this metric.
+
+ );
+ }
+
+ return (
+
+
+ Per Fold Comparison
+
+
+ {Array.from({ length: maxFolds }, (_, foldIdx) => (
+
+ {foldData.map((rd) => {
+ const val = rd.folds[foldIdx];
+ const pct = val != null ? (val / maxVal) * 100 : 0;
+ return (
+
+
+ {val?.toFixed(4) ?? "—"}
+
+
+
+ );
+ })}
+
+ ))}
+
+ {/* Fold labels */}
+
+ {Array.from({ length: maxFolds }, (_, i) => (
+
+ F{i + 1}
+
+ ))}
+
+ {/* Legend */}
+
+ {foldData.map((rd) => (
+
+
+ Run #{rd.runId}
+
+ ))}
+
+
+ );
+}
diff --git a/app-next/src/components/run/run-header.tsx b/app-next/src/components/run/run-header.tsx
new file mode 100644
index 00000000..6b470ab3
--- /dev/null
+++ b/app-next/src/components/run/run-header.tsx
@@ -0,0 +1,180 @@
+import Link from "next/link";
+import {
+ User,
+ CheckCircle2,
+ XCircle,
+ Heart,
+ CloudDownload,
+ MessageCircle,
+ Eye,
+ EyeOff,
+ GitCompareArrows,
+} from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { entityColors } from "@/constants/entityColors";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ENTITY_ICONS } from "@/constants/entityIcons";
+
+function truncateFlowName(name: string): string {
+ const words = name.split(" ");
+ if (words.length <= 8) return name;
+ return `${words.slice(0, 4).join(" ")} ... ${words.slice(-4).join(" ")}`;
+}
+
+interface RunTaskSourceData {
+ data_id?: number;
+ name?: string;
+}
+
+interface RunTask {
+ source_data?: RunTaskSourceData;
+ task_type?: string;
+}
+
+interface Run {
+ run_id: number;
+ flow_id?: number;
+ flow_name?: string;
+ task_id?: number;
+ task?: RunTask;
+ uploader?: string;
+ uploader_id?: number;
+ uploader_date?: string;
+ upload_time?: string;
+ visibility?: string;
+ error_message?: string | null;
+ nr_of_likes?: number;
+ nr_of_downloads?: number;
+ nr_of_issues?: number;
+ nr_of_downvotes?: number;
+}
+
+interface RunHeaderProps {
+ run: Run;
+}
+
+export function RunHeader({ run }: RunHeaderProps) {
+ const hasError = !!run.error_message;
+ const isPublic = run.visibility === "public" || !run.visibility;
+ const uploaderLabel =
+ run.uploader || (run.uploader_id ? `user/${run.uploader_id}` : null);
+ const uploaderHref = run.uploader
+ ? `/users/${run.uploader}`
+ : run.uploader_id
+ ? `/users/${run.uploader_id}`
+ : null;
+ const flowLabel = run.flow_name
+ ? truncateFlowName(run.flow_name)
+ : run.flow_id
+ ? `Flow #${run.flow_id}`
+ : null;
+
+ return (
+
+
+
+
+
+ {/* Line 1: Run ID + status badges */}
+
+
+ Run #{run.run_id}
+
+ {hasError ? (
+
+
+ Failed
+
+ ) : (
+
+
+ Success
+
+ )}
+
+ {isPublic ? (
+
+ ) : (
+
+ )}
+ {isPublic ? "Public" : "Private"}
+
+
+
+ {/* Line 2: Uploader + engagement metrics */}
+
+ {uploaderLabel && uploaderHref && (
+
+
+ {uploaderLabel}
+
+ )}
+
+
+ {run.nr_of_likes || 0} likes
+
+
+
+ {run.nr_of_downloads || 0} downloads
+
+
+
+ {run.nr_of_issues || 0} issues
+
+
+
+ {/* Line 3: Task ID + truncated flow name */}
+
+ {run.task_id && (
+
+
+ Task #{run.task_id}
+
+ )}
+ {run.flow_id && flowLabel && (
+
+
+
+ {flowLabel}
+
+
+ )}
+ |
+
+
+ Compare with…
+
+
+
+
+
+ );
+}
diff --git a/app-next/src/components/run/run-metrics-section.tsx b/app-next/src/components/run/run-metrics-section.tsx
new file mode 100644
index 00000000..bfe9e206
--- /dev/null
+++ b/app-next/src/components/run/run-metrics-section.tsx
@@ -0,0 +1,439 @@
+"use client";
+
+import { useState, useMemo, useCallback, useEffect, useRef } from "react";
+import {
+ Search,
+ ChevronLeft,
+ ChevronRight,
+ LayoutGrid,
+ Table2,
+ Download,
+ ClipboardCopy,
+ Check,
+} from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { MetricItemWithCharts } from "./metric-item";
+import { RunMetricsTable } from "./run-metrics-table";
+
+interface Evaluation {
+ name: string;
+ value: string | number;
+ stdev?: string | number;
+ array_data?: Record;
+ per_fold?: Array;
+}
+
+interface Run {
+ output_data?: {
+ evaluation?: Evaluation[];
+ };
+}
+
+interface RunMetricsSectionProps {
+ run: Run;
+}
+
+type ViewMode = "cards" | "table";
+
+const ITEMS_PER_PAGE = 20;
+
+// ─── Export helpers ─────────────────────────────────────────────────────
+function evaluationsToCSV(evaluations: Evaluation[]): string {
+ const header = "metric,value,stdev,fold_count";
+ const rows = evaluations.map((e) => {
+ const v =
+ typeof e.value === "number" ? e.value : parseFloat(String(e.value));
+ const s = e.stdev
+ ? typeof e.stdev === "number"
+ ? e.stdev
+ : parseFloat(String(e.stdev))
+ : "";
+ const folds = e.per_fold?.flat().length ?? 0;
+ return `${e.name},${isNaN(v) ? e.value : v.toFixed(4)},${s === "" ? "" : (s as number).toFixed(4)},${folds || ""}`;
+ });
+ return [header, ...rows].join("\n");
+}
+
+function evaluationsToJSON(evaluations: Evaluation[]): string {
+ const data = evaluations.map((e) => {
+ const v =
+ typeof e.value === "number" ? e.value : parseFloat(String(e.value));
+ const s = e.stdev
+ ? typeof e.stdev === "number"
+ ? e.stdev
+ : parseFloat(String(e.stdev))
+ : null;
+ return {
+ metric: e.name,
+ value: isNaN(v) ? e.value : Number(v.toFixed(4)),
+ stdev: s !== null && !isNaN(s) ? Number(s.toFixed(4)) : null,
+ fold_count: e.per_fold?.flat().length ?? 0,
+ ...(e.array_data ? { per_class: e.array_data } : {}),
+ ...(e.per_fold ? { per_fold: e.per_fold } : {}),
+ };
+ });
+ return JSON.stringify(data, null, 2);
+}
+
+function downloadBlob(content: string, filename: string, mime: string) {
+ const blob = new Blob([content], { type: mime });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+export function RunMetricsSection({ run }: RunMetricsSectionProps) {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [currentPage, setCurrentPage] = useState(1);
+ const [viewMode, setViewMode] = useState("cards");
+ const [collapsedMetrics, setCollapsedMetrics] = useState>(
+ new Set(),
+ );
+ const [focusIdx, setFocusIdx] = useState(-1);
+ const [exportCopied, setExportCopied] = useState(false);
+ const [exportOpen, setExportOpen] = useState(false);
+ const searchRef = useRef(null);
+ const listRef = useRef(null);
+ const exportRef = useRef(null);
+
+ const toggleMetric = useCallback((name: string) => {
+ setCollapsedMetrics((prev) => {
+ const next = new Set(prev);
+ if (next.has(name)) next.delete(name);
+ else next.add(name);
+ return next;
+ });
+ }, []);
+
+ // Memoize evaluations to prevent unnecessary re-renders
+ const evaluations = useMemo(
+ () => run.output_data?.evaluation || [],
+ [run.output_data?.evaluation],
+ );
+
+ // Filter evaluations based on search term
+ const filteredEvaluations = useMemo(() => {
+ if (!searchTerm.trim()) return evaluations;
+ const term = searchTerm.toLowerCase();
+ return evaluations.filter((e) => e.name.toLowerCase().includes(term));
+ }, [evaluations, searchTerm]);
+
+ // Pagination (cards only)
+ const totalPages = Math.ceil(filteredEvaluations.length / ITEMS_PER_PAGE);
+ const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
+ const paginatedEvaluations = filteredEvaluations.slice(
+ startIndex,
+ startIndex + ITEMS_PER_PAGE,
+ );
+
+ // Reset to page 1 when search changes
+ const handleSearchChange = (value: string) => {
+ setSearchTerm(value);
+ setCurrentPage(1);
+ setFocusIdx(-1);
+ };
+
+ // ─── Export actions ────────────────────────────────────────────────────
+ const handleExportCSV = useCallback(() => {
+ const csv = evaluationsToCSV(filteredEvaluations);
+ downloadBlob(csv, "metrics.csv", "text/csv");
+ setExportOpen(false);
+ }, [filteredEvaluations]);
+
+ const handleExportJSON = useCallback(() => {
+ const json = evaluationsToJSON(filteredEvaluations);
+ downloadBlob(json, "metrics.json", "application/json");
+ setExportOpen(false);
+ }, [filteredEvaluations]);
+
+ const handleCopyTable = useCallback(() => {
+ const lines = filteredEvaluations.map((e) => {
+ const v =
+ typeof e.value === "number" ? e.value : parseFloat(String(e.value));
+ const s = e.stdev
+ ? typeof e.stdev === "number"
+ ? e.stdev
+ : parseFloat(String(e.stdev))
+ : null;
+ const folds = e.per_fold?.flat().length ?? 0;
+ return `${e.name}\t${isNaN(v) ? e.value : v.toFixed(4)}\t${s !== null && !isNaN(s) ? `±${s.toFixed(4)}` : ""}\t${folds || ""}`;
+ });
+ const header = "Metric\tValue\tStdev\tFolds";
+ navigator.clipboard.writeText([header, ...lines].join("\n"));
+ setExportCopied(true);
+ setTimeout(() => setExportCopied(false), 1500);
+ setExportOpen(false);
+ }, [filteredEvaluations]);
+
+ // Close export dropdown on outside click
+ useEffect(() => {
+ if (!exportOpen) return;
+ const handler = (e: MouseEvent) => {
+ if (exportRef.current && !exportRef.current.contains(e.target as Node)) {
+ setExportOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [exportOpen]);
+
+ // ─── Keyboard navigation ──────────────────────────────────────────────
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ // Don't capture when typing in inputs
+ const tag = (e.target as HTMLElement)?.tagName;
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
+ if (e.key === "Escape") {
+ (e.target as HTMLElement).blur();
+ setSearchTerm("");
+ setCurrentPage(1);
+ setFocusIdx(-1);
+ e.preventDefault();
+ }
+ return;
+ }
+
+ const total =
+ viewMode === "cards"
+ ? paginatedEvaluations.length
+ : filteredEvaluations.length;
+
+ switch (e.key) {
+ case "j":
+ case "ArrowDown":
+ e.preventDefault();
+ setFocusIdx((prev) => Math.min(prev + 1, total - 1));
+ break;
+ case "k":
+ case "ArrowUp":
+ e.preventDefault();
+ setFocusIdx((prev) => Math.max(prev - 1, 0));
+ break;
+ case "Enter":
+ if (focusIdx >= 0) {
+ e.preventDefault();
+ const evalName =
+ viewMode === "cards"
+ ? paginatedEvaluations[focusIdx]?.name
+ : filteredEvaluations[focusIdx]?.name;
+ if (evalName) toggleMetric(evalName);
+ }
+ break;
+ case "/":
+ e.preventDefault();
+ searchRef.current?.focus();
+ break;
+ case "Escape":
+ setFocusIdx(-1);
+ break;
+ }
+ };
+ document.addEventListener("keydown", handler);
+ return () => document.removeEventListener("keydown", handler);
+ }, [
+ viewMode,
+ paginatedEvaluations,
+ filteredEvaluations,
+ focusIdx,
+ toggleMetric,
+ ]);
+
+ // Scroll focused card into view
+ useEffect(() => {
+ if (viewMode !== "cards" || focusIdx < 0) return;
+ const el = listRef.current?.children[focusIdx] as HTMLElement | undefined;
+ el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
+ }, [focusIdx, viewMode]);
+
+ if (evaluations.length === 0) {
+ return (
+
+ No evaluation metrics available
+
+ );
+ }
+
+ return (
+
+ {/* Controls bar */}
+
+ {/* Left: search */}
+
+
+ handleSearchChange(e.target.value)}
+ className="pl-9"
+ />
+
+
+ {/* Right: view toggle + export + pagination */}
+
+ {/* View toggle */}
+
+
+
+
+
+ {/* Export dropdown */}
+
+
+ {exportOpen && (
+
+
+
+
+
+ )}
+
+
+ {/* Count + pagination (cards only) */}
+
+ {viewMode === "cards" ? (
+ <>
+ {startIndex + 1}–
+ {Math.min(
+ startIndex + ITEMS_PER_PAGE,
+ filteredEvaluations.length,
+ )}{" "}
+ of {filteredEvaluations.length}
+ >
+ ) : (
+ <>{filteredEvaluations.length} metrics>
+ )}
+ {searchTerm && ` (filtered from ${evaluations.length})`}
+
+
+ {viewMode === "cards" && totalPages > 1 && (
+
+
+
+ {currentPage} / {totalPages}
+
+
+
+ )}
+
+
+
+ {/* View body */}
+ {viewMode === "table" ? (
+
+ ) : (
+
+ {paginatedEvaluations.map((evaluation, idx) => (
+
+
+
+ ))}
+
+ )}
+
+ {/* Keyboard hint */}
+
+
+ j
+
+ k
+ {" "}
+ navigate
+
+
+ Enter{" "}
+ expand
+
+
+ / search
+
+
+ Esc{" "}
+ clear
+
+
+
+ );
+}
diff --git a/app-next/src/components/run/run-metrics-table.tsx b/app-next/src/components/run/run-metrics-table.tsx
new file mode 100644
index 00000000..cb30f183
--- /dev/null
+++ b/app-next/src/components/run/run-metrics-table.tsx
@@ -0,0 +1,414 @@
+"use client";
+
+import { useState, useMemo, useCallback } from "react";
+import {
+ ArrowUpDown,
+ ArrowUp,
+ ArrowDown,
+ ChevronRight,
+ Copy,
+ Check,
+} from "lucide-react";
+import type { Evaluation } from "./metric-item";
+
+// Format metric names — shared with metric-item.tsx
+const formatMetricName = (name: string) => {
+ const mapping: Record = {
+ predictive_accuracy: "Predictive Accuracy",
+ area_under_roc_curve: "Area Under ROC Curve (AUC)",
+ root_mean_squared_error: "Root Mean Squared Error (RMSE)",
+ mean_absolute_error: "Mean Absolute Error (MAE)",
+ f_measure: "F-Measure",
+ precision: "Precision",
+ recall: "Recall",
+ kappa: "Kappa",
+ kb_relative_information_score: "KB Relative Information Score",
+ weighted_recall: "Weighted Recall",
+ };
+ return (
+ mapping[name] ||
+ name.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())
+ );
+};
+
+type SortField = "name" | "value" | "stdev" | "folds";
+type SortDir = "asc" | "desc";
+
+// Must be declared outside component — React 19 forbids creating components during render
+function SortIcon({
+ field,
+ sortField,
+ sortDir,
+}: {
+ field: SortField;
+ sortField: SortField;
+ sortDir: SortDir;
+}) {
+ if (sortField !== field)
+ return ;
+ return sortDir === "asc" ? (
+
+ ) : (
+
+ );
+}
+
+function parseNum(v: string | number | undefined): number {
+ if (v === undefined || v === null) return NaN;
+ return typeof v === "number" ? v : parseFloat(String(v));
+}
+
+interface RunMetricsTableProps {
+ evaluations: Evaluation[];
+ searchTerm: string;
+}
+
+export function RunMetricsTable({
+ evaluations,
+ searchTerm,
+}: RunMetricsTableProps) {
+ const [sortField, setSortField] = useState("name");
+ const [sortDir, setSortDir] = useState("asc");
+ const [expandedRow, setExpandedRow] = useState(null);
+ const [copiedIdx, setCopiedIdx] = useState(null);
+
+ // Filter
+ const filtered = useMemo(() => {
+ if (!searchTerm.trim()) return evaluations;
+ const term = searchTerm.toLowerCase();
+ return evaluations.filter((e) => e.name.toLowerCase().includes(term));
+ }, [evaluations, searchTerm]);
+
+ // Sort
+ const sorted = useMemo(() => {
+ const arr = [...filtered];
+ arr.sort((a, b) => {
+ let cmp = 0;
+ switch (sortField) {
+ case "name":
+ cmp = formatMetricName(a.name).localeCompare(
+ formatMetricName(b.name),
+ );
+ break;
+ case "value": {
+ const va = parseNum(a.value);
+ const vb = parseNum(b.value);
+ cmp = (isNaN(va) ? -Infinity : va) - (isNaN(vb) ? -Infinity : vb);
+ break;
+ }
+ case "stdev": {
+ const sa = parseNum(a.stdev);
+ const sb = parseNum(b.stdev);
+ cmp = (isNaN(sa) ? -Infinity : sa) - (isNaN(sb) ? -Infinity : sb);
+ break;
+ }
+ case "folds": {
+ const fa = a.per_fold?.flat().length ?? 0;
+ const fb = b.per_fold?.flat().length ?? 0;
+ cmp = fa - fb;
+ break;
+ }
+ }
+ return sortDir === "asc" ? cmp : -cmp;
+ });
+ return arr;
+ }, [filtered, sortField, sortDir]);
+
+ const toggleSort = (field: SortField) => {
+ if (sortField === field) {
+ setSortDir((d) => (d === "asc" ? "desc" : "asc"));
+ } else {
+ setSortField(field);
+ setSortDir(field === "name" ? "asc" : "desc"); // numbers default desc
+ }
+ };
+
+ const copyRow = useCallback(
+ (e: React.MouseEvent, evaluation: Evaluation, idx: number) => {
+ e.stopPropagation();
+ const v = parseNum(evaluation.value);
+ const s = parseNum(evaluation.stdev);
+ const text = !isNaN(s)
+ ? `${evaluation.name}: ${v.toFixed(4)} ± ${s.toFixed(4)}`
+ : `${evaluation.name}: ${v.toFixed(4)}`;
+ navigator.clipboard.writeText(text);
+ setCopiedIdx(idx);
+ setTimeout(() => setCopiedIdx(null), 1500);
+ },
+ [],
+ );
+
+ if (sorted.length === 0) {
+ return (
+
+ No metrics matching “{searchTerm}”
+
+ );
+ }
+
+ return (
+
+
+
+
+ |
+ toggleSort("name")}
+ >
+ Metric
+
+ |
+ toggleSort("value")}
+ >
+ Value
+
+ |
+ toggleSort("stdev")}
+ >
+ ± Stdev
+
+ |
+ toggleSort("folds")}
+ >
+ Folds
+
+ |
+ |
+
+
+
+ {sorted.map((evaluation, idx) => {
+ const value = parseNum(evaluation.value);
+ const stdev = parseNum(evaluation.stdev);
+ const foldCount = evaluation.per_fold?.flat().length ?? 0;
+ const hasDetail =
+ (evaluation.array_data &&
+ Object.keys(evaluation.array_data).length > 0) ||
+ foldCount > 0;
+ const isExpanded = expandedRow === evaluation.name;
+
+ return (
+
+ setExpandedRow(isExpanded ? null : evaluation.name)
+ }
+ onCopy={(e) => copyRow(e, evaluation, idx)}
+ />
+ );
+ })}
+
+
+
+ );
+}
+
+// ─── Table Row + Expandable Detail ─────────────────────────────────────
+const COLORS = [
+ "#3b82f6",
+ "#22c55e",
+ "#f59e0b",
+ "#ef4444",
+ "#8b5cf6",
+ "#ec4899",
+ "#14b8a6",
+ "#f97316",
+];
+
+function RowWithDetail({
+ evaluation,
+ idx,
+ value,
+ stdev,
+ foldCount,
+ hasDetail,
+ isExpanded,
+ copiedIdx,
+ onToggle,
+ onCopy,
+}: {
+ evaluation: Evaluation;
+ idx: number;
+ value: number;
+ stdev: number;
+ foldCount: number;
+ hasDetail: boolean;
+ isExpanded: boolean;
+ copiedIdx: number | null;
+ onToggle: () => void;
+ onCopy: (e: React.MouseEvent) => void;
+}) {
+ return (
+ <>
+
+ {/* Expand chevron */}
+ |
+ {hasDetail ? (
+
+ ) : null}
+ |
+ {/* Metric name */}
+
+ {formatMetricName(evaluation.name)}
+ |
+ {/* Value */}
+
+ {!isNaN(value) ? value.toFixed(4) : String(evaluation.value)}
+ |
+ {/* Stdev */}
+
+ {!isNaN(stdev) ? `±${stdev.toFixed(4)}` : "—"}
+ |
+ {/* Folds */}
+
+ {foldCount > 0 ? foldCount : "—"}
+ |
+ {/* Copy */}
+
+
+ |
+
+
+ {/* Expanded detail row */}
+ {isExpanded && (
+
+ |
+
+
+ |
+
+ )}
+ >
+ );
+}
+
+// ─── Expanded row detail: per-class + per-fold ─────────────────────────
+function ExpandedDetail({ evaluation }: { evaluation: Evaluation }) {
+ const arrayData = evaluation.array_data;
+ const perFold = evaluation.per_fold;
+ const hasArray = arrayData && Object.keys(arrayData).length > 0;
+ const flatFolds = perFold?.flat().filter((v) => !isNaN(v)) ?? [];
+ const hasFolds = flatFolds.length > 0;
+
+ if (!hasArray && !hasFolds) return null;
+
+ return (
+
+ {/* Per Class */}
+ {hasArray && (
+
+
+ Per Class
+
+
+ {Object.entries(arrayData!)
+ .map(([key, val], i) => {
+ const num =
+ typeof val === "number" ? val : parseFloat(String(val));
+ return { key, num, fill: COLORS[i % COLORS.length] };
+ })
+ .filter((d) => !isNaN(d.num))
+ .sort((a, b) => b.num - a.num)
+ .map((d) => (
+
+
+ {d.key}
+
+
+
(typeof v === "number" ? v : parseFloat(String(v)))))) * 100, 1)}%`,
+ backgroundColor: d.fill,
+ }}
+ />
+
+
+ {d.num.toFixed(4)}
+
+
+ ))}
+
+
+ )}
+
+ {/* Per Fold */}
+ {hasFolds && (
+
+
+ Per Fold
+
+
+ {flatFolds.map((val, i) => {
+ const maxVal = Math.max(...flatFolds);
+ const pct = maxVal > 0 ? (val / maxVal) * 100 : 0;
+ return (
+
+
+ {val.toFixed(4)}
+
+
+
+ );
+ })}
+
+
+ {flatFolds.map((_, i) => (
+
+ F{i + 1}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/app-next/src/components/run/run-navigation-menu.tsx b/app-next/src/components/run/run-navigation-menu.tsx
new file mode 100644
index 00000000..dc921ff2
--- /dev/null
+++ b/app-next/src/components/run/run-navigation-menu.tsx
@@ -0,0 +1,303 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { ENTITY_ICONS, entityColors } from "@/constants";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ BarChart3,
+ Settings2,
+ FileText,
+ Tags,
+ Menu,
+ X,
+ ChevronRight,
+ ChevronLeft,
+ ArrowLeft,
+ LineChart,
+ Grid3x3,
+ Download,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+interface RunNavigationMenuProps {
+ hasMetrics: boolean;
+ hasParameters: boolean;
+ hasTags?: boolean;
+ hasSetup: boolean;
+ hasAnalyses?: boolean;
+ hasOutputFiles?: boolean;
+ metricsCount: number;
+ parametersCount: number;
+ tagsCount?: number;
+}
+
+export function RunNavigationMenu({
+ hasMetrics,
+ hasParameters,
+ hasTags,
+ hasSetup,
+ hasAnalyses,
+ hasOutputFiles = true,
+ metricsCount,
+ parametersCount,
+ tagsCount,
+}: RunNavigationMenuProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [isCollapsed, setIsCollapsed] = useState(false);
+
+ // Navigation items for "On This Page"
+ const pageNavItems = [
+ ...(hasMetrics
+ ? [
+ {
+ id: "metrics",
+ label: "Evaluation Metrics",
+ icon: BarChart3,
+ count: metricsCount,
+ },
+ ]
+ : []),
+ ...(hasAnalyses
+ ? [
+ {
+ id: "analyses",
+ label: "Analyses",
+ icon: LineChart,
+ },
+ ]
+ : []),
+ ...(hasParameters
+ ? [
+ {
+ id: "parameters",
+ label: "Parameters",
+ icon: Settings2,
+ count: parametersCount,
+ },
+ ]
+ : []),
+ ...(hasTags
+ ? [
+ {
+ id: "tags",
+ label: "Tags",
+ icon: Tags,
+ count: tagsCount,
+ },
+ ]
+ : []),
+ ...(hasOutputFiles
+ ? [
+ {
+ id: "output-files",
+ label: "Output Files",
+ icon: Download,
+ },
+ ]
+ : []),
+ ...(hasSetup
+ ? [
+ {
+ id: "setup",
+ label: "Setup",
+ icon: FileText,
+ },
+ ]
+ : []),
+ ];
+
+ return (
+ <>
+ {/* Mobile/Tablet: Floating Menu Button */}
+
+
+
+
+ {/* Mobile/Tablet: Slide-out Panel */}
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
setIsOpen(false)}
+ />
+
+ {/* Slide-out Panel */}
+
+
+
+
Navigation
+
+
+
+
+ {/* Table of Contents */}
+