Henrik Albers commited on
Commit
42f149e
·
1 Parent(s): 31ca965

convert to next.js

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +1 -44
  2. .gitignore +40 -9
  3. Dockerfile +57 -9
  4. README.md +7 -62
  5. app.py +0 -185
  6. components/challange_card_component.py +0 -196
  7. components/challenge_list_component.py +0 -86
  8. components/challenges_tab_component.py +0 -545
  9. components/filter_component.py +0 -170
  10. components/filter_models_component.py +0 -76
  11. eslint.config.mjs +18 -0
  12. next.config.ts +6 -0
  13. package-lock.json +0 -0
  14. package.json +37 -0
  15. postcss.config.mjs +7 -0
  16. public/file.svg +1 -0
  17. public/globe.svg +1 -0
  18. public/next.svg +1 -0
  19. public/vercel.svg +1 -0
  20. public/window.svg +1 -0
  21. requirements.txt +0 -7
  22. src/app/add-model/page.tsx +221 -0
  23. src/app/api/v1/API_STRUCTURE.md +75 -0
  24. src/app/api/v1/definitions/[definitionId]/rounds/route.ts +50 -0
  25. src/app/api/v1/definitions/route.ts +35 -0
  26. src/app/api/v1/models/[modelId]/definitions/[definitionId]/series/[seriesId]/forecasts/route.ts +53 -0
  27. src/app/api/v1/models/[modelId]/rankings/route.ts +43 -0
  28. src/app/api/v1/models/[modelId]/route.ts +40 -0
  29. src/app/api/v1/models/[modelId]/series-by-definition/route.ts +40 -0
  30. src/app/api/v1/models/ranking-filters/route.ts +35 -0
  31. src/app/api/v1/models/rankings/route.ts +28 -0
  32. src/app/api/v1/rounds/[roundId]/leaderboard/route.ts +47 -0
  33. src/app/api/v1/rounds/[roundId]/models/route.ts +47 -0
  34. src/app/api/v1/rounds/[roundId]/route.ts +47 -0
  35. src/app/api/v1/rounds/[roundId]/series/[seriesId]/data/route.ts +59 -0
  36. src/app/api/v1/rounds/[roundId]/series/[seriesId]/forecasts/route.ts +43 -0
  37. src/app/api/v1/rounds/[roundId]/series/route.ts +38 -0
  38. src/app/api/v1/rounds/metadata/route.ts +35 -0
  39. src/app/api/v1/rounds/route.ts +57 -0
  40. src/app/challenges/[challengeId]/[roundId]/page.tsx +455 -0
  41. src/app/challenges/[challengeId]/page.tsx +750 -0
  42. src/app/challenges/page.tsx +254 -0
  43. src/app/globals.css +31 -0
  44. src/app/layout.tsx +40 -0
  45. src/app/models/[modelId]/page.tsx +161 -0
  46. src/app/page.tsx +272 -0
  47. src/components/Breadcrumbs.tsx +84 -0
  48. src/components/ChallengeList.tsx +67 -0
  49. src/components/FilterPanel.tsx +227 -0
  50. src/components/ModelPerformanceCharts.tsx +195 -0
.dockerignore CHANGED
@@ -1,44 +1 @@
1
- # Python
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
- # Build local
2
- docker-compose.yml
3
- venv-ts-frontend/
4
- .streamlit/
5
- # Secrets
6
- .env
7
-
8
- # Other
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  .DS_Store
10
- */__pycache__/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- FROM python:3.12-slim
2
 
 
 
 
 
 
 
3
  WORKDIR /app
4
 
5
- RUN apt-get update && apt-get install -y \
6
- gcc \
7
- && rm -rf /var/lib/apt/lists/*
 
 
 
 
 
8
 
9
- COPY requirements.txt .
10
- RUN pip install --no-cache-dir -r requirements.txt
11
 
 
 
 
 
12
  COPY . .
13
 
14
- EXPOSE 8501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
17
 
18
- ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
 
 
 
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 Arena
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
- # TS-Arena Streamlit Dashboard
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
- 1. **State Management**: Streamlit uses session state and reruns the entire script on interaction
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
- ## File Structure
70
 
71
- - `app.py` - Main Streamlit application
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&apos;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&apos;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
+