Henrik Albers commited on
Commit ·
42f149e
1
Parent(s): 31ca965
convert to next.js
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +1 -44
- .gitignore +40 -9
- Dockerfile +57 -9
- README.md +7 -62
- app.py +0 -185
- components/challange_card_component.py +0 -196
- components/challenge_list_component.py +0 -86
- components/challenges_tab_component.py +0 -545
- components/filter_component.py +0 -170
- components/filter_models_component.py +0 -76
- eslint.config.mjs +18 -0
- next.config.ts +6 -0
- package-lock.json +0 -0
- package.json +37 -0
- postcss.config.mjs +7 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- requirements.txt +0 -7
- src/app/add-model/page.tsx +221 -0
- src/app/api/v1/API_STRUCTURE.md +75 -0
- src/app/api/v1/definitions/[definitionId]/rounds/route.ts +50 -0
- src/app/api/v1/definitions/route.ts +35 -0
- src/app/api/v1/models/[modelId]/definitions/[definitionId]/series/[seriesId]/forecasts/route.ts +53 -0
- src/app/api/v1/models/[modelId]/rankings/route.ts +43 -0
- src/app/api/v1/models/[modelId]/route.ts +40 -0
- src/app/api/v1/models/[modelId]/series-by-definition/route.ts +40 -0
- src/app/api/v1/models/ranking-filters/route.ts +35 -0
- src/app/api/v1/models/rankings/route.ts +28 -0
- src/app/api/v1/rounds/[roundId]/leaderboard/route.ts +47 -0
- src/app/api/v1/rounds/[roundId]/models/route.ts +47 -0
- src/app/api/v1/rounds/[roundId]/route.ts +47 -0
- src/app/api/v1/rounds/[roundId]/series/[seriesId]/data/route.ts +59 -0
- src/app/api/v1/rounds/[roundId]/series/[seriesId]/forecasts/route.ts +43 -0
- src/app/api/v1/rounds/[roundId]/series/route.ts +38 -0
- src/app/api/v1/rounds/metadata/route.ts +35 -0
- src/app/api/v1/rounds/route.ts +57 -0
- src/app/challenges/[challengeId]/[roundId]/page.tsx +455 -0
- src/app/challenges/[challengeId]/page.tsx +750 -0
- src/app/challenges/page.tsx +254 -0
- src/app/globals.css +31 -0
- src/app/layout.tsx +40 -0
- src/app/models/[modelId]/page.tsx +161 -0
- src/app/page.tsx +272 -0
- src/components/Breadcrumbs.tsx +84 -0
- src/components/ChallengeList.tsx +67 -0
- src/components/FilterPanel.tsx +227 -0
- src/components/ModelPerformanceCharts.tsx +195 -0
.dockerignore
CHANGED
|
@@ -1,44 +1 @@
|
|
| 1 |
-
|
| 2 |
-
__pycache__/
|
| 3 |
-
*.py[cod]
|
| 4 |
-
*$py.class
|
| 5 |
-
*.so
|
| 6 |
-
.Python
|
| 7 |
-
env/
|
| 8 |
-
venv/
|
| 9 |
-
ENV/
|
| 10 |
-
env.bak/
|
| 11 |
-
venv.bak/
|
| 12 |
-
|
| 13 |
-
# IDE
|
| 14 |
-
.vscode/
|
| 15 |
-
.idea/
|
| 16 |
-
*.swp
|
| 17 |
-
*.swo
|
| 18 |
-
*~
|
| 19 |
-
|
| 20 |
-
# OS
|
| 21 |
-
.DS_Store
|
| 22 |
-
Thumbs.db
|
| 23 |
-
|
| 24 |
-
# Git
|
| 25 |
-
.git/
|
| 26 |
-
.gitignore
|
| 27 |
-
|
| 28 |
-
# Documentation
|
| 29 |
-
*.md
|
| 30 |
-
!README.md
|
| 31 |
-
|
| 32 |
-
# Tests
|
| 33 |
-
tests/
|
| 34 |
-
test_*.py
|
| 35 |
-
*_test.py
|
| 36 |
-
|
| 37 |
-
# Logs
|
| 38 |
-
*.log
|
| 39 |
-
logs/
|
| 40 |
-
|
| 41 |
-
# Temporary files
|
| 42 |
-
*.tmp
|
| 43 |
-
*.temp
|
| 44 |
-
.cache/
|
|
|
|
| 1 |
+
.env*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
CHANGED
|
@@ -1,10 +1,41 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
.
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
.DS_Store
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
Dockerfile
CHANGED
|
@@ -1,18 +1,66 @@
|
|
| 1 |
-
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
COPY requirements.txt .
|
| 10 |
-
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
COPY . .
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker.io/docker/dockerfile:1
|
| 2 |
|
| 3 |
+
FROM node:20-alpine AS base
|
| 4 |
+
|
| 5 |
+
# Install dependencies only when needed
|
| 6 |
+
FROM base AS deps
|
| 7 |
+
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
| 8 |
+
RUN apk add --no-cache libc6-compat
|
| 9 |
WORKDIR /app
|
| 10 |
|
| 11 |
+
# Install dependencies based on the preferred package manager
|
| 12 |
+
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
| 13 |
+
RUN \
|
| 14 |
+
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
| 15 |
+
elif [ -f package-lock.json ]; then npm ci; \
|
| 16 |
+
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
| 17 |
+
else echo "Lockfile not found." && exit 1; \
|
| 18 |
+
fi
|
| 19 |
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
# Rebuild the source code only when needed
|
| 22 |
+
FROM base AS builder
|
| 23 |
+
WORKDIR /app
|
| 24 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 25 |
COPY . .
|
| 26 |
|
| 27 |
+
# Next.js collects completely anonymous telemetry data about general usage.
|
| 28 |
+
# Learn more here: https://nextjs.org/telemetry
|
| 29 |
+
# Uncomment the following line in case you want to disable telemetry during the build.
|
| 30 |
+
# ENV NEXT_TELEMETRY_DISABLED=1
|
| 31 |
+
|
| 32 |
+
RUN \
|
| 33 |
+
if [ -f yarn.lock ]; then yarn run build; \
|
| 34 |
+
elif [ -f package-lock.json ]; then npm run build; \
|
| 35 |
+
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
| 36 |
+
else echo "Lockfile not found." && exit 1; \
|
| 37 |
+
fi
|
| 38 |
+
|
| 39 |
+
# Production image, copy all the files and run next
|
| 40 |
+
FROM base AS runner
|
| 41 |
+
WORKDIR /app
|
| 42 |
+
|
| 43 |
+
ENV NODE_ENV=production
|
| 44 |
+
# Uncomment the following line in case you want to disable telemetry during runtime.
|
| 45 |
+
# ENV NEXT_TELEMETRY_DISABLED=1
|
| 46 |
+
|
| 47 |
+
RUN addgroup --system --gid 1001 nodejs
|
| 48 |
+
RUN adduser --system --uid 1001 nextjs
|
| 49 |
+
|
| 50 |
+
COPY --from=builder /app/public ./public
|
| 51 |
+
|
| 52 |
+
# Automatically leverage output traces to reduce image size
|
| 53 |
+
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
| 54 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
| 55 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
| 56 |
+
|
| 57 |
+
USER nextjs
|
| 58 |
+
|
| 59 |
+
EXPOSE 3000
|
| 60 |
|
| 61 |
+
ENV PORT=3000
|
| 62 |
|
| 63 |
+
# server.js is created by next build from the standalone output
|
| 64 |
+
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
|
| 65 |
+
ENV HOSTNAME="0.0.0.0"
|
| 66 |
+
CMD ["node", "server.js"]
|
README.md
CHANGED
|
@@ -1,74 +1,19 @@
|
|
| 1 |
---
|
| 2 |
-
title: TS
|
| 3 |
emoji: 🚀
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
-
app_port: 8501
|
| 8 |
-
tags:
|
| 9 |
-
- streamlit
|
| 10 |
-
models:
|
| 11 |
-
- google/timesfm-2.0-500m-pytorch
|
| 12 |
-
- Maple728/TimeMoE-50M
|
| 13 |
-
- Salesforce/moirai-1.1-R-large
|
| 14 |
-
- thuml/sundial-base-128m
|
| 15 |
-
|
| 16 |
pinned: false
|
|
|
|
| 17 |
short_description: Time Series Forecasting Arena
|
| 18 |
---
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
This is a Streamlit version of the TS-Arena Challenge Dashboard.
|
| 23 |
-
|
| 24 |
-
## Installation
|
| 25 |
-
|
| 26 |
-
1. Install dependencies:
|
| 27 |
-
```bash
|
| 28 |
-
pip install -r requirements.txt
|
| 29 |
-
```
|
| 30 |
-
|
| 31 |
-
2. Set up environment variables:
|
| 32 |
-
```bash
|
| 33 |
-
export X_API_URL="your_api_url_here"
|
| 34 |
-
export X_API_KEY="your_api_key_here"
|
| 35 |
-
```
|
| 36 |
-
|
| 37 |
-
Or create a `.env` file with:
|
| 38 |
-
```
|
| 39 |
-
X_API_URL=your_api_url_here
|
| 40 |
-
X_API_KEY=your_api_key_here
|
| 41 |
-
```
|
| 42 |
-
|
| 43 |
-
## Running the App
|
| 44 |
-
|
| 45 |
-
```bash
|
| 46 |
-
streamlit run app.py
|
| 47 |
-
```
|
| 48 |
-
|
| 49 |
-
The app will be available at `http://localhost:8501`
|
| 50 |
-
|
| 51 |
-
## Features
|
| 52 |
-
|
| 53 |
-
- **Model Ranking**: View and filter model rankings based on performance metrics (Tab 1)
|
| 54 |
-
- **Challenge Visualization**: Select and visualize active and completed challenges (Tab 2)
|
| 55 |
-
- **Time Series Selection**: Choose specific time series to analyze within a challenge
|
| 56 |
-
- **Interactive Plots**: Plotly charts with zoom, pan, and hover features
|
| 57 |
-
- **Upcoming Challenges**: View upcoming challenges in the sidebar
|
| 58 |
-
- **Add Model**: Information on how to participate and register your models (Tab 3)
|
| 59 |
-
|
| 60 |
-
## Differences from Gradio Version
|
| 61 |
-
|
| 62 |
-
Streamlit has some differences in behavior compared to Gradio:
|
| 63 |
|
| 64 |
-
|
| 65 |
-
2. **Layout**: Streamlit uses `st.sidebar` for the sidebar and columns for layout
|
| 66 |
-
3. **Interactivity**: Dropdowns and multiselect widgets automatically trigger reruns
|
| 67 |
-
4. **Tabs**: Uses `st.tabs()` instead of Gradio's `gr.Tab()`
|
| 68 |
|
| 69 |
-
|
| 70 |
|
| 71 |
-
|
| 72 |
-
- `utils/api_client.py` - API client for fetching challenge data
|
| 73 |
-
- `utils/utils.py` - Utility functions for parsing durations
|
| 74 |
-
- `requirements.txt` - Python dependencies
|
|
|
|
| 1 |
---
|
| 2 |
+
title: TS-Arena
|
| 3 |
emoji: 🚀
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: red
|
| 6 |
sdk: docker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 3000
|
| 9 |
short_description: Time Series Forecasting Arena
|
| 10 |
---
|
| 11 |
+
# TS-Arena Frontend
|
| 12 |
|
| 13 |
+
## Concept
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
TS-Arena is a platform designed for **pre-registered forecasts into the real future**. This approach ensures rigorous, information leakage-free evaluations by comparing model predictions against data that did not exist at the time of the forecast.
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
+
This repository contains the backend infrastructure that powers the TS-Arena platform. It is designed to be self-hostable, allowing you to run your own instance of the benchmarking environment.
|
| 18 |
|
| 19 |
+
For more information visit our [Git repository](https://github.com/DAG-UPB/ts-arena-backend).
|
|
|
|
|
|
|
|
|
app.py
DELETED
|
@@ -1,185 +0,0 @@
|
|
| 1 |
-
from components.challenges_tab_component import render_challenges_tab_component
|
| 2 |
-
from components.filter_component import render_filter_component, set_rankings_session_state
|
| 3 |
-
import streamlit as st
|
| 4 |
-
import pandas as pd
|
| 5 |
-
import sys
|
| 6 |
-
import os
|
| 7 |
-
|
| 8 |
-
from components.challenge_list_component import render_challenge_list_component
|
| 9 |
-
|
| 10 |
-
# Add src to path for imports
|
| 11 |
-
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
| 12 |
-
from utils.api_client import ChallengeStatus, DashboardAPIClient
|
| 13 |
-
|
| 14 |
-
# Get API URL from environment variable
|
| 15 |
-
api_url = os.getenv('X_API_URL', '')
|
| 16 |
-
if api_url:
|
| 17 |
-
os.environ['DASHBOARD_API_URL'] = api_url
|
| 18 |
-
print(f"Loaded API URL from X_API_URL: {api_url}", file=sys.stderr)
|
| 19 |
-
else:
|
| 20 |
-
print("Warning: X_API_URL environment variable not set, using default", file=sys.stderr)
|
| 21 |
-
|
| 22 |
-
# Get API key from environment variable
|
| 23 |
-
api_key = os.getenv('X_API_KEY', '')
|
| 24 |
-
if api_key:
|
| 25 |
-
os.environ['DASHBOARD_API_KEY'] = api_key
|
| 26 |
-
print(f"Loaded API key from X_API_KEY environment variable", file=sys.stderr)
|
| 27 |
-
else:
|
| 28 |
-
print("Warning: X_API_KEY environment variable not set", file=sys.stderr)
|
| 29 |
-
|
| 30 |
-
# Initialize API client
|
| 31 |
-
api_client = DashboardAPIClient()
|
| 32 |
-
|
| 33 |
-
# Page config
|
| 34 |
-
st.set_page_config(
|
| 35 |
-
page_title="TS-Arena Challenge Dashboard",
|
| 36 |
-
page_icon="🏟️",
|
| 37 |
-
layout="wide",
|
| 38 |
-
initial_sidebar_state="expanded"
|
| 39 |
-
)
|
| 40 |
-
|
| 41 |
-
# Custom CSS to make sidebar wider
|
| 42 |
-
st.markdown("""
|
| 43 |
-
<style>
|
| 44 |
-
[data-testid="stSidebar"] {
|
| 45 |
-
min-width: 400px;
|
| 46 |
-
max-width: 400px;
|
| 47 |
-
}
|
| 48 |
-
.challenge-list-container {
|
| 49 |
-
max-height: 45vh;
|
| 50 |
-
overflow-y: auto;
|
| 51 |
-
overflow-x: hidden;
|
| 52 |
-
padding-right: 5px;
|
| 53 |
-
}
|
| 54 |
-
.upcoming-list-container {
|
| 55 |
-
max-height: 45vh;
|
| 56 |
-
overflow-y: auto;
|
| 57 |
-
overflow-x: hidden;
|
| 58 |
-
padding-right: 5px;
|
| 59 |
-
}
|
| 60 |
-
/* Custom scrollbar styling */
|
| 61 |
-
.challenge-list-container::-webkit-scrollbar,
|
| 62 |
-
.upcoming-list-container::-webkit-scrollbar {
|
| 63 |
-
width: 8px;
|
| 64 |
-
}
|
| 65 |
-
.challenge-list-container::-webkit-scrollbar-track,
|
| 66 |
-
.upcoming-list-container::-webkit-scrollbar-track {
|
| 67 |
-
background: #f1f1f1;
|
| 68 |
-
border-radius: 4px;
|
| 69 |
-
}
|
| 70 |
-
.challenge-list-container::-webkit-scrollbar-thumb,
|
| 71 |
-
.upcoming-list-container::-webkit-scrollbar-thumb {
|
| 72 |
-
background: #888;
|
| 73 |
-
border-radius: 4px;
|
| 74 |
-
}
|
| 75 |
-
.challenge-list-container::-webkit-scrollbar-thumb:hover,
|
| 76 |
-
.upcoming-list-container::-webkit-scrollbar-thumb:hover {
|
| 77 |
-
background: #555;
|
| 78 |
-
}
|
| 79 |
-
</style>
|
| 80 |
-
""", unsafe_allow_html=True)
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
# Main app
|
| 84 |
-
st.title("🏟️ TS-Arena – The Live-Forecasting Benchmark (Prototype)")
|
| 85 |
-
|
| 86 |
-
# Sidebar for challenge list
|
| 87 |
-
with st.sidebar:
|
| 88 |
-
st.header("🗓️ Upcoming")
|
| 89 |
-
upcoming_challenges = api_client.list_upcoming_challenges()
|
| 90 |
-
# Show only newest 5 challenges to reduce loading time
|
| 91 |
-
show_first = 5
|
| 92 |
-
challenges_display = upcoming_challenges[:show_first]
|
| 93 |
-
render_challenge_list_component(challenges=challenges_display, api_client=api_client, challange_list_type="upcoming", show_first=5)
|
| 94 |
-
|
| 95 |
-
# Create tabs
|
| 96 |
-
tab1, tab2, tab3 = st.tabs(["Model Ranking", "Challenges", "Add Model"])
|
| 97 |
-
|
| 98 |
-
with tab1:
|
| 99 |
-
with st.spinner("Loading model ranking..."):
|
| 100 |
-
render_filter_component(api_client=api_client, filter_type="model_ranking")
|
| 101 |
-
st.markdown("---")
|
| 102 |
-
if st.session_state.get('filtered_rankings') is None:
|
| 103 |
-
set_rankings_session_state(api_client=api_client)
|
| 104 |
-
st.rerun()
|
| 105 |
-
# Main content area
|
| 106 |
-
st.header("🏆 Model Ranking")
|
| 107 |
-
|
| 108 |
-
# Display rankings table from session state if available
|
| 109 |
-
filtered_rankings = st.session_state.get('filtered_rankings', None)
|
| 110 |
-
if filtered_rankings:
|
| 111 |
-
df_rankings = pd.concat([pd.DataFrame(df) for df in filtered_rankings], ignore_index=True)
|
| 112 |
-
tab_headers = list(df_rankings['time_ranges'].unique())
|
| 113 |
-
tabs = st.tabs(tab_headers)
|
| 114 |
-
for unique_timerange, tab in zip(tab_headers, tabs):
|
| 115 |
-
filtered_by_time = df_rankings[df_rankings['time_ranges'] == unique_timerange].reset_index(drop=True)
|
| 116 |
-
with tab:
|
| 117 |
-
st.dataframe(filtered_by_time, use_container_width=True)
|
| 118 |
-
else:
|
| 119 |
-
st.info("No models match the selected filters")
|
| 120 |
-
|
| 121 |
-
with tab2:
|
| 122 |
-
render_challenges_tab_component(api_client=api_client)
|
| 123 |
-
|
| 124 |
-
with tab3:
|
| 125 |
-
st.header("🚀 Join the TS-Arena Benchmark!")
|
| 126 |
-
|
| 127 |
-
st.markdown("""
|
| 128 |
-
Ready to test your forecasting models against the best? Participate actively in our benchmark challenges!
|
| 129 |
-
Simply send us an email and we'll provide you with an API key to get started.
|
| 130 |
-
|
| 131 |
-
### How it works:
|
| 132 |
-
|
| 133 |
-
Email us with your organization name and we'll send you an API key.
|
| 134 |
-
With this key, you can register your own models and officially participate in active competitions
|
| 135 |
-
once your model is registered.
|
| 136 |
-
""")
|
| 137 |
-
|
| 138 |
-
col1, col2 = st.columns(2)
|
| 139 |
-
|
| 140 |
-
with col1:
|
| 141 |
-
st.markdown("""
|
| 142 |
-
<a href="mailto:DataAnalytics@wiwi.uni-paderborn.de?subject=TS-Arena API Key Request&body=Hello,%0D%0A%0D%0AI would like to participate in the TS-Arena benchmark.%0D%0A%0D%0AOrganization: [Please specify your organization]%0D%0A%0D%0ABest regards"
|
| 143 |
-
style="display: inline-block; padding: 10px 20px; background-color: #667eea; color: white; text-decoration: none; border-radius: 5px; font-weight: bold;">
|
| 144 |
-
📧 Request API Key
|
| 145 |
-
</a>
|
| 146 |
-
""", unsafe_allow_html=True)
|
| 147 |
-
|
| 148 |
-
with col2:
|
| 149 |
-
st.markdown("""
|
| 150 |
-
<a href="mailto:DataAnalytics@wiwi.uni-paderborn.de?subject=TS-Arena Questions&body=Hello,%0D%0A%0D%0AI have questions about participating in the TS-Arena benchmark.%0D%0A%0D%0ABest regards"
|
| 151 |
-
style="display: inline-block; padding: 10px 20px; background-color: #764ba2; color: white; text-decoration: none; border-radius: 5px; font-weight: bold;">
|
| 152 |
-
❓ Ask Questions
|
| 153 |
-
</a>
|
| 154 |
-
""", unsafe_allow_html=True)
|
| 155 |
-
|
| 156 |
-
st.markdown("---")
|
| 157 |
-
|
| 158 |
-
st.markdown("""
|
| 159 |
-
### Bring the benchmark to life with your models!
|
| 160 |
-
|
| 161 |
-
- 🔑 **Personal API Key** for model registration and submission
|
| 162 |
-
- 📊 **Access to benchmark datasets** and challenge specifications
|
| 163 |
-
- 🏆 **Rankings and leaderboards** showing your model's performance
|
| 164 |
-
- 📈 **Detailed evaluation metrics** across multiple time series
|
| 165 |
-
- 🤝 **Community support** from fellow forecasting researchers
|
| 166 |
-
|
| 167 |
-
### Requirements:
|
| 168 |
-
|
| 169 |
-
- Valid organization or affiliation
|
| 170 |
-
- Commitment to fair participation
|
| 171 |
-
- Adherence to benchmark guidelines
|
| 172 |
-
|
| 173 |
-
For more information, please contact us via email!
|
| 174 |
-
|
| 175 |
-
### Help Us Validate Your Model Implementation:
|
| 176 |
-
Transparency is at the heart of our live benchmarking project. We have integrated a wide range of state-of-the-art time series forecasting models to provide the community with real-time performance insights.
|
| 177 |
-
To ensure that every model is evaluated under optimal conditions, **we invite the original authors and maintainers to review our implementations.** If you are a developer of one of the featured models, we would value your feedback on:
|
| 178 |
-
|
| 179 |
-
- Model configuration and hyperparameters.
|
| 180 |
-
- Data preprocessing steps.
|
| 181 |
-
- Implementation-specific nuances.
|
| 182 |
-
|
| 183 |
-
Our goal is to represent your work as accurately as possible. Please visit our [GitHub Repository](https://github.com/DAG-UPB/ts-arena) to review the code or open an issue for any suggested improvements.
|
| 184 |
-
""")
|
| 185 |
-
# TODO: Change link to GitHub Repository
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/challange_card_component.py
DELETED
|
@@ -1,196 +0,0 @@
|
|
| 1 |
-
import sys
|
| 2 |
-
import streamlit as st
|
| 3 |
-
import time
|
| 4 |
-
from datetime import datetime, timezone
|
| 5 |
-
|
| 6 |
-
from utils.api_client import ChallengeStatus
|
| 7 |
-
from utils.utils import duration_to_max_unit, parse_iso8601_duration, to_local
|
| 8 |
-
|
| 9 |
-
@st.fragment(run_every="1s")
|
| 10 |
-
def timer_fragment():
|
| 11 |
-
now = datetime.now(timezone.utc)
|
| 12 |
-
|
| 13 |
-
st.markdown(
|
| 14 |
-
f"""
|
| 15 |
-
<div style='font-size:1.1em; font-weight:bold; color:#1f2937;'>
|
| 16 |
-
⏱️ {now.strftime("%Y-%m-%d %H:%M:%S %Z")}
|
| 17 |
-
</div>
|
| 18 |
-
""",
|
| 19 |
-
unsafe_allow_html=True,
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
def render_challenge_card(challenge, api_client):
|
| 23 |
-
"""Render a challenge card in the sidebar."""
|
| 24 |
-
status = challenge.get('status', 'unknown')
|
| 25 |
-
desc = challenge.get('description', 'TBA')
|
| 26 |
-
challenge_id = str(challenge.get('challenge_id', ''))[:8]
|
| 27 |
-
n_series = challenge.get('n_time_series', 0)
|
| 28 |
-
model_count = challenge.get('model_count', 0)
|
| 29 |
-
forecast_horizon_interval = challenge.get('horizon', 'TBA')
|
| 30 |
-
context_length_interval = challenge.get('context_length', 'TBA')
|
| 31 |
-
|
| 32 |
-
# Try to get frequency from series data for accurate parsing
|
| 33 |
-
frequency_iso = 'PT1H' # Default to 1 hour
|
| 34 |
-
forecast_horizon_from_series = None
|
| 35 |
-
context_length_from_series = None
|
| 36 |
-
|
| 37 |
-
try:
|
| 38 |
-
challenge_id_full = challenge.get('challenge_id')
|
| 39 |
-
series_list = api_client.get_challenge_series(challenge_id_full)
|
| 40 |
-
if series_list and len(series_list) > 0:
|
| 41 |
-
frequency_iso = series_list[0].get('frequency', 'PT1H')
|
| 42 |
-
# Get forecast_horizon and context from series level (more accurate)
|
| 43 |
-
forecast_horizon_from_series = series_list[0].get('horizon')
|
| 44 |
-
except Exception as e:
|
| 45 |
-
print(f"DEBUG: Could not get series data for {challenge_id}: {e}", file=sys.stderr)
|
| 46 |
-
|
| 47 |
-
# Use series-level horizon if available, otherwise fall back to challenge-level
|
| 48 |
-
horizon_to_parse = forecast_horizon_from_series or forecast_horizon_interval
|
| 49 |
-
context_to_parse = context_length_from_series or context_length_interval
|
| 50 |
-
|
| 51 |
-
# Parse intervals with frequency - handle both string intervals and numeric values
|
| 52 |
-
if horizon_to_parse != 'TBA' and horizon_to_parse is not None:
|
| 53 |
-
if isinstance(horizon_to_parse, (int, float)):
|
| 54 |
-
forecast_horizon_num = int(horizon_to_parse)
|
| 55 |
-
elif isinstance(horizon_to_parse, str):
|
| 56 |
-
try:
|
| 57 |
-
_, horizon_seconds = parse_iso8601_duration(horizon_to_parse)
|
| 58 |
-
_, frequency_seconds = parse_iso8601_duration(frequency_iso)
|
| 59 |
-
if frequency_seconds > 0:
|
| 60 |
-
forecast_horizon_num = int(horizon_seconds / frequency_seconds)
|
| 61 |
-
else:
|
| 62 |
-
forecast_horizon_num = 'TBA'
|
| 63 |
-
except Exception as parse_e:
|
| 64 |
-
forecast_horizon_num = 'TBA'
|
| 65 |
-
else:
|
| 66 |
-
forecast_horizon_num = 'TBA'
|
| 67 |
-
else:
|
| 68 |
-
forecast_horizon_num = 'TBA'
|
| 69 |
-
|
| 70 |
-
if context_to_parse != 'TBA' and context_to_parse is not None:
|
| 71 |
-
if isinstance(context_to_parse, (int, float)):
|
| 72 |
-
context_length_num = int(context_to_parse)
|
| 73 |
-
elif isinstance(context_to_parse, str):
|
| 74 |
-
try:
|
| 75 |
-
_, context_seconds = parse_iso8601_duration(context_to_parse)
|
| 76 |
-
_, frequency_seconds = parse_iso8601_duration(frequency_iso)
|
| 77 |
-
if frequency_seconds > 0:
|
| 78 |
-
context_length_num = int(context_seconds / frequency_seconds)
|
| 79 |
-
else:
|
| 80 |
-
context_length_num = 'TBA'
|
| 81 |
-
except Exception as parse_e:
|
| 82 |
-
context_length_num = 'TBA'
|
| 83 |
-
else:
|
| 84 |
-
context_length_num = 'TBA'
|
| 85 |
-
else:
|
| 86 |
-
context_length_num = 'TBA'
|
| 87 |
-
|
| 88 |
-
# Parse frequency for display using duration_to_max_unit
|
| 89 |
-
frequency_display = frequency_iso
|
| 90 |
-
try:
|
| 91 |
-
freq_parts, _ = parse_iso8601_duration(frequency_iso)
|
| 92 |
-
frequency_display = duration_to_max_unit(freq_parts)
|
| 93 |
-
except Exception as e:
|
| 94 |
-
print(f"DEBUG: Could not parse frequency for display: {e}", file=sys.stderr)
|
| 95 |
-
|
| 96 |
-
status_color = '#16a34a' if status == ChallengeStatus.ACTIVE.value else '#2563eb'
|
| 97 |
-
|
| 98 |
-
# Get dates
|
| 99 |
-
reg_start = challenge.get('registration_start', '')
|
| 100 |
-
final_eval = challenge.get('end_time', '')
|
| 101 |
-
|
| 102 |
-
dates_html = ""
|
| 103 |
-
if reg_start:
|
| 104 |
-
reg_start_iso = to_local(reg_start, "UTC").isoformat()
|
| 105 |
-
dates_html += f"""<div><div id="reg-time-{challenge_id_full}" style="font-size: 0.85em; color: #4b5563;">
|
| 106 |
-
📅 Registration opens: <span></span>
|
| 107 |
-
</div>
|
| 108 |
-
|
| 109 |
-
<script>
|
| 110 |
-
if (!document.querySelector("#reg-time-{challenge_id_full} span").textContent) {{
|
| 111 |
-
const iso{challenge_id_full} = "{reg_start_iso}";
|
| 112 |
-
const date{challenge_id_full} = new Date(iso{challenge_id_full});
|
| 113 |
-
|
| 114 |
-
const formatted{challenge_id_full} = date{challenge_id_full}.toLocaleString(undefined, {{
|
| 115 |
-
year: "numeric",
|
| 116 |
-
month: "2-digit",
|
| 117 |
-
day: "2-digit",
|
| 118 |
-
hour: "2-digit",
|
| 119 |
-
minute: "2-digit",
|
| 120 |
-
second: "2-digit"
|
| 121 |
-
}});
|
| 122 |
-
|
| 123 |
-
document.querySelector("#reg-time-{challenge_id_full} span").textContent = formatted{challenge_id_full};
|
| 124 |
-
}}
|
| 125 |
-
</script>"""
|
| 126 |
-
if final_eval:
|
| 127 |
-
final_iso = str(to_local(final_eval, "UTC").isoformat())
|
| 128 |
-
dates_html += f"""<div><div id="final-time-{challenge_id_full}" style="font-size: 0.85em; color: #4b5563;">
|
| 129 |
-
⏰ Final Evaluation: <span></span>
|
| 130 |
-
</div>
|
| 131 |
-
|
| 132 |
-
<script>
|
| 133 |
-
if (!document.querySelector("#final-time-{challenge_id_full} span").textContent) {{
|
| 134 |
-
const iso{challenge_id_full} = "{final_iso}";
|
| 135 |
-
const date{challenge_id_full} = new Date(iso{challenge_id_full});
|
| 136 |
-
|
| 137 |
-
const formatted{challenge_id_full} = date{challenge_id_full}.toLocaleString(undefined, {{
|
| 138 |
-
year: "numeric",
|
| 139 |
-
month: "2-digit",
|
| 140 |
-
day: "2-digit",
|
| 141 |
-
hour: "2-digit",
|
| 142 |
-
minute: "2-digit",
|
| 143 |
-
second: "2-digit"
|
| 144 |
-
}});
|
| 145 |
-
|
| 146 |
-
document.querySelector("#final-time-{challenge_id_full} span").textContent = formatted{challenge_id_full};
|
| 147 |
-
}}
|
| 148 |
-
</script></div></div>"""
|
| 149 |
-
|
| 150 |
-
# Countdown Closes in & Starts in
|
| 151 |
-
time = None
|
| 152 |
-
match status:
|
| 153 |
-
# Time has to be in CET for JS countdown.
|
| 154 |
-
case ChallengeStatus.REGISTRATION.value:
|
| 155 |
-
time = to_local(challenge.get('registration_end', ''), "CET")
|
| 156 |
-
case ChallengeStatus.ACTIVE.value:
|
| 157 |
-
time = to_local(challenge.get('end_time', ''), "CET")
|
| 158 |
-
case ChallengeStatus.ANNOUNCED.value:
|
| 159 |
-
time = to_local(challenge.get('registration_start', ''), "CET")
|
| 160 |
-
case _:
|
| 161 |
-
pass
|
| 162 |
-
|
| 163 |
-
markdown = f"""
|
| 164 |
-
<div style='border: 2px solid {status_color}; padding: 15px; margin-bottom: 15px; border-radius: 8px;
|
| 165 |
-
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);'>
|
| 166 |
-
<div style='display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;'>
|
| 167 |
-
<div style='font-size: 1.2em; font-weight: bold; color: {status_color};'>{desc}</div>
|
| 168 |
-
<div style='background: {status_color}; color: white; padding: 4px 12px; border-radius: 12px;
|
| 169 |
-
font-size: 0.85em; font-weight: bold;'>{status.upper()}</div>
|
| 170 |
-
</div>
|
| 171 |
-
<div style='color: #6b7280; font-size: 0.9em; margin-bottom: 8px;'>ID: {challenge_id}</div>
|
| 172 |
-
<div style='display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px;'>
|
| 173 |
-
<div style='background: white; padding: 8px; border-radius: 5px; border: 1px solid #e5e7eb;'>
|
| 174 |
-
<div style='font-size: 0.75em; color: #9ca3af;'>TIME SERIES</div>
|
| 175 |
-
<div style='font-size: 1.1em; font-weight: bold; color: #1f2937;'>{n_series}</div>
|
| 176 |
-
</div>
|
| 177 |
-
<div style='background: white; padding: 8px; border-radius: 5px; border: 1px solid #e5e7eb;'>
|
| 178 |
-
<div style='font-size: 0.75em; color: #9ca3af;'>MODELS</div>
|
| 179 |
-
<div style='font-size: 1.1em; font-weight: bold; color: #1f2937;'>{model_count}</div>
|
| 180 |
-
</div>
|
| 181 |
-
<div style='background: white; padding: 8px; border-radius: 5px; border: 1px solid #e5e7eb;'>
|
| 182 |
-
<div style='font-size: 0.75em; color: #9ca3af;'>HORIZON (STEPS x FREQ)</div>
|
| 183 |
-
<div style='font-size: 1.1em; font-weight: bold; color: #1f2937;'>{forecast_horizon_num} x {frequency_display}</div>
|
| 184 |
-
</div>
|
| 185 |
-
<div style='background: white; padding: 8px; border-radius: 5px; border: 1px solid #e5e7eb;'>
|
| 186 |
-
<div style='font-size: 0.75em; color: #9ca3af;'>CONTEXT LENGTH</div>
|
| 187 |
-
<div style='font-size: 1.1em; font-weight: bold; color: #1f2937;'>{context_length_num}</div>
|
| 188 |
-
</div>
|
| 189 |
-
</div>
|
| 190 |
-
<div style='margin-top: 10px; padding: 8px; background: rgba(255,255,255,0.7); border-radius: 5px;'>
|
| 191 |
-
{dates_html}
|
| 192 |
-
</div>
|
| 193 |
-
<div class='ts-arena-countdown' data-challenge-time="{time}" data-reg-status="{status}" style='font-size: 0.9em; margin-top: 5px; padding: 5px; background: #f0f4ff; border-radius: 3px;'>{'Loading...'}</div>
|
| 194 |
-
</div>"""
|
| 195 |
-
return markdown
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/challenge_list_component.py
DELETED
|
@@ -1,86 +0,0 @@
|
|
| 1 |
-
import streamlit as st
|
| 2 |
-
import streamlit.components.v1 as components
|
| 3 |
-
import sys
|
| 4 |
-
|
| 5 |
-
from components.challange_card_component import render_challenge_card
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def get_time_js_script()->str:
|
| 9 |
-
return """<script>
|
| 10 |
-
(function(){
|
| 11 |
-
function updateCountdowns(){
|
| 12 |
-
var els = document.querySelectorAll('.ts-arena-countdown');
|
| 13 |
-
if(!els) return "Problem";
|
| 14 |
-
els.forEach(function(el){
|
| 15 |
-
var status = el.getAttribute('data-reg-status');
|
| 16 |
-
if (!status) return;
|
| 17 |
-
var ds = el.getAttribute('data-challenge-time');
|
| 18 |
-
if (!ds) return;
|
| 19 |
-
var targetDate = new Date(ds);
|
| 20 |
-
if(isNaN(targetDate.getTime())) return;
|
| 21 |
-
var now = new Date();
|
| 22 |
-
const cetString = now.toLocaleString('en-US', { timeZone: 'Europe/Paris' });
|
| 23 |
-
now = new Date(cetString);
|
| 24 |
-
var diff = Math.floor((targetDate - now) / 1000);
|
| 25 |
-
var display_time = '';
|
| 26 |
-
console.log('MADE IT TO DIFF')
|
| 27 |
-
if(diff > 0){
|
| 28 |
-
var days = Math.floor(diff / (24*3600));
|
| 29 |
-
diff = diff % (24*3600);
|
| 30 |
-
var hours = Math.floor(diff / 3600);
|
| 31 |
-
diff = diff % 3600;
|
| 32 |
-
var minutes = Math.floor(diff / 60);
|
| 33 |
-
if(days > 0) display_time = days + 'd ' + hours + 'h ' + minutes + 'm';
|
| 34 |
-
else if(hours > 0) display_time = hours + 'h ' + minutes + 'm';
|
| 35 |
-
else display_time = minutes + 'm';
|
| 36 |
-
}
|
| 37 |
-
var diplay_text = 'TBD';
|
| 38 |
-
if (status == 'registration') {
|
| 39 |
-
if (diff <= 0) {
|
| 40 |
-
diplay_text = 'Registration closed';
|
| 41 |
-
} else {
|
| 42 |
-
diplay_text = 'Registration closes in: ' + display_time;
|
| 43 |
-
}
|
| 44 |
-
}
|
| 45 |
-
if (status == 'announced') {
|
| 46 |
-
if (diff <= 0) {
|
| 47 |
-
diplay_text = 'Registration open now!';
|
| 48 |
-
} else {
|
| 49 |
-
diplay_text = 'Registration opens in: ' + display_time;
|
| 50 |
-
}
|
| 51 |
-
}
|
| 52 |
-
if (status == 'active') {
|
| 53 |
-
if (diff <= 0) {
|
| 54 |
-
diplay_text = 'Challenge ended';
|
| 55 |
-
} else {
|
| 56 |
-
diplay_text = 'Results in: ' + display_time;
|
| 57 |
-
}
|
| 58 |
-
}
|
| 59 |
-
el.innerHTML = '\u23F1 <strong style="color: #667eea;">' + diplay_text + '</strong>';
|
| 60 |
-
|
| 61 |
-
});
|
| 62 |
-
}
|
| 63 |
-
updateCountdowns();
|
| 64 |
-
setInterval(updateCountdowns, 3000);
|
| 65 |
-
})();
|
| 66 |
-
</script>
|
| 67 |
-
"""
|
| 68 |
-
|
| 69 |
-
def render_challenge_list_component(challenges, api_client, challange_list_type: str = "ongoing", show_first: int = 5):
|
| 70 |
-
# Create scrollable container for active challenges
|
| 71 |
-
challenge_container = st.container(height="stretch")
|
| 72 |
-
with challenge_container:
|
| 73 |
-
try:
|
| 74 |
-
if challenges:
|
| 75 |
-
cards = []
|
| 76 |
-
for challenge in challenges:
|
| 77 |
-
cards.append(render_challenge_card(challenge, api_client=api_client))
|
| 78 |
-
components.html("<div class='ts-arena-upcoming-container' style='font-family: Arial, sans-serif;'>"+"".join(cards)+get_time_js_script()+"</div>", height=700, scrolling=True)
|
| 79 |
-
return
|
| 80 |
-
except Exception as e:
|
| 81 |
-
st.write(e)
|
| 82 |
-
print(f"Error loading {challange_list_type} challenges: {e}", file=sys.stderr)
|
| 83 |
-
if challange_list_type == "ongoing":
|
| 84 |
-
st.write("No active challenges")
|
| 85 |
-
else:
|
| 86 |
-
st.write("No upcoming challenges")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/challenges_tab_component.py
DELETED
|
@@ -1,545 +0,0 @@
|
|
| 1 |
-
from datetime import datetime, timezone
|
| 2 |
-
import sys
|
| 3 |
-
import traceback
|
| 4 |
-
from typing import Any, Dict, List, Optional
|
| 5 |
-
import pandas as pd
|
| 6 |
-
from components.filter_models_component import render_filter_models_component
|
| 7 |
-
import plotly.graph_objects as go
|
| 8 |
-
import plotly.express as px
|
| 9 |
-
import numpy as np
|
| 10 |
-
import streamlit as st
|
| 11 |
-
|
| 12 |
-
from components.filter_component import render_filter_component, set_challenges_session_state
|
| 13 |
-
from utils.api_client import ChallengeStatus, DashboardAPIClient, hash_func_dashboard_api_client
|
| 14 |
-
from utils.utils import duration_to_max_unit, parse_iso8601_duration
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def get_challenge_horizon_steps_from_series(series_list):
|
| 18 |
-
"""
|
| 19 |
-
Uses the series list to determine the forecast horizon in steps.
|
| 20 |
-
"""
|
| 21 |
-
frequency_iso = series_list[0].get('frequency', 'PT1H')
|
| 22 |
-
horizon_iso = series_list[0].get('horizon')
|
| 23 |
-
return get_challenge_horizon(frequency_iso, horizon_iso)
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def get_challenge_horizon_steps_from_challenge(challenge):
|
| 27 |
-
frequency_iso = challenge.get('frequency', 'PT1H')
|
| 28 |
-
horizon_iso = challenge.get('horizon')
|
| 29 |
-
return get_challenge_horizon(frequency_iso, horizon_iso)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def get_challenge_horizon(frequency_iso: str, horizon_iso: str) -> int:
|
| 33 |
-
if horizon_iso and frequency_iso:
|
| 34 |
-
try:
|
| 35 |
-
_, horizon_seconds = parse_iso8601_duration(horizon_iso)
|
| 36 |
-
_, frequency_seconds = parse_iso8601_duration(frequency_iso)
|
| 37 |
-
if frequency_seconds > 0:
|
| 38 |
-
steps = int(horizon_seconds / frequency_seconds)
|
| 39 |
-
return steps
|
| 40 |
-
except Exception as parse_e:
|
| 41 |
-
print(f"Error parsing horizon: {parse_e}", file=sys.stderr)
|
| 42 |
-
return -1
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
@st.cache_data(hash_funcs={DashboardAPIClient: hash_func_dashboard_api_client}, show_spinner="Loading forecasts...")
|
| 46 |
-
def get_forecasts(api_client: DashboardAPIClient, challenge_id: str, series_id: int) -> Dict[str, Any]:
|
| 47 |
-
forecasts = api_client.get_series_forecasts(challenge_id, series_id)
|
| 48 |
-
forecasts = dict(
|
| 49 |
-
sorted(forecasts.items(), key=lambda item: item[1]["current_mase"])
|
| 50 |
-
)
|
| 51 |
-
return forecasts
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def get_series_choices_for_challenge(challenge_id, api_client: DashboardAPIClient) -> List[Dict[str, Any]]:
|
| 55 |
-
"""Get list of time series for a given challenge."""
|
| 56 |
-
if not challenge_id:
|
| 57 |
-
return []
|
| 58 |
-
|
| 59 |
-
try:
|
| 60 |
-
# Don't try to get series for mock challenges
|
| 61 |
-
if str(challenge_id).startswith('mock'):
|
| 62 |
-
return []
|
| 63 |
-
|
| 64 |
-
# Get series list from API
|
| 65 |
-
series_list = api_client.get_challenge_series(challenge_id)
|
| 66 |
-
if not series_list:
|
| 67 |
-
return []
|
| 68 |
-
|
| 69 |
-
# Format series choices with ID and name
|
| 70 |
-
choices = []
|
| 71 |
-
for series_info in series_list:
|
| 72 |
-
series_id = series_info.get('series_id')
|
| 73 |
-
series_name = series_info.get('name', f'Series {series_id}')
|
| 74 |
-
series_description = series_info.get('description', series_id)
|
| 75 |
-
choices.append({
|
| 76 |
-
'id': series_id,
|
| 77 |
-
'name': series_name,
|
| 78 |
-
'description': series_description,
|
| 79 |
-
'display': f"{series_description} (ID: {series_id})"
|
| 80 |
-
})
|
| 81 |
-
|
| 82 |
-
return choices
|
| 83 |
-
except Exception as e:
|
| 84 |
-
print(f"Error getting series choices: {e}", file=sys.stderr)
|
| 85 |
-
return []
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
@st.cache_data(show_spinner="Drawing plots...")
|
| 89 |
-
def make_demo_forecast_plot(forecast_horizon, challenge_desc="Demo Challenge"):
|
| 90 |
-
"""Generate demo forecast plot with synthetic data."""
|
| 91 |
-
np.random.seed(42)
|
| 92 |
-
|
| 93 |
-
historical_len = 50
|
| 94 |
-
forecast_len = int(forecast_horizon)
|
| 95 |
-
time = np.arange(historical_len + forecast_len)
|
| 96 |
-
|
| 97 |
-
historical_data = 100 + 10 * np.sin(np.linspace(0, 4 * np.pi, historical_len)) + np.random.normal(0, 2, historical_len)
|
| 98 |
-
|
| 99 |
-
fig = go.Figure()
|
| 100 |
-
|
| 101 |
-
# Add historical data
|
| 102 |
-
fig.add_trace(go.Scatter(
|
| 103 |
-
x=time[:historical_len],
|
| 104 |
-
y=historical_data,
|
| 105 |
-
mode='lines',
|
| 106 |
-
name='Historical Data',
|
| 107 |
-
line=dict(color='black', width=3),
|
| 108 |
-
legendgroup='historical',
|
| 109 |
-
))
|
| 110 |
-
|
| 111 |
-
model_names = [
|
| 112 |
-
"ARIMA", "Prophet", "LSTM", "XGBoost",
|
| 113 |
-
"Random Forest", "ETS", "Theta", "TBATS",
|
| 114 |
-
"Neural Prophet", "Ensemble"
|
| 115 |
-
]
|
| 116 |
-
|
| 117 |
-
colors = px.colors.qualitative.Plotly + px.colors.qualitative.Set2
|
| 118 |
-
|
| 119 |
-
for i, model_name in enumerate(model_names):
|
| 120 |
-
base_forecast = 100 + 10 * np.sin(np.linspace(4 * np.pi, 4 * np.pi + 2 * np.pi, forecast_len))
|
| 121 |
-
noise = np.random.normal(0, 1 + i * 0.3, forecast_len)
|
| 122 |
-
trend = np.linspace(0, i * 0.5, forecast_len)
|
| 123 |
-
forecast = base_forecast + noise + trend
|
| 124 |
-
|
| 125 |
-
forecast_x = np.concatenate([[time[historical_len - 1]], time[historical_len:]])
|
| 126 |
-
forecast_y = np.concatenate([[historical_data[-1]], forecast])
|
| 127 |
-
|
| 128 |
-
fig.add_trace(go.Scatter(
|
| 129 |
-
x=forecast_x,
|
| 130 |
-
y=forecast_y,
|
| 131 |
-
mode='lines',
|
| 132 |
-
name=model_name,
|
| 133 |
-
line=dict(color=colors[i % len(colors)], width=2, dash='solid'),
|
| 134 |
-
legendgroup=f'model_{i}',
|
| 135 |
-
hovertemplate=f'<b>{model_name}</b><br>Time: %{{x}}<br>Value: %{{y:.2f}}<extra></extra>'
|
| 136 |
-
))
|
| 137 |
-
|
| 138 |
-
upper_bound = forecast + (3 + i * 0.5)
|
| 139 |
-
lower_bound = forecast - (3 + i * 0.5)
|
| 140 |
-
|
| 141 |
-
fig.add_trace(go.Scatter(
|
| 142 |
-
x=np.concatenate([time[historical_len:], time[historical_len:][::-1]]),
|
| 143 |
-
y=np.concatenate([upper_bound, lower_bound[::-1]]),
|
| 144 |
-
fill='toself',
|
| 145 |
-
fillcolor=colors[i % len(colors)],
|
| 146 |
-
opacity=0.15,
|
| 147 |
-
line=dict(width=0),
|
| 148 |
-
name=f'{model_name} CI',
|
| 149 |
-
legendgroup=f'model_{i}',
|
| 150 |
-
showlegend=False,
|
| 151 |
-
hoverinfo='skip'
|
| 152 |
-
))
|
| 153 |
-
|
| 154 |
-
fig.add_vline(
|
| 155 |
-
x=historical_len - 0.5,
|
| 156 |
-
line_dash="dash",
|
| 157 |
-
line_color="gray",
|
| 158 |
-
opacity=0.7,
|
| 159 |
-
annotation_text="Forecast Start",
|
| 160 |
-
annotation_position="top"
|
| 161 |
-
)
|
| 162 |
-
|
| 163 |
-
fig.update_layout(
|
| 164 |
-
title={
|
| 165 |
-
'text': f'📈 {challenge_desc} - Forecast Comparison ({forecast_len} steps ahead)',
|
| 166 |
-
'x': 0.5,
|
| 167 |
-
'xanchor': 'center',
|
| 168 |
-
'font': {'size': 20, 'color': '#2c3e50'}
|
| 169 |
-
},
|
| 170 |
-
xaxis_title='Time',
|
| 171 |
-
yaxis_title='Value',
|
| 172 |
-
hovermode='x unified',
|
| 173 |
-
template='plotly_white',
|
| 174 |
-
height=600,
|
| 175 |
-
font=dict(family="Arial, sans-serif", size=12),
|
| 176 |
-
plot_bgcolor='rgba(245, 245, 245, 0.5)',
|
| 177 |
-
)
|
| 178 |
-
|
| 179 |
-
fig.update_xaxes(
|
| 180 |
-
showgrid=True,
|
| 181 |
-
gridwidth=1,
|
| 182 |
-
gridcolor='lightgray',
|
| 183 |
-
showline=True,
|
| 184 |
-
linewidth=2,
|
| 185 |
-
linecolor='gray'
|
| 186 |
-
)
|
| 187 |
-
|
| 188 |
-
fig.update_yaxes(
|
| 189 |
-
showgrid=True,
|
| 190 |
-
gridwidth=1,
|
| 191 |
-
gridcolor='lightgray',
|
| 192 |
-
showline=True,
|
| 193 |
-
linewidth=2,
|
| 194 |
-
linecolor='gray'
|
| 195 |
-
)
|
| 196 |
-
|
| 197 |
-
return fig
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
@st.cache_data(hash_funcs={DashboardAPIClient: hash_func_dashboard_api_client}, show_spinner="Drawing plots...")
|
| 201 |
-
def plot_real_challenge_data(challenge: Dict[str, Any], forecast_horizon: int, api_client: DashboardAPIClient, selected_series_ids: List[int] = None, selected_readable_model_ids: List[str] = None) -> Optional[go.Figure]:
|
| 202 |
-
"""Plot real challenge data from API with selected series and forecasts."""
|
| 203 |
-
try:
|
| 204 |
-
challenge_id = challenge.get('challenge_id')
|
| 205 |
-
challenge_desc = challenge.get('description', 'Challenge')
|
| 206 |
-
|
| 207 |
-
# Get all series for this challenge
|
| 208 |
-
series_list = api_client.get_challenge_series(challenge_id)
|
| 209 |
-
if not series_list:
|
| 210 |
-
return None
|
| 211 |
-
|
| 212 |
-
# Filter series based on selection
|
| 213 |
-
if selected_series_ids:
|
| 214 |
-
series_list = [s for s in series_list if s.get('series_id') in selected_series_ids]
|
| 215 |
-
|
| 216 |
-
if not series_list:
|
| 217 |
-
fig = go.Figure()
|
| 218 |
-
fig.add_annotation(
|
| 219 |
-
text="No series selected or found",
|
| 220 |
-
xref="paper", yref="paper",
|
| 221 |
-
x=0.5, y=0.5, showarrow=False,
|
| 222 |
-
font=dict(size=16, color="gray")
|
| 223 |
-
)
|
| 224 |
-
return fig
|
| 225 |
-
|
| 226 |
-
# Create figure
|
| 227 |
-
fig = go.Figure()
|
| 228 |
-
|
| 229 |
-
# Color palette for models
|
| 230 |
-
colors = px.colors.qualitative.Plotly + px.colors.qualitative.Set2 + px.colors.qualitative.Set3
|
| 231 |
-
model_color_map = {}
|
| 232 |
-
color_idx = 0
|
| 233 |
-
|
| 234 |
-
inferred_frequency = None
|
| 235 |
-
steps_to_show = forecast_horizon
|
| 236 |
-
|
| 237 |
-
# Process each series
|
| 238 |
-
for series_idx, series_info in enumerate(series_list):
|
| 239 |
-
series_id = series_info.get('series_id')
|
| 240 |
-
series_name = series_info.get('name', f'Series {series_id}')
|
| 241 |
-
|
| 242 |
-
# Get context data (historical)
|
| 243 |
-
context_df = api_client.get_challenge_data_for_series(
|
| 244 |
-
challenge_id, series_id,
|
| 245 |
-
series_info.get('context_start_time'),
|
| 246 |
-
series_info.get('context_end_time')
|
| 247 |
-
)
|
| 248 |
-
|
| 249 |
-
# Get actual data (test/live data)
|
| 250 |
-
actual_df = api_client.get_challenge_data_for_series(
|
| 251 |
-
challenge_id, series_id,
|
| 252 |
-
series_info.get('context_end_time'),
|
| 253 |
-
series_info.get('end_time')
|
| 254 |
-
)
|
| 255 |
-
|
| 256 |
-
# Infer frequency
|
| 257 |
-
if inferred_frequency is None and not context_df.empty and len(context_df) >= 2:
|
| 258 |
-
try:
|
| 259 |
-
frequency_iso = series_info.get('frequency', 'PT1H')
|
| 260 |
-
horizon_iso = series_info.get('horizon') or challenge.get('horizon')
|
| 261 |
-
|
| 262 |
-
if horizon_iso and frequency_iso:
|
| 263 |
-
_, horizon_seconds = parse_iso8601_duration(horizon_iso)
|
| 264 |
-
_, frequency_seconds = parse_iso8601_duration(frequency_iso)
|
| 265 |
-
|
| 266 |
-
if frequency_seconds > 0:
|
| 267 |
-
steps_to_show = int(horizon_seconds / frequency_seconds)
|
| 268 |
-
freq_parts, _ = parse_iso8601_duration(frequency_iso)
|
| 269 |
-
inferred_frequency = duration_to_max_unit(freq_parts)
|
| 270 |
-
except Exception as e:
|
| 271 |
-
print(f"Error calculating steps: {e}", file=sys.stderr)
|
| 272 |
-
|
| 273 |
-
# Add context data (historical)
|
| 274 |
-
if not context_df.empty:
|
| 275 |
-
hist_name = "Historical Data" if len(series_list) == 1 else f"Historical - {series_name}"
|
| 276 |
-
fig.add_trace(go.Scatter(
|
| 277 |
-
x=context_df["ts"],
|
| 278 |
-
y=context_df["value"],
|
| 279 |
-
name=hist_name,
|
| 280 |
-
mode="lines",
|
| 281 |
-
line=dict(color="black", width=3),
|
| 282 |
-
legendgroup=f"series_{series_id}",
|
| 283 |
-
hovertemplate=f"<b>{hist_name}</b><br>Time: %{{x}}<br>Value: %{{y}}<extra></extra>",
|
| 284 |
-
))
|
| 285 |
-
|
| 286 |
-
# Get forecasts for this series
|
| 287 |
-
forecasts = get_forecasts(api_client, challenge_id, series_id)
|
| 288 |
-
|
| 289 |
-
# Add forecasts
|
| 290 |
-
for model_readable_id, data in forecasts.items():
|
| 291 |
-
df = data["data"]
|
| 292 |
-
model_label = data.get("label", model_readable_id)
|
| 293 |
-
if df.empty:
|
| 294 |
-
continue
|
| 295 |
-
|
| 296 |
-
if not model_readable_id in selected_readable_model_ids:
|
| 297 |
-
continue
|
| 298 |
-
|
| 299 |
-
if model_label not in model_color_map:
|
| 300 |
-
model_color_map[model_label] = colors[color_idx % len(colors)]
|
| 301 |
-
color_idx += 1
|
| 302 |
-
|
| 303 |
-
color = model_color_map[model_label]
|
| 304 |
-
prelim_mase = data.get('current_mase')
|
| 305 |
-
if prelim_mase is None:
|
| 306 |
-
prelim_mase = "N/A"
|
| 307 |
-
else:
|
| 308 |
-
prelim_mase = f"{prelim_mase:.2f}"
|
| 309 |
-
display_name = f"{model_label} - Prelim MASE: {prelim_mase}"
|
| 310 |
-
if len(series_list) > 1:
|
| 311 |
-
display_name += f" ({series_name})"
|
| 312 |
-
|
| 313 |
-
# Connect forecast to last historical point
|
| 314 |
-
if not context_df.empty and not df.empty:
|
| 315 |
-
last_hist_ts = context_df["ts"].iloc[-1]
|
| 316 |
-
last_hist_val = context_df["value"].iloc[-1]
|
| 317 |
-
forecast_x = pd.concat([pd.Series([last_hist_ts]), df["ts"]]).reset_index(drop=True)
|
| 318 |
-
forecast_y = pd.concat([pd.Series([last_hist_val]), df["y"]]).reset_index(drop=True)
|
| 319 |
-
else:
|
| 320 |
-
forecast_x = df["ts"]
|
| 321 |
-
forecast_y = df["y"]
|
| 322 |
-
|
| 323 |
-
fig.add_trace(go.Scatter(
|
| 324 |
-
x=forecast_x,
|
| 325 |
-
y=forecast_y,
|
| 326 |
-
name=display_name,
|
| 327 |
-
mode="lines+markers",
|
| 328 |
-
line=dict(color=color, width=2),
|
| 329 |
-
marker=dict(size=4),
|
| 330 |
-
legendgroup=model_label,
|
| 331 |
-
hovertemplate=f"<b>{display_name}</b><br>Time: %{{x}}<br>Value: %{{y:.2f}}<extra></extra>",
|
| 332 |
-
))
|
| 333 |
-
|
| 334 |
-
# Add actual data for first series only
|
| 335 |
-
if series_idx == 0 and not actual_df.empty:
|
| 336 |
-
actual_df_limited = actual_df.head(steps_to_show)
|
| 337 |
-
if not actual_df_limited.empty:
|
| 338 |
-
fig.add_trace(go.Scatter(
|
| 339 |
-
x=actual_df_limited["ts"],
|
| 340 |
-
y=actual_df_limited["value"],
|
| 341 |
-
name=f"Actual - {series_name}",
|
| 342 |
-
mode="lines",
|
| 343 |
-
line=dict(color="grey", width=3, dash="dot"),
|
| 344 |
-
marker=dict(size=6, symbol="diamond"),
|
| 345 |
-
legendgroup=f"series_{series_id}",
|
| 346 |
-
hovertemplate=f"<b>Actual - {series_name}</b><br>Time: %{{x}}<br>Value: %{{y}}<extra></extra>",
|
| 347 |
-
))
|
| 348 |
-
|
| 349 |
-
# Update layout
|
| 350 |
-
fig.update_layout(
|
| 351 |
-
xaxis_title='Time',
|
| 352 |
-
yaxis_title='Value',
|
| 353 |
-
hovermode='x unified',
|
| 354 |
-
template='plotly_white',
|
| 355 |
-
height=600,
|
| 356 |
-
font=dict(family="Arial, sans-serif", size=12),
|
| 357 |
-
plot_bgcolor='rgba(245, 245, 245, 0.5)',
|
| 358 |
-
)
|
| 359 |
-
|
| 360 |
-
fig.update_xaxes(
|
| 361 |
-
showgrid=True,
|
| 362 |
-
gridwidth=1,
|
| 363 |
-
gridcolor='lightgray',
|
| 364 |
-
showline=True,
|
| 365 |
-
linewidth=2,
|
| 366 |
-
linecolor='gray'
|
| 367 |
-
)
|
| 368 |
-
|
| 369 |
-
fig.update_yaxes(
|
| 370 |
-
showgrid=True,
|
| 371 |
-
gridwidth=1,
|
| 372 |
-
gridcolor='lightgray',
|
| 373 |
-
showline=True,
|
| 374 |
-
linewidth=2,
|
| 375 |
-
linecolor='gray'
|
| 376 |
-
)
|
| 377 |
-
|
| 378 |
-
return fig
|
| 379 |
-
|
| 380 |
-
except Exception as e:
|
| 381 |
-
print(f"Error plotting real challenge data: {e}", file=sys.stderr)
|
| 382 |
-
traceback.print_exc()
|
| 383 |
-
return None
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
def render_challenges_tab_component(api_client: DashboardAPIClient):
|
| 387 |
-
render_filter_component(api_client=api_client, filter_type="active_challenges")
|
| 388 |
-
st.markdown("---")
|
| 389 |
-
|
| 390 |
-
# Main content area
|
| 391 |
-
st.header("🎯 Visualize a Challenge")
|
| 392 |
-
|
| 393 |
-
# Challenge selection
|
| 394 |
-
if st.session_state.get('filtered_challenges') is None:
|
| 395 |
-
with st.spinner("Loading active challenges..."):
|
| 396 |
-
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT")
|
| 397 |
-
set_challenges_session_state(
|
| 398 |
-
api_client=api_client,
|
| 399 |
-
selected_from_date=now+ "00:00:00.000Z",
|
| 400 |
-
selected_to_date=now + "23:59:59.999Z",
|
| 401 |
-
selected_statuses=[ChallengeStatus.ACTIVE.value],
|
| 402 |
-
)
|
| 403 |
-
st.rerun()
|
| 404 |
-
active_completed_challenges = st.session_state['filtered_challenges']
|
| 405 |
-
challenge_options = {f"{c.get('status')} • {c.get('description')}, Start Date: {datetime.strptime(c.get('start_time'), '%Y-%m-%dT%H:%M:%S.%fZ').strftime('%Y-%m-%d %H:%M:%S') if c.get('start_time') else 'N/A'} ({str(c.get('challenge_id', ''))[:8]})": c
|
| 406 |
-
for c in active_completed_challenges}
|
| 407 |
-
|
| 408 |
-
if challenge_options:
|
| 409 |
-
selected_challenge_key = st.selectbox(
|
| 410 |
-
"Challenge Selection",
|
| 411 |
-
options=list(challenge_options.keys()),
|
| 412 |
-
key="challenge_select"
|
| 413 |
-
)
|
| 414 |
-
|
| 415 |
-
selected_challenge = challenge_options[selected_challenge_key]
|
| 416 |
-
challenge_id = str(selected_challenge.get('challenge_id', ''))
|
| 417 |
-
challenge_id_short = challenge_id[:8] if challenge_id != '' else ''
|
| 418 |
-
challenge_name = selected_challenge.get('description', 'Challenge')
|
| 419 |
-
|
| 420 |
-
# Series selection
|
| 421 |
-
series_options = get_series_choices_for_challenge(challenge_id, api_client)
|
| 422 |
-
|
| 423 |
-
# Display challenge heading
|
| 424 |
-
st.subheader(f"📊 {challenge_name}")
|
| 425 |
-
|
| 426 |
-
# Get detailed challenge info
|
| 427 |
-
status = selected_challenge.get('status', 'unknown')
|
| 428 |
-
n_series = selected_challenge.get('n_time_series', 0)
|
| 429 |
-
model_count = selected_challenge.get('model_count', 0)
|
| 430 |
-
|
| 431 |
-
if status == ChallengeStatus.ANNOUNCED.value:
|
| 432 |
-
if n_series == 0:
|
| 433 |
-
n_series = "tbd"
|
| 434 |
-
if model_count == 0:
|
| 435 |
-
model_count = "tbd"
|
| 436 |
-
|
| 437 |
-
challenge_id = str(selected_challenge.get('challenge_id', ''))[:8]
|
| 438 |
-
|
| 439 |
-
# Get frequency and calculate horizon/context in steps
|
| 440 |
-
frequency_iso = 'PT1H' # Default
|
| 441 |
-
context_length_num = 'N/A'
|
| 442 |
-
frequency_display = 'N/A'
|
| 443 |
-
|
| 444 |
-
try:
|
| 445 |
-
challenge_id_full = selected_challenge.get('challenge_id')
|
| 446 |
-
series_list = api_client.get_challenge_series(challenge_id_full)
|
| 447 |
-
forecast_horizon_steps_num = get_challenge_horizon_steps_from_series(series_list)
|
| 448 |
-
if forecast_horizon_steps_num == -1:
|
| 449 |
-
forecast_horizon_steps_num = get_challenge_horizon_steps_from_challenge(selected_challenge)
|
| 450 |
-
if series_list and len(series_list) > 0:
|
| 451 |
-
frequency_iso = series_list[0].get('frequency', 'PT1H')
|
| 452 |
-
context_iso = series_list[0].get('context_length') or selected_challenge.get('context_length')
|
| 453 |
-
|
| 454 |
-
# Parse frequency for display
|
| 455 |
-
try:
|
| 456 |
-
freq_parts, _ = parse_iso8601_duration(frequency_iso)
|
| 457 |
-
frequency_display = duration_to_max_unit(freq_parts)
|
| 458 |
-
except:
|
| 459 |
-
frequency_display = frequency_iso
|
| 460 |
-
|
| 461 |
-
context_length_num = context_iso
|
| 462 |
-
except Exception as e:
|
| 463 |
-
print(f"Error getting series data for challenge info: {e}", file=sys.stderr)
|
| 464 |
-
|
| 465 |
-
# Status color
|
| 466 |
-
status_color = '#16a34a' if status == ChallengeStatus.ACTIVE.value else '#2563eb'
|
| 467 |
-
|
| 468 |
-
# Compact single-line info display matching Gradio version
|
| 469 |
-
st.markdown(f"""
|
| 470 |
-
<div style='display: flex; align-items: center; gap: 20px; flex-wrap: wrap; margin-bottom: 20px;'>
|
| 471 |
-
<div style='background: {status_color}; color: white; padding: 3px 10px; border-radius: 4px; font-size: 0.8em; font-weight: 600;'>{status.upper()}</div>
|
| 472 |
-
<div style='color: #6b7280; font-size: 0.9em;'>ID: <strong>{challenge_id}</strong></div>
|
| 473 |
-
<div style='height: 20px; width: 1px; background: #d1d5db;'></div>
|
| 474 |
-
<div style='color: #6b7280; font-size: 0.9em;'>Series: <strong style='color: #1f2937;'>{n_series}</strong></div>
|
| 475 |
-
<div style='color: #6b7280; font-size: 0.9em;'>Models: <strong style='color: #1f2937;'>{model_count}</strong></div>
|
| 476 |
-
<div style='color: #6b7280; font-size: 0.9em;'>Horizon: <strong style='color: #1f2937;'>{forecast_horizon_steps_num if forecast_horizon_steps_num != -1 else 'N/A' } Steps</strong></div>
|
| 477 |
-
<div style='color: #6b7280; font-size: 0.9em;'>Context: <strong style='color: #1f2937;'>{context_length_num}</strong></div>
|
| 478 |
-
<div style='color: #6b7280; font-size: 0.9em;'>Frequency: <strong style='color: #1f2937;'>{frequency_display}</strong></div>
|
| 479 |
-
</div>
|
| 480 |
-
""", unsafe_allow_html=True)
|
| 481 |
-
|
| 482 |
-
models = api_client.list_models_for_challenge(challenge_id)
|
| 483 |
-
selected_series_ids, selected_readable_model_ids = render_filter_models_component(series_options, models)
|
| 484 |
-
|
| 485 |
-
# Individual plots for each series (default)
|
| 486 |
-
with st.spinner("Loading individual series plots..."):
|
| 487 |
-
try:
|
| 488 |
-
challenge_id_full = selected_challenge.get('challenge_id')
|
| 489 |
-
|
| 490 |
-
if not str(challenge_id_full).startswith('mock'):
|
| 491 |
-
# Get series list
|
| 492 |
-
series_list = api_client.get_challenge_series(challenge_id_full)
|
| 493 |
-
|
| 494 |
-
if series_list and selected_series_ids:
|
| 495 |
-
# Filter to selected series
|
| 496 |
-
filtered_series = [s for s in series_list if s.get('series_id') in selected_series_ids]
|
| 497 |
-
|
| 498 |
-
if filtered_series:
|
| 499 |
-
# Create scrollable container for individual plots
|
| 500 |
-
individual_plots_container = st.container(height=800)
|
| 501 |
-
with individual_plots_container:
|
| 502 |
-
for series_info in filtered_series:
|
| 503 |
-
series_id = series_info.get('series_id')
|
| 504 |
-
series_name = series_info.get('name', f'Series {series_id}')
|
| 505 |
-
series_desc = series_info.get('description', '')
|
| 506 |
-
if series_desc:
|
| 507 |
-
expander_title = f"📈 {series_desc} (ID: {series_id})"
|
| 508 |
-
else:
|
| 509 |
-
expander_title = f"📈 {series_name} (ID: {series_id})"
|
| 510 |
-
with st.expander(expander_title, expanded=True):
|
| 511 |
-
# Plot this individual series
|
| 512 |
-
fig = plot_real_challenge_data(
|
| 513 |
-
challenge=selected_challenge,
|
| 514 |
-
forecast_horizon=forecast_horizon_steps_num,
|
| 515 |
-
api_client=api_client,
|
| 516 |
-
selected_series_ids=[series_id],
|
| 517 |
-
selected_readable_model_ids=selected_readable_model_ids,
|
| 518 |
-
)
|
| 519 |
-
if fig:
|
| 520 |
-
st.plotly_chart(fig, width="stretch")
|
| 521 |
-
else:
|
| 522 |
-
st.warning(f"Could not load data for {series_name}")
|
| 523 |
-
else:
|
| 524 |
-
st.info("No series selected")
|
| 525 |
-
else:
|
| 526 |
-
st.info("No series available or none selected")
|
| 527 |
-
else:
|
| 528 |
-
st.info("Individual series plots not available for demo data")
|
| 529 |
-
fig = make_demo_forecast_plot(forecast_horizon_steps_num, challenge_name)
|
| 530 |
-
st.plotly_chart(fig, width="stretch")
|
| 531 |
-
|
| 532 |
-
except Exception as e:
|
| 533 |
-
st.error(f"Error loading individual plots: {str(e)}")
|
| 534 |
-
traceback.print_exc()
|
| 535 |
-
# Interactive features info
|
| 536 |
-
st.markdown("""
|
| 537 |
-
**Interactive Features:**
|
| 538 |
-
- 🖱️ **Click legend items** to show/hide individual models
|
| 539 |
-
- 🔍 **Zoom** by dragging a box on the chart
|
| 540 |
-
- 👆 **Pan** by clicking and dragging
|
| 541 |
-
- 🔄 **Reset** by double-clicking the chart
|
| 542 |
-
- 📊 **Hover** to see exact values
|
| 543 |
-
""")
|
| 544 |
-
else:
|
| 545 |
-
st.info("No challenges available")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/filter_component.py
DELETED
|
@@ -1,170 +0,0 @@
|
|
| 1 |
-
from typing import List, Optional
|
| 2 |
-
import streamlit as st
|
| 3 |
-
import pandas as pd
|
| 4 |
-
import sys
|
| 5 |
-
|
| 6 |
-
from utils.api_client import ChallengeStatus, DashboardAPIClient
|
| 7 |
-
|
| 8 |
-
# Cached function to set rankings in session state
|
| 9 |
-
def set_rankings_session_state(
|
| 10 |
-
api_client: DashboardAPIClient,
|
| 11 |
-
selected_domains: Optional[List[str]] = None,
|
| 12 |
-
selected_categories: Optional[List[str]] = None,
|
| 13 |
-
selected_frequencies: Optional[List[str]] = None,
|
| 14 |
-
selected_horizons: Optional[List[str]] = None
|
| 15 |
-
):
|
| 16 |
-
if 'filtered_rankings' not in st.session_state:
|
| 17 |
-
st.session_state['filtered_rankings'] = None
|
| 18 |
-
|
| 19 |
-
filter_options = api_client.get_filter_options()
|
| 20 |
-
filtered_rankings = []
|
| 21 |
-
for time_range in filter_options.get("time_ranges", ['All']):
|
| 22 |
-
loop_dict = api_client.get_filtered_rankings(
|
| 23 |
-
domains=selected_domains if selected_domains else None,
|
| 24 |
-
categories=selected_categories if selected_categories else None,
|
| 25 |
-
frequencies=selected_frequencies if selected_frequencies else None,
|
| 26 |
-
horizons=selected_horizons if selected_horizons else None,
|
| 27 |
-
time_range=time_range
|
| 28 |
-
)
|
| 29 |
-
loop_dict = [{**item, "time_ranges": time_range} for item in loop_dict]
|
| 30 |
-
filtered_rankings.append(loop_dict)
|
| 31 |
-
st.session_state['filtered_rankings'] = filtered_rankings
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def set_challenges_session_state(
|
| 35 |
-
api_client: DashboardAPIClient,
|
| 36 |
-
selected_domains: Optional[List[str]] = None,
|
| 37 |
-
selected_categories: Optional[List[str]] = None,
|
| 38 |
-
selected_frequencies: Optional[List[str]] = None,
|
| 39 |
-
selected_horizons: Optional[List[str]] = None,
|
| 40 |
-
selected_from_date: Optional[str] = None,
|
| 41 |
-
selected_to_date: Optional[str] = None,
|
| 42 |
-
selected_statuses: Optional[List[str]] = None
|
| 43 |
-
):
|
| 44 |
-
filtered_rankings = api_client.get_filtered_challenges(
|
| 45 |
-
domain=selected_domains if selected_domains else None,
|
| 46 |
-
category=selected_categories if selected_categories else None,
|
| 47 |
-
frequency=selected_frequencies if selected_frequencies else None,
|
| 48 |
-
horizon=selected_horizons if selected_horizons else None,
|
| 49 |
-
from_date=selected_from_date if selected_from_date else None,
|
| 50 |
-
to_date=selected_to_date if selected_to_date else None,
|
| 51 |
-
status=selected_statuses if selected_statuses else None
|
| 52 |
-
)
|
| 53 |
-
if filtered_rankings:
|
| 54 |
-
st.session_state["filtered_challenges"] = filtered_rankings
|
| 55 |
-
else:
|
| 56 |
-
st.session_state["filtered_challenges"] = []
|
| 57 |
-
st.info("No challenges match the selected filters")
|
| 58 |
-
|
| 59 |
-
def render_filter_component(api_client: DashboardAPIClient, filter_type: str = "model_ranking"):
|
| 60 |
-
"""
|
| 61 |
-
Render the filter component in the Streamlit app.
|
| 62 |
-
|
| 63 |
-
Args:
|
| 64 |
-
api_client: Instance of DashboardAPIClient to fetch filter options and rankings.
|
| 65 |
-
"""
|
| 66 |
-
|
| 67 |
-
# Add filter section right after the header
|
| 68 |
-
if filter_type == "model_ranking":
|
| 69 |
-
st.markdown("### 🔍 Filter Model Rankings")
|
| 70 |
-
elif filter_type == "active_challenges":
|
| 71 |
-
st.markdown("### 🔍 Filter Active Challenges")
|
| 72 |
-
|
| 73 |
-
# Get filter options from API
|
| 74 |
-
filter_options = api_client.get_filter_options()
|
| 75 |
-
|
| 76 |
-
# Create columns for filters
|
| 77 |
-
col1, col2, col3 = st.columns(3)
|
| 78 |
-
col5, col6, col7, col8 = st.columns(4)
|
| 79 |
-
|
| 80 |
-
with col1:
|
| 81 |
-
selected_domains = st.multiselect(
|
| 82 |
-
"Domain",
|
| 83 |
-
options=filter_options.get("domains", []),
|
| 84 |
-
help="Filter by domain",
|
| 85 |
-
key=f"domain_filter_{filter_type}"
|
| 86 |
-
)
|
| 87 |
-
|
| 88 |
-
with col2:
|
| 89 |
-
selected_categories = st.multiselect(
|
| 90 |
-
"Category",
|
| 91 |
-
options=filter_options.get("categories", []),
|
| 92 |
-
help="Filter by category",
|
| 93 |
-
key=f"category_filter_{filter_type}"
|
| 94 |
-
)
|
| 95 |
-
|
| 96 |
-
with col3:
|
| 97 |
-
selected_frequencies = st.multiselect(
|
| 98 |
-
"Frequency",
|
| 99 |
-
options=filter_options.get("frequencies", []),
|
| 100 |
-
help="Filter by time series frequency",
|
| 101 |
-
key=f"frequency_filter_{filter_type}"
|
| 102 |
-
)
|
| 103 |
-
|
| 104 |
-
with col5:
|
| 105 |
-
selected_horizons = st.multiselect(
|
| 106 |
-
"Horizon",
|
| 107 |
-
options=filter_options.get("horizons", []),
|
| 108 |
-
help="Filter by forecast horizon",
|
| 109 |
-
key=f"horizon_filter_{filter_type}"
|
| 110 |
-
)
|
| 111 |
-
|
| 112 |
-
# Active Challenges specific filters
|
| 113 |
-
selected_statuses = None
|
| 114 |
-
selected_from_date = None
|
| 115 |
-
selected_to_date = None
|
| 116 |
-
if filter_type == "active_challenges":
|
| 117 |
-
with col6:
|
| 118 |
-
selected_from_date = st.date_input(
|
| 119 |
-
"End Date From",
|
| 120 |
-
value="today",
|
| 121 |
-
help="Filter by Date",
|
| 122 |
-
key=f"from_date_filter_{filter_type}"
|
| 123 |
-
)
|
| 124 |
-
selected_from_date = selected_from_date.strftime(
|
| 125 |
-
"%Y-%m-%dT00:00:00.000Z")
|
| 126 |
-
with col7:
|
| 127 |
-
selected_to_date = st.date_input(
|
| 128 |
-
"End Date To",
|
| 129 |
-
value='today',
|
| 130 |
-
help="Filter by Date",
|
| 131 |
-
key=f"to_date_filter_{filter_type}"
|
| 132 |
-
)
|
| 133 |
-
selected_to_date = selected_to_date.strftime(
|
| 134 |
-
"%Y-%m-%dT23:59:59.999Z")
|
| 135 |
-
with col8:
|
| 136 |
-
selected_statuses = st.multiselect(
|
| 137 |
-
"Status",
|
| 138 |
-
options=[ChallengeStatus.ACTIVE.value,
|
| 139 |
-
ChallengeStatus.COMPLETED.value],
|
| 140 |
-
default=[ChallengeStatus.ACTIVE.value],
|
| 141 |
-
help="Filter by challenge status",
|
| 142 |
-
key=f"status_filter_{filter_type}"
|
| 143 |
-
)
|
| 144 |
-
# TODO: Filter for registration start and end date
|
| 145 |
-
|
| 146 |
-
# Apply filters button
|
| 147 |
-
if st.button("🔍 Apply Filters", type="primary", key=f"apply_filters_button_{filter_type}"):
|
| 148 |
-
try:
|
| 149 |
-
if filter_type == "model_ranking":
|
| 150 |
-
set_rankings_session_state(
|
| 151 |
-
api_client,
|
| 152 |
-
selected_domains=selected_domains,
|
| 153 |
-
selected_categories=selected_categories,
|
| 154 |
-
selected_frequencies=selected_frequencies,
|
| 155 |
-
selected_horizons=selected_horizons
|
| 156 |
-
)
|
| 157 |
-
|
| 158 |
-
elif filter_type == "active_challenges":
|
| 159 |
-
set_challenges_session_state(
|
| 160 |
-
api_client,
|
| 161 |
-
selected_domains=selected_domains,
|
| 162 |
-
selected_categories=selected_categories,
|
| 163 |
-
selected_frequencies=selected_frequencies,
|
| 164 |
-
selected_horizons=selected_horizons,
|
| 165 |
-
selected_from_date=selected_from_date,
|
| 166 |
-
selected_to_date=selected_to_date,
|
| 167 |
-
selected_statuses=selected_statuses
|
| 168 |
-
)
|
| 169 |
-
except Exception as e:
|
| 170 |
-
st.error(f"Error applying filters: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/filter_models_component.py
DELETED
|
@@ -1,76 +0,0 @@
|
|
| 1 |
-
import streamlit as st
|
| 2 |
-
|
| 3 |
-
def render_filter_models_component(series_options: list[dict], models: list[dict]):
|
| 4 |
-
# Select series:
|
| 5 |
-
if series_options:
|
| 6 |
-
# Default to first two series only
|
| 7 |
-
default_selection = [s['description'] for s in series_options[:2]]
|
| 8 |
-
|
| 9 |
-
selected_series = st.multiselect(
|
| 10 |
-
"Time Series Selection",
|
| 11 |
-
options=[s['description'] for s in series_options],
|
| 12 |
-
default=default_selection,
|
| 13 |
-
help="Select which time series to display in the plot",
|
| 14 |
-
key="series_select"
|
| 15 |
-
)
|
| 16 |
-
|
| 17 |
-
# Extract series IDs from selection
|
| 18 |
-
selected_series_ids = [
|
| 19 |
-
s['id'] for s in series_options
|
| 20 |
-
if s['description'] in selected_series
|
| 21 |
-
]
|
| 22 |
-
else:
|
| 23 |
-
selected_series_ids = None
|
| 24 |
-
|
| 25 |
-
# Select models:
|
| 26 |
-
if models:
|
| 27 |
-
selected_readable_model_ids = [model["readable_id"] for model in models]
|
| 28 |
-
max_value = [model["model_size"] for model in models if model["model_size"] is not None]
|
| 29 |
-
max_value = max(max_value) if max_value else 0
|
| 30 |
-
# Filter size
|
| 31 |
-
selected_size = st.number_input(
|
| 32 |
-
"Model Max Size",
|
| 33 |
-
min_value=0,
|
| 34 |
-
value=max_value,
|
| 35 |
-
step=1,
|
| 36 |
-
help="Filter models by size (in million)",
|
| 37 |
-
key="model_size_filter"
|
| 38 |
-
)
|
| 39 |
-
# Nones are always displayed
|
| 40 |
-
deselected_sizes_ids = [model["readable_id"] for model in models if model["model_size"] is not None and model["model_size"] > selected_size]
|
| 41 |
-
|
| 42 |
-
# Filter name
|
| 43 |
-
selected_names = st.text_input(
|
| 44 |
-
"Model Name Contains",
|
| 45 |
-
value="",
|
| 46 |
-
help="Filter models by name containing this text. Multiple names can be separated by commas.",
|
| 47 |
-
key="model_name_filter"
|
| 48 |
-
)
|
| 49 |
-
if selected_names.strip(",").strip() == "":
|
| 50 |
-
deselected_name_ids = []
|
| 51 |
-
else:
|
| 52 |
-
deselected_name_ids = selected_readable_model_ids.copy()
|
| 53 |
-
for multiple in selected_names.split(","):
|
| 54 |
-
multiple = multiple.strip()
|
| 55 |
-
if multiple:
|
| 56 |
-
deselected_name_ids = list(set([model["readable_id"] for model in models if multiple.lower() not in model["name"].lower() and multiple.lower() != model["readable_id"].lower()]) & set(deselected_name_ids))
|
| 57 |
-
|
| 58 |
-
# Filter by architecture
|
| 59 |
-
available_architectures = list(set(model["architecture"] for model in models if model["architecture"] is not None))
|
| 60 |
-
select_architecture = st.multiselect(
|
| 61 |
-
"Model Architecture",
|
| 62 |
-
options=available_architectures,
|
| 63 |
-
default=available_architectures,
|
| 64 |
-
help="Filter by model architecture",
|
| 65 |
-
key="model_architecture_filter"
|
| 66 |
-
)
|
| 67 |
-
deselected_architecture_ids = [
|
| 68 |
-
model["readable_id"] for model in models
|
| 69 |
-
if model["architecture"] not in select_architecture
|
| 70 |
-
]
|
| 71 |
-
|
| 72 |
-
# Combine filters
|
| 73 |
-
selected_readable_model_ids = list(set(selected_readable_model_ids) - set(deselected_sizes_ids) - set(deselected_name_ids) - set(deselected_architecture_ids))
|
| 74 |
-
else:
|
| 75 |
-
selected_readable_model_ids = None
|
| 76 |
-
return selected_series_ids, selected_readable_model_ids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
next.config.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
// next.config.js
|
| 3 |
+
module.exports = {
|
| 4 |
+
// ... rest of the configuration.
|
| 5 |
+
output: "standalone",
|
| 6 |
+
};
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "ts-arena",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint",
|
| 10 |
+
"lint:fix": "eslint --fix"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@headlessui/react": "^2.2.9",
|
| 14 |
+
"@tanstack/react-table": "^8.21.3",
|
| 15 |
+
"humanize-duration": "^3.33.2",
|
| 16 |
+
"iso8601-duration": "^2.1.3",
|
| 17 |
+
"lucide-react": "^0.563.0",
|
| 18 |
+
"next": "16.1.1",
|
| 19 |
+
"plotly.js": "^3.3.1",
|
| 20 |
+
"react": "19.2.3",
|
| 21 |
+
"react-dom": "19.2.3",
|
| 22 |
+
"react-plotly.js": "^2.6.0"
|
| 23 |
+
},
|
| 24 |
+
"devDependencies": {
|
| 25 |
+
"@tailwindcss/postcss": "^4",
|
| 26 |
+
"@types/humanize-duration": "^3.27.4",
|
| 27 |
+
"@types/node": "^20",
|
| 28 |
+
"@types/plotly.js": "^3.0.9",
|
| 29 |
+
"@types/react": "^19",
|
| 30 |
+
"@types/react-dom": "^19",
|
| 31 |
+
"@types/react-plotly.js": "^2.6.4",
|
| 32 |
+
"eslint": "^9",
|
| 33 |
+
"eslint-config-next": "16.1.1",
|
| 34 |
+
"tailwindcss": "^4",
|
| 35 |
+
"typescript": "^5"
|
| 36 |
+
}
|
| 37 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
public/file.svg
ADDED
|
|
public/globe.svg
ADDED
|
|
public/next.svg
ADDED
|
|
public/vercel.svg
ADDED
|
|
public/window.svg
ADDED
|
|
requirements.txt
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 1 |
-
streamlit>=1.28.0
|
| 2 |
-
plotly>=5.17.0
|
| 3 |
-
numpy>=1.24.0
|
| 4 |
-
pandas>=2.0.0
|
| 5 |
-
requests>=2.31.0
|
| 6 |
-
isodate>=0.6.1
|
| 7 |
-
python-dotenv>=1.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/add-model/page.tsx
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Breadcrumbs from '@/src/components/Breadcrumbs';
|
| 2 |
+
import { Mail, Key, TrendingUp, Trophy, BarChart3, Users, CheckCircle, Github } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
export default function InfoPage() {
|
| 5 |
+
return (
|
| 6 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 7 |
+
<Breadcrumbs items={[{ label: 'Add Model', href: '/add-model' }]} />
|
| 8 |
+
|
| 9 |
+
<div className="mb-6">
|
| 10 |
+
<h1 className="text-3xl font-bold text-gray-900">Join the TS-Arena Benchmark</h1>
|
| 11 |
+
<p className="mt-2 text-gray-600">
|
| 12 |
+
Test your forecasting models against the best in real-time benchmark challenges
|
| 13 |
+
</p>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
{/* Main CTA Section */}
|
| 17 |
+
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-200 p-6 mb-8">
|
| 18 |
+
<h2 className="text-xl font-semibold text-gray-900 mb-3">Get Started</h2>
|
| 19 |
+
<p className="text-gray-700 mb-4">
|
| 20 |
+
Ready to test your forecasting models against the best? Participate actively in our benchmark challenges!
|
| 21 |
+
Simply send us an email and we'll provide you with an API key to get started. Find detailed participation instructions in our{' '}
|
| 22 |
+
<a
|
| 23 |
+
href="https://github.com/DAG-UPB/ts-arena-backend"
|
| 24 |
+
target="_blank"
|
| 25 |
+
rel="noopener noreferrer"
|
| 26 |
+
className="text-blue-700 hover:text-blue-900 font-semibold underline"
|
| 27 |
+
>
|
| 28 |
+
Git repository
|
| 29 |
+
</a>.
|
| 30 |
+
</p>
|
| 31 |
+
|
| 32 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 33 |
+
<a
|
| 34 |
+
href="mailto:DataAnalytics@wiwi.uni-paderborn.de?subject=TS-Arena API Key Request&body=Hello,%0D%0A%0D%0AI would like to participate in the TS-Arena benchmark.%0D%0A%0D%0AOrganization: [Please specify your organization]%0D%0A%0D%0ABest regards"
|
| 35 |
+
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors shadow-sm"
|
| 36 |
+
>
|
| 37 |
+
<Mail className="w-5 h-5" />
|
| 38 |
+
Request API Key
|
| 39 |
+
</a>
|
| 40 |
+
<a
|
| 41 |
+
href="mailto:DataAnalytics@wiwi.uni-paderborn.de?subject=TS-Arena Questions&body=Hello,%0D%0A%0D%0AI have questions about participating in the TS-Arena benchmark.%0D%0A%0D%0ABest regards"
|
| 42 |
+
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-white hover:bg-gray-50 text-gray-900 font-semibold rounded-lg border border-gray-300 transition-colors shadow-sm"
|
| 43 |
+
>
|
| 44 |
+
<Mail className="w-5 h-5" />
|
| 45 |
+
Ask Questions
|
| 46 |
+
</a>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
{/* How it Works */}
|
| 51 |
+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
| 52 |
+
<h2 className="text-xl font-semibold text-gray-900 mb-4">How it Works</h2>
|
| 53 |
+
<div className="space-y-4 text-gray-700">
|
| 54 |
+
<div className="flex gap-4">
|
| 55 |
+
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold">
|
| 56 |
+
1
|
| 57 |
+
</div>
|
| 58 |
+
<div>
|
| 59 |
+
<h3 className="font-medium text-gray-900 mb-1">Request an API Key</h3>
|
| 60 |
+
<p className="text-sm text-gray-600">
|
| 61 |
+
Email us with your organization name and we'll send you an API key.
|
| 62 |
+
</p>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div className="flex gap-4">
|
| 67 |
+
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold">
|
| 68 |
+
2
|
| 69 |
+
</div>
|
| 70 |
+
<div>
|
| 71 |
+
<h3 className="font-medium text-gray-900 mb-1">Register Your Model</h3>
|
| 72 |
+
<p className="text-sm text-gray-600">
|
| 73 |
+
Use your API key to register models for challenges in the registration phase.
|
| 74 |
+
</p>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div className="flex gap-4">
|
| 79 |
+
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold">
|
| 80 |
+
3
|
| 81 |
+
</div>
|
| 82 |
+
<div>
|
| 83 |
+
<h3 className="font-medium text-gray-900 mb-1">Submit Forecasts</h3>
|
| 84 |
+
<p className="text-sm text-gray-600">
|
| 85 |
+
Obtain time-series context via our API and submit your forecasts before registration closes.
|
| 86 |
+
</p>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<div className="flex gap-4">
|
| 91 |
+
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold">
|
| 92 |
+
4
|
| 93 |
+
</div>
|
| 94 |
+
<div>
|
| 95 |
+
<h3 className="font-medium text-gray-900 mb-1">Track Performance</h3>
|
| 96 |
+
<p className="text-sm text-gray-600">
|
| 97 |
+
During the active phase, your model is evaluated on live data. Monitor performance on leaderboards and explore forecasts through interactive plots.
|
| 98 |
+
</p>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
{/* Benefits Grid */}
|
| 105 |
+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
| 106 |
+
<h2 className="text-xl font-semibold text-gray-900 mb-4">What You Get</h2>
|
| 107 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 108 |
+
<div className="flex gap-3">
|
| 109 |
+
<Key className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
| 110 |
+
<div>
|
| 111 |
+
<h3 className="font-medium text-gray-900">Personal API Key</h3>
|
| 112 |
+
<p className="text-sm text-gray-600">For model registration and submission</p>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<div className="flex gap-3">
|
| 117 |
+
<BarChart3 className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
| 118 |
+
<div>
|
| 119 |
+
<h3 className="font-medium text-gray-900">Benchmark Datasets</h3>
|
| 120 |
+
<p className="text-sm text-gray-600">Access to datasets and challenge specifications</p>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<div className="flex gap-3">
|
| 125 |
+
<Trophy className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
| 126 |
+
<div>
|
| 127 |
+
<h3 className="font-medium text-gray-900">Rankings & Leaderboards</h3>
|
| 128 |
+
<p className="text-sm text-gray-600">Real-time performance tracking</p>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<div className="flex gap-3">
|
| 133 |
+
<TrendingUp className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
| 134 |
+
<div>
|
| 135 |
+
<h3 className="font-medium text-gray-900">Detailed Metrics</h3>
|
| 136 |
+
<p className="text-sm text-gray-600">Comprehensive evaluation across time series</p>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
<div className="flex gap-3">
|
| 141 |
+
<Users className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
| 142 |
+
<div>
|
| 143 |
+
<h3 className="font-medium text-gray-900">Community Support</h3>
|
| 144 |
+
<p className="text-sm text-gray-600">Connect with forecasting researchers</p>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
{/* Requirements */}
|
| 151 |
+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
| 152 |
+
<h2 className="text-xl font-semibold text-gray-900 mb-4">Requirements</h2>
|
| 153 |
+
<div className="space-y-2">
|
| 154 |
+
<div className="flex gap-2 items-start">
|
| 155 |
+
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
| 156 |
+
<span className="text-gray-700">Valid organization or affiliation</span>
|
| 157 |
+
</div>
|
| 158 |
+
<div className="flex gap-2 items-start">
|
| 159 |
+
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
| 160 |
+
<span className="text-gray-700">Commitment to fair participation</span>
|
| 161 |
+
</div>
|
| 162 |
+
<div className="flex gap-2 items-start">
|
| 163 |
+
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
| 164 |
+
<span className="text-gray-700">Adherence to benchmark guidelines</span>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
{/* Model Validation Section */}
|
| 170 |
+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
| 171 |
+
<div className="flex items-start gap-3 mb-4">
|
| 172 |
+
<Github className="w-6 h-6 text-gray-900 flex-shrink-0 mt-1" />
|
| 173 |
+
<div>
|
| 174 |
+
<h2 className="text-xl font-semibold text-gray-900 mb-2">Help Us Validate Model Implementations</h2>
|
| 175 |
+
<p className="text-gray-700">
|
| 176 |
+
Transparency is at the heart of our live benchmarking project. We have integrated a wide range of
|
| 177 |
+
state-of-the-art time series forecasting models to provide the community with real-time performance insights.
|
| 178 |
+
</p>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
| 183 |
+
<p className="text-gray-700 mb-3">
|
| 184 |
+
To ensure that every model is evaluated under optimal conditions, <strong>we invite the original authors
|
| 185 |
+
and maintainers to review our implementations.</strong> If you are a developer of one of the featured models,
|
| 186 |
+
we would value your feedback on:
|
| 187 |
+
</p>
|
| 188 |
+
<ul className="space-y-2 ml-4">
|
| 189 |
+
<li className="flex gap-2 items-start text-gray-700">
|
| 190 |
+
<span className="text-blue-600 font-bold">•</span>
|
| 191 |
+
<span>Model configuration and hyperparameters</span>
|
| 192 |
+
</li>
|
| 193 |
+
<li className="flex gap-2 items-start text-gray-700">
|
| 194 |
+
<span className="text-blue-600 font-bold">•</span>
|
| 195 |
+
<span>Data preprocessing steps</span>
|
| 196 |
+
</li>
|
| 197 |
+
<li className="flex gap-2 items-start text-gray-700">
|
| 198 |
+
<span className="text-blue-600 font-bold">•</span>
|
| 199 |
+
<span>Implementation-specific nuances</span>
|
| 200 |
+
</li>
|
| 201 |
+
</ul>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<p className="text-gray-700 mb-4">
|
| 205 |
+
Our goal is to represent your work as accurately as possible. Please visit our GitHub repository
|
| 206 |
+
to review the code or open an issue for any suggested improvements.
|
| 207 |
+
</p>
|
| 208 |
+
|
| 209 |
+
<a
|
| 210 |
+
href="https://github.com/DAG-UPB"
|
| 211 |
+
target="_blank"
|
| 212 |
+
rel="noopener noreferrer"
|
| 213 |
+
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-900 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
| 214 |
+
>
|
| 215 |
+
<Github className="w-5 h-5" />
|
| 216 |
+
View on GitHub
|
| 217 |
+
</a>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
);
|
| 221 |
+
}
|
src/app/api/v1/API_STRUCTURE.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Structure Documentation
|
| 2 |
+
## Structure
|
| 3 |
+
|
| 4 |
+
```
|
| 5 |
+
/src/app/api/v1/
|
| 6 |
+
├── definitions/
|
| 7 |
+
│ ├── route.ts GET /api/v1/definitions
|
| 8 |
+
│ └── [definitionId]/
|
| 9 |
+
│ └── rounds/
|
| 10 |
+
│ └── route.ts GET /api/v1/definitions/{id}/rounds
|
| 11 |
+
├── rounds/
|
| 12 |
+
│ ├── route.ts GET /api/v1/rounds
|
| 13 |
+
│ ├── metadata/
|
| 14 |
+
│ │ └── route.ts GET /api/v1/rounds/metadata
|
| 15 |
+
│ └── [roundId]/
|
| 16 |
+
│ ├── route.ts GET /api/v1/rounds/{id}
|
| 17 |
+
│ ├── leaderboard/
|
| 18 |
+
│ │ └── route.ts GET /api/v1/rounds/{id}/leaderboard
|
| 19 |
+
│ ├── models/
|
| 20 |
+
│ │ └── route.ts GET /api/v1/rounds/{id}/models
|
| 21 |
+
│ └── series/
|
| 22 |
+
│ ├── route.ts GET /api/v1/rounds/{id}/series
|
| 23 |
+
│ └── [seriesId]/
|
| 24 |
+
│ ├── data/
|
| 25 |
+
│ │ └── route.ts GET /api/v1/rounds/{id}/series/{id}/data
|
| 26 |
+
│ └── forecasts/
|
| 27 |
+
│ └── route.ts GET /api/v1/rounds/{id}/series/{id}/forecasts
|
| 28 |
+
└── models/
|
| 29 |
+
├── rankings/
|
| 30 |
+
│ └── route.ts GET /api/v1/models/rankings
|
| 31 |
+
├── ranking-filters/
|
| 32 |
+
│ └── route.ts GET /api/v1/models/ranking-filters
|
| 33 |
+
└── [modelId]/
|
| 34 |
+
├── route.ts GET /api/v1/models/{id}
|
| 35 |
+
├── rankings/
|
| 36 |
+
│ └── route.ts GET /api/v1/models/{id}/rankings
|
| 37 |
+
├── series-by-definition/
|
| 38 |
+
│ └── route.ts GET /api/v1/models/{id}/series-by-definition
|
| 39 |
+
└── definitions/
|
| 40 |
+
└── [definitionId]/
|
| 41 |
+
└── series/
|
| 42 |
+
└── [seriesId]/
|
| 43 |
+
└── forecasts/
|
| 44 |
+
└── route.ts GET /api/v1/models/{id}/definitions/{id}/series/{id}/forecasts
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
## Service Layer
|
| 48 |
+
|
| 49 |
+
The service layer has been organized into three domain-specific files:
|
| 50 |
+
|
| 51 |
+
### definitionService.ts
|
| 52 |
+
Handles challenge definition-related API calls:
|
| 53 |
+
- `getDefinitionRounds()` - Get rounds for a specific definition
|
| 54 |
+
|
| 55 |
+
### roundService.ts
|
| 56 |
+
Handles round-related API calls:
|
| 57 |
+
- `getChallenges()` - List all rounds (previously called challenges)
|
| 58 |
+
- `getRoundsMetadata()` - Get filter metadata
|
| 59 |
+
- `getChallengeSeries()` - Get series for a round
|
| 60 |
+
- `getSeriesData()` - Get time series data
|
| 61 |
+
- `getSeriesForecasts()` - Get forecasts for a series
|
| 62 |
+
- `getRoundModels()` - Get models participating in a round
|
| 63 |
+
|
| 64 |
+
### modelService.ts
|
| 65 |
+
Handles model-related API calls:
|
| 66 |
+
- `getFilteredRankings()` - Get model rankings with filters
|
| 67 |
+
- `getRankingFilters()` - Get available ranking filters
|
| 68 |
+
- `getModelDetails()` - Get model details
|
| 69 |
+
- `getModelRankings()` - Get model ranking history
|
| 70 |
+
- `getModelSeriesByDefinition()` - Get series grouped by definition
|
| 71 |
+
- `getModelSeriesForecasts()` - Get model forecasts across rounds
|
| 72 |
+
|
| 73 |
+
## Authentication
|
| 74 |
+
|
| 75 |
+
All API routes forward the `X-API-Key` header to the backend API for authentication.
|
src/app/api/v1/definitions/[definitionId]/rounds/route.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
request: NextRequest,
|
| 8 |
+
{ params }: { params: Promise<{ definitionId: string }> | { definitionId: string } }
|
| 9 |
+
) {
|
| 10 |
+
const resolvedParams = await Promise.resolve(params);
|
| 11 |
+
const { definitionId } = resolvedParams;
|
| 12 |
+
|
| 13 |
+
if (!definitionId || definitionId === 'undefined') {
|
| 14 |
+
return NextResponse.json(
|
| 15 |
+
{ error: 'Invalid definition ID' },
|
| 16 |
+
{ status: 400 }
|
| 17 |
+
);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// Forward query params to backend
|
| 21 |
+
const searchParams = request.nextUrl.searchParams;
|
| 22 |
+
const queryString = searchParams.toString();
|
| 23 |
+
const url = `${API_BASE_URL}/api/v1/definitions/${definitionId}/rounds${queryString ? '?' + queryString : ''}`;
|
| 24 |
+
|
| 25 |
+
console.log('Calling definition rounds API:', url);
|
| 26 |
+
|
| 27 |
+
try {
|
| 28 |
+
const response = await fetch(url, {
|
| 29 |
+
headers: {
|
| 30 |
+
'X-API-Key': API_KEY,
|
| 31 |
+
}
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
if (!response.ok) {
|
| 35 |
+
return NextResponse.json(
|
| 36 |
+
{ error: 'Failed to fetch rounds for definition' },
|
| 37 |
+
{ status: response.status }
|
| 38 |
+
);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const data = await response.json();
|
| 42 |
+
return NextResponse.json(data);
|
| 43 |
+
} catch (error) {
|
| 44 |
+
console.error('Error fetching rounds for definition:', error);
|
| 45 |
+
return NextResponse.json(
|
| 46 |
+
{ error: 'Internal server error' },
|
| 47 |
+
{ status: 500 }
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
}
|
src/app/api/v1/definitions/route.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET() {
|
| 7 |
+
const url = `${API_BASE_URL}/api/v1/definitions`;
|
| 8 |
+
|
| 9 |
+
console.log('Calling challenge definitions API:', url);
|
| 10 |
+
console.log('API_BASE_URL:', API_BASE_URL);
|
| 11 |
+
|
| 12 |
+
try {
|
| 13 |
+
const response = await fetch(url, {
|
| 14 |
+
headers: {
|
| 15 |
+
'X-API-Key': API_KEY,
|
| 16 |
+
}
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
if (!response.ok) {
|
| 20 |
+
return NextResponse.json(
|
| 21 |
+
{ error: 'Failed to fetch challenge definitions' },
|
| 22 |
+
{ status: response.status }
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const data = await response.json();
|
| 27 |
+
return NextResponse.json(data);
|
| 28 |
+
} catch (error) {
|
| 29 |
+
console.error('Error fetching challenge definitions:', error);
|
| 30 |
+
return NextResponse.json(
|
| 31 |
+
{ error: 'Internal server error' },
|
| 32 |
+
{ status: 500 }
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
}
|
src/app/api/v1/models/[modelId]/definitions/[definitionId]/series/[seriesId]/forecasts/route.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
request: NextRequest,
|
| 8 |
+
{ params }: { params: Promise<{ modelId: string; definitionId: string; seriesId: string }> }
|
| 9 |
+
) {
|
| 10 |
+
const { modelId, definitionId, seriesId } = await params;
|
| 11 |
+
|
| 12 |
+
// Parse IDs as numbers for the external API
|
| 13 |
+
const modelIdNum = parseInt(modelId, 10);
|
| 14 |
+
const definitionIdNum = parseInt(definitionId, 10);
|
| 15 |
+
const seriesIdNum = parseInt(seriesId, 10);
|
| 16 |
+
|
| 17 |
+
if (isNaN(modelIdNum) || isNaN(definitionIdNum) || isNaN(seriesIdNum)) {
|
| 18 |
+
return NextResponse.json({ error: 'Invalid model ID, definition ID, or series ID' }, { status: 400 });
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// Get query parameters for date filtering
|
| 22 |
+
const searchParams = request.nextUrl.searchParams;
|
| 23 |
+
const startDate = searchParams.get('start_date');
|
| 24 |
+
const endDate = searchParams.get('end_date');
|
| 25 |
+
|
| 26 |
+
// Build URL with query parameters
|
| 27 |
+
const urlParams = new URLSearchParams();
|
| 28 |
+
if (startDate) urlParams.append('start_time', startDate);
|
| 29 |
+
if (endDate) urlParams.append('end_time', endDate);
|
| 30 |
+
|
| 31 |
+
const queryString = urlParams.toString();
|
| 32 |
+
const url = `${API_BASE_URL}/api/v1/models/${modelIdNum}/definitions/${definitionIdNum}/series/${seriesIdNum}/forecasts${queryString ? '?' + queryString : ''}`;
|
| 33 |
+
|
| 34 |
+
try {
|
| 35 |
+
const response = await fetch(url, {
|
| 36 |
+
headers: {
|
| 37 |
+
'X-API-Key': API_KEY,
|
| 38 |
+
}
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
const data = await response.json();
|
| 42 |
+
|
| 43 |
+
if (!response.ok) {
|
| 44 |
+
console.error('API returned error:', data);
|
| 45 |
+
return NextResponse.json(data, { status: response.status });
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
return NextResponse.json(data);
|
| 49 |
+
} catch (error) {
|
| 50 |
+
console.error('Fetch error:', error);
|
| 51 |
+
return NextResponse.json({ error: 'Failed to fetch forecasts' }, { status: 500 });
|
| 52 |
+
}
|
| 53 |
+
}
|
src/app/api/v1/models/[modelId]/rankings/route.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
request: NextRequest,
|
| 8 |
+
{ params }: { params: Promise<{ modelId: string }> }
|
| 9 |
+
) {
|
| 10 |
+
const { modelId } = await params;
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
// Parse modelId as number for the external API
|
| 14 |
+
const modelIdNum = parseInt(modelId, 10);
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
if (isNaN(modelIdNum)) {
|
| 18 |
+
return NextResponse.json({ error: 'Invalid model ID' }, { status: 400 });
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const url = `${API_BASE_URL}/api/v1/models/${modelIdNum}/rankings`;
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
try {
|
| 25 |
+
const response = await fetch(url, {
|
| 26 |
+
headers: {
|
| 27 |
+
'X-API-Key': API_KEY,
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
const data = await response.json();
|
| 32 |
+
|
| 33 |
+
if (!response.ok) {
|
| 34 |
+
console.error('API returned error:', data);
|
| 35 |
+
return NextResponse.json(data, { status: response.status });
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return NextResponse.json(data);
|
| 39 |
+
} catch (error) {
|
| 40 |
+
console.error('Fetch error:', error);
|
| 41 |
+
return NextResponse.json({ error: 'Failed to fetch rankings' }, { status: 500 });
|
| 42 |
+
}
|
| 43 |
+
}
|
src/app/api/v1/models/[modelId]/route.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
request: NextRequest,
|
| 8 |
+
{ params }: { params: Promise<{ modelId: string }> }
|
| 9 |
+
) {
|
| 10 |
+
const { modelId } = await params;
|
| 11 |
+
|
| 12 |
+
// Parse modelId as number for the external API
|
| 13 |
+
const modelIdNum = parseInt(modelId, 10);
|
| 14 |
+
|
| 15 |
+
if (isNaN(modelIdNum)) {
|
| 16 |
+
return NextResponse.json({ error: 'Invalid model ID' }, { status: 400 });
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const url = `${API_BASE_URL}/api/v1/models/${modelIdNum}`;
|
| 20 |
+
|
| 21 |
+
try {
|
| 22 |
+
const response = await fetch(url, {
|
| 23 |
+
headers: {
|
| 24 |
+
'X-API-Key': API_KEY,
|
| 25 |
+
}
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
const data = await response.json();
|
| 29 |
+
|
| 30 |
+
if (!response.ok) {
|
| 31 |
+
console.error('API returned error:', data);
|
| 32 |
+
return NextResponse.json(data, { status: response.status });
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return NextResponse.json(data);
|
| 36 |
+
} catch (error) {
|
| 37 |
+
console.error('Fetch error:', error);
|
| 38 |
+
return NextResponse.json({ error: 'Failed to fetch model details' }, { status: 500 });
|
| 39 |
+
}
|
| 40 |
+
}
|
src/app/api/v1/models/[modelId]/series-by-definition/route.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
request: NextRequest,
|
| 8 |
+
{ params }: { params: Promise<{ modelId: string }> }
|
| 9 |
+
) {
|
| 10 |
+
const { modelId } = await params;
|
| 11 |
+
|
| 12 |
+
// Parse modelId as number for the external API
|
| 13 |
+
const modelIdNum = parseInt(modelId, 10);
|
| 14 |
+
|
| 15 |
+
if (isNaN(modelIdNum)) {
|
| 16 |
+
return NextResponse.json({ error: 'Invalid model ID' }, { status: 400 });
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const url = `${API_BASE_URL}/api/v1/models/${modelIdNum}/series-by-definition`;
|
| 20 |
+
|
| 21 |
+
try {
|
| 22 |
+
const response = await fetch(url, {
|
| 23 |
+
headers: {
|
| 24 |
+
'X-API-Key': API_KEY,
|
| 25 |
+
}
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
const data = await response.json();
|
| 29 |
+
|
| 30 |
+
if (!response.ok) {
|
| 31 |
+
console.error('API returned error:', data);
|
| 32 |
+
return NextResponse.json(data, { status: response.status });
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return NextResponse.json(data);
|
| 36 |
+
} catch (error) {
|
| 37 |
+
console.error('Fetch error:', error);
|
| 38 |
+
return NextResponse.json({ error: 'Failed to fetch series by definition' }, { status: 500 });
|
| 39 |
+
}
|
| 40 |
+
}
|
src/app/api/v1/models/ranking-filters/route.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET() {
|
| 7 |
+
const url = `${API_BASE_URL}/api/v1/models/ranking-filters`;
|
| 8 |
+
|
| 9 |
+
console.log('Calling ranking filters API:', url);
|
| 10 |
+
console.log('API_BASE_URL:', API_BASE_URL);
|
| 11 |
+
|
| 12 |
+
try {
|
| 13 |
+
const response = await fetch(url, {
|
| 14 |
+
headers: {
|
| 15 |
+
'X-API-Key': API_KEY,
|
| 16 |
+
}
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
if (!response.ok) {
|
| 20 |
+
return NextResponse.json(
|
| 21 |
+
{ error: 'Failed to fetch ranking filters' },
|
| 22 |
+
{ status: response.status }
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const data = await response.json();
|
| 27 |
+
return NextResponse.json(data);
|
| 28 |
+
} catch (error) {
|
| 29 |
+
console.error('Error fetching ranking filters:', error);
|
| 30 |
+
return NextResponse.json(
|
| 31 |
+
{ error: 'Internal server error' },
|
| 32 |
+
{ status: 500 }
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
}
|
src/app/api/v1/models/rankings/route.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(request: NextRequest) {
|
| 7 |
+
const searchParams = request.nextUrl.searchParams;
|
| 8 |
+
|
| 9 |
+
const params = new URLSearchParams();
|
| 10 |
+
searchParams.forEach((value, key) => {
|
| 11 |
+
params.append(key, value);
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
const url = `${API_BASE_URL}/api/v1/models/rankings${params.toString() ? '?' + params.toString() : ''}`;
|
| 15 |
+
|
| 16 |
+
console.log('Calling external API:', url);
|
| 17 |
+
console.log('API_BASE_URL:', API_BASE_URL);
|
| 18 |
+
|
| 19 |
+
// ToDo: Add error handling
|
| 20 |
+
const response = await fetch(url, {
|
| 21 |
+
headers: {
|
| 22 |
+
'X-API-Key': API_KEY,
|
| 23 |
+
}
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
const data = await response.json();
|
| 27 |
+
return NextResponse.json(data);
|
| 28 |
+
}
|
src/app/api/v1/rounds/[roundId]/leaderboard/route.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
request: NextRequest,
|
| 8 |
+
{ params }: { params: Promise<{ roundId: string }> | { roundId: string } }
|
| 9 |
+
) {
|
| 10 |
+
const resolvedParams = await Promise.resolve(params);
|
| 11 |
+
const { roundId } = resolvedParams;
|
| 12 |
+
|
| 13 |
+
if (!roundId || roundId === 'undefined') {
|
| 14 |
+
return NextResponse.json(
|
| 15 |
+
{ error: 'Invalid round ID' },
|
| 16 |
+
{ status: 400 }
|
| 17 |
+
);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const url = `${API_BASE_URL}/api/v1/rounds/${roundId}/leaderboard`;
|
| 21 |
+
|
| 22 |
+
console.log('Calling round leaderboard API:', url);
|
| 23 |
+
|
| 24 |
+
try {
|
| 25 |
+
const response = await fetch(url, {
|
| 26 |
+
headers: {
|
| 27 |
+
'X-API-Key': API_KEY,
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
if (!response.ok) {
|
| 32 |
+
return NextResponse.json(
|
| 33 |
+
{ error: 'Failed to fetch leaderboard' },
|
| 34 |
+
{ status: response.status }
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const data = await response.json();
|
| 39 |
+
return NextResponse.json(data);
|
| 40 |
+
} catch (error) {
|
| 41 |
+
console.error('Error fetching leaderboard:', error);
|
| 42 |
+
return NextResponse.json(
|
| 43 |
+
{ error: 'Internal server error' },
|
| 44 |
+
{ status: 500 }
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
}
|
src/app/api/v1/rounds/[roundId]/models/route.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
request: NextRequest,
|
| 8 |
+
{ params }: { params: Promise<{ roundId: string }> | { roundId: string } }
|
| 9 |
+
) {
|
| 10 |
+
const resolvedParams = await Promise.resolve(params);
|
| 11 |
+
const { roundId } = resolvedParams;
|
| 12 |
+
|
| 13 |
+
if (!roundId || roundId === 'undefined') {
|
| 14 |
+
return NextResponse.json(
|
| 15 |
+
{ error: 'Invalid round ID' },
|
| 16 |
+
{ status: 400 }
|
| 17 |
+
);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const url = `${API_BASE_URL}/api/v1/rounds/${roundId}/models`;
|
| 21 |
+
|
| 22 |
+
console.log('Calling round models API:', url);
|
| 23 |
+
|
| 24 |
+
try {
|
| 25 |
+
const response = await fetch(url, {
|
| 26 |
+
headers: {
|
| 27 |
+
'X-API-Key': API_KEY,
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
if (!response.ok) {
|
| 32 |
+
return NextResponse.json(
|
| 33 |
+
{ error: 'Failed to fetch models for round' },
|
| 34 |
+
{ status: response.status }
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const data = await response.json();
|
| 39 |
+
return NextResponse.json(data);
|
| 40 |
+
} catch (error) {
|
| 41 |
+
console.error('Error fetching models for round:', error);
|
| 42 |
+
return NextResponse.json(
|
| 43 |
+
{ error: 'Internal server error' },
|
| 44 |
+
{ status: 500 }
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
}
|
src/app/api/v1/rounds/[roundId]/route.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
request: NextRequest,
|
| 8 |
+
{ params }: { params: Promise<{ roundId: string }> | { roundId: string } }
|
| 9 |
+
) {
|
| 10 |
+
const resolvedParams = await Promise.resolve(params);
|
| 11 |
+
const { roundId } = resolvedParams;
|
| 12 |
+
|
| 13 |
+
if (!roundId || roundId === 'undefined') {
|
| 14 |
+
return NextResponse.json(
|
| 15 |
+
{ error: 'Invalid round ID' },
|
| 16 |
+
{ status: 400 }
|
| 17 |
+
);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const url = `${API_BASE_URL}/api/v1/rounds/${roundId}`;
|
| 21 |
+
|
| 22 |
+
console.log('Calling round API:', url);
|
| 23 |
+
|
| 24 |
+
try {
|
| 25 |
+
const response = await fetch(url, {
|
| 26 |
+
headers: {
|
| 27 |
+
'X-API-Key': API_KEY,
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
if (!response.ok) {
|
| 32 |
+
return NextResponse.json(
|
| 33 |
+
{ error: 'Failed to fetch round' },
|
| 34 |
+
{ status: response.status }
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const data = await response.json();
|
| 39 |
+
return NextResponse.json(data);
|
| 40 |
+
} catch (error) {
|
| 41 |
+
console.error('Error fetching round:', error);
|
| 42 |
+
return NextResponse.json(
|
| 43 |
+
{ error: 'Internal server error' },
|
| 44 |
+
{ status: 500 }
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
}
|
src/app/api/v1/rounds/[roundId]/series/[seriesId]/data/route.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
request: Request,
|
| 8 |
+
{ params }: { params: Promise<{ roundId: string; seriesId: string }> }
|
| 9 |
+
) {
|
| 10 |
+
const { roundId, seriesId } = await params;
|
| 11 |
+
const { searchParams } = new URL(request.url);
|
| 12 |
+
const startTime = searchParams.get('start_time');
|
| 13 |
+
const endTime = searchParams.get('end_time');
|
| 14 |
+
|
| 15 |
+
console.log('Data API route - params:', { roundId, seriesId, startTime, endTime });
|
| 16 |
+
|
| 17 |
+
if (!startTime || !endTime) {
|
| 18 |
+
return NextResponse.json(
|
| 19 |
+
{ error: 'start_time and end_time query parameters are required' },
|
| 20 |
+
{ status: 400 }
|
| 21 |
+
);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const queryParams = new URLSearchParams({
|
| 25 |
+
start_time: startTime,
|
| 26 |
+
end_time: endTime,
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
const url = `${API_BASE_URL}/api/v1/rounds/${roundId}/series/${seriesId}/data?${queryParams.toString()}`;
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
const response = await fetch(url, {
|
| 33 |
+
headers: {
|
| 34 |
+
'X-API-Key': API_KEY,
|
| 35 |
+
}
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
console.log('Data API response status:', response.status);
|
| 39 |
+
|
| 40 |
+
if (!response.ok) {
|
| 41 |
+
const errorText = await response.text();
|
| 42 |
+
console.error('Data API error response:', response.status, errorText);
|
| 43 |
+
return NextResponse.json(
|
| 44 |
+
{ error: 'Failed to fetch series data', details: errorText, externalStatus: response.status },
|
| 45 |
+
{ status: response.status }
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const data = await response.json();
|
| 50 |
+
console.log('Data API success');
|
| 51 |
+
return NextResponse.json(data);
|
| 52 |
+
} catch (error) {
|
| 53 |
+
console.error('Error fetching series data:', error);
|
| 54 |
+
return NextResponse.json(
|
| 55 |
+
{ error: 'Internal server error', details: error instanceof Error ? error.message : String(error) },
|
| 56 |
+
{ status: 500 }
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
+
}
|
src/app/api/v1/rounds/[roundId]/series/[seriesId]/forecasts/route.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
request: Request,
|
| 8 |
+
{ params }: { params: Promise<{ roundId: string; seriesId: string }> }
|
| 9 |
+
) {
|
| 10 |
+
const { roundId, seriesId } = await params;
|
| 11 |
+
const url = `${API_BASE_URL}/api/v1/rounds/${roundId}/series/${seriesId}/forecasts`;
|
| 12 |
+
|
| 13 |
+
console.log('Forecasts API route - params:', { roundId, seriesId });
|
| 14 |
+
|
| 15 |
+
try {
|
| 16 |
+
const response = await fetch(url, {
|
| 17 |
+
headers: {
|
| 18 |
+
'X-API-Key': API_KEY,
|
| 19 |
+
}
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
console.log('Forecasts API response status:', response.status);
|
| 23 |
+
|
| 24 |
+
if (!response.ok) {
|
| 25 |
+
const errorText = await response.text();
|
| 26 |
+
console.error('Forecasts API error response:', response.status, errorText);
|
| 27 |
+
return NextResponse.json(
|
| 28 |
+
{ error: 'Failed to fetch forecasts', details: errorText, externalStatus: response.status },
|
| 29 |
+
{ status: response.status }
|
| 30 |
+
);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const data = await response.json();
|
| 34 |
+
console.log('Forecasts API success, data keys:', Object.keys(data));
|
| 35 |
+
return NextResponse.json(data);
|
| 36 |
+
} catch (error) {
|
| 37 |
+
console.error('Error fetching forecasts:', error);
|
| 38 |
+
return NextResponse.json(
|
| 39 |
+
{ error: 'Internal server error', details: error instanceof Error ? error.message : String(error) },
|
| 40 |
+
{ status: 500 }
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
}
|
src/app/api/v1/rounds/[roundId]/series/route.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
request: Request,
|
| 8 |
+
{ params }: { params: Promise<{ roundId: string }> }
|
| 9 |
+
) {
|
| 10 |
+
const { roundId } = await params;
|
| 11 |
+
const url = `${API_BASE_URL}/api/v1/rounds/${roundId}/series`;
|
| 12 |
+
|
| 13 |
+
console.log('Calling external API:', url);
|
| 14 |
+
|
| 15 |
+
try {
|
| 16 |
+
const response = await fetch(url, {
|
| 17 |
+
headers: {
|
| 18 |
+
'X-API-Key': API_KEY,
|
| 19 |
+
}
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
if (!response.ok) {
|
| 23 |
+
return NextResponse.json(
|
| 24 |
+
{ error: 'Failed to fetch round series' },
|
| 25 |
+
{ status: response.status }
|
| 26 |
+
);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const data = await response.json();
|
| 30 |
+
return NextResponse.json(data);
|
| 31 |
+
} catch (error) {
|
| 32 |
+
console.error('Error fetching round series:', error);
|
| 33 |
+
return NextResponse.json(
|
| 34 |
+
{ error: 'Internal server error' },
|
| 35 |
+
{ status: 500 }
|
| 36 |
+
);
|
| 37 |
+
}
|
| 38 |
+
}
|
src/app/api/v1/rounds/metadata/route.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET() {
|
| 7 |
+
const url = `${API_BASE_URL}/api/v1/rounds/metadata`;
|
| 8 |
+
|
| 9 |
+
console.log('Calling rounds metadata API:', url);
|
| 10 |
+
console.log('API_BASE_URL:', API_BASE_URL);
|
| 11 |
+
|
| 12 |
+
try {
|
| 13 |
+
const response = await fetch(url, {
|
| 14 |
+
headers: {
|
| 15 |
+
'X-API-Key': API_KEY,
|
| 16 |
+
}
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
if (!response.ok) {
|
| 20 |
+
return NextResponse.json(
|
| 21 |
+
{ error: 'Failed to fetch rounds metadata' },
|
| 22 |
+
{ status: response.status }
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const data = await response.json();
|
| 27 |
+
return NextResponse.json(data);
|
| 28 |
+
} catch (error) {
|
| 29 |
+
console.error('Error fetching rounds metadata:', error);
|
| 30 |
+
return NextResponse.json(
|
| 31 |
+
{ error: 'Internal server error' },
|
| 32 |
+
{ status: 500 }
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
}
|
src/app/api/v1/rounds/route.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_DASH_BOARD_API_URL || '';
|
| 4 |
+
const API_KEY = process.env.NEXT_PUBLIC_DASH_BOARD_API_KEY || '';
|
| 5 |
+
|
| 6 |
+
export async function GET(request: NextRequest) {
|
| 7 |
+
const searchParams = request.nextUrl.searchParams;
|
| 8 |
+
|
| 9 |
+
// Build query parameters from the request
|
| 10 |
+
const queryParams = new URLSearchParams();
|
| 11 |
+
|
| 12 |
+
// Add filter parameters if they exist
|
| 13 |
+
const domains = searchParams.getAll('domain');
|
| 14 |
+
const categories = searchParams.getAll('category');
|
| 15 |
+
const frequencies = searchParams.getAll('frequency');
|
| 16 |
+
const horizons = searchParams.getAll('horizon');
|
| 17 |
+
const from = searchParams.get('from');
|
| 18 |
+
const to = searchParams.get('to');
|
| 19 |
+
const status = searchParams.get('status');
|
| 20 |
+
const searchTerm = searchParams.get('search');
|
| 21 |
+
|
| 22 |
+
domains.forEach(d => queryParams.append('domain', d));
|
| 23 |
+
categories.forEach(c => queryParams.append('category', c));
|
| 24 |
+
frequencies.forEach(f => queryParams.append('frequency', f));
|
| 25 |
+
horizons.forEach(h => queryParams.append('horizon', h));
|
| 26 |
+
if (from) queryParams.append('from', from);
|
| 27 |
+
if (to) queryParams.append('to', to);
|
| 28 |
+
if (status && status !== 'all') queryParams.append('status', status);
|
| 29 |
+
if (searchTerm) queryParams.append('search', searchTerm);
|
| 30 |
+
|
| 31 |
+
const queryString = queryParams.toString();
|
| 32 |
+
const url = `${API_BASE_URL}/api/v1/rounds${queryString ? '?' + queryString : ''}`;
|
| 33 |
+
|
| 34 |
+
try {
|
| 35 |
+
const response = await fetch(url, {
|
| 36 |
+
headers: {
|
| 37 |
+
'X-API-Key': API_KEY,
|
| 38 |
+
}
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
if (!response.ok) {
|
| 42 |
+
return NextResponse.json(
|
| 43 |
+
{ error: 'Failed to fetch rounds' },
|
| 44 |
+
{ status: response.status }
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const data = await response.json();
|
| 49 |
+
return NextResponse.json(data);
|
| 50 |
+
} catch (error) {
|
| 51 |
+
console.error('Error fetching rounds:', error);
|
| 52 |
+
return NextResponse.json(
|
| 53 |
+
{ error: 'Internal server error' },
|
| 54 |
+
{ status: 500 }
|
| 55 |
+
);
|
| 56 |
+
}
|
| 57 |
+
}
|
src/app/challenges/[challengeId]/[roundId]/page.tsx
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react';
|
| 4 |
+
import { useParams, useRouter } from 'next/navigation';
|
| 5 |
+
import Breadcrumbs from '@/src/components/Breadcrumbs';
|
| 6 |
+
import TimeSeriesChart from '@/src/components/TimeSeriesChart';
|
| 7 |
+
import Link from 'next/link';
|
| 8 |
+
import { Clock, ChevronRight, Info } from 'lucide-react';
|
| 9 |
+
|
| 10 |
+
interface RoundMetadata {
|
| 11 |
+
round_id: number;
|
| 12 |
+
name: string;
|
| 13 |
+
description: string;
|
| 14 |
+
status: string;
|
| 15 |
+
context_length: number;
|
| 16 |
+
horizon: string;
|
| 17 |
+
start_time: string;
|
| 18 |
+
end_time: string;
|
| 19 |
+
registration_start: string;
|
| 20 |
+
registration_end: string;
|
| 21 |
+
frequency?: string;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
interface LeaderboardEntry {
|
| 25 |
+
model_id: number;
|
| 26 |
+
readable_id: string;
|
| 27 |
+
model_name: string;
|
| 28 |
+
series_id: number;
|
| 29 |
+
series_name: string;
|
| 30 |
+
forecast_count: number;
|
| 31 |
+
mase: number | null;
|
| 32 |
+
rmse: number | null;
|
| 33 |
+
is_final: boolean;
|
| 34 |
+
rank: number;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
interface ModelRow {
|
| 38 |
+
model_id: number;
|
| 39 |
+
readable_id: string;
|
| 40 |
+
model_name: string;
|
| 41 |
+
seriesRanks: Record<number, { rank: number; mase: number | null }>;
|
| 42 |
+
avgRank: number;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export default function RoundDetail() {
|
| 46 |
+
const params = useParams();
|
| 47 |
+
const router = useRouter();
|
| 48 |
+
const challengeId = params.challengeId;
|
| 49 |
+
const [round, setRound] = useState<RoundMetadata | null>(null);
|
| 50 |
+
const [loading, setLoading] = useState(true);
|
| 51 |
+
const [error, setError] = useState<string | null>(null);
|
| 52 |
+
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
|
| 53 |
+
const [leaderboardLoading, setLeaderboardLoading] = useState(false);
|
| 54 |
+
const [leaderboardPage, setLeaderboardPage] = useState(1);
|
| 55 |
+
const MODELS_PER_PAGE = 10;
|
| 56 |
+
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
const fetchRound = async () => {
|
| 59 |
+
// Handle params.roundId which can be string | string[] | undefined
|
| 60 |
+
const roundIdParam = params.roundId;
|
| 61 |
+
if (!roundIdParam) {
|
| 62 |
+
setError('Invalid round ID: undefined');
|
| 63 |
+
setLoading(false);
|
| 64 |
+
return;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const roundId = Array.isArray(roundIdParam) ? roundIdParam[0] : roundIdParam;
|
| 68 |
+
|
| 69 |
+
try {
|
| 70 |
+
setLoading(true);
|
| 71 |
+
setError(null);
|
| 72 |
+
|
| 73 |
+
// Fetch the round data using the rounds endpoint
|
| 74 |
+
const response = await fetch(`/api/v1/rounds/${roundId}`);
|
| 75 |
+
|
| 76 |
+
if (!response.ok) {
|
| 77 |
+
throw new Error('Failed to fetch round data');
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const data: RoundMetadata = await response.json();
|
| 81 |
+
setRound(data);
|
| 82 |
+
} catch (err) {
|
| 83 |
+
setError(err instanceof Error ? err.message : 'An error occurred');
|
| 84 |
+
console.error('Error fetching round:', err);
|
| 85 |
+
} finally {
|
| 86 |
+
setLoading(false);
|
| 87 |
+
}
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
fetchRound();
|
| 91 |
+
}, [params.roundId]);
|
| 92 |
+
|
| 93 |
+
// Fetch leaderboard data
|
| 94 |
+
useEffect(() => {
|
| 95 |
+
const fetchLeaderboard = async () => {
|
| 96 |
+
const roundIdParam = params.roundId;
|
| 97 |
+
if (!roundIdParam) return;
|
| 98 |
+
|
| 99 |
+
const roundId = Array.isArray(roundIdParam) ? roundIdParam[0] : roundIdParam;
|
| 100 |
+
|
| 101 |
+
try {
|
| 102 |
+
setLeaderboardLoading(true);
|
| 103 |
+
const response = await fetch(`/api/v1/rounds/${roundId}/leaderboard`);
|
| 104 |
+
|
| 105 |
+
if (!response.ok) {
|
| 106 |
+
console.error('Failed to fetch leaderboard');
|
| 107 |
+
return;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const data: LeaderboardEntry[] = await response.json();
|
| 111 |
+
setLeaderboard(data);
|
| 112 |
+
} catch (err) {
|
| 113 |
+
console.error('Error fetching leaderboard:', err);
|
| 114 |
+
} finally {
|
| 115 |
+
setLeaderboardLoading(false);
|
| 116 |
+
}
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
fetchLeaderboard();
|
| 120 |
+
}, [params.roundId]);
|
| 121 |
+
|
| 122 |
+
if (loading) {
|
| 123 |
+
return (
|
| 124 |
+
<div className="min-h-screen bg-gray-50 p-6">
|
| 125 |
+
<div className="max-w-7xl mx-auto">
|
| 126 |
+
<div className="flex justify-center items-center h-64">
|
| 127 |
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
if (error || !round) {
|
| 135 |
+
return (
|
| 136 |
+
<div className="min-h-screen bg-gray-50 p-6">
|
| 137 |
+
<div className="max-w-7xl mx-auto">
|
| 138 |
+
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded">
|
| 139 |
+
<p className="font-semibold">Error</p>
|
| 140 |
+
<p className="text-sm">{error || 'Round not found'}</p>
|
| 141 |
+
</div>
|
| 142 |
+
<button
|
| 143 |
+
onClick={() => router.back()}
|
| 144 |
+
className="mt-4 text-blue-600 hover:text-blue-800 text-sm font-medium"
|
| 145 |
+
>
|
| 146 |
+
← Go back
|
| 147 |
+
</button>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
return (
|
| 154 |
+
<div className="min-h-screen bg-gray-50 p-6">
|
| 155 |
+
<div className="max-w-7xl mx-auto">
|
| 156 |
+
<Breadcrumbs
|
| 157 |
+
items={[
|
| 158 |
+
{ label: 'Challenges', href: '/challenges' },
|
| 159 |
+
{ label: `Challenge #${challengeId}`, href: `/challenges/${challengeId}` },
|
| 160 |
+
{ label: `Round #${params.roundId || ''}`, href: `/challenges/${challengeId}/${params.roundId}` }
|
| 161 |
+
]}
|
| 162 |
+
/>
|
| 163 |
+
|
| 164 |
+
<header className="mb-8">
|
| 165 |
+
<div className="flex items-start justify-between">
|
| 166 |
+
<div>
|
| 167 |
+
<h1 className="text-3xl font-bold text-gray-900">
|
| 168 |
+
{round.name || `Round ${params.roundId}`}
|
| 169 |
+
</h1>
|
| 170 |
+
{round.description && (
|
| 171 |
+
<p className="text-gray-600 mt-2">{round.description}</p>
|
| 172 |
+
)}
|
| 173 |
+
</div>
|
| 174 |
+
{round.status && (
|
| 175 |
+
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
|
| 176 |
+
round.status === 'active'
|
| 177 |
+
? 'bg-green-100 text-green-800'
|
| 178 |
+
: round.status === 'completed'
|
| 179 |
+
? 'bg-blue-100 text-blue-800'
|
| 180 |
+
: round.status === 'registration'
|
| 181 |
+
? 'bg-yellow-100 text-yellow-800'
|
| 182 |
+
: 'bg-gray-100 text-gray-800'
|
| 183 |
+
}`}>
|
| 184 |
+
{round.status.charAt(0).toUpperCase() + round.status.slice(1)}
|
| 185 |
+
</span>
|
| 186 |
+
)}
|
| 187 |
+
</div>
|
| 188 |
+
</header>
|
| 189 |
+
|
| 190 |
+
{/* Registration Banner */}
|
| 191 |
+
{round.status === 'registration' && (
|
| 192 |
+
<Link
|
| 193 |
+
href="/add-model"
|
| 194 |
+
className="mb-6 bg-yellow-50 border border-yellow-200 rounded-lg px-6 py-4 block hover:bg-yellow-100 hover:border-yellow-300 transition-colors cursor-pointer"
|
| 195 |
+
>
|
| 196 |
+
<div className="flex items-center">
|
| 197 |
+
<Clock className="h-6 w-6 text-yellow-600 mr-3 flex-shrink-0" />
|
| 198 |
+
<div>
|
| 199 |
+
<p className="text-yellow-800 font-medium">
|
| 200 |
+
Registration is open — submit your forecasts by{' '}
|
| 201 |
+
<span className="font-semibold">
|
| 202 |
+
{round.registration_end
|
| 203 |
+
? new Date(round.registration_end).toLocaleDateString('en-US', {
|
| 204 |
+
weekday: 'long',
|
| 205 |
+
year: 'numeric',
|
| 206 |
+
month: 'long',
|
| 207 |
+
day: 'numeric',
|
| 208 |
+
hour: '2-digit',
|
| 209 |
+
minute: '2-digit'
|
| 210 |
+
})
|
| 211 |
+
: 'the registration deadline'}
|
| 212 |
+
</span>
|
| 213 |
+
</p>
|
| 214 |
+
</div>
|
| 215 |
+
<ChevronRight className="h-5 w-5 text-yellow-600 ml-auto flex-shrink-0" />
|
| 216 |
+
</div>
|
| 217 |
+
</Link>
|
| 218 |
+
)}
|
| 219 |
+
|
| 220 |
+
{/* Leaderboard Section */}
|
| 221 |
+
<div className="mb-8 bg-white rounded-lg shadow-md overflow-hidden">
|
| 222 |
+
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
| 223 |
+
<h2 className="text-xl font-semibold text-gray-900">Round Leaderboard</h2>
|
| 224 |
+
<p className="text-sm text-gray-500 mt-1">Model rankings per series based on MASE score</p>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
{round.status === 'registration' ? (
|
| 228 |
+
<div className="px-6 py-12 text-center text-gray-500">
|
| 229 |
+
<Clock className="mx-auto h-12 w-12 text-gray-400 mb-4" strokeWidth={1.5} />
|
| 230 |
+
<p className="text-lg font-medium text-gray-600">No leaderboard data available yet</p>
|
| 231 |
+
<p className="text-sm text-gray-400 mt-1">The leaderboard will be available once the round begins.</p>
|
| 232 |
+
</div>
|
| 233 |
+
) : leaderboardLoading ? (
|
| 234 |
+
<div className="px-6 py-12 text-center text-gray-500">
|
| 235 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
| 236 |
+
Loading leaderboard...
|
| 237 |
+
</div>
|
| 238 |
+
) : leaderboard.length > 0 ? (
|
| 239 |
+
(() => {
|
| 240 |
+
// Get unique series IDs and build a map of series_id to series_name
|
| 241 |
+
const seriesIds = [...new Set(leaderboard.map(entry => entry.series_id))].sort((a, b) => a - b);
|
| 242 |
+
const seriesNameMap = new Map<number, string>();
|
| 243 |
+
leaderboard.forEach(entry => {
|
| 244 |
+
if (!seriesNameMap.has(entry.series_id)) {
|
| 245 |
+
seriesNameMap.set(entry.series_id, entry.series_name);
|
| 246 |
+
}
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
// Group data by model
|
| 250 |
+
const modelMap = new Map<number, ModelRow>();
|
| 251 |
+
leaderboard.forEach(entry => {
|
| 252 |
+
if (!modelMap.has(entry.model_id)) {
|
| 253 |
+
modelMap.set(entry.model_id, {
|
| 254 |
+
model_id: entry.model_id,
|
| 255 |
+
readable_id: entry.readable_id,
|
| 256 |
+
model_name: entry.model_name,
|
| 257 |
+
seriesRanks: {},
|
| 258 |
+
avgRank: 0
|
| 259 |
+
});
|
| 260 |
+
}
|
| 261 |
+
const model = modelMap.get(entry.model_id)!;
|
| 262 |
+
model.seriesRanks[entry.series_id] = { rank: entry.rank, mase: entry.mase };
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
// Calculate average rank and sort
|
| 266 |
+
const modelRows = Array.from(modelMap.values()).map(model => {
|
| 267 |
+
const ranks = Object.values(model.seriesRanks).map(r => r.rank);
|
| 268 |
+
model.avgRank = ranks.length > 0 ? ranks.reduce((sum, r) => sum + r, 0) / ranks.length : Infinity;
|
| 269 |
+
return model;
|
| 270 |
+
}).sort((a, b) => a.avgRank - b.avgRank);
|
| 271 |
+
|
| 272 |
+
// Pagination
|
| 273 |
+
const totalModels = modelRows.length;
|
| 274 |
+
const totalPages = Math.ceil(totalModels / MODELS_PER_PAGE);
|
| 275 |
+
const startIndex = (leaderboardPage - 1) * MODELS_PER_PAGE;
|
| 276 |
+
const paginatedModels = modelRows.slice(startIndex, startIndex + MODELS_PER_PAGE);
|
| 277 |
+
|
| 278 |
+
return (
|
| 279 |
+
<div>
|
| 280 |
+
<div className="overflow-x-auto">
|
| 281 |
+
<table className="min-w-full divide-y divide-gray-200">
|
| 282 |
+
<thead className="bg-gray-50">
|
| 283 |
+
<tr>
|
| 284 |
+
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sticky left-0 bg-gray-50 z-10">
|
| 285 |
+
Model
|
| 286 |
+
</th>
|
| 287 |
+
<th scope="col" className="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider bg-blue-50">
|
| 288 |
+
Avg Rank
|
| 289 |
+
</th>
|
| 290 |
+
{seriesIds.map(seriesId => (
|
| 291 |
+
<th key={seriesId} scope="col" className="px-2 py-3 text-center text-xs font-medium text-gray-500 tracking-wider align-bottom min-w-[60px] max-w-[100px]">
|
| 292 |
+
<div className="text-xs leading-tight">
|
| 293 |
+
{(seriesNameMap.get(seriesId) || `Series ${seriesId}`).replace(/[_-]/g, ' ')}
|
| 294 |
+
</div>
|
| 295 |
+
</th>
|
| 296 |
+
))}
|
| 297 |
+
</tr>
|
| 298 |
+
</thead>
|
| 299 |
+
<tbody className="bg-white divide-y divide-gray-200">
|
| 300 |
+
{paginatedModels.map((model) => (
|
| 301 |
+
<tr key={model.model_id} className="hover:bg-gray-50">
|
| 302 |
+
<td className="px-4 py-3 whitespace-nowrap sticky left-0 bg-white z-10">
|
| 303 |
+
<Link
|
| 304 |
+
href={`/models/${model.model_id}`}
|
| 305 |
+
className="text-sm font-medium text-blue-600 hover:text-blue-800"
|
| 306 |
+
>
|
| 307 |
+
{model.model_name}
|
| 308 |
+
</Link>
|
| 309 |
+
<p className="text-xs text-gray-500">{model.readable_id}</p>
|
| 310 |
+
</td>
|
| 311 |
+
<td className="px-3 py-3 text-center text-sm font-semibold text-gray-900 bg-blue-50">
|
| 312 |
+
{model.avgRank.toFixed(2)}
|
| 313 |
+
</td>
|
| 314 |
+
{seriesIds.map(seriesId => {
|
| 315 |
+
const rankData = model.seriesRanks[seriesId];
|
| 316 |
+
if (!rankData) {
|
| 317 |
+
return (
|
| 318 |
+
<td key={seriesId} className="px-3 py-3 text-center text-sm text-gray-400">
|
| 319 |
+
-
|
| 320 |
+
</td>
|
| 321 |
+
);
|
| 322 |
+
}
|
| 323 |
+
return (
|
| 324 |
+
<td key={seriesId} className="px-3 py-3 text-center">
|
| 325 |
+
<span
|
| 326 |
+
className={`inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
|
| 327 |
+
rankData.rank === 1
|
| 328 |
+
? 'bg-yellow-100 text-yellow-800'
|
| 329 |
+
: rankData.rank === 2
|
| 330 |
+
? 'bg-gray-200 text-gray-800'
|
| 331 |
+
: rankData.rank === 3
|
| 332 |
+
? 'bg-orange-100 text-orange-800'
|
| 333 |
+
: 'bg-gray-100 text-gray-600'
|
| 334 |
+
}`}
|
| 335 |
+
title={rankData.mase !== null ? `MASE: ${rankData.mase.toFixed(4)}` : 'MASE: N/A'}
|
| 336 |
+
>
|
| 337 |
+
{rankData.rank}
|
| 338 |
+
</span>
|
| 339 |
+
</td>
|
| 340 |
+
);
|
| 341 |
+
})}
|
| 342 |
+
</tr>
|
| 343 |
+
))}
|
| 344 |
+
</tbody>
|
| 345 |
+
</table>
|
| 346 |
+
</div>
|
| 347 |
+
|
| 348 |
+
{/* Pagination Controls */}
|
| 349 |
+
{totalPages > 1 && (
|
| 350 |
+
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
|
| 351 |
+
<div className="text-sm text-gray-700">
|
| 352 |
+
Showing {startIndex + 1} to {Math.min(startIndex + MODELS_PER_PAGE, totalModels)} of {totalModels} models
|
| 353 |
+
</div>
|
| 354 |
+
<div className="flex items-center space-x-2">
|
| 355 |
+
<button
|
| 356 |
+
onClick={() => setLeaderboardPage(leaderboardPage - 1)}
|
| 357 |
+
disabled={leaderboardPage === 1}
|
| 358 |
+
className={`px-3 py-1 rounded text-sm font-medium ${
|
| 359 |
+
leaderboardPage === 1
|
| 360 |
+
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
| 361 |
+
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
| 362 |
+
}`}
|
| 363 |
+
>
|
| 364 |
+
Previous
|
| 365 |
+
</button>
|
| 366 |
+
|
| 367 |
+
<div className="flex items-center space-x-1">
|
| 368 |
+
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => {
|
| 369 |
+
const showPage =
|
| 370 |
+
page === 1 ||
|
| 371 |
+
page === totalPages ||
|
| 372 |
+
(page >= leaderboardPage - 1 && page <= leaderboardPage + 1);
|
| 373 |
+
|
| 374 |
+
const showEllipsis =
|
| 375 |
+
(page === 2 && leaderboardPage > 3) ||
|
| 376 |
+
(page === totalPages - 1 && leaderboardPage < totalPages - 2);
|
| 377 |
+
|
| 378 |
+
if (!showPage && !showEllipsis) return null;
|
| 379 |
+
|
| 380 |
+
if (showEllipsis) {
|
| 381 |
+
return (
|
| 382 |
+
<span key={page} className="px-2 text-gray-500">
|
| 383 |
+
...
|
| 384 |
+
</span>
|
| 385 |
+
);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
return (
|
| 389 |
+
<button
|
| 390 |
+
key={page}
|
| 391 |
+
onClick={() => setLeaderboardPage(page)}
|
| 392 |
+
className={`px-3 py-1 rounded text-sm font-medium ${
|
| 393 |
+
leaderboardPage === page
|
| 394 |
+
? 'bg-blue-600 text-white'
|
| 395 |
+
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
| 396 |
+
}`}
|
| 397 |
+
>
|
| 398 |
+
{page}
|
| 399 |
+
</button>
|
| 400 |
+
);
|
| 401 |
+
})}
|
| 402 |
+
</div>
|
| 403 |
+
|
| 404 |
+
<button
|
| 405 |
+
onClick={() => setLeaderboardPage(leaderboardPage + 1)}
|
| 406 |
+
disabled={leaderboardPage === totalPages}
|
| 407 |
+
className={`px-3 py-1 rounded text-sm font-medium ${
|
| 408 |
+
leaderboardPage === totalPages
|
| 409 |
+
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
| 410 |
+
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
| 411 |
+
}`}
|
| 412 |
+
>
|
| 413 |
+
Next
|
| 414 |
+
</button>
|
| 415 |
+
</div>
|
| 416 |
+
</div>
|
| 417 |
+
)}
|
| 418 |
+
</div>
|
| 419 |
+
);
|
| 420 |
+
})()
|
| 421 |
+
) : (
|
| 422 |
+
<div className="px-6 py-12 text-center text-gray-500">
|
| 423 |
+
No leaderboard data available for this round
|
| 424 |
+
</div>
|
| 425 |
+
)}
|
| 426 |
+
</div>
|
| 427 |
+
|
| 428 |
+
{/* Time Series Chart Section */}
|
| 429 |
+
{round.status === 'registration' ? (
|
| 430 |
+
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
| 431 |
+
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
| 432 |
+
<h2 className="text-xl font-semibold text-gray-900">Time Series Data</h2>
|
| 433 |
+
</div>
|
| 434 |
+
<div className="px-6 py-12 text-center text-gray-500">
|
| 435 |
+
<Info className="mx-auto h-12 w-12 text-gray-400 mb-4" strokeWidth={1.5} />
|
| 436 |
+
<p className="text-lg font-medium text-gray-600">Series data not yet available</p>
|
| 437 |
+
<p className="text-sm text-gray-400 mt-1">The time series will be revealed once the round starts.</p>
|
| 438 |
+
</div>
|
| 439 |
+
</div>
|
| 440 |
+
) : (
|
| 441 |
+
<TimeSeriesChart
|
| 442 |
+
challengeId={round.round_id}
|
| 443 |
+
challengeName={round.name || `Round ${params.roundId}`}
|
| 444 |
+
challengeDescription={round.description || undefined}
|
| 445 |
+
startDate={round.start_time || undefined}
|
| 446 |
+
endDate={round.end_time || undefined}
|
| 447 |
+
frequency={round.frequency || undefined}
|
| 448 |
+
horizon={round.horizon}
|
| 449 |
+
status={round.status}
|
| 450 |
+
/>
|
| 451 |
+
)}
|
| 452 |
+
</div>
|
| 453 |
+
</div>
|
| 454 |
+
);
|
| 455 |
+
}
|
src/app/challenges/[challengeId]/page.tsx
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useMemo, useCallback } from 'react';
|
| 4 |
+
import { useParams, useRouter } from 'next/navigation';
|
| 5 |
+
import { Info } from 'lucide-react';
|
| 6 |
+
import Breadcrumbs from '@/src/components/Breadcrumbs';
|
| 7 |
+
import RankingsTable from '@/src/components/RankingsTable';
|
| 8 |
+
import type { ChallengeDefinition, DefinitionRound, PaginationInfo } from '@/src/types/challenge';
|
| 9 |
+
import { getFilteredRankings, type RankingsResponse } from '@/src/services/modelService';
|
| 10 |
+
import { getDefinitionRounds } from '@/src/services/definitionService';
|
| 11 |
+
import Link from 'next/link';
|
| 12 |
+
import { ChevronDown, ChevronRight } from 'lucide-react';
|
| 13 |
+
|
| 14 |
+
export default function ChallengeDefinitionDetail() {
|
| 15 |
+
const params = useParams();
|
| 16 |
+
const router = useRouter();
|
| 17 |
+
const [definition, setDefinition] = useState<ChallengeDefinition | null>(null);
|
| 18 |
+
const [rankings, setRankings] = useState<RankingsResponse | null>(null);
|
| 19 |
+
const [selectedCalculationDate, setSelectedCalculationDate] = useState<string>(
|
| 20 |
+
new Date().toISOString().split('T')[0]
|
| 21 |
+
);
|
| 22 |
+
const [loading, setLoading] = useState(true);
|
| 23 |
+
const [rankingsLoading, setRankingsLoading] = useState(false);
|
| 24 |
+
const [rankingsPage, setRankingsPage] = useState(1);
|
| 25 |
+
const [roundsData, setRoundsData] = useState<Record<string, { rounds: DefinitionRound[]; pagination: PaginationInfo | null }>>({});
|
| 26 |
+
const [roundsLoading, setRoundsLoading] = useState<Record<string, boolean>>({});
|
| 27 |
+
const [expandedStatuses, setExpandedStatuses] = useState<Record<string, boolean>>({
|
| 28 |
+
active: true,
|
| 29 |
+
registration: true,
|
| 30 |
+
completed: false,
|
| 31 |
+
cancelled: false,
|
| 32 |
+
});
|
| 33 |
+
const [error, setError] = useState<string | null>(null);
|
| 34 |
+
|
| 35 |
+
const ROUNDS_PER_PAGE = 10;
|
| 36 |
+
const STATUSES = ['active', 'registration', 'completed', 'cancelled'];
|
| 37 |
+
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
const fetchDefinition = async () => {
|
| 40 |
+
try {
|
| 41 |
+
setLoading(true);
|
| 42 |
+
const response = await fetch('/api/v1/definitions');
|
| 43 |
+
|
| 44 |
+
if (!response.ok) {
|
| 45 |
+
throw new Error('Failed to fetch challenge definitions');
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const data: ChallengeDefinition[] = await response.json();
|
| 49 |
+
const found = data.find(d => d.id === Number(params.challengeId));
|
| 50 |
+
|
| 51 |
+
if (!found) {
|
| 52 |
+
setError('Challenge definition not found');
|
| 53 |
+
} else {
|
| 54 |
+
setDefinition(found);
|
| 55 |
+
}
|
| 56 |
+
} catch (err) {
|
| 57 |
+
setError(err instanceof Error ? err.message : 'An error occurred');
|
| 58 |
+
console.error('Error fetching challenge definition:', err);
|
| 59 |
+
} finally {
|
| 60 |
+
setLoading(false);
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
if (params.challengeId) {
|
| 65 |
+
fetchDefinition();
|
| 66 |
+
}
|
| 67 |
+
}, [params.challengeId]);
|
| 68 |
+
|
| 69 |
+
// Fetch rankings for the definition
|
| 70 |
+
useEffect(() => {
|
| 71 |
+
const fetchRankings = async () => {
|
| 72 |
+
if (!definition || !definition.id) return;
|
| 73 |
+
|
| 74 |
+
try {
|
| 75 |
+
setRankingsLoading(true);
|
| 76 |
+
const filters: any = {
|
| 77 |
+
definition_id: definition.id,
|
| 78 |
+
limit: 100
|
| 79 |
+
};
|
| 80 |
+
if (selectedCalculationDate) {
|
| 81 |
+
filters.calculation_date = selectedCalculationDate;
|
| 82 |
+
}
|
| 83 |
+
const rankingsData = await getFilteredRankings(filters);
|
| 84 |
+
setRankings(rankingsData);
|
| 85 |
+
} catch (err) {
|
| 86 |
+
console.error('Error fetching rankings:', err);
|
| 87 |
+
} finally {
|
| 88 |
+
setRankingsLoading(false);
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
fetchRankings();
|
| 93 |
+
}, [definition, selectedCalculationDate]);
|
| 94 |
+
|
| 95 |
+
// Reset to page 1 when calculation date changes
|
| 96 |
+
useEffect(() => {
|
| 97 |
+
setRankingsPage(1);
|
| 98 |
+
}, [selectedCalculationDate]);
|
| 99 |
+
|
| 100 |
+
// Fetch rounds for the definition (per status with pagination)
|
| 101 |
+
const fetchRoundsForStatus = useCallback(async (status: string, page: number = 1) => {
|
| 102 |
+
if (!definition || !definition.id) return;
|
| 103 |
+
|
| 104 |
+
const ROUNDS_PER_PAGE = 10;
|
| 105 |
+
|
| 106 |
+
try {
|
| 107 |
+
setRoundsLoading(prev => ({ ...prev, [status]: true }));
|
| 108 |
+
const response = await getDefinitionRounds(definition.id, {
|
| 109 |
+
page,
|
| 110 |
+
pageSize: ROUNDS_PER_PAGE,
|
| 111 |
+
status,
|
| 112 |
+
});
|
| 113 |
+
setRoundsData(prev => ({
|
| 114 |
+
...prev,
|
| 115 |
+
[status]: {
|
| 116 |
+
rounds: response.items,
|
| 117 |
+
pagination: response.pagination,
|
| 118 |
+
},
|
| 119 |
+
}));
|
| 120 |
+
} catch (err) {
|
| 121 |
+
console.error(`Error fetching ${status} rounds:`, err);
|
| 122 |
+
} finally {
|
| 123 |
+
setRoundsLoading(prev => ({ ...prev, [status]: false }));
|
| 124 |
+
}
|
| 125 |
+
}, [definition]);
|
| 126 |
+
|
| 127 |
+
// Initial fetch of rounds for all statuses
|
| 128 |
+
useEffect(() => {
|
| 129 |
+
if (!definition || !definition.id) return;
|
| 130 |
+
|
| 131 |
+
STATUSES.forEach(status => {
|
| 132 |
+
fetchRoundsForStatus(status, 1);
|
| 133 |
+
});
|
| 134 |
+
}, [definition, fetchRoundsForStatus]);
|
| 135 |
+
|
| 136 |
+
const handlePageChange = (status: string, page: number) => {
|
| 137 |
+
fetchRoundsForStatus(status, page);
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
// Rankings pagination calculations
|
| 141 |
+
const RANKINGS_PER_PAGE = 10;
|
| 142 |
+
const totalRankings = rankings?.rankings.length || 0;
|
| 143 |
+
const totalRankingsPages = Math.ceil(totalRankings / RANKINGS_PER_PAGE);
|
| 144 |
+
const startRankingIndex = (rankingsPage - 1) * RANKINGS_PER_PAGE;
|
| 145 |
+
const endRankingIndex = startRankingIndex + RANKINGS_PER_PAGE;
|
| 146 |
+
const paginatedRankings = rankings?.rankings.slice(startRankingIndex, endRankingIndex) || [];
|
| 147 |
+
|
| 148 |
+
const toggleStatus = (status: string) => {
|
| 149 |
+
setExpandedStatuses(prev => ({
|
| 150 |
+
...prev,
|
| 151 |
+
[status]: !prev[status]
|
| 152 |
+
}));
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
const getStatusConfig = (status: string) => {
|
| 156 |
+
const configs: Record<string, { label: string; color: string; bgColor: string; borderColor: string }> = {
|
| 157 |
+
registration: {
|
| 158 |
+
label: 'Registration',
|
| 159 |
+
color: 'text-yellow-800',
|
| 160 |
+
bgColor: 'bg-yellow-50',
|
| 161 |
+
borderColor: 'border-yellow-200'
|
| 162 |
+
},
|
| 163 |
+
active: {
|
| 164 |
+
label: 'Active',
|
| 165 |
+
color: 'text-green-800',
|
| 166 |
+
bgColor: 'bg-green-50',
|
| 167 |
+
borderColor: 'border-green-200'
|
| 168 |
+
},
|
| 169 |
+
completed: {
|
| 170 |
+
label: 'Completed',
|
| 171 |
+
color: 'text-blue-800',
|
| 172 |
+
bgColor: 'bg-blue-50',
|
| 173 |
+
borderColor: 'border-blue-200'
|
| 174 |
+
},
|
| 175 |
+
cancelled: {
|
| 176 |
+
label: 'Cancelled',
|
| 177 |
+
color: 'text-gray-800',
|
| 178 |
+
bgColor: 'bg-gray-50',
|
| 179 |
+
borderColor: 'border-gray-200'
|
| 180 |
+
}
|
| 181 |
+
};
|
| 182 |
+
return configs[status] || {
|
| 183 |
+
label: status.charAt(0).toUpperCase() + status.slice(1),
|
| 184 |
+
color: 'text-gray-800',
|
| 185 |
+
bgColor: 'bg-gray-50',
|
| 186 |
+
borderColor: 'border-gray-200'
|
| 187 |
+
};
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
// Get statuses that have data
|
| 191 |
+
const statusesWithData = useMemo(() => {
|
| 192 |
+
return STATUSES.filter(status => {
|
| 193 |
+
const data = roundsData[status];
|
| 194 |
+
return data && data.pagination && data.pagination.total_items > 0;
|
| 195 |
+
});
|
| 196 |
+
}, [roundsData]);
|
| 197 |
+
|
| 198 |
+
if (loading) {
|
| 199 |
+
return (
|
| 200 |
+
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 201 |
+
<div className="flex justify-center items-center h-64">
|
| 202 |
+
<div className="text-gray-500">Loading challenge definition...</div>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
if (error || !definition) {
|
| 209 |
+
return (
|
| 210 |
+
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 211 |
+
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded">
|
| 212 |
+
<p className="font-semibold">Error</p>
|
| 213 |
+
<p className="text-sm">{error || 'Challenge definition not found'}</p>
|
| 214 |
+
</div>
|
| 215 |
+
<button
|
| 216 |
+
onClick={() => router.back()}
|
| 217 |
+
className="mt-4 text-blue-600 hover:text-blue-800 text-sm font-medium"
|
| 218 |
+
>
|
| 219 |
+
← Go back
|
| 220 |
+
</button>
|
| 221 |
+
</div>
|
| 222 |
+
);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
return (
|
| 226 |
+
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 227 |
+
<Breadcrumbs
|
| 228 |
+
items={[
|
| 229 |
+
{ label: 'Challenges', href: '/challenges' },
|
| 230 |
+
{ label: `Challenge #${params.challengeId}`, href: `/challenges/${params.challengeId}` }
|
| 231 |
+
]}
|
| 232 |
+
/>
|
| 233 |
+
|
| 234 |
+
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
| 235 |
+
<div className="px-6 py-5 border-b border-gray-200 bg-gray-50">
|
| 236 |
+
<div className="flex items-start justify-between">
|
| 237 |
+
<div>
|
| 238 |
+
<h1 className="text-2xl font-bold text-gray-900">{definition.name}</h1>
|
| 239 |
+
<p className="mt-1 text-sm text-gray-500">ID: {definition.id}</p>
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
|
| 244 |
+
<div className="px-6 py-5">
|
| 245 |
+
<div className="space-y-6">
|
| 246 |
+
<div>
|
| 247 |
+
<h2 className="text-lg font-semibold text-gray-900 mb-2">Description</h2>
|
| 248 |
+
<p className="text-gray-700">{definition.description}</p>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 252 |
+
<div>
|
| 253 |
+
<h3 className="text-sm font-semibold text-gray-900 mb-3">Schedule Information</h3>
|
| 254 |
+
<dl className="space-y-3">
|
| 255 |
+
<div>
|
| 256 |
+
<dt className="text-sm font-medium text-gray-500">Schedule ID</dt>
|
| 257 |
+
<dd className="mt-1 text-sm text-gray-900 font-mono bg-gray-50 px-3 py-2 rounded">
|
| 258 |
+
{definition.schedule_id}
|
| 259 |
+
</dd>
|
| 260 |
+
</div>
|
| 261 |
+
<div>
|
| 262 |
+
<dt className="text-sm font-medium text-gray-500">Frequency</dt>
|
| 263 |
+
<dd className="mt-1 text-sm text-gray-900 font-medium">
|
| 264 |
+
{definition.frequency}
|
| 265 |
+
</dd>
|
| 266 |
+
</div>
|
| 267 |
+
<div>
|
| 268 |
+
<dt className="text-sm font-medium text-gray-500">Horizon</dt>
|
| 269 |
+
<dd className="mt-1 text-sm text-gray-900 font-medium">
|
| 270 |
+
{definition.horizon}
|
| 271 |
+
</dd>
|
| 272 |
+
</div>
|
| 273 |
+
{(definition.next_registration_start || definition.next_registration_end) && (
|
| 274 |
+
<div className="pt-2 border-t border-gray-200">
|
| 275 |
+
<dt className="text-sm font-medium text-gray-700 mb-2">Next Registration Period</dt>
|
| 276 |
+
{definition.next_registration_start && (
|
| 277 |
+
<dd className="mt-1 text-sm text-gray-900">
|
| 278 |
+
<span className="text-gray-500">Opens:</span>{' '}
|
| 279 |
+
<span className="font-medium">
|
| 280 |
+
{new Date(definition.next_registration_start).toLocaleDateString('en-US', {
|
| 281 |
+
weekday: 'short',
|
| 282 |
+
year: 'numeric',
|
| 283 |
+
month: 'short',
|
| 284 |
+
day: 'numeric',
|
| 285 |
+
hour: '2-digit',
|
| 286 |
+
minute: '2-digit'
|
| 287 |
+
})}
|
| 288 |
+
</span>
|
| 289 |
+
</dd>
|
| 290 |
+
)}
|
| 291 |
+
{definition.next_registration_end && (
|
| 292 |
+
<dd className="mt-1 text-sm text-gray-900">
|
| 293 |
+
<span className="text-gray-500">Closes:</span>{' '}
|
| 294 |
+
<span className="font-medium">
|
| 295 |
+
{new Date(definition.next_registration_end).toLocaleDateString('en-US', {
|
| 296 |
+
weekday: 'short',
|
| 297 |
+
year: 'numeric',
|
| 298 |
+
month: 'short',
|
| 299 |
+
day: 'numeric',
|
| 300 |
+
hour: '2-digit',
|
| 301 |
+
minute: '2-digit'
|
| 302 |
+
})}
|
| 303 |
+
</span>
|
| 304 |
+
</dd>
|
| 305 |
+
)}
|
| 306 |
+
</div>
|
| 307 |
+
)}
|
| 308 |
+
</dl>
|
| 309 |
+
</div>
|
| 310 |
+
|
| 311 |
+
<div>
|
| 312 |
+
<h3 className="text-sm font-semibold text-gray-900 mb-3">Configuration</h3>
|
| 313 |
+
<dl className="space-y-3">
|
| 314 |
+
<div>
|
| 315 |
+
<dt className="text-sm font-medium text-gray-500">Context Length</dt>
|
| 316 |
+
<dd className="mt-1 text-sm text-gray-900 font-medium">
|
| 317 |
+
{definition.context_length}
|
| 318 |
+
</dd>
|
| 319 |
+
</div>
|
| 320 |
+
{definition.domain && (
|
| 321 |
+
<div>
|
| 322 |
+
<dt className="text-sm font-medium text-gray-500">Domain</dt>
|
| 323 |
+
<dd className="mt-1 text-sm text-gray-900 font-medium">
|
| 324 |
+
{definition.domain}
|
| 325 |
+
</dd>
|
| 326 |
+
</div>
|
| 327 |
+
)}
|
| 328 |
+
{definition.subdomain && (
|
| 329 |
+
<div>
|
| 330 |
+
<dt className="text-sm font-medium text-gray-500">Subdomain</dt>
|
| 331 |
+
<dd className="mt-1 text-sm text-gray-900 font-medium">
|
| 332 |
+
{definition.subdomain}
|
| 333 |
+
</dd>
|
| 334 |
+
</div>
|
| 335 |
+
)}
|
| 336 |
+
</dl>
|
| 337 |
+
</div>
|
| 338 |
+
</div>
|
| 339 |
+
</div>
|
| 340 |
+
</div>
|
| 341 |
+
</div>
|
| 342 |
+
|
| 343 |
+
{/* Rankings Section */}
|
| 344 |
+
{definition && (
|
| 345 |
+
<div className="mt-8">
|
| 346 |
+
<div className="flex items-center justify-between mb-4">
|
| 347 |
+
<h2 className="text-2xl font-semibold text-gray-900">Model Rankings</h2>
|
| 348 |
+
|
| 349 |
+
{/* Calculation Date Filter */}
|
| 350 |
+
<div className="flex items-center gap-2">
|
| 351 |
+
<label className="text-xs text-gray-500">Calculation Date</label>
|
| 352 |
+
<div className="relative group">
|
| 353 |
+
<Info className="w-4 h-4 text-gray-400 cursor-help" />
|
| 354 |
+
<div className="absolute right-0 top-6 w-64 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-10">
|
| 355 |
+
<div className="font-medium mb-1">Calculation Date</div>
|
| 356 |
+
<div className="text-gray-200">
|
| 357 |
+
Sets the cutoff date for model evaluation. Use this to view rankings as they appeared on a specific date. All models are ranked based on their performance up to this date.
|
| 358 |
+
</div>
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
<input
|
| 362 |
+
type="date"
|
| 363 |
+
value={selectedCalculationDate}
|
| 364 |
+
onChange={(e) => setSelectedCalculationDate(e.target.value)}
|
| 365 |
+
className="px-2 py-1 text-xs bg-white border border-gray-200 rounded text-gray-600 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400"
|
| 366 |
+
/>
|
| 367 |
+
</div>
|
| 368 |
+
</div>
|
| 369 |
+
|
| 370 |
+
{rankingsLoading ? (
|
| 371 |
+
<div className="bg-white rounded-lg shadow-md p-12 text-center text-gray-500">
|
| 372 |
+
Loading rankings...
|
| 373 |
+
</div>
|
| 374 |
+
) : rankings && rankings.rankings.length > 0 ? (
|
| 375 |
+
<>
|
| 376 |
+
<RankingsTable rankings={paginatedRankings} />
|
| 377 |
+
|
| 378 |
+
{/* Rankings Pagination */}
|
| 379 |
+
{totalRankingsPages > 1 && (
|
| 380 |
+
<div className="mt-4 bg-white rounded-lg shadow-md px-6 py-4 flex items-center justify-between">
|
| 381 |
+
<div className="text-sm text-gray-700">
|
| 382 |
+
Showing {startRankingIndex + 1} to {Math.min(endRankingIndex, totalRankings)} of {totalRankings} models
|
| 383 |
+
</div>
|
| 384 |
+
<div className="flex items-center space-x-2">
|
| 385 |
+
<button
|
| 386 |
+
onClick={() => setRankingsPage(p => Math.max(1, p - 1))}
|
| 387 |
+
disabled={rankingsPage === 1}
|
| 388 |
+
className={`px-3 py-1 rounded text-sm font-medium ${
|
| 389 |
+
rankingsPage === 1
|
| 390 |
+
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
| 391 |
+
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
| 392 |
+
}`}
|
| 393 |
+
>
|
| 394 |
+
Previous
|
| 395 |
+
</button>
|
| 396 |
+
|
| 397 |
+
<div className="flex items-center space-x-1">
|
| 398 |
+
{Array.from({ length: totalRankingsPages }, (_, i) => i + 1).map((page) => {
|
| 399 |
+
const showPage =
|
| 400 |
+
page === 1 ||
|
| 401 |
+
page === totalRankingsPages ||
|
| 402 |
+
(page >= rankingsPage - 1 && page <= rankingsPage + 1);
|
| 403 |
+
|
| 404 |
+
const showEllipsis =
|
| 405 |
+
(page === 2 && rankingsPage > 3) ||
|
| 406 |
+
(page === totalRankingsPages - 1 && rankingsPage < totalRankingsPages - 2);
|
| 407 |
+
|
| 408 |
+
if (!showPage && !showEllipsis) return null;
|
| 409 |
+
|
| 410 |
+
if (showEllipsis) {
|
| 411 |
+
return (
|
| 412 |
+
<span key={page} className="px-2 text-gray-500">
|
| 413 |
+
...
|
| 414 |
+
</span>
|
| 415 |
+
);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
return (
|
| 419 |
+
<button
|
| 420 |
+
key={page}
|
| 421 |
+
onClick={() => setRankingsPage(page)}
|
| 422 |
+
className={`px-3 py-1 rounded text-sm font-medium ${
|
| 423 |
+
rankingsPage === page
|
| 424 |
+
? 'bg-blue-600 text-white'
|
| 425 |
+
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
| 426 |
+
}`}
|
| 427 |
+
>
|
| 428 |
+
{page}
|
| 429 |
+
</button>
|
| 430 |
+
);
|
| 431 |
+
})}
|
| 432 |
+
</div>
|
| 433 |
+
|
| 434 |
+
<button
|
| 435 |
+
onClick={() => setRankingsPage(p => Math.min(totalRankingsPages, p + 1))}
|
| 436 |
+
disabled={rankingsPage === totalRankingsPages}
|
| 437 |
+
className={`px-3 py-1 rounded text-sm font-medium ${
|
| 438 |
+
rankingsPage === totalRankingsPages
|
| 439 |
+
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
| 440 |
+
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
| 441 |
+
}`}
|
| 442 |
+
>
|
| 443 |
+
Next
|
| 444 |
+
</button>
|
| 445 |
+
</div>
|
| 446 |
+
</div>
|
| 447 |
+
)}
|
| 448 |
+
</>
|
| 449 |
+
) : (
|
| 450 |
+
<div className="bg-white rounded-lg shadow-md p-12 text-center text-gray-500">
|
| 451 |
+
No rankings available
|
| 452 |
+
</div>
|
| 453 |
+
)}
|
| 454 |
+
</div>
|
| 455 |
+
)}
|
| 456 |
+
|
| 457 |
+
{/* Rounds Section */}
|
| 458 |
+
{definition && (
|
| 459 |
+
<div className="mt-8 bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
| 460 |
+
<div className="px-6 py-5 border-b border-gray-200 bg-gray-50">
|
| 461 |
+
<h2 className="text-xl font-bold text-gray-900">Challenge Rounds</h2>
|
| 462 |
+
<p className="mt-1 text-sm text-gray-500">
|
| 463 |
+
All rounds associated with this challenge definition, grouped by status
|
| 464 |
+
</p>
|
| 465 |
+
</div>
|
| 466 |
+
|
| 467 |
+
<div className="divide-y divide-gray-200">
|
| 468 |
+
{STATUSES.map((status) => {
|
| 469 |
+
const statusData = roundsData[status];
|
| 470 |
+
const config = getStatusConfig(status);
|
| 471 |
+
const isExpanded = expandedStatuses[status];
|
| 472 |
+
const isLoading = roundsLoading[status];
|
| 473 |
+
const rounds = statusData?.rounds || [];
|
| 474 |
+
const pagination = statusData?.pagination;
|
| 475 |
+
const totalRounds = pagination?.total_items || 0;
|
| 476 |
+
const currentPage = pagination?.page || 1;
|
| 477 |
+
const totalPages = pagination?.total_pages || 1;
|
| 478 |
+
|
| 479 |
+
return (
|
| 480 |
+
<div key={status} className="border-b border-gray-200 last:border-b-0">
|
| 481 |
+
{/* Status Header */}
|
| 482 |
+
<button
|
| 483 |
+
onClick={() => toggleStatus(status)}
|
| 484 |
+
className={`w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors ${config.bgColor}`}
|
| 485 |
+
>
|
| 486 |
+
<div className="flex items-center space-x-3">
|
| 487 |
+
{isExpanded ? (
|
| 488 |
+
<ChevronDown className="h-5 w-5 text-gray-500" />
|
| 489 |
+
) : (
|
| 490 |
+
<ChevronRight className="h-5 w-5 text-gray-500" />
|
| 491 |
+
)}
|
| 492 |
+
<span className={`text-lg font-semibold ${config.color}`}>
|
| 493 |
+
{config.label}
|
| 494 |
+
</span>
|
| 495 |
+
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
| 496 |
+
status === 'active'
|
| 497 |
+
? 'bg-green-100 text-green-800'
|
| 498 |
+
: status === 'registration'
|
| 499 |
+
? 'bg-yellow-100 text-yellow-800'
|
| 500 |
+
: status === 'completed'
|
| 501 |
+
? 'bg-blue-100 text-blue-800'
|
| 502 |
+
: status === 'cancelled'
|
| 503 |
+
? 'bg-gray-100 text-gray-800'
|
| 504 |
+
: 'bg-gray-100 text-gray-800'
|
| 505 |
+
}`}>
|
| 506 |
+
{totalRounds} {totalRounds === 1 ? 'round' : 'rounds'}
|
| 507 |
+
</span>
|
| 508 |
+
</div>
|
| 509 |
+
</button>
|
| 510 |
+
|
| 511 |
+
{/* Expandable Content */}
|
| 512 |
+
{isExpanded && (
|
| 513 |
+
<div className="overflow-x-auto">
|
| 514 |
+
{isLoading ? (
|
| 515 |
+
<div className="px-6 py-8 flex items-center justify-center">
|
| 516 |
+
<div className="flex items-center gap-3 text-gray-500">
|
| 517 |
+
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
| 518 |
+
<span>Loading rounds...</span>
|
| 519 |
+
</div>
|
| 520 |
+
</div>
|
| 521 |
+
) : totalRounds === 0 ? (
|
| 522 |
+
<div className="px-6 py-8 text-center text-gray-500">
|
| 523 |
+
No rounds available for this status
|
| 524 |
+
</div>
|
| 525 |
+
) : (
|
| 526 |
+
<>
|
| 527 |
+
<table className="min-w-full divide-y divide-gray-200">
|
| 528 |
+
<thead className="bg-gray-50">
|
| 529 |
+
<tr>
|
| 530 |
+
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 531 |
+
Name
|
| 532 |
+
</th>
|
| 533 |
+
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 534 |
+
Description
|
| 535 |
+
</th>
|
| 536 |
+
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 537 |
+
Registration
|
| 538 |
+
</th>
|
| 539 |
+
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 540 |
+
Start Time
|
| 541 |
+
</th>
|
| 542 |
+
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 543 |
+
End Time
|
| 544 |
+
</th>
|
| 545 |
+
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 546 |
+
Context Length
|
| 547 |
+
</th>
|
| 548 |
+
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 549 |
+
Frequency
|
| 550 |
+
</th>
|
| 551 |
+
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 552 |
+
Horizon
|
| 553 |
+
</th>
|
| 554 |
+
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 555 |
+
Domains
|
| 556 |
+
</th>
|
| 557 |
+
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 558 |
+
Categories
|
| 559 |
+
</th>
|
| 560 |
+
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 561 |
+
Subcategories
|
| 562 |
+
</th>
|
| 563 |
+
</tr>
|
| 564 |
+
</thead>
|
| 565 |
+
<tbody className="bg-white divide-y divide-gray-200">
|
| 566 |
+
{rounds.map((round, roundIndex) => {
|
| 567 |
+
return (
|
| 568 |
+
<tr key={`round-${round.id}-${roundIndex}`} className="hover:bg-gray-50">
|
| 569 |
+
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
| 570 |
+
<Link
|
| 571 |
+
href={`/challenges/${params.challengeId}/${round.id}`}
|
| 572 |
+
className="text-blue-600 hover:text-blue-800 hover:underline"
|
| 573 |
+
>
|
| 574 |
+
{round.name}
|
| 575 |
+
</Link>
|
| 576 |
+
</td>
|
| 577 |
+
<td className="px-6 py-4 text-sm text-gray-900 max-w-xs">
|
| 578 |
+
{round.description}
|
| 579 |
+
</td>
|
| 580 |
+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
| 581 |
+
<div>{new Date(round.registration_start).toLocaleDateString('en-US', {
|
| 582 |
+
year: 'numeric',
|
| 583 |
+
month: 'short',
|
| 584 |
+
day: 'numeric',
|
| 585 |
+
hour: '2-digit',
|
| 586 |
+
minute: '2-digit',
|
| 587 |
+
})}</div>
|
| 588 |
+
<div className="text-xs text-gray-500">to</div>
|
| 589 |
+
<div>{new Date(round.registration_end).toLocaleDateString('en-US', {
|
| 590 |
+
year: 'numeric',
|
| 591 |
+
month: 'short',
|
| 592 |
+
day: 'numeric',
|
| 593 |
+
hour: '2-digit',
|
| 594 |
+
minute: '2-digit',
|
| 595 |
+
})}</div>
|
| 596 |
+
</td>
|
| 597 |
+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
| 598 |
+
{new Date(round.start_time).toLocaleDateString('en-US', {
|
| 599 |
+
year: 'numeric',
|
| 600 |
+
month: 'short',
|
| 601 |
+
day: 'numeric',
|
| 602 |
+
hour: '2-digit',
|
| 603 |
+
minute: '2-digit',
|
| 604 |
+
})}
|
| 605 |
+
</td>
|
| 606 |
+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
| 607 |
+
{new Date(round.end_time).toLocaleDateString('en-US', {
|
| 608 |
+
year: 'numeric',
|
| 609 |
+
month: 'short',
|
| 610 |
+
day: 'numeric',
|
| 611 |
+
hour: '2-digit',
|
| 612 |
+
minute: '2-digit',
|
| 613 |
+
})}
|
| 614 |
+
</td>
|
| 615 |
+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
| 616 |
+
{round.context_length}
|
| 617 |
+
</td>
|
| 618 |
+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
| 619 |
+
{round.frequency}
|
| 620 |
+
</td>
|
| 621 |
+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
| 622 |
+
{round.horizon}
|
| 623 |
+
</td>
|
| 624 |
+
<td className="px-6 py-4 text-sm text-gray-900">
|
| 625 |
+
<div className="flex flex-wrap gap-1">
|
| 626 |
+
{round.domains && round.domains.length > 0 ? (
|
| 627 |
+
round.domains.map((domain, idx) => (
|
| 628 |
+
<span key={idx} className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
| 629 |
+
{domain}
|
| 630 |
+
</span>
|
| 631 |
+
))
|
| 632 |
+
) : (
|
| 633 |
+
<span className="text-gray-400 text-xs">-</span>
|
| 634 |
+
)}
|
| 635 |
+
</div>
|
| 636 |
+
</td>
|
| 637 |
+
<td className="px-6 py-4 text-sm text-gray-900">
|
| 638 |
+
<div className="flex flex-wrap gap-1">
|
| 639 |
+
{round.categories && round.categories.length > 0 ? (
|
| 640 |
+
round.categories.map((category, idx) => (
|
| 641 |
+
<span key={idx} className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-800">
|
| 642 |
+
{category}
|
| 643 |
+
</span>
|
| 644 |
+
))
|
| 645 |
+
) : (
|
| 646 |
+
<span className="text-gray-400 text-xs">-</span>
|
| 647 |
+
)}
|
| 648 |
+
</div>
|
| 649 |
+
</td>
|
| 650 |
+
<td className="px-6 py-4 text-sm text-gray-900">
|
| 651 |
+
<div className="flex flex-wrap gap-1">
|
| 652 |
+
{round.subcategories && round.subcategories.length > 0 ? (
|
| 653 |
+
round.subcategories.map((subcategory, idx) => (
|
| 654 |
+
<span key={idx} className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-pink-100 text-pink-800">
|
| 655 |
+
{subcategory}
|
| 656 |
+
</span>
|
| 657 |
+
))
|
| 658 |
+
) : (
|
| 659 |
+
<span className="text-gray-400 text-xs">-</span>
|
| 660 |
+
)}
|
| 661 |
+
</div>
|
| 662 |
+
</td>
|
| 663 |
+
</tr>
|
| 664 |
+
)})}
|
| 665 |
+
</tbody>
|
| 666 |
+
</table>
|
| 667 |
+
|
| 668 |
+
{/* Pagination Controls */}
|
| 669 |
+
{totalPages > 1 && (
|
| 670 |
+
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
|
| 671 |
+
<div className="text-sm text-gray-700">
|
| 672 |
+
Showing {((currentPage - 1) * ROUNDS_PER_PAGE) + 1} to {Math.min(currentPage * ROUNDS_PER_PAGE, totalRounds)} of {totalRounds} rounds
|
| 673 |
+
</div>
|
| 674 |
+
<div className="flex items-center space-x-2">
|
| 675 |
+
<button
|
| 676 |
+
onClick={() => handlePageChange(status, currentPage - 1)}
|
| 677 |
+
disabled={!pagination?.has_previous}
|
| 678 |
+
className={`px-3 py-1 rounded text-sm font-medium ${
|
| 679 |
+
!pagination?.has_previous
|
| 680 |
+
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
| 681 |
+
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
| 682 |
+
}`}
|
| 683 |
+
>
|
| 684 |
+
Previous
|
| 685 |
+
</button>
|
| 686 |
+
|
| 687 |
+
<div className="flex items-center space-x-1">
|
| 688 |
+
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => {
|
| 689 |
+
const showPage =
|
| 690 |
+
page === 1 ||
|
| 691 |
+
page === totalPages ||
|
| 692 |
+
(page >= currentPage - 1 && page <= currentPage + 1);
|
| 693 |
+
|
| 694 |
+
const showEllipsis =
|
| 695 |
+
(page === 2 && currentPage > 3) ||
|
| 696 |
+
(page === totalPages - 1 && currentPage < totalPages - 2);
|
| 697 |
+
|
| 698 |
+
if (!showPage && !showEllipsis) return null;
|
| 699 |
+
|
| 700 |
+
if (showEllipsis) {
|
| 701 |
+
return (
|
| 702 |
+
<span key={page} className="px-2 text-gray-500">
|
| 703 |
+
...
|
| 704 |
+
</span>
|
| 705 |
+
);
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
return (
|
| 709 |
+
<button
|
| 710 |
+
key={page}
|
| 711 |
+
onClick={() => handlePageChange(status, page)}
|
| 712 |
+
className={`px-3 py-1 rounded text-sm font-medium ${
|
| 713 |
+
currentPage === page
|
| 714 |
+
? 'bg-blue-600 text-white'
|
| 715 |
+
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
| 716 |
+
}`}
|
| 717 |
+
>
|
| 718 |
+
{page}
|
| 719 |
+
</button>
|
| 720 |
+
);
|
| 721 |
+
})}
|
| 722 |
+
</div>
|
| 723 |
+
|
| 724 |
+
<button
|
| 725 |
+
onClick={() => handlePageChange(status, currentPage + 1)}
|
| 726 |
+
disabled={!pagination?.has_next}
|
| 727 |
+
className={`px-3 py-1 rounded text-sm font-medium ${
|
| 728 |
+
!pagination?.has_next
|
| 729 |
+
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
| 730 |
+
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
| 731 |
+
}`}
|
| 732 |
+
>
|
| 733 |
+
Next
|
| 734 |
+
</button>
|
| 735 |
+
</div>
|
| 736 |
+
</div>
|
| 737 |
+
)}
|
| 738 |
+
</>
|
| 739 |
+
)}
|
| 740 |
+
</div>
|
| 741 |
+
)}
|
| 742 |
+
</div>
|
| 743 |
+
);
|
| 744 |
+
})}
|
| 745 |
+
</div>
|
| 746 |
+
</div>
|
| 747 |
+
)}
|
| 748 |
+
</div>
|
| 749 |
+
);
|
| 750 |
+
}
|
src/app/challenges/page.tsx
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useMemo } from 'react';
|
| 4 |
+
import Link from 'next/link';
|
| 5 |
+
import Breadcrumbs from '@/src/components/Breadcrumbs';
|
| 6 |
+
import type { ChallengeDefinition } from '@/src/types/challenge';
|
| 7 |
+
|
| 8 |
+
type GroupByOption = 'none' | 'frequency' | 'horizon' | 'domain' | 'subdomain';
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
export default function ChallengeDefinitions() {
|
| 12 |
+
const [definitions, setDefinitions] = useState<ChallengeDefinition[]>([]);
|
| 13 |
+
const [loading, setLoading] = useState(true);
|
| 14 |
+
const [error, setError] = useState<string | null>(null);
|
| 15 |
+
const [groupBy, setGroupBy] = useState<GroupByOption>('none');
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
const fetchDefinitions = async () => {
|
| 19 |
+
try {
|
| 20 |
+
setLoading(true);
|
| 21 |
+
const response = await fetch('/api/v1/definitions');
|
| 22 |
+
|
| 23 |
+
if (!response.ok) {
|
| 24 |
+
throw new Error('Failed to fetch challenges');
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const data = await response.json();
|
| 28 |
+
setDefinitions(data);
|
| 29 |
+
} catch (err) {
|
| 30 |
+
setError(err instanceof Error ? err.message : 'An error occurred');
|
| 31 |
+
console.error('Error fetching challenges:', err);
|
| 32 |
+
} finally {
|
| 33 |
+
setLoading(false);
|
| 34 |
+
}
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
fetchDefinitions();
|
| 38 |
+
}, []);
|
| 39 |
+
|
| 40 |
+
const groupedDefinitions = useMemo(() => {
|
| 41 |
+
if (groupBy === 'none') {
|
| 42 |
+
return { 'All Challenges': [...definitions] };
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const groups: Record<string, ChallengeDefinition[]> = {};
|
| 46 |
+
|
| 47 |
+
definitions.forEach((def) => {
|
| 48 |
+
let key: string;
|
| 49 |
+
|
| 50 |
+
switch (groupBy) {
|
| 51 |
+
case 'frequency':
|
| 52 |
+
key = def.frequency || 'No Frequency';
|
| 53 |
+
break;
|
| 54 |
+
case 'horizon':
|
| 55 |
+
key = def.horizon || 'No Horizon';
|
| 56 |
+
break;
|
| 57 |
+
case 'domain':
|
| 58 |
+
key = def.domain || 'No Domain';
|
| 59 |
+
break;
|
| 60 |
+
case 'subdomain':
|
| 61 |
+
key = def.subdomain || 'No Subdomain';
|
| 62 |
+
break;
|
| 63 |
+
default:
|
| 64 |
+
key = 'Other';
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if (!groups[key]) {
|
| 68 |
+
groups[key] = [];
|
| 69 |
+
}
|
| 70 |
+
groups[key].push(def);
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
// Sort groups so "No X" entries appear last
|
| 74 |
+
const sortedGroups: Record<string, ChallengeDefinition[]> = {};
|
| 75 |
+
const sortedKeys = Object.keys(groups).sort((a, b) => {
|
| 76 |
+
const aIsNo = a.startsWith('No ');
|
| 77 |
+
const bIsNo = b.startsWith('No ');
|
| 78 |
+
|
| 79 |
+
if (aIsNo && !bIsNo) return 1;
|
| 80 |
+
if (!aIsNo && bIsNo) return -1;
|
| 81 |
+
return a.localeCompare(b);
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
sortedKeys.forEach(key => {
|
| 85 |
+
sortedGroups[key] = groups[key];
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
return sortedGroups;
|
| 89 |
+
}, [definitions, groupBy]);
|
| 90 |
+
|
| 91 |
+
if (loading) {
|
| 92 |
+
return (
|
| 93 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 94 |
+
<div className="flex justify-center items-center h-64">
|
| 95 |
+
<div className="text-gray-500">Loading challenges...</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if (error) {
|
| 102 |
+
return (
|
| 103 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 104 |
+
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded">
|
| 105 |
+
<p className="font-semibold">Error loading challenges</p>
|
| 106 |
+
<p className="text-sm">{error}</p>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 114 |
+
<Breadcrumbs items={[{ label: 'Challenges', href: '/challenges' }]} />
|
| 115 |
+
<div className="mb-6">
|
| 116 |
+
<h1 className="text-3xl font-bold text-gray-900">Challenges</h1>
|
| 117 |
+
<p className="mt-2 text-gray-600">
|
| 118 |
+
Available challenges and their configurations
|
| 119 |
+
</p>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div className="mb-6 flex items-center justify-end gap-3">
|
| 123 |
+
<label htmlFor="groupBy" className="text-sm font-medium text-gray-700">
|
| 124 |
+
Group by:
|
| 125 |
+
</label>
|
| 126 |
+
<select
|
| 127 |
+
id="groupBy"
|
| 128 |
+
value={groupBy}
|
| 129 |
+
onChange={(e) => setGroupBy(e.target.value as GroupByOption)}
|
| 130 |
+
className="block rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
| 131 |
+
>
|
| 132 |
+
<option value="none">None</option>
|
| 133 |
+
<option value="frequency">Frequency</option>
|
| 134 |
+
<option value="horizon">Horizon</option>
|
| 135 |
+
<option value="domain">Domain</option>
|
| 136 |
+
<option value="subdomain">Subdomain</option>
|
| 137 |
+
</select>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
<div className="space-y-8">
|
| 141 |
+
{Object.entries(groupedDefinitions).map(([groupName, groupDefs]) => (
|
| 142 |
+
<div key={groupName}>
|
| 143 |
+
{groupBy !== 'none' && (
|
| 144 |
+
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
| 145 |
+
{groupName}
|
| 146 |
+
<span className="ml-2 text-sm font-normal text-gray-500">
|
| 147 |
+
({groupDefs.length} {groupDefs.length === 1 ? 'challenge' : 'challenges'})
|
| 148 |
+
</span>
|
| 149 |
+
</h2>
|
| 150 |
+
)}
|
| 151 |
+
|
| 152 |
+
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
| 153 |
+
{groupDefs.map((definition) => (
|
| 154 |
+
<Link
|
| 155 |
+
key={definition.id}
|
| 156 |
+
href={`/challenges/${definition.id}`}
|
| 157 |
+
className="bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden flex flex-col cursor-pointer"
|
| 158 |
+
>
|
| 159 |
+
<div className="p-6 flex-1">
|
| 160 |
+
<div className="flex items-start justify-between mb-3">
|
| 161 |
+
<h2 className="text-xl font-semibold text-gray-900">
|
| 162 |
+
{definition.name}
|
| 163 |
+
</h2>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<p className="text-gray-600 text-sm mb-4">{definition.description}</p>
|
| 167 |
+
|
| 168 |
+
<div className="space-y-2 text-sm">
|
| 169 |
+
|
| 170 |
+
<div className="flex justify-between">
|
| 171 |
+
<span className="text-gray-500">Frequency:</span>
|
| 172 |
+
<span className="text-gray-900 font-medium">
|
| 173 |
+
{definition.frequency}
|
| 174 |
+
</span>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<div className="flex justify-between">
|
| 178 |
+
<span className="text-gray-500">Horizon:</span>
|
| 179 |
+
<span className="text-gray-900 font-medium">
|
| 180 |
+
{definition.horizon}
|
| 181 |
+
</span>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
<div className="flex justify-between">
|
| 185 |
+
<span className="text-gray-500">Context Length:</span>
|
| 186 |
+
<span className="text-gray-900 font-medium">
|
| 187 |
+
{definition.context_length}
|
| 188 |
+
</span>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
{definition.domain && (
|
| 192 |
+
<div className="flex justify-between">
|
| 193 |
+
<span className="text-gray-500">Domain:</span>
|
| 194 |
+
<span className="text-gray-900 font-medium">
|
| 195 |
+
{definition.domain}
|
| 196 |
+
</span>
|
| 197 |
+
</div>
|
| 198 |
+
)}
|
| 199 |
+
|
| 200 |
+
{definition.subdomain && (
|
| 201 |
+
<div className="flex justify-between">
|
| 202 |
+
<span className="text-gray-500">Subdomain:</span>
|
| 203 |
+
<span className="text-gray-900 font-medium">
|
| 204 |
+
{definition.subdomain}
|
| 205 |
+
</span>
|
| 206 |
+
</div>
|
| 207 |
+
)}
|
| 208 |
+
|
| 209 |
+
{(definition.next_registration_start || definition.next_registration_end) && (
|
| 210 |
+
<div className="pt-2 mt-2 border-t border-gray-200">
|
| 211 |
+
<div className="text-xs font-medium text-gray-700 mb-1">Next Registration:</div>
|
| 212 |
+
{definition.next_registration_start && (
|
| 213 |
+
<div className="text-xs text-gray-600">
|
| 214 |
+
Opens: {new Date(definition.next_registration_start).toLocaleDateString('en-US', {
|
| 215 |
+
month: 'short',
|
| 216 |
+
day: 'numeric',
|
| 217 |
+
hour: '2-digit',
|
| 218 |
+
minute: '2-digit'
|
| 219 |
+
})}
|
| 220 |
+
</div>
|
| 221 |
+
)}
|
| 222 |
+
{definition.next_registration_end && (
|
| 223 |
+
<div className="text-xs text-gray-600">
|
| 224 |
+
Closes: {new Date(definition.next_registration_end).toLocaleDateString('en-US', {
|
| 225 |
+
month: 'short',
|
| 226 |
+
day: 'numeric',
|
| 227 |
+
hour: '2-digit',
|
| 228 |
+
minute: '2-digit'
|
| 229 |
+
})}
|
| 230 |
+
</div>
|
| 231 |
+
)}
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<div className="bg-gray-50 px-6 py-3 border-t border-gray-200">
|
| 238 |
+
<span className="text-xs text-gray-500">ID: {definition.id}</span>
|
| 239 |
+
</div>
|
| 240 |
+
</Link>
|
| 241 |
+
))}
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
))}
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
{definitions.length === 0 && (
|
| 248 |
+
<div className="text-center py-12">
|
| 249 |
+
<p className="text-gray-500">No challenges available</p>
|
| 250 |
+
</div>
|
| 251 |
+
)}
|
| 252 |
+
</div>
|
| 253 |
+
);
|
| 254 |
+
}
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--background: #ffffff;
|
| 5 |
+
--foreground: #171717;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
@theme inline {
|
| 9 |
+
--color-background: var(--background);
|
| 10 |
+
--color-foreground: var(--foreground);
|
| 11 |
+
--font-sans: var(--font-geist-sans);
|
| 12 |
+
--font-mono: var(--font-geist-mono);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
@media (prefers-color-scheme: dark) {
|
| 16 |
+
:root {
|
| 17 |
+
--background: #0a0a0a;
|
| 18 |
+
--foreground: #ededed;
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
body {
|
| 23 |
+
background: var(--background);
|
| 24 |
+
color: var(--foreground);
|
| 25 |
+
font-family: Arial, Helvetica, sans-serif;
|
| 26 |
+
overflow-x: hidden;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
html {
|
| 30 |
+
overflow-x: hidden;
|
| 31 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import Navigation from "@/src/components/Navigation";
|
| 5 |
+
|
| 6 |
+
const geistSans = Geist({
|
| 7 |
+
variable: "--font-geist-sans",
|
| 8 |
+
subsets: ["latin"],
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
const geistMono = Geist_Mono({
|
| 12 |
+
variable: "--font-geist-mono",
|
| 13 |
+
subsets: ["latin"],
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
export const metadata: Metadata = {
|
| 17 |
+
title: "TS-Arena",
|
| 18 |
+
description: "Time Series Forecasting Arena - Browse challenges and visualize time series data",
|
| 19 |
+
// TODO: Change icon
|
| 20 |
+
icons: {
|
| 21 |
+
icon: '/file.svg',
|
| 22 |
+
},
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
export default function RootLayout({
|
| 26 |
+
children,
|
| 27 |
+
}: Readonly<{
|
| 28 |
+
children: React.ReactNode;
|
| 29 |
+
}>) {
|
| 30 |
+
return (
|
| 31 |
+
<html lang="en">
|
| 32 |
+
<body
|
| 33 |
+
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
| 34 |
+
>
|
| 35 |
+
<Navigation />
|
| 36 |
+
{children}
|
| 37 |
+
</body>
|
| 38 |
+
</html>
|
| 39 |
+
);
|
| 40 |
+
}
|
src/app/models/[modelId]/page.tsx
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react';
|
| 4 |
+
import { useParams } from 'next/navigation';
|
| 5 |
+
import Breadcrumbs from '@/src/components/Breadcrumbs';
|
| 6 |
+
import ModelPerformanceCharts from '@/src/components/ModelPerformanceCharts';
|
| 7 |
+
import ModelSeriesList from '@/src/components/ModelSeriesList';
|
| 8 |
+
import { getModelRankings, ModelDetailRankings, getModelSeriesByDefinition, ModelSeriesByDefinition, getModelDetails, ModelDetails } from '@/src/services/modelService';
|
| 9 |
+
|
| 10 |
+
export default function ModelDetailPage() {
|
| 11 |
+
const params = useParams();
|
| 12 |
+
const modelId = params.modelId as string;
|
| 13 |
+
|
| 14 |
+
const [modelDetails, setModelDetails] = useState<ModelDetails | null>(null);
|
| 15 |
+
const [rankingsData, setRankingsData] = useState<ModelDetailRankings | null>(null);
|
| 16 |
+
const [seriesData, setSeriesData] = useState<ModelSeriesByDefinition | null>(null);
|
| 17 |
+
const [loading, setLoading] = useState(true);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
if (!modelId) {
|
| 21 |
+
setLoading(false);
|
| 22 |
+
return;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const fetchData = async () => {
|
| 26 |
+
setLoading(true);
|
| 27 |
+
try {
|
| 28 |
+
const [details, rankings, series] = await Promise.all([
|
| 29 |
+
getModelDetails(modelId),
|
| 30 |
+
getModelRankings(modelId),
|
| 31 |
+
getModelSeriesByDefinition(modelId)
|
| 32 |
+
]);
|
| 33 |
+
setModelDetails(details);
|
| 34 |
+
setRankingsData(rankings);
|
| 35 |
+
setSeriesData(series);
|
| 36 |
+
} catch (error) {
|
| 37 |
+
console.error('Error fetching model data:', error);
|
| 38 |
+
} finally {
|
| 39 |
+
setLoading(false);
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
fetchData();
|
| 44 |
+
}, [modelId]);
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<div className="min-h-screen bg-gray-50 p-8">
|
| 48 |
+
<div className="max-w-7xl mx-auto">
|
| 49 |
+
<Breadcrumbs
|
| 50 |
+
items={[
|
| 51 |
+
{ label: 'Rankings', href: '/' },
|
| 52 |
+
{ label: `Model #${modelId}`, href: `/models/${modelId}` }
|
| 53 |
+
]}
|
| 54 |
+
/>
|
| 55 |
+
|
| 56 |
+
<div className="bg-white rounded-lg shadow-md p-8 mb-6">
|
| 57 |
+
<h1 className="text-3xl font-bold text-gray-900 mb-6">Model Details</h1>
|
| 58 |
+
|
| 59 |
+
{loading ? (
|
| 60 |
+
<div className="text-center">
|
| 61 |
+
<div className="text-lg text-gray-600">Loading model details...</div>
|
| 62 |
+
</div>
|
| 63 |
+
) : modelDetails ? (
|
| 64 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 65 |
+
<div>
|
| 66 |
+
<label className="block text-sm font-medium text-gray-500 mb-1">
|
| 67 |
+
Model Name
|
| 68 |
+
</label>
|
| 69 |
+
<div className="text-lg text-gray-900">{modelDetails.name}</div>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div>
|
| 73 |
+
<label className="block text-sm font-medium text-gray-500 mb-1">
|
| 74 |
+
Model ID
|
| 75 |
+
</label>
|
| 76 |
+
<div className="text-lg text-gray-900 font-mono">{modelDetails.readable_id}</div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div>
|
| 80 |
+
<label className="block text-sm font-medium text-gray-500 mb-1">
|
| 81 |
+
Model Family
|
| 82 |
+
</label>
|
| 83 |
+
<div className="text-lg text-gray-900">{modelDetails.model_family}</div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div>
|
| 87 |
+
<label className="block text-sm font-medium text-gray-500 mb-1">
|
| 88 |
+
Model Size
|
| 89 |
+
</label>
|
| 90 |
+
<div className="text-lg text-gray-900">{modelDetails.model_size.toLocaleString()} parameters</div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<div>
|
| 94 |
+
<label className="block text-sm font-medium text-gray-500 mb-1">
|
| 95 |
+
Hosting
|
| 96 |
+
</label>
|
| 97 |
+
<div className="text-lg text-gray-900">{modelDetails.hosting}</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div>
|
| 101 |
+
<label className="block text-sm font-medium text-gray-500 mb-1">
|
| 102 |
+
Architecture
|
| 103 |
+
</label>
|
| 104 |
+
<div className="text-lg text-gray-900">{modelDetails.architecture}</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<div>
|
| 108 |
+
<label className="block text-sm font-medium text-gray-500 mb-1">
|
| 109 |
+
Pretraining Data
|
| 110 |
+
</label>
|
| 111 |
+
<div className="text-lg text-gray-900">{modelDetails.pretraining_data}</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<div>
|
| 115 |
+
<label className="block text-sm font-medium text-gray-500 mb-1">
|
| 116 |
+
Publishing Date
|
| 117 |
+
</label>
|
| 118 |
+
<div className="text-lg text-gray-900">
|
| 119 |
+
{new Date(modelDetails.publishing_date).toLocaleDateString()}
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
) : (
|
| 124 |
+
<div className="text-center">
|
| 125 |
+
<div className="text-lg text-gray-600">Failed to load model details.</div>
|
| 126 |
+
</div>
|
| 127 |
+
)}
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<div className="mb-6">
|
| 131 |
+
<h2 className="text-2xl font-semibold text-gray-900 mb-4">Performance by Challenge</h2>
|
| 132 |
+
{loading ? (
|
| 133 |
+
<div className="bg-white rounded-lg shadow-md p-8 text-center">
|
| 134 |
+
<div className="text-lg text-gray-600">Loading rankings...</div>
|
| 135 |
+
</div>
|
| 136 |
+
) : rankingsData ? (
|
| 137 |
+
<ModelPerformanceCharts definitionRankings={rankingsData.definition_rankings} />
|
| 138 |
+
) : (
|
| 139 |
+
<div className="bg-white rounded-lg shadow-md p-8 text-center">
|
| 140 |
+
<div className="text-lg text-gray-600">Failed to load rankings data.</div>
|
| 141 |
+
</div>
|
| 142 |
+
)}
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<div>
|
| 146 |
+
{loading ? (
|
| 147 |
+
<div className="bg-white rounded-lg shadow-md p-8 text-center">
|
| 148 |
+
<div className="text-lg text-gray-600">Loading series data...</div>
|
| 149 |
+
</div>
|
| 150 |
+
) : seriesData ? (
|
| 151 |
+
<ModelSeriesList definitions={seriesData.definitions} modelId={modelId} />
|
| 152 |
+
) : (
|
| 153 |
+
<div className="bg-white rounded-lg shadow-md p-8 text-center">
|
| 154 |
+
<div className="text-lg text-gray-600">Failed to load series data.</div>
|
| 155 |
+
</div>
|
| 156 |
+
)}
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
);
|
| 161 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react';
|
| 4 |
+
import { Info } from 'lucide-react';
|
| 5 |
+
import Breadcrumbs from '@/src/components/Breadcrumbs';
|
| 6 |
+
import RankingsTable from '@/src/components/RankingsTable';
|
| 7 |
+
import TimeSeriesChart from '@/src/components/TimeSeriesChart';
|
| 8 |
+
import { getFilteredRankings, getRankingFilters, ModelRanking, FilterOptions, ChallengeDefinition } from '@/src/services/modelService';
|
| 9 |
+
import { getDefinitionRounds } from '@/src/services/definitionService';
|
| 10 |
+
|
| 11 |
+
const DEFINITION_ID = 226;
|
| 12 |
+
const SERIES_ID = 1371;
|
| 13 |
+
|
| 14 |
+
interface RankingsData {
|
| 15 |
+
overall: ModelRanking[];
|
| 16 |
+
byDefinition: Record<number, ModelRanking[]>;
|
| 17 |
+
byFrequencyHorizon: Record<string, ModelRanking[]>;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export default function Home() {
|
| 21 |
+
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
| 22 |
+
definitions: [],
|
| 23 |
+
frequency_horizons: [],
|
| 24 |
+
});
|
| 25 |
+
const [rankingsData, setRankingsData] = useState<RankingsData>({
|
| 26 |
+
overall: [],
|
| 27 |
+
byDefinition: {},
|
| 28 |
+
byFrequencyHorizon: {},
|
| 29 |
+
});
|
| 30 |
+
const [selectedCalculationDate, setSelectedCalculationDate] = useState<string>(
|
| 31 |
+
new Date().toISOString().split('T')[0]
|
| 32 |
+
);
|
| 33 |
+
const [loading, setLoading] = useState(true);
|
| 34 |
+
const [isMounted, setIsMounted] = useState(false);
|
| 35 |
+
const [oldestActiveRound, setOldestActiveRound] = useState<any>(null);
|
| 36 |
+
const [roundLoading, setRoundLoading] = useState(true);
|
| 37 |
+
|
| 38 |
+
// Format frequency_horizon for display (e.g., "00:15:00::1 day" -> "15min / 1 day")
|
| 39 |
+
const formatFrequencyHorizon = (fh: string) => {
|
| 40 |
+
const parts = fh.split('::');
|
| 41 |
+
if (parts.length !== 2) return fh;
|
| 42 |
+
|
| 43 |
+
const [freq, horizon] = parts;
|
| 44 |
+
const freqMatch = freq.match(/(\d+):(\d+):(\d+)/);
|
| 45 |
+
let freqStr = freq;
|
| 46 |
+
if (freqMatch) {
|
| 47 |
+
const hours = parseInt(freqMatch[1]);
|
| 48 |
+
const mins = parseInt(freqMatch[2]);
|
| 49 |
+
if (hours > 0) {
|
| 50 |
+
freqStr = `${hours}h`;
|
| 51 |
+
} else if (mins > 0) {
|
| 52 |
+
freqStr = `${mins}min`;
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return `${freqStr} / ${horizon}`;
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
useEffect(() => {
|
| 60 |
+
setIsMounted(true);
|
| 61 |
+
return () => setIsMounted(false);
|
| 62 |
+
}, []);
|
| 63 |
+
|
| 64 |
+
// Fetch oldest active round for definition 226
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
const fetchOldestActiveRound = async () => {
|
| 67 |
+
try {
|
| 68 |
+
setRoundLoading(true);
|
| 69 |
+
const response = await getDefinitionRounds(DEFINITION_ID, { status: 'active' });
|
| 70 |
+
|
| 71 |
+
if (response.items && response.items.length > 0) {
|
| 72 |
+
// Sort by start_time to get the oldest
|
| 73 |
+
const sorted = [...response.items].sort((a, b) =>
|
| 74 |
+
new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
|
| 75 |
+
);
|
| 76 |
+
setOldestActiveRound(sorted[0]);
|
| 77 |
+
}
|
| 78 |
+
} catch (error) {
|
| 79 |
+
console.error('Error fetching active rounds:', error);
|
| 80 |
+
} finally {
|
| 81 |
+
setRoundLoading(false);
|
| 82 |
+
}
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
fetchOldestActiveRound();
|
| 86 |
+
}, []);
|
| 87 |
+
|
| 88 |
+
useEffect(() => {
|
| 89 |
+
if (!isMounted) return;
|
| 90 |
+
|
| 91 |
+
const fetchAllData = async () => {
|
| 92 |
+
try {
|
| 93 |
+
setLoading(true);
|
| 94 |
+
|
| 95 |
+
// First fetch filter options
|
| 96 |
+
const options = await getRankingFilters();
|
| 97 |
+
setFilterOptions(options);
|
| 98 |
+
|
| 99 |
+
// Build base filters
|
| 100 |
+
const baseFilters: any = { limit: 100 };
|
| 101 |
+
if (selectedCalculationDate) {
|
| 102 |
+
baseFilters.calculation_date = selectedCalculationDate;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Fetch overall rankings (no definition/frequency filters)
|
| 106 |
+
const overallResponse = await getFilteredRankings(baseFilters);
|
| 107 |
+
|
| 108 |
+
// Fetch rankings for each definition (limit concurrency)
|
| 109 |
+
const byDefinitionPromises = options.definitions.map(async (def: ChallengeDefinition) => {
|
| 110 |
+
const response = await getFilteredRankings({
|
| 111 |
+
...baseFilters,
|
| 112 |
+
definition_id: def.id,
|
| 113 |
+
});
|
| 114 |
+
return { id: def.id, rankings: response.rankings };
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
// Fetch rankings for each frequency/horizon (limit concurrency)
|
| 118 |
+
const byFrequencyHorizonPromises = options.frequency_horizons.map(async (fh: string) => {
|
| 119 |
+
const response = await getFilteredRankings({
|
| 120 |
+
...baseFilters,
|
| 121 |
+
frequency_horizon: fh,
|
| 122 |
+
});
|
| 123 |
+
return { fh, rankings: response.rankings };
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
// Process in batches to avoid overwhelming the browser
|
| 127 |
+
const definitionResults = await Promise.all(byDefinitionPromises);
|
| 128 |
+
const frequencyHorizonResults = await Promise.all(byFrequencyHorizonPromises);
|
| 129 |
+
|
| 130 |
+
const byDefinition: Record<number, ModelRanking[]> = {};
|
| 131 |
+
definitionResults.forEach(({ id, rankings }) => {
|
| 132 |
+
byDefinition[id] = rankings;
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
const byFrequencyHorizon: Record<string, ModelRanking[]> = {};
|
| 136 |
+
frequencyHorizonResults.forEach(({ fh, rankings }) => {
|
| 137 |
+
byFrequencyHorizon[fh] = rankings;
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
setRankingsData({
|
| 141 |
+
overall: overallResponse.rankings,
|
| 142 |
+
byDefinition,
|
| 143 |
+
byFrequencyHorizon,
|
| 144 |
+
});
|
| 145 |
+
} catch (error) {
|
| 146 |
+
console.error('Error fetching rankings:', error);
|
| 147 |
+
} finally {
|
| 148 |
+
setLoading(false);
|
| 149 |
+
}
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
fetchAllData();
|
| 153 |
+
}, [selectedCalculationDate, isMounted]);
|
| 154 |
+
|
| 155 |
+
if (loading) {
|
| 156 |
+
return (
|
| 157 |
+
<div className="min-h-screen bg-gray-50 p-8">
|
| 158 |
+
<main className="max-w-7xl mx-auto">
|
| 159 |
+
<Breadcrumbs items={[{ label: 'Rankings', href: '/' }]} />
|
| 160 |
+
<h1 className="text-3xl font-bold text-gray-900 mb-8">
|
| 161 |
+
TS-Arena Model Rankings
|
| 162 |
+
</h1>
|
| 163 |
+
<div className="text-center py-12">
|
| 164 |
+
<div className="text-lg text-gray-600">Loading rankings...</div>
|
| 165 |
+
</div>
|
| 166 |
+
</main>
|
| 167 |
+
</div>
|
| 168 |
+
);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
return (
|
| 172 |
+
<div className="min-h-screen bg-gray-50 p-8">
|
| 173 |
+
<main className="max-w-7xl mx-auto">
|
| 174 |
+
<Breadcrumbs items={[{ label: 'Rankings', href: '/' }]} />
|
| 175 |
+
<div className="flex items-center justify-between mb-8">
|
| 176 |
+
<h1 className="text-3xl font-bold text-gray-900">
|
| 177 |
+
TS-Arena Model Rankings
|
| 178 |
+
</h1>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
{/* Time Series Chart Section */}
|
| 182 |
+
{roundLoading ? (
|
| 183 |
+
<div className="bg-white rounded-lg shadow-md p-8 mb-8">
|
| 184 |
+
<div className="flex items-center justify-center">
|
| 185 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
| 186 |
+
<span className="ml-3 text-gray-600">Loading time series data...</span>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
) : oldestActiveRound ? (
|
| 190 |
+
<div className="mb-8">
|
| 191 |
+
<TimeSeriesChart
|
| 192 |
+
challengeId={oldestActiveRound.id}
|
| 193 |
+
challengeName={oldestActiveRound.name || oldestActiveRound.round_name}
|
| 194 |
+
challengeDescription={oldestActiveRound.description}
|
| 195 |
+
startDate={oldestActiveRound.start_time}
|
| 196 |
+
endDate={oldestActiveRound.end_time}
|
| 197 |
+
frequency={oldestActiveRound.frequency}
|
| 198 |
+
seriesId={SERIES_ID}
|
| 199 |
+
on_title_page={true}
|
| 200 |
+
definitionId={DEFINITION_ID}
|
| 201 |
+
/>
|
| 202 |
+
</div>
|
| 203 |
+
) : (
|
| 204 |
+
<div className="bg-white rounded-lg shadow-md p-8 text-center text-gray-500 mb-8">
|
| 205 |
+
<p>No active rounds available at the moment.</p>
|
| 206 |
+
</div>
|
| 207 |
+
)}
|
| 208 |
+
|
| 209 |
+
{/* Overall Rankings (Full Table) */}
|
| 210 |
+
<div className="mb-8">
|
| 211 |
+
<div className="flex items-center justify-between mb-4">
|
| 212 |
+
<h2 className="text-2xl font-semibold text-gray-900">Overall Rankings</h2>
|
| 213 |
+
|
| 214 |
+
{/* Calculation Date Filter */}
|
| 215 |
+
<div className="flex items-center gap-2">
|
| 216 |
+
<label className="text-xs text-gray-500">Calculation Date</label>
|
| 217 |
+
<div className="relative group">
|
| 218 |
+
<Info className="w-4 h-4 text-gray-400 cursor-help" />
|
| 219 |
+
<div className="absolute right-0 top-6 w-64 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-10">
|
| 220 |
+
<div className="font-medium mb-1">Calculation Date</div>
|
| 221 |
+
<div className="text-gray-200">
|
| 222 |
+
Sets the cutoff date for model evaluation. Use this to view rankings as they appeared on a specific date. All models are ranked based on their performance up to this date.
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
<input
|
| 227 |
+
type="date"
|
| 228 |
+
value={selectedCalculationDate}
|
| 229 |
+
onChange={(e) => setSelectedCalculationDate(e.target.value)}
|
| 230 |
+
className="px-2 py-1 text-xs bg-white border border-gray-200 rounded text-gray-600 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400"
|
| 231 |
+
/>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
<RankingsTable rankings={rankingsData.overall} />
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
{/* Rankings by Challenge Definition */}
|
| 238 |
+
<div className="mb-8">
|
| 239 |
+
<h2 className="text-2xl font-semibold text-gray-900 mb-4">Rankings by Challenge</h2>
|
| 240 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 241 |
+
{filterOptions.definitions.map((def) => (
|
| 242 |
+
<RankingsTable
|
| 243 |
+
key={def.id}
|
| 244 |
+
rankings={rankingsData.byDefinition[def.id] || []}
|
| 245 |
+
compact
|
| 246 |
+
title={def.name}
|
| 247 |
+
limit={10}
|
| 248 |
+
definitionId={def.id}
|
| 249 |
+
/>
|
| 250 |
+
))}
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
{/* Rankings by Frequency/Horizon */}
|
| 255 |
+
<div className="mb-8">
|
| 256 |
+
<h2 className="text-2xl font-semibold text-gray-900 mb-4">Rankings by Frequency / Horizon</h2>
|
| 257 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 258 |
+
{filterOptions.frequency_horizons.map((fh) => (
|
| 259 |
+
<RankingsTable
|
| 260 |
+
key={fh}
|
| 261 |
+
rankings={rankingsData.byFrequencyHorizon[fh] || []}
|
| 262 |
+
compact
|
| 263 |
+
title={formatFrequencyHorizon(fh)}
|
| 264 |
+
limit={10}
|
| 265 |
+
/>
|
| 266 |
+
))}
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
</main>
|
| 270 |
+
</div>
|
| 271 |
+
);
|
| 272 |
+
}
|
src/components/Breadcrumbs.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { usePathname } from 'next/navigation';
|
| 5 |
+
import { ChevronRight } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
interface BreadcrumbItem {
|
| 8 |
+
label: string;
|
| 9 |
+
href: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
interface BreadcrumbsProps {
|
| 13 |
+
items?: BreadcrumbItem[];
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export default function Breadcrumbs({ items }: BreadcrumbsProps) {
|
| 17 |
+
const pathname = usePathname();
|
| 18 |
+
|
| 19 |
+
// Generate breadcrumbs from path if not provided
|
| 20 |
+
const breadcrumbs = items || generateBreadcrumbsFromPath(pathname);
|
| 21 |
+
|
| 22 |
+
if (breadcrumbs.length === 0) {
|
| 23 |
+
return null;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<nav className="flex items-center space-x-2 text-sm text-gray-600 mb-6">
|
| 28 |
+
<Link
|
| 29 |
+
href="/"
|
| 30 |
+
className="hover:text-gray-900 transition-colors"
|
| 31 |
+
>
|
| 32 |
+
Home
|
| 33 |
+
</Link>
|
| 34 |
+
{breadcrumbs.map((crumb, index) => (
|
| 35 |
+
<div key={crumb.href} className="flex items-center space-x-2">
|
| 36 |
+
<ChevronRight className="h-4 w-4 text-gray-400" />
|
| 37 |
+
{index === breadcrumbs.length - 1 ? (
|
| 38 |
+
<span className="font-medium text-gray-900">{crumb.label}</span>
|
| 39 |
+
) : (
|
| 40 |
+
<Link
|
| 41 |
+
href={crumb.href}
|
| 42 |
+
className="hover:text-gray-900 transition-colors"
|
| 43 |
+
>
|
| 44 |
+
{crumb.label}
|
| 45 |
+
</Link>
|
| 46 |
+
)}
|
| 47 |
+
</div>
|
| 48 |
+
))}
|
| 49 |
+
</nav>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function generateBreadcrumbsFromPath(pathname: string): BreadcrumbItem[] {
|
| 54 |
+
const paths = pathname.split('/').filter(Boolean);
|
| 55 |
+
const breadcrumbs: BreadcrumbItem[] = [];
|
| 56 |
+
|
| 57 |
+
let currentPath = '';
|
| 58 |
+
paths.forEach((path, index) => {
|
| 59 |
+
currentPath += `/${path}`;
|
| 60 |
+
|
| 61 |
+
// Create readable labels
|
| 62 |
+
let label = path
|
| 63 |
+
.split('-')
|
| 64 |
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
| 65 |
+
.join(' ');
|
| 66 |
+
|
| 67 |
+
// Special handling for specific routes
|
| 68 |
+
if (path === 'challenges') {
|
| 69 |
+
label = 'Challenges';
|
| 70 |
+
} else if (path === 'add-model') {
|
| 71 |
+
label = 'Add Model';
|
| 72 |
+
} else if (!isNaN(Number(path))) {
|
| 73 |
+
// If it's a number (ID), keep it as is
|
| 74 |
+
label = `#${path}`;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
breadcrumbs.push({
|
| 78 |
+
label,
|
| 79 |
+
href: currentPath,
|
| 80 |
+
});
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
return breadcrumbs;
|
| 84 |
+
}
|
src/components/ChallengeList.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import type { Challenge } from '@/src/types/challenge';
|
| 4 |
+
|
| 5 |
+
interface ChallengeListProps {
|
| 6 |
+
challenges: Challenge[];
|
| 7 |
+
selectedChallengeId?: number;
|
| 8 |
+
onSelectChallenge: (challengeId: number) => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function ChallengeList({
|
| 12 |
+
challenges,
|
| 13 |
+
selectedChallengeId,
|
| 14 |
+
onSelectChallenge
|
| 15 |
+
}: ChallengeListProps) {
|
| 16 |
+
|
| 17 |
+
return (
|
| 18 |
+
<div className="space-y-4">
|
| 19 |
+
{/* Challenge List */}
|
| 20 |
+
<div className="grid gap-3">
|
| 21 |
+
{challenges.length === 0 ? (
|
| 22 |
+
<div className="text-center py-8 text-gray-500">
|
| 23 |
+
No challenges found matching your filters.
|
| 24 |
+
</div>
|
| 25 |
+
) : (
|
| 26 |
+
challenges.map((challenge) => (
|
| 27 |
+
<button
|
| 28 |
+
key={challenge.challenge_id}
|
| 29 |
+
onClick={() => onSelectChallenge(challenge.challenge_id)}
|
| 30 |
+
className={`w-full text-left p-4 border rounded-lg transition-all ${
|
| 31 |
+
selectedChallengeId === challenge.challenge_id
|
| 32 |
+
? 'border-blue-500 bg-blue-50 shadow-md'
|
| 33 |
+
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
|
| 34 |
+
}`}
|
| 35 |
+
>
|
| 36 |
+
<div className="flex justify-between items-start mb-2">
|
| 37 |
+
<h3 className="font-semibold text-lg">{challenge.name || `Challenge ${challenge.challenge_id}`}</h3>
|
| 38 |
+
<span
|
| 39 |
+
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
| 40 |
+
challenge.status === 'active'
|
| 41 |
+
? 'bg-green-100 text-green-800'
|
| 42 |
+
: 'bg-gray-100 text-gray-800'
|
| 43 |
+
}`}
|
| 44 |
+
>
|
| 45 |
+
{challenge.status}
|
| 46 |
+
</span>
|
| 47 |
+
</div>
|
| 48 |
+
<div className="text-sm text-gray-600 space-y-1">
|
| 49 |
+
<p>ID: {challenge.challenge_id}</p>
|
| 50 |
+
{challenge.description && <p className="italic">{challenge.description}</p>}
|
| 51 |
+
<div className="flex gap-4 text-xs text-gray-500 mt-2">
|
| 52 |
+
{challenge.start_time && (
|
| 53 |
+
<span>Start: {new Date(challenge.start_time).toLocaleDateString()}</span>
|
| 54 |
+
)}
|
| 55 |
+
{challenge.end_time && (
|
| 56 |
+
<span>End: {new Date(challenge.end_time).toLocaleDateString()}</span>
|
| 57 |
+
)}
|
| 58 |
+
<span>Series: {challenge.n_time_series}</span>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
</button>
|
| 62 |
+
))
|
| 63 |
+
)}
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
);
|
| 67 |
+
}
|
src/components/FilterPanel.tsx
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { Listbox, ListboxButton, Label, ListboxOption, ListboxOptions } from '@headlessui/react';
|
| 4 |
+
|
| 5 |
+
export interface RoundsFilterOptions {
|
| 6 |
+
domains: string[];
|
| 7 |
+
categories: string[];
|
| 8 |
+
subcategories: string[];
|
| 9 |
+
frequencies: string[];
|
| 10 |
+
horizons: string[];
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface FilterPanelProps {
|
| 14 |
+
filterOptions: RoundsFilterOptions;
|
| 15 |
+
selectedDomains: string[];
|
| 16 |
+
selectedCategories: string[];
|
| 17 |
+
selectedFrequencies: string[];
|
| 18 |
+
selectedHorizons: string[];
|
| 19 |
+
status?: 'active' | 'completed' | 'all';
|
| 20 |
+
endDateFrom?: string;
|
| 21 |
+
endDateTo?: string;
|
| 22 |
+
searchTerm?: string;
|
| 23 |
+
onDomainsChange: (domains: string[]) => void;
|
| 24 |
+
onCategoriesChange: (categories: string[]) => void;
|
| 25 |
+
onFrequenciesChange: (frequencies: string[]) => void;
|
| 26 |
+
onHorizonsChange: (horizons: string[]) => void;
|
| 27 |
+
onStatusChange: (status: 'active' | 'completed' | 'all') => void;
|
| 28 |
+
onEndDateFromChange: (date: string) => void;
|
| 29 |
+
onEndDateToChange: (date: string) => void;
|
| 30 |
+
onSearchTermChange: (searchTerm: string) => void;
|
| 31 |
+
onClearFilters: () => void;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
interface MultiSelectProps {
|
| 35 |
+
label: string;
|
| 36 |
+
options: string[];
|
| 37 |
+
selected: string[];
|
| 38 |
+
onChange: (values: string[]) => void;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function MultiSelect({ label, options, selected, onChange }: MultiSelectProps) {
|
| 42 |
+
const handleRemove = (value: string, e: React.MouseEvent) => {
|
| 43 |
+
e.stopPropagation();
|
| 44 |
+
onChange(selected.filter((v) => v !== value));
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const handleSelect = (values: string[]) => {
|
| 48 |
+
onChange(values);
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<Listbox value={selected} onChange={handleSelect} multiple>
|
| 53 |
+
<div className="relative">
|
| 54 |
+
<Label className="block text-sm font-medium text-gray-700 mb-2">
|
| 55 |
+
{label}
|
| 56 |
+
</Label>
|
| 57 |
+
|
| 58 |
+
<ListboxButton className="relative w-full min-h-[42px] p-2 text-left bg-white border border-gray-300 rounded-md cursor-pointer hover:border-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
|
| 59 |
+
{selected.length > 0 ? (
|
| 60 |
+
<div className="flex flex-wrap gap-1">
|
| 61 |
+
{selected.map((item) => (
|
| 62 |
+
<span
|
| 63 |
+
key={item}
|
| 64 |
+
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-md border border-blue-200"
|
| 65 |
+
>
|
| 66 |
+
{item}
|
| 67 |
+
<span
|
| 68 |
+
className="hover:text-blue-900 cursor-pointer"
|
| 69 |
+
onClick={(e) => handleRemove(item, e)}
|
| 70 |
+
>
|
| 71 |
+
×
|
| 72 |
+
</span>
|
| 73 |
+
</span>
|
| 74 |
+
))}
|
| 75 |
+
</div>
|
| 76 |
+
) : (
|
| 77 |
+
<span className="text-gray-400 text-sm">Select {label.toLowerCase()}...</span>
|
| 78 |
+
)}
|
| 79 |
+
</ListboxButton>
|
| 80 |
+
|
| 81 |
+
<ListboxOptions className="absolute z-20 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto focus:outline-none transition duration-100 ease-in data-[leave]:data-[closed]:opacity-0">
|
| 82 |
+
{options.map((option) => (
|
| 83 |
+
<ListboxOption
|
| 84 |
+
key={option}
|
| 85 |
+
value={option}
|
| 86 |
+
className="flex items-center px-3 py-2 cursor-pointer data-[focus]:bg-blue-50 data-[selected]:bg-blue-100 data-[selected]:font-medium"
|
| 87 |
+
>
|
| 88 |
+
{option}
|
| 89 |
+
</ListboxOption>
|
| 90 |
+
))}
|
| 91 |
+
</ListboxOptions>
|
| 92 |
+
</div>
|
| 93 |
+
</Listbox>
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
export default function FilterPanel({
|
| 98 |
+
filterOptions,
|
| 99 |
+
selectedDomains,
|
| 100 |
+
selectedCategories,
|
| 101 |
+
selectedFrequencies,
|
| 102 |
+
selectedHorizons,
|
| 103 |
+
status,
|
| 104 |
+
endDateFrom,
|
| 105 |
+
endDateTo,
|
| 106 |
+
searchTerm,
|
| 107 |
+
onDomainsChange,
|
| 108 |
+
onCategoriesChange,
|
| 109 |
+
onFrequenciesChange,
|
| 110 |
+
onHorizonsChange,
|
| 111 |
+
onStatusChange,
|
| 112 |
+
onEndDateFromChange,
|
| 113 |
+
onEndDateToChange,
|
| 114 |
+
onSearchTermChange,
|
| 115 |
+
onClearFilters,
|
| 116 |
+
}: FilterPanelProps) {
|
| 117 |
+
return (
|
| 118 |
+
<div className="bg-white p-6 rounded-lg shadow-md mb-6">
|
| 119 |
+
<div className="flex justify-between items-center mb-4">
|
| 120 |
+
<h2 className="text-xl font-semibold">Filters</h2>
|
| 121 |
+
<button
|
| 122 |
+
onClick={onClearFilters}
|
| 123 |
+
className="px-4 py-2 text-sm text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-md transition-colors"
|
| 124 |
+
>
|
| 125 |
+
Clear All
|
| 126 |
+
</button>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div className="space-y-4">
|
| 130 |
+
{/* Status and Search in same row */}
|
| 131 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 132 |
+
<div>
|
| 133 |
+
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-2">
|
| 134 |
+
Status
|
| 135 |
+
</label>
|
| 136 |
+
<select
|
| 137 |
+
id="status"
|
| 138 |
+
value={status || 'all'}
|
| 139 |
+
onChange={(e) => onStatusChange(e.target.value as 'active' | 'completed' | 'all')}
|
| 140 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
| 141 |
+
>
|
| 142 |
+
<option value="all">All</option>
|
| 143 |
+
<option value="active">Active</option>
|
| 144 |
+
<option value="completed">Completed</option>
|
| 145 |
+
</select>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<div>
|
| 149 |
+
<label htmlFor="searchTerm" className="block text-sm font-medium text-gray-700 mb-2">
|
| 150 |
+
Search
|
| 151 |
+
</label>
|
| 152 |
+
<input
|
| 153 |
+
id="searchTerm"
|
| 154 |
+
type="text"
|
| 155 |
+
placeholder="Search by name, ID, or description..."
|
| 156 |
+
value={searchTerm || ''}
|
| 157 |
+
onChange={(e) => onSearchTermChange(e.target.value)}
|
| 158 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
| 159 |
+
/>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
{/* Multi-select filters */}
|
| 164 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 165 |
+
<MultiSelect
|
| 166 |
+
label="Domain"
|
| 167 |
+
options={filterOptions.domains}
|
| 168 |
+
selected={selectedDomains}
|
| 169 |
+
onChange={onDomainsChange}
|
| 170 |
+
/>
|
| 171 |
+
|
| 172 |
+
<MultiSelect
|
| 173 |
+
label="Category"
|
| 174 |
+
options={filterOptions.categories}
|
| 175 |
+
selected={selectedCategories}
|
| 176 |
+
onChange={onCategoriesChange}
|
| 177 |
+
/>
|
| 178 |
+
|
| 179 |
+
<MultiSelect
|
| 180 |
+
label="Frequency"
|
| 181 |
+
options={filterOptions.frequencies}
|
| 182 |
+
selected={selectedFrequencies}
|
| 183 |
+
onChange={onFrequenciesChange}
|
| 184 |
+
/>
|
| 185 |
+
|
| 186 |
+
<MultiSelect
|
| 187 |
+
label="Horizon"
|
| 188 |
+
options={filterOptions.horizons}
|
| 189 |
+
selected={selectedHorizons}
|
| 190 |
+
onChange={onHorizonsChange}
|
| 191 |
+
/>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
{/* Date Range Filters */}
|
| 195 |
+
<div>
|
| 196 |
+
<h3 className="text-sm font-medium text-gray-700 mb-3">End Date Range</h3>
|
| 197 |
+
<div className="grid grid-cols-2 gap-3">
|
| 198 |
+
<div>
|
| 199 |
+
<label htmlFor="endDateFrom" className="block text-xs text-gray-600 mb-1">
|
| 200 |
+
From
|
| 201 |
+
</label>
|
| 202 |
+
<input
|
| 203 |
+
id="endDateFrom"
|
| 204 |
+
type="date"
|
| 205 |
+
value={endDateFrom || ''}
|
| 206 |
+
onChange={(e) => onEndDateFromChange(e.target.value)}
|
| 207 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
| 208 |
+
/>
|
| 209 |
+
</div>
|
| 210 |
+
<div>
|
| 211 |
+
<label htmlFor="endDateTo" className="block text-xs text-gray-600 mb-1">
|
| 212 |
+
To
|
| 213 |
+
</label>
|
| 214 |
+
<input
|
| 215 |
+
id="endDateTo"
|
| 216 |
+
type="date"
|
| 217 |
+
value={endDateTo || ''}
|
| 218 |
+
onChange={(e) => onEndDateToChange(e.target.value)}
|
| 219 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
| 220 |
+
/>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
);
|
| 227 |
+
}
|
src/components/ModelPerformanceCharts.tsx
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react';
|
| 4 |
+
import Link from 'next/link';
|
| 5 |
+
import dynamic from 'next/dynamic';
|
| 6 |
+
import { DefinitionRankingWithHistory } from '@/src/services/modelService';
|
| 7 |
+
import { ChevronDown, ChevronRight } from 'lucide-react';
|
| 8 |
+
|
| 9 |
+
const Plot = dynamic(() => import('react-plotly.js'), { ssr: false });
|
| 10 |
+
|
| 11 |
+
interface ModelPerformanceChartsProps {
|
| 12 |
+
definitionRankings: DefinitionRankingWithHistory[];
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function ModelPerformanceCharts({ definitionRankings }: ModelPerformanceChartsProps) {
|
| 16 |
+
const [expandedDefinitions, setExpandedDefinitions] = useState<Record<number, boolean>>({});
|
| 17 |
+
|
| 18 |
+
const toggleDefinition = (definitionId: number) => {
|
| 19 |
+
setExpandedDefinitions(prev => ({
|
| 20 |
+
...prev,
|
| 21 |
+
[definitionId]: !prev[definitionId]
|
| 22 |
+
}));
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
if (!definitionRankings || definitionRankings.length === 0) {
|
| 26 |
+
return (
|
| 27 |
+
<div className="bg-white rounded-lg shadow-md p-8 text-center text-gray-500">
|
| 28 |
+
No ranking data available for this model.
|
| 29 |
+
</div>
|
| 30 |
+
);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
return (
|
| 34 |
+
<div className="space-y-4">
|
| 35 |
+
{definitionRankings.map((definition) => {
|
| 36 |
+
const isExpanded = expandedDefinitions[definition.definition_id];
|
| 37 |
+
const dailyRankings = definition.daily_rankings || [];
|
| 38 |
+
|
| 39 |
+
// Sort rankings by date
|
| 40 |
+
const sortedRankings = [...dailyRankings].sort((a, b) =>
|
| 41 |
+
new Date(a.calculation_date).getTime() - new Date(b.calculation_date).getTime()
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
// Prepare data for Plotly
|
| 45 |
+
const dates = sortedRankings.map(r => r.calculation_date);
|
| 46 |
+
const eloScores = sortedRankings.map(r => r.elo_score);
|
| 47 |
+
const eloUpper = sortedRankings.map(r => r.elo_ci_upper);
|
| 48 |
+
const eloLower = sortedRankings.map(r => r.elo_ci_lower);
|
| 49 |
+
const ranks = sortedRankings.map(r => r.rank_position);
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div key={definition.definition_id} className="bg-white rounded-lg shadow-md overflow-hidden border border-gray-200">
|
| 53 |
+
<button
|
| 54 |
+
onClick={() => toggleDefinition(definition.definition_id)}
|
| 55 |
+
className="w-full px-6 py-4 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors"
|
| 56 |
+
>
|
| 57 |
+
<div className="flex items-center space-x-3">
|
| 58 |
+
{isExpanded ? (
|
| 59 |
+
<ChevronDown className="h-5 w-5 text-gray-500" />
|
| 60 |
+
) : (
|
| 61 |
+
<ChevronRight className="h-5 w-5 text-gray-500" />
|
| 62 |
+
)}
|
| 63 |
+
<h3 className="text-lg font-semibold text-gray-900">
|
| 64 |
+
{definition.definition_name}
|
| 65 |
+
</h3>
|
| 66 |
+
</div>
|
| 67 |
+
<div className="flex items-center space-x-4">
|
| 68 |
+
{sortedRankings.length > 0 && (
|
| 69 |
+
<>
|
| 70 |
+
<span className="text-blue-600 text-base font-semibold">ELO: {sortedRankings[sortedRankings.length - 1].elo_score.toFixed(1)}</span>
|
| 71 |
+
<span className="text-gray-700 text-base font-semibold">Rank: #{sortedRankings[sortedRankings.length - 1].rank_position}</span>
|
| 72 |
+
</>
|
| 73 |
+
)}
|
| 74 |
+
<Link
|
| 75 |
+
href={`/challenges/${definition.definition_id}`}
|
| 76 |
+
onClick={(e) => e.stopPropagation()}
|
| 77 |
+
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
| 78 |
+
>
|
| 79 |
+
View Challenge →
|
| 80 |
+
</Link>
|
| 81 |
+
</div>
|
| 82 |
+
</button>
|
| 83 |
+
|
| 84 |
+
{isExpanded && sortedRankings.length > 0 && (
|
| 85 |
+
<div className="p-6">
|
| 86 |
+
<Plot
|
| 87 |
+
data={[
|
| 88 |
+
{
|
| 89 |
+
x: dates,
|
| 90 |
+
y: eloLower,
|
| 91 |
+
fill: 'none',
|
| 92 |
+
line: { color: 'transparent' },
|
| 93 |
+
name: '95% CI Lower',
|
| 94 |
+
type: 'scatter',
|
| 95 |
+
mode: 'lines',
|
| 96 |
+
showlegend: false,
|
| 97 |
+
hoverinfo: 'skip',
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
x: dates,
|
| 101 |
+
y: eloUpper,
|
| 102 |
+
fill: 'tonexty',
|
| 103 |
+
fillcolor: 'rgba(59, 130, 246, 0.2)',
|
| 104 |
+
line: { color: 'transparent' },
|
| 105 |
+
name: '95% CI Upper',
|
| 106 |
+
type: 'scatter',
|
| 107 |
+
mode: 'lines',
|
| 108 |
+
showlegend: false,
|
| 109 |
+
hoverinfo: 'skip',
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
x: dates,
|
| 113 |
+
y: eloScores,
|
| 114 |
+
name: 'ELO Score',
|
| 115 |
+
type: 'scatter',
|
| 116 |
+
mode: 'lines+markers',
|
| 117 |
+
line: { color: 'rgb(59, 130, 246)', width: 2 },
|
| 118 |
+
marker: { color: 'rgb(59, 130, 246)', size: 6 },
|
| 119 |
+
customdata: ranks,
|
| 120 |
+
hovertemplate:
|
| 121 |
+
'<b>Date:</b> %{x}<br>' +
|
| 122 |
+
'<b>ELO Score:</b> %{y:.1f}<br>' +
|
| 123 |
+
'<b>Rank:</b> #%{customdata}<br>' +
|
| 124 |
+
'<extra></extra>',
|
| 125 |
+
},
|
| 126 |
+
] as any}
|
| 127 |
+
layout={{
|
| 128 |
+
title: 'ELO Score Over Time',
|
| 129 |
+
xaxis: {
|
| 130 |
+
title: 'Date',
|
| 131 |
+
gridcolor: '#e5e7eb',
|
| 132 |
+
showgrid: true,
|
| 133 |
+
},
|
| 134 |
+
yaxis: {
|
| 135 |
+
title: 'ELO Score',
|
| 136 |
+
gridcolor: '#e5e7eb',
|
| 137 |
+
showgrid: true,
|
| 138 |
+
range: [0, Math.max(...eloUpper, ...eloScores, ...eloLower) * 1.05],
|
| 139 |
+
},
|
| 140 |
+
hovermode: 'closest',
|
| 141 |
+
showlegend: true,
|
| 142 |
+
legend: {
|
| 143 |
+
x: 0,
|
| 144 |
+
y: 1,
|
| 145 |
+
bgcolor: 'rgba(255, 255, 255, 0.8)',
|
| 146 |
+
},
|
| 147 |
+
margin: { l: 60, r: 40, t: 50, b: 60 },
|
| 148 |
+
paper_bgcolor: 'white',
|
| 149 |
+
plot_bgcolor: 'white',
|
| 150 |
+
} as any}
|
| 151 |
+
config={{
|
| 152 |
+
responsive: true,
|
| 153 |
+
displayModeBar: true,
|
| 154 |
+
displaylogo: false,
|
| 155 |
+
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
| 156 |
+
}}
|
| 157 |
+
style={{ width: '100%', height: '400px' }}
|
| 158 |
+
/>
|
| 159 |
+
|
| 160 |
+
{/* Current stats */}
|
| 161 |
+
<div className="mt-4 grid grid-cols-3 gap-4 text-sm">
|
| 162 |
+
<div className="bg-gray-50 p-3 rounded">
|
| 163 |
+
<div className="text-gray-500 text-xs">Current Rank</div>
|
| 164 |
+
<div className="text-lg font-semibold text-gray-900">
|
| 165 |
+
#{sortedRankings[sortedRankings.length - 1].rank_position}
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
<div className="bg-gray-50 p-3 rounded">
|
| 169 |
+
<div className="text-gray-500 text-xs">Current ELO</div>
|
| 170 |
+
<div className="text-lg font-semibold text-gray-900">
|
| 171 |
+
{sortedRankings[sortedRankings.length - 1].elo_score.toFixed(1)}
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
<div className="bg-gray-50 p-3 rounded">
|
| 175 |
+
<div className="text-gray-500 text-xs">95% CI</div>
|
| 176 |
+
<div className="text-lg font-semibold text-gray-900">
|
| 177 |
+
[{sortedRankings[sortedRankings.length - 1].elo_ci_lower.toFixed(0)}, {sortedRankings[sortedRankings.length - 1].elo_ci_upper.toFixed(0)}]
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
)}
|
| 183 |
+
|
| 184 |
+
{isExpanded && sortedRankings.length === 0 && (
|
| 185 |
+
<div className="p-6 text-center text-gray-500">
|
| 186 |
+
No ranking history available for this challenge.
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
</div>
|
| 190 |
+
);
|
| 191 |
+
})}
|
| 192 |
+
</div>
|
| 193 |
+
);
|
| 194 |
+
}
|
| 195 |
+
|