diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 37de683e..00000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Claude Code Review - -on: - issue_comment: - types: [created] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - if: github.event.issue.pull_request && (contains(github.event.comment.body, '@claude')) - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - issues: read - id-token: write - - steps: - - name: Wait for PR Head to Update # So that the branch has time to fully update on push - run: sleep 15 - - name: Checkout PR Branch - uses: actions/checkout@v4 - with: - ref: refs/pull/${{ github.event.issue.number }}/head - fetch-depth: 0 # full clone to get all commits, not just most recent - - - name: Prepare Prompt - id: prepare_prompt - # Get the comment body (remove '@claude' mention), - # Build default prompt, - # Combine it with the user-provided specific prompt (if it exists), - # Feed Claude that prompt - - run: | - PROMPT_TEXT=$(echo "${{ github.event.comment.body }}" | sed -E 's/@claude//i' | xargs) - - DEFAULT_PROMPT="In addition to any instructions above, please review this pull request for the following: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Be constructive and helpful in your feedback." - - if [[ -n "$PROMPT_TEXT" ]]; then - FINAL_PROMPT=$(printf "%s\n\n%s" "$PROMPT_TEXT" "$DEFAULT_PROMPT") - else - FINAL_PROMPT="$DEFAULT_PROMPT" - fi - - echo "prompt<> $GITHUB_OUTPUT - echo "$FINAL_PROMPT" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@beta - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) - # model: "claude-opus-4-1-20250805" - - # Direct prompt for automated review (no @claude mention needed) - direct_prompt: ${{ steps.prepare_prompt.outputs.prompt }} - - # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR - # use_sticky_comment: true - - # Optional: Customize review based on file types - # direct_prompt: | - # Review this PR focusing on: - # - For TypeScript files: Type safety and proper interface usage - # - For API endpoints: Security, input validation, and error handling - # - For React components: Performance, accessibility, and best practices - # - For tests: Coverage, edge cases, and test quality - - # Optional: Different prompts for different authors - # direct_prompt: | - # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && - # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || - # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} - - # Optional: Add specific tools for running tests or linting - # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" - - # Optional: Skip review for certain conditions - # if: | - # !contains(github.event.pull_request.title, '[skip-review]') && - # !contains(github.event.pull_request.title, '[WIP]') - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index bc773072..00000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@beta - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) - # model: "claude-opus-4-1-20250805" - - # Optional: Customize the trigger phrase (default: @claude) - # trigger_phrase: "/claude" - - # Optional: Trigger when specific user is assigned to an issue - # assignee_trigger: "claude-bot" - - # Optional: Allow Claude to run specific commands - # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" - - # Optional: Add custom instructions for Claude to customize its behavior for your project - # custom_instructions: | - # Follow our coding standards - # Ensure all new code has tests - # Use TypeScript for new files - - # Optional: Custom environment variables for Claude - # claude_env: | - # NODE_ENV: test - diff --git a/TOURNAMENT_SETUP.md b/TOURNAMENT_SETUP.md index c77b6399..4185b630 100644 --- a/TOURNAMENT_SETUP.md +++ b/TOURNAMENT_SETUP.md @@ -19,7 +19,37 @@ The Epidemics 10 Tournament is a multi-participant forecasting competition built 1. **Set up Google Sheets** (see structure below) 2. **Deploy Google Apps Script** (see Apps Script code below) 3. **Configure API URL** in `/app/.env` -4. **Navigate to** `/tournament` in the app +4. **Configure the tournament** in `/app/src/config/tournament.js` +5. **Navigate to** the tournament `path` from that config, such as `/epidemics10` + +## Tournament Configuration + +Tournament instances live in `/app/src/config/tournament.js`. + +To add a new event like CSTE2026, add one object to `TOURNAMENT_REGISTRY` with: + +- a stable `id` +- `enabled: true` +- `path`, such as `/cste2026` +- `navLabel` +- `name` and `description` +- `apiUrl` for that event's Apps Script backend +- `sheetId` for documentation +- `storageKeyPrefix`, such as `cste2026` +- a `challenges` array + +Each challenge object needs: + +- a stable `id` +- a stable numeric `number` +- `dataset`, `datasetKey`, `dataPath`, `fileSuffix`, `location`, `target` +- `forecastDate` +- `horizons` +- `enabled: true` + +Routes, sidebar navigation, localStorage keys, enabled challenge counts, and leaderboard API selection are derived from this registry. Set a tournament or challenge to `enabled: false` to keep it in code but hidden. + +The Google Sheets backend stores `challenge_id` as the primary challenge key. Keep `challenge_num` only as a legacy/display field so old rows can be migrated safely. For a separate leaderboard, use a separate spreadsheet and Apps Script URL for the tournament entry. --- @@ -28,6 +58,7 @@ The Epidemics 10 Tournament is a multi-participant forecasting competition built Your sheet should have the following tabs: ### 1. **Participants** (Sheet 1) + ``` participant_id | name | joined_at ---------------|---------------|------------------- @@ -36,16 +67,19 @@ uuid-2 | TeamBlue | 2025-11-18 10:15 ``` ### 2. **Submissions** (Sheet 2) + **Simple format - just raw forecasts (scoring done on frontend):** + ``` -submission_id | participant_id | challenge_num | horizon | median | q25 | q75 | q025 | q975 | submitted_at --------------|----------------|---------------|---------|--------|------|------|------|------|------------------- -sub-1 | uuid-1 | 1 | 1 | 1500 | 1200 | 1800 | 900 | 2100 | 2025-11-18 10:30 -sub-1 | uuid-1 | 1 | 2 | 1600 | 1300 | 1900 | 1000 | 2200 | 2025-11-18 10:30 -sub-1 | uuid-1 | 1 | 3 | 1700 | 1400 | 2000 | 1100 | 2300 | 2025-11-18 10:30 -sub-2 | uuid-1 | 2 | 1 | 2300 | 2000 | 2600 | 1800 | 2900 | 2025-11-18 11:00 +submission_id | participant_id | challenge_id | challenge_num | horizon | median | q25 | q75 | q025 | q975 | submitted_at +-------------|----------------|--------------|---------------|---------|--------|------|------|------|------|------------------- +sub-1 | uuid-1 | ch-1 | 1 | 1 | 1500 | 1200 | 1800 | 900 | 2100 | 2025-11-18 10:30 +sub-1 | uuid-1 | ch-1 | 1 | 2 | 1600 | 1300 | 1900 | 1000 | 2200 | 2025-11-18 10:30 +sub-1 | uuid-1 | ch-1 | 1 | 3 | 1700 | 1400 | 2000 | 1100 | 2300 | 2025-11-18 10:30 +sub-2 | uuid-1 | ch-2 | 2 | 1 | 2300 | 2000 | 2600 | 1800 | 2900 | 2025-11-18 11:00 ``` -**Note**: No WIS column - all scoring is calculated on the frontend + +**Note**: `challenge_id` is the primary key. `challenge_num` is retained for display/backward compatibility. No WIS column - all scoring is calculated on the frontend. --- @@ -69,23 +103,28 @@ sub-2 | uuid-1 | 2 | 1 | 2300 | 2000 | 2600 | ```javascript // Paste this in Google Apps Script Editor -const SHEET_ID = '17J5KWUrVuqmqqBcVJg2A-dfVdrL4LjXTvlztCDpS0g0'; +const SHEET_ID = "17J5KWUrVuqmqqBcVJg2A-dfVdrL4LjXTvlztCDpS0g0"; function doGet(e) { const ss = SpreadsheetApp.openById(SHEET_ID); const action = e.parameter.action; let result; - if (action === 'getLeaderboard') { - result = getLeaderboard(ss); - } else if (action === 'getParticipant') { - result = getParticipant(ss, e.parameter.participantId); + if (action === "getLeaderboard") { + result = getLeaderboard(ss, e.parameter.tournamentId); + } else if (action === "getParticipant") { + result = getParticipant( + ss, + e.parameter.participantId, + e.parameter.tournamentId, + ); } else { - result = {error: 'Invalid action'}; + result = { error: "Invalid action" }; } - return ContentService.createTextOutput(JSON.stringify(result)) - .setMimeType(ContentService.MimeType.JSON); + return ContentService.createTextOutput(JSON.stringify(result)).setMimeType( + ContentService.MimeType.JSON, + ); } function doPost(e) { @@ -94,20 +133,21 @@ function doPost(e) { const action = data.action; let result; - if (action === 'register') { + if (action === "register") { result = registerParticipant(ss, data); - } else if (action === 'submitForecast') { + } else if (action === "submitForecast") { result = submitForecast(ss, data); } else { - result = {error: 'Invalid action'}; + result = { error: "Invalid action" }; } - return ContentService.createTextOutput(JSON.stringify(result)) - .setMimeType(ContentService.MimeType.JSON); + return ContentService.createTextOutput(JSON.stringify(result)).setMimeType( + ContentService.MimeType.JSON, + ); } function registerParticipant(ss, data) { - const sheet = ss.getSheetByName('Participants'); + const sheet = ss.getSheetByName("Participants"); const participantId = Utilities.getUuid(); const timestamp = new Date().toISOString(); @@ -118,7 +158,7 @@ function registerParticipant(ss, data) { return { success: true, participantId: existingData[i][0], - message: 'Welcome back!' + message: "Welcome back!", }; } } @@ -129,30 +169,37 @@ function registerParticipant(ss, data) { return { success: true, participantId: participantId, - message: 'Registration successful!' + message: "Registration successful!", }; } function submitForecast(ss, data) { - const submissionsSheet = ss.getSheetByName('Submissions'); + const submissionsSheet = ss.getSheetByName("Submissions"); const submissionId = Utilities.getUuid(); const timestamp = new Date().toISOString(); + const challengeId = data.challengeId || `ch-${data.challengeNum}`; // Handle multiple horizons (new format) - const forecasts = data.forecasts || [{ - horizon: 1, - median: data.median, - q25: data.q25, - q75: data.q75, - q025: data.q025, - q975: data.q975 - }]; + const forecasts = data.forecasts || [ + { + horizon: 1, + median: data.median, + q25: data.q25, + q75: data.q75, + q025: data.q025, + q975: data.q975, + }, + ]; // Delete existing submissions for this challenge const existingData = submissionsSheet.getDataRange().getValues(); const rowsToDelete = []; for (let i = existingData.length - 1; i >= 1; i--) { - if (existingData[i][1] === data.participantId && existingData[i][2] === data.challengeNum) { + const rowChallengeId = existingData[i][2] || `ch-${existingData[i][3]}`; + if ( + existingData[i][1] === data.participantId && + rowChallengeId === challengeId + ) { rowsToDelete.push(i + 1); // +1 because sheet rows are 1-indexed } } @@ -163,10 +210,11 @@ function submitForecast(ss, data) { } // Add new submissions (one row per horizon) - forecasts.forEach(forecast => { + forecasts.forEach((forecast) => { submissionsSheet.appendRow([ submissionId, data.participantId, + challengeId, data.challengeNum, forecast.horizon, forecast.median, @@ -174,20 +222,20 @@ function submitForecast(ss, data) { forecast.q75, forecast.q025, forecast.q975, - timestamp + timestamp, ]); }); return { success: true, submissionId: submissionId, - message: 'Forecast submitted!' + message: "Forecast submitted!", }; } -function getLeaderboard(ss) { - const participantsSheet = ss.getSheetByName('Participants'); - const submissionsSheet = ss.getSheetByName('Submissions'); +function getLeaderboard(ss, tournamentId) { + const participantsSheet = ss.getSheetByName("Participants"); + const submissionsSheet = ss.getSheetByName("Submissions"); // Get all participants const participantData = participantsSheet.getDataRange().getValues(); @@ -196,7 +244,7 @@ function getLeaderboard(ss) { for (let i = 1; i < participantData.length; i++) { participants.push({ participantId: participantData[i][0], - name: participantData[i][1] + name: participantData[i][1], }); } @@ -208,29 +256,32 @@ function getLeaderboard(ss) { for (let i = 1; i < submissionData.length; i++) { const participantId = submissionData[i][1]; - const challengeNum = submissionData[i][2]; - const horizon = submissionData[i][3]; + const challengeId = submissionData[i][2] || `ch-${submissionData[i][3]}`; + const challengeNum = submissionData[i][3]; + const horizon = submissionData[i][4]; if (!participantSubmissions[participantId]) { participantSubmissions[participantId] = {}; } - if (!participantSubmissions[participantId][challengeNum]) { - participantSubmissions[participantId][challengeNum] = []; + if (!participantSubmissions[participantId][challengeId]) { + participantSubmissions[participantId][challengeId] = []; } - participantSubmissions[participantId][challengeNum].push({ + participantSubmissions[participantId][challengeId].push({ + challengeId: challengeId, + challengeNum: challengeNum, horizon: horizon, - median: submissionData[i][4], - q25: submissionData[i][5], - q75: submissionData[i][6], - q025: submissionData[i][7], - q975: submissionData[i][8] + median: submissionData[i][5], + q25: submissionData[i][6], + q75: submissionData[i][7], + q025: submissionData[i][8], + q975: submissionData[i][9], }); } // Build leaderboard data (frontend will calculate WIS) - const leaderboard = participants.map(participant => { + const leaderboard = participants.map((participant) => { const submissions = participantSubmissions[participant.participantId] || {}; const completed = Object.keys(submissions).length; @@ -238,19 +289,19 @@ function getLeaderboard(ss) { participantId: participant.participantId, name: participant.name, completed: completed, - submissions: submissions // Send raw submissions to frontend for scoring + submissions: submissions, // Send raw submissions to frontend for scoring }; }); return { success: true, - leaderboard: leaderboard + leaderboard: leaderboard, }; } -function getParticipant(ss, participantId) { - const participantsSheet = ss.getSheetByName('Participants'); - const submissionsSheet = ss.getSheetByName('Submissions'); +function getParticipant(ss, participantId, tournamentId) { + const participantsSheet = ss.getSheetByName("Participants"); + const submissionsSheet = ss.getSheetByName("Submissions"); // Get participant info const participantData = participantsSheet.getDataRange().getValues(); @@ -259,7 +310,7 @@ function getParticipant(ss, participantId) { if (participantData[i][0] === participantId) { participant = { participantId: participantData[i][0], - name: participantData[i][1] + name: participantData[i][1], }; break; } @@ -268,7 +319,7 @@ function getParticipant(ss, participantId) { if (!participant) { return { success: false, - error: 'Participant not found' + error: "Participant not found", }; } @@ -278,38 +329,40 @@ function getParticipant(ss, participantId) { for (let i = 1; i < submissionData.length; i++) { if (submissionData[i][1] === participantId) { - const challengeNum = submissionData[i][2]; - const horizon = submissionData[i][3]; + const challengeId = submissionData[i][2] || `ch-${submissionData[i][3]}`; + const challengeNum = submissionData[i][3]; + const horizon = submissionData[i][4]; - if (!submissionsByChallenge[challengeNum]) { - submissionsByChallenge[challengeNum] = { + if (!submissionsByChallenge[challengeId]) { + submissionsByChallenge[challengeId] = { + challengeId: challengeId, challengeNum: challengeNum, forecasts: [], - submittedAt: submissionData[i][9] + submittedAt: submissionData[i][10], }; } - submissionsByChallenge[challengeNum].forecasts.push({ + submissionsByChallenge[challengeId].forecasts.push({ horizon: horizon, - median: submissionData[i][4], - q25: submissionData[i][5], - q75: submissionData[i][6], - q025: submissionData[i][7], - q975: submissionData[i][8] + median: submissionData[i][5], + q25: submissionData[i][6], + q75: submissionData[i][7], + q025: submissionData[i][8], + q975: submissionData[i][9], }); } } // Convert to array and sort forecasts by horizon - const submissions = Object.values(submissionsByChallenge).map(sub => ({ + const submissions = Object.values(submissionsByChallenge).map((sub) => ({ ...sub, - forecasts: sub.forecasts.sort((a, b) => a.horizon - b.horizon) + forecasts: sub.forecasts.sort((a, b) => a.horizon - b.horizon), })); return { success: true, participant: participant, - submissions: submissions + submissions: submissions, }; } ``` @@ -323,8 +376,8 @@ After deploying the Apps Script, add the Web App URL to your React config: ```javascript // /app/src/config/tournament.js export const TOURNAMENT_CONFIG = { - apiUrl: 'YOUR_APPS_SCRIPT_WEB_APP_URL_HERE', - sheetId: '17J5KWUrVuqmqqBcVJg2A-dfVdrL4LjXTvlztCDpS0g0', + apiUrl: "YOUR_APPS_SCRIPT_WEB_APP_URL_HERE", + sheetId: "17J5KWUrVuqmqqBcVJg2A-dfVdrL4LjXTvlztCDpS0g0", // ... rest of config }; ``` @@ -346,12 +399,14 @@ export const TOURNAMENT_CONFIG = { ### How It Works **Google Sheets = Dumb Data Store** + - Only stores raw forecast submissions (median, q25, q75, q025, q975) - No WIS calculations - No ranking logic - Just stores and retrieves data **Frontend = Smart Scoring Engine** + 1. **TournamentLeaderboard** fetches all participants' forecasts from Google Sheets 2. Loads ground truth data from the same files Forecastle uses 3. Calculates WIS for each participant/challenge using `scoreUserForecast()` @@ -369,6 +424,7 @@ export const TOURNAMENT_CONFIG = { ### Historical Challenges Each tournament challenge uses a specific past date where ground truth is already available: + - Challenge 1: US Flu on **2024-01-20** - Challenge 2: US COVID on **2024-02-10** - Challenge 3: US RSV on **2024-01-27** diff --git a/app/index.html b/app/index.html index 3b4ee41b..1b2f9876 100644 --- a/app/index.html +++ b/app/index.html @@ -24,14 +24,26 @@ gtag("config", "%VITE_GA_MEASUREMENT_ID%"); + + ); +}; + +export default Seo; diff --git a/app/src/components/forecastle/ForecastleGame.jsx b/app/src/components/forecastle/ForecastleGame.jsx index b806811e..8b1345c8 100644 --- a/app/src/components/forecastle/ForecastleGame.jsx +++ b/app/src/components/forecastle/ForecastleGame.jsx @@ -1,6 +1,5 @@ import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; -import { Helmet } from "react-helmet-async"; import { Alert, Badge, @@ -51,6 +50,7 @@ import { import ForecastleChartCanvas from "./ForecastleChartCanvas"; import ForecastleInputControls from "./ForecastleInputControls"; import ForecastleStatsModal from "./ForecastleStatsModal"; +import Seo from "../Seo"; const addWeeksToDate = (dateString, weeks) => { const base = new Date(`${dateString}T00:00:00Z`); @@ -1396,9 +1396,11 @@ const ForecastleGame = () => { return ( <> - - RespiLens | Forecastle - + {renderContent()} { icon: IconTarget, active: location.pathname.startsWith("/forecastle"), }, - { - href: "/epidemics10", - label: "Epidemics10", + ...ENABLED_TOURNAMENTS.map((tournament) => ({ + href: tournament.path, + label: tournament.navLabel, icon: IconTrophy, - active: location.pathname.startsWith("/epidemics10"), - }, + active: location.pathname.startsWith(tournament.path), + })), { href: "/myrespilens", label: "MyRespiLens", diff --git a/app/src/components/myplots/MiniPlot.jsx b/app/src/components/myplots/MiniPlot.jsx index ea23303e..1d72bf3c 100644 --- a/app/src/components/myplots/MiniPlot.jsx +++ b/app/src/components/myplots/MiniPlot.jsx @@ -15,7 +15,7 @@ import useQuantileForecastTraces from "../../hooks/useQuantileForecastTraces"; import { MODEL_COLORS } from "../../config/datasets"; import { nhsnSlugToNameMap, targetDisplayNameMap } from "../../utils/mapUtils"; -const MiniPlot = ({ plot }) => { +const MiniPlot = ({ plot, onMetadataLoad, plotHeight = 210 }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -32,6 +32,7 @@ const MiniPlot = ({ plot }) => { if (!response.ok) throw new Error(`Data not found`); const json = await response.json(); setData(json); + onMetadataLoad?.(json?.metadata || null); } catch (err) { setError(err.message); } finally { @@ -39,7 +40,7 @@ const MiniPlot = ({ plot }) => { } }; fetchData(); - }, [plot.fullDataPath]); + }, [plot.fullDataPath, onMetadataLoad]); const { traces: forecastTraces } = useQuantileForecastTraces({ groundTruth: isNHSN ? null : data?.ground_truth, @@ -137,8 +138,8 @@ const MiniPlot = ({ plot }) => { return { autosize: true, - height: 230, - margin: { l: 45, r: 10, t: 10, b: 35 }, + height: plotHeight, + margin: { l: 40, r: 8, t: 8, b: 30 }, showlegend: false, template: colorScheme === "dark" ? "plotly_dark" : "plotly_white", paper_bgcolor: "rgba(0,0,0,0)", @@ -178,7 +179,7 @@ const MiniPlot = ({ plot }) => { })) : [], }; - }, [colorScheme, plot.settings, isNHSN, data, finalTraces]); + }, [colorScheme, plot.settings, isNHSN, data, finalTraces, plotHeight]); // Helper for hover label content const tooltipContent = useMemo(() => { @@ -254,13 +255,13 @@ const MiniPlot = ({ plot }) => { if (loading) return ( -
+
); if (error) return ( -
+
Error loading chart @@ -276,7 +277,7 @@ const MiniPlot = ({ plot }) => { w={350} events={{ hover: true, focus: false, touch: true }} > - + + value + .toLowerCase() + .replace(/-/g, " ") + .replace(/\b(forecasts?|forecast hub|hub|surveillance|data|view)\b/g, "") + .replace(/\s+/g, " ") + .trim(); const MyPlots = () => { const [userSavedPlots, setUserSavedPlots] = useState([]); + const [plotMetadata, setPlotMetadata] = useState({}); useEffect(() => { const plots = getSavedPlots(); @@ -35,6 +48,62 @@ const MyPlots = () => { }; const hasPlots = userSavedPlots.length > 0; + const plotCount = userSavedPlots.length; + + const gridConfig = (() => { + if (plotCount <= 1) { + return { + cols: { base: 1, md: 1, xl: 1 }, + plotHeight: 500, + cardMinHeight: "auto", + }; + } + + if (plotCount === 2) { + return { + cols: { base: 1, md: 2, xl: 2 }, + plotHeight: 360, + cardMinHeight: "auto", + }; + } + + if (plotCount === 3) { + return { + cols: { base: 1, md: 2, xl: 3 }, + plotHeight: 300, + cardMinHeight: "auto", + }; + } + + if (plotCount === 4) { + return { + cols: { base: 1, md: 2, xl: 2 }, + plotHeight: 250, + cardMinHeight: "calc((100vh - 300px) / 2)", + }; + } + + return { + cols: { base: 1, md: 2, xl: 3 }, + plotHeight: 210, + cardMinHeight: plotCount <= 6 ? "calc((100vh - 300px) / 2)" : "320px", + }; + })(); + + const handleMetadataLoad = (plotId, metadata) => { + setPlotMetadata((current) => { + if ( + current[plotId]?.location_name === metadata?.location_name && + current[plotId]?.dataset === metadata?.dataset + ) { + return current; + } + return { + ...current, + [plotId]: metadata, + }; + }); + }; const pageContainerStyle = { width: "100%", @@ -47,152 +116,211 @@ const MyPlots = () => { }; return ( - - {!hasPlots ? ( -
- + + + {!hasPlots ? ( +
+ + + + + + +
+ + No plots saved yet... + + + You haven't added any visualizations to My Plots yet. + Click the "Add to My Plots" button on any plot to see it + here with any editorializations you choose. This feature is + in its alpha release; if you encounter bugs or have + suggestions, please report them{" "} + + here. + + +
+ + + Plots are stored locally in your browser. + +
+
+
+ ) : ( + - - - - - -
- - No plots saved yet... - - - You haven't added any visualizations to My Plots yet. - Click the "Add to My Plots" button on any plot to see it here - with any editorializations you choose. This feature is in its - alpha release; if you encounter bugs or have suggestions, - please report them{" "} - - here. - - -
- - - Plots are stored locally in your browser. - -
-
-
- ) : ( - - - -
- My Plots - - Your personalized library of saved visualizations. - - - This feature is in its alpha release, and is still under - develoment. If you encounter a bug or have a suggestion, - please{" "} - - let us know. - - -
- - {userSavedPlots.length} Saved - -
-
- - - {userSavedPlots.map((plot) => ( - + +
+ My Plots + + Your personalized library of saved visualizations. + + + This feature is in its alpha release, and is still under + develoment. If you encounter a bug or have a suggestion, + please{" "} + + let us know. + + +
+ + {userSavedPlots.length} Saved + +
+
+ + + - - - - - - {plot.viewDisplayName.toUpperCase()} - - - {plot.settings.location.toUpperCase()} - - - - - + {userSavedPlots.map((plot) => { + const metadata = plotMetadata[plot.id]; + const locationName = + metadata?.location_name || plot.settings.location; + const pathogenLabel = + getDatasetTitleFromView(plot.viewType) || + metadata?.dataset || + plot.viewDisplayName; + const showViewBadge = + normalizeLabel(plot.viewDisplayName) !== + normalizeLabel(pathogenLabel); + + return ( - + + + + + {locationName} + + + {pathogenLabel} + + + + + + + + + + + handleDelete(plot.id)} + aria-label="Remove plot" + > + + + + + + + {showViewBadge && ( + + + {plot.viewDisplayName} + + + )} + + + + handleMetadataLoad(plot.id, metadata) + } + /> + + - - - - - - ))} - -
- )} -
+ ); + })} + +
+ + )} +
+ ); }; diff --git a/app/src/components/myrespi/MyRespiLensDashboard.jsx b/app/src/components/myrespi/MyRespiLensDashboard.jsx index df09703b..ff1a9933 100644 --- a/app/src/components/myrespi/MyRespiLensDashboard.jsx +++ b/app/src/components/myrespi/MyRespiLensDashboard.jsx @@ -1,5 +1,4 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { Helmet } from "react-helmet-async"; import { Container, Title, @@ -30,6 +29,7 @@ import Plotly from "plotly.js/dist/plotly"; import ModelSelector from "../ModelSelector"; import DateSelector from "../DateSelector"; import { MODEL_COLORS } from "../../config/datasets"; +import Seo from "../Seo"; const formatTargetNameForTitle = (name) => { if (!name) return "Value"; @@ -611,9 +611,11 @@ const MyRespiLensDashboard = () => { return ( <> - - RespiLens | MyRespiLens - +
{ const [loading, setLoading] = useState(true); @@ -159,9 +159,11 @@ const NarrativeBrowser = ({ onNarrativeSelect }) => { return ( <> - - RespiLens | Narratives - + {/* Header */} diff --git a/app/src/components/narratives/SlideNarrativeViewer.jsx b/app/src/components/narratives/SlideNarrativeViewer.jsx index 032dbcbf..c0839c94 100644 --- a/app/src/components/narratives/SlideNarrativeViewer.jsx +++ b/app/src/components/narratives/SlideNarrativeViewer.jsx @@ -1,6 +1,5 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { Helmet } from "react-helmet-async"; import { Container, Paper, @@ -26,6 +25,7 @@ import { IconCode, } from "@tabler/icons-react"; import DataVisualizationContainer from "../DataVisualizationContainer"; +import Seo from "../Seo"; // Plotly Gaussian Chart Component const PlotlyGaussianChart = () => { @@ -512,6 +512,7 @@ The final view returns to the national perspective with our latest forecasts, sh
@@ -536,9 +537,20 @@ The final view returns to the national perspective with our latest forecasts, sh return ( <> - - RespiLens | Narrative Viewer - +
diff --git a/app/src/components/reporting/ReportingDelayPage.jsx b/app/src/components/reporting/ReportingDelayPage.jsx index 8b7fa2ba..62c78246 100644 --- a/app/src/components/reporting/ReportingDelayPage.jsx +++ b/app/src/components/reporting/ReportingDelayPage.jsx @@ -46,6 +46,7 @@ import { Chart } from "react-chartjs-2"; import Plot from "react-plotly.js"; import { driver } from "driver.js"; import "driver.js/dist/driver.css"; +import Seo from "../Seo"; ChartJS.register( CategoryScale, @@ -649,600 +650,612 @@ const ReportingDelayPage = () => { }; return ( - - - - } - w="fit-content" - > - Reporting triangle explorer - - - Do you need to nowcast? What is your reporting delay distribution? - - - Analyze your reporting delay distribution with RespiLens and - epinowcast. Everything runs locally in your browser. - - - - - - Introduction - - Do you need nowcasting ? What does your reporting delay - distrubution look like ? Let's dive into that using this little - app. Nothing leave your computer (say how to check). So upload a - data with some columns indicating the refrence date of an event, - the report date when it was reported, and the value reported. - Optionally you can have other columns like location, age group, or - target type to filter the data. You'll see your reporting - distrubtion and the so called reporting triangle introduced by - (probably Kaitlyn Johnson et al. but really i need to check this). - This For any deeper dive open the link below to epinowcast on - which this work is based. + <> + + + + + } + w="fit-content" + > + Reporting triangle explorer + + + Do you need to nowcast? What is your reporting delay distribution? + + + Analyze your reporting delay distribution with RespiLens and + epinowcast. Everything runs locally in your browser. - - - EpiNowcast - - - Baselinenowcast - - - - - - - { - event.preventDefault(); - setIsDragging(true); - }} - onDragLeave={() => setIsDragging(false)} - onDrop={(event) => { - event.preventDefault(); - setIsDragging(false); - handleFile(event.dataTransfer.files?.[0]); - }} - style={{ - borderStyle: "dashed", - borderColor: isDragging - ? "var(--mantine-color-blue-6)" - : undefined, - background: isDragging - ? "var(--mantine-color-blue-0)" - : undefined, - }} - > - - - - - Drag & drop a CSV file here - - Expected columns: reference_date,{" "} - report_date, value. - - - Each row should be a cumulative total for one reference date as - reported on a later report date. - - - Optional columns like location, age, or target are supported and - can be filtered after upload. - - - - - - - Current dataset: {fileName} - - {error && ( - - {error} Showing the last valid dataset. - - )} - {!mappingComplete && ( - - Please map the required columns to continue. - - )} - handleFile(event.target.files?.[0])} - /> - - - + - - Column mapping - - {csvHeaders.length} columns detected - - - - Map your CSV columns to the required fields. Each upload resets - the mapping. + Introduction + + Do you need nowcasting ? What does your reporting delay + distrubution look like ? Let's dive into that using this little + app. Nothing leave your computer (say how to check). So upload a + data with some columns indicating the refrence date of an event, + the report date when it was reported, and the value reported. + Optionally you can have other columns like location, age group, + or target type to filter the data. You'll see your reporting + distrubtion and the so called reporting triangle introduced by + (probably Kaitlyn Johnson et al. but really i need to check + this). This For any deeper dive open the link below to + epinowcast on which this work is based. - - { - setColumnMapping((prev) => ({ - ...prev, - reportDate: value ?? "", - })); - setAnalysisStarted(false); - }} + + - { - setColumnFilters((prev) => ({ - ...prev, - [column]: value, - })); - setAnalysisStarted(false); - }} - clearable - searchable - size="sm" - /> - ))} - - - - )} - {!mappingComplete && ( - - Please map reference date, report date, and value to continue. - - )} - - - {analysisStarted && ( - <> - + + { + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(event) => { + event.preventDefault(); + setIsDragging(false); + handleFile(event.dataTransfer.files?.[0]); + }} + style={{ + borderStyle: "dashed", + borderColor: isDragging + ? "var(--mantine-color-blue-6)" + : undefined, + background: isDragging + ? "var(--mantine-color-blue-0)" + : undefined, + }} + > + + + + + Drag & drop a CSV file here + + Expected columns: reference_date,{" "} + report_date, value. + + + Each row should be a cumulative total for one reference date + as reported on a later report date. + + + Optional columns like location, age, or target are supported + and can be filtered after upload. + + + + + + + Current dataset: {fileName} + + {error && ( + + {error} Showing the last valid dataset. + + )} + {!mappingComplete && ( + + Please map the required columns to continue. + + )} + handleFile(event.target.files?.[0])} + /> + + + + - Window & cutoff + Column mapping - {triangle.referenceDates.length} reference dates + {csvHeaders.length} columns detected - Use the slider to focus on a subset of reference dates (rows), - and set how far after reference dates to include reports - (columns). + Map your CSV columns to the required fields. Each upload + resets the mapping. - - - - Reference-date window - - - formatDateLabel(allReferenceDates[value]) - } - /> - - Showing {activeRangeLabel} - - - - setMaxLagUnits(Number(value) || 0)} - min={0} - max={Math.max(0, Math.ceil(maxLagDays / unitDays))} - clampBehavior="strict" - size="sm" - /> - - Latest observed delay: {maxLagDays} days (~ - {Math.ceil(maxLagDays / unitDays)} {unit}s) - - + + { + setColumnMapping((prev) => ({ + ...prev, + reportDate: value ?? "", + })); + setAnalysisStarted(false); + }} + size="sm" + /> + { + setColumnFilters((prev) => ({ + ...prev, + [column]: value, + })); + setAnalysisStarted(false); + }} + clearable + searchable + size="sm" + /> + ))} + + + + )} + {!mappingComplete && ( + + Please map reference date, report date, and value to + continue. + + )} + + - - - - - Revision trajectories - first 3 reference dates - - - View how reported totals evolve over successive reports. - - - - - - + {analysisStarted && ( + <> + - Delay distribution + Window & cutoff - {summary.total} total reports + {triangle.referenceDates.length} reference dates - How long it takes for reports to arrive after the reference - date. + Use the slider to focus on a subset of reference dates + (rows), and set how far after reference dates to include + reports (columns). - + + + + Reference-date window + + + formatDateLabel(allReferenceDates[value]) + } + /> + + Showing {activeRangeLabel} + + + + setMaxLagUnits(Number(value) || 0)} + min={0} + max={Math.max(0, Math.ceil(maxLagDays / unitDays))} + clampBehavior="strict" + size="sm" + /> + + Latest observed delay: {maxLagDays} days (~ + {Math.ceil(maxLagDays / unitDays)} {unit}s) + + + - - - - - Reporting triangle - - - {triangle.referenceDates.length} reference dates - - setIsTriangleFullscreen((prev) => !prev)} - > - {isTriangleFullscreen ? ( - - ) : ( - - )} - - - - - + + + + + Revision trajectories + first 3 reference dates + - Rows = reference date, columns ={" "} - report date. + View how reported totals evolve over successive reports. + + + + + + + + Delay distribution + + {summary.total} total reports + + - Diagonal cells (delay = 0) represent reports received on - the same day as the reference date. + How long it takes for reports to arrive after the + reference date. + - - setShowHeatmap(event.currentTarget.checked) - } - size="sm" - /> - - - Darker cells are larger cumulative counts. - {isTriangleTruncated && - " Showing a recent subset to keep the table responsive."} - - {showHeatmap ? ( - - ) : ( - - - - - - Reference date - - {displayReportDates.map((date) => ( - - {formatDateLabel(date)} - - ))} - - - {triangleRows} -
-
- )} -
-
+
+ - - Nota Bene - - - Late reports can be structurally different (e.g., lab - corrections, backfills). - - - Holiday effects and reporting interruptions bias delay - estimates. - - - Changes in case definitions or data pipelines break - comparability over time. - - - - - - - - Summary - - - Based on this dataset: - - The reported quantity is{" "} - - 95% complete after {summary.delay95} days - - , with a - - {" "} - median delay of {summary.medianDelay} days - - . + + Reporting triangle + + + {triangle.referenceDates.length} reference dates + + setIsTriangleFullscreen((prev) => !prev)} + > + {isTriangleFullscreen ? ( + + ) : ( + + )} + + + + + + + Rows = reference date, columns ={" "} + report date. - - {recommendation} + + Diagonal cells (delay = 0) represent reports received on + the same day as the reference date. - - - - - - Decision tree for nowcasting - - - + + setShowHeatmap(event.currentTarget.checked) } - > - Simple: use baselinenowcast + size="sm" + /> + + + Darker cells are larger cumulative counts. + {isTriangleTruncated && + " Showing a recent subset to keep the table responsive."} + + {showHeatmap ? ( + + ) : ( + + + + + + Reference date + + {displayReportDates.map((date) => ( + + {formatDateLabel(date)} + + ))} + + + {triangleRows} +
+
+ )} +
+
+ + + + + Nota Bene + + + Late reports can be structurally different (e.g., lab + corrections, backfills). + - Better but more complex: use epinowcast (tood link) + Holiday effects and reporting interruptions bias delay + estimates. - (both use the same data format) - For more information about nowcasting (epinowcast, the - forum) + Changes in case definitions or data pipelines break + comparability over time. + + + + + Summary + + + Based on this dataset: + + The reported quantity is{" "} + + 95% complete after {summary.delay95} days + + , with a + + {" "} + median delay of {summary.medianDelay} days + + . + + + {recommendation} + + + + + + + + Decision tree for nowcasting + + + + } + > + Simple: use baselinenowcast + + Better but more complex: use epinowcast (tood link) + + (both use the same data format) + + For more information about nowcasting (epinowcast, the + forum) + + + + + + + + + + Useful links + + + + Baseline nowcast toolkit + + + + + EpiNowcast documentation + + + + + + Want help operationalizing? Start with the baseline + nowcast decision tree and upgrade to EpiNowcast when you + need probabilistic delay distributions. + + -
- - - - Useful links - - - - Baseline nowcast toolkit - - - - - EpiNowcast documentation - - - - - - Want help operationalizing? Start with the baseline nowcast - decision tree and upgrade to EpiNowcast when you need - probabilistic delay distributions. - - - - - - )} - - setIsTriangleFullscreen(false)} - fullScreen - title="Reporting triangle" - padding="md" - > - - - - Showing {activeRangeLabel} · Cutoff {maxLagUnits} {unit}(s) - - setShowHeatmap(event.currentTarget.checked)} - size="sm" - /> - - {showHeatmap ? ( - - ) : ( - - - - - Reference date - {displayReportDates.map((date) => ( - - {formatDateLabel(date)} - - ))} - - - {triangleRows} -
-
+ )}
-
-
+ setIsTriangleFullscreen(false)} + fullScreen + title="Reporting triangle" + padding="md" + > + + + + Showing {activeRangeLabel} · Cutoff {maxLagUnits} {unit}(s) + + + setShowHeatmap(event.currentTarget.checked) + } + size="sm" + /> + + {showHeatmap ? ( + + ) : ( + + + + + Reference date + {displayReportDates.map((date) => ( + + {formatDateLabel(date)} + + ))} + + + {triangleRows} +
+
+ )} +
+
+ + ); }; diff --git a/app/src/components/tools/ToolsPage.jsx b/app/src/components/tools/ToolsPage.jsx index 05bf92eb..4eca245e 100644 --- a/app/src/components/tools/ToolsPage.jsx +++ b/app/src/components/tools/ToolsPage.jsx @@ -11,6 +11,7 @@ import { } from "@mantine/core"; import { IconClock, IconTools } from "@tabler/icons-react"; import { Link } from "react-router-dom"; +import Seo from "../Seo"; const tools = [ { @@ -25,45 +26,52 @@ const tools = [ const ToolsPage = () => { return ( - - - - - RespiLens Toolboox - - - Browse lightweight utilities for data QA and perhaps more at some - point. - + <> + + + + + + RespiLens Toolboox + + + Browse lightweight utilities for data QA and perhaps more at some + point. + - - {tools.map((tool) => ( - - - - - - {tool.title} + + {tools.map((tool) => ( + + + + + + {tool.title} + + {tool.badge} - {tool.badge} - - - {tool.description} - - - - - ))} - - - + + {tool.description} + + + + + ))} + + + + ); }; diff --git a/app/src/components/tournament/TournamentChallengeCard.jsx b/app/src/components/tournament/TournamentChallengeCard.jsx index 348d6f45..a7713fa7 100644 --- a/app/src/components/tournament/TournamentChallengeCard.jsx +++ b/app/src/components/tournament/TournamentChallengeCard.jsx @@ -22,6 +22,7 @@ import { } from "@tabler/icons-react"; import { useForecastData } from "../../hooks/useForecastData"; import { submitForecast, getParticipant } from "../../utils/tournamentAPI"; +import { TOURNAMENT_CONFIG } from "../../config"; import { initialiseForecastInputs, convertToIntervals, @@ -40,6 +41,7 @@ const addWeeksToDate = (dateString, weeks) => { }; const TournamentChallengeCard = ({ + tournamentConfig = TOURNAMENT_CONFIG, challenge, participantId, isCompleted, @@ -115,9 +117,14 @@ const TournamentChallengeCard = ({ const loadExistingSubmission = async () => { if (isCompleted && participantId) { try { - const participantData = await getParticipant(participantId); + const participantData = await getParticipant( + participantId, + tournamentConfig, + ); const submission = participantData.submissions.find( - (sub) => sub.challengeNum === challenge.number, + (sub) => + sub.challengeId === challenge.id || + Number(sub.challengeNum) === Number(challenge.number), ); if (submission && submission.forecasts) { setExistingSubmission(submission); @@ -141,7 +148,13 @@ const TournamentChallengeCard = ({ }; loadExistingSubmission(); - }, [isCompleted, participantId, challenge.number]); + }, [ + isCompleted, + participantId, + challenge.id, + challenge.number, + tournamentConfig, + ]); // Use the challenge's forecast date (historical date) const forecastDate = challenge.forecastDate; @@ -264,7 +277,12 @@ const TournamentChallengeCard = ({ try { // Submit forecasts for all horizons (scoring will be done on frontend) - await submitForecast(participantId, challenge.number, forecastEntries); + await submitForecast( + participantId, + challenge.number, + forecastEntries, + tournamentConfig, + ); setModalOpened(false); setInputMode("median"); diff --git a/app/src/components/tournament/TournamentDashboard.jsx b/app/src/components/tournament/TournamentDashboard.jsx index 84feb2a8..998dc54c 100644 --- a/app/src/components/tournament/TournamentDashboard.jsx +++ b/app/src/components/tournament/TournamentDashboard.jsx @@ -1,15 +1,16 @@ import { useState, useEffect } from "react"; -import { Container, Tabs } from "@mantine/core"; +import { Alert, Container, Tabs } from "@mantine/core"; import { IconTrophy, IconChartLine } from "@tabler/icons-react"; import { getStoredParticipantId, getStoredParticipantName, } from "../../utils/tournamentAPI"; +import { TOURNAMENT_CONFIG } from "../../config"; import TournamentRegistration from "./TournamentRegistration"; import TournamentGame from "./TournamentGame"; import TournamentLeaderboard from "./TournamentLeaderboard"; -const TournamentDashboard = () => { +const TournamentDashboard = ({ tournamentConfig = TOURNAMENT_CONFIG }) => { const [participantId, setParticipantId] = useState(null); const [participantName, setParticipantName] = useState(null); const [loading, setLoading] = useState(true); @@ -17,16 +18,14 @@ const TournamentDashboard = () => { // Load participant data on mount useEffect(() => { - const storedId = getStoredParticipantId(); - const storedName = getStoredParticipantName(); + const storedId = getStoredParticipantId(tournamentConfig); + const storedName = getStoredParticipantName(tournamentConfig); - if (storedId) { - setParticipantId(storedId); - setParticipantName(storedName); - } + setParticipantId(storedId || null); + setParticipantName(storedName || null); setLoading(false); - }, []); + }, [tournamentConfig]); // Handle successful registration const handleRegistration = (id, name) => { @@ -39,9 +38,24 @@ const TournamentDashboard = () => { setActiveTab("leaderboard"); }; + if (tournamentConfig.numChallenges === 0) { + return ( + + + Enable at least one challenge in the tournament registry. + + + ); + } + // Show registration if not registered if (!participantId && !loading) { - return ; + return ( + + ); } if (loading) { @@ -65,6 +79,7 @@ const TournamentDashboard = () => { { - + diff --git a/app/src/components/tournament/TournamentGame.jsx b/app/src/components/tournament/TournamentGame.jsx index cfe59e47..37a93eaf 100644 --- a/app/src/components/tournament/TournamentGame.jsx +++ b/app/src/components/tournament/TournamentGame.jsx @@ -51,7 +51,17 @@ const addWeeksToDate = (dateString, weeks) => { return base.toISOString().slice(0, 10); }; -const TournamentGame = ({ participantId, participantName, onAllCompleted }) => { +const getSubmissionForecasts = (submissions, challenge) => { + if (!submissions) return null; + return submissions[challenge.id] || submissions[challenge.number] || null; +}; + +const TournamentGame = ({ + tournamentConfig = TOURNAMENT_CONFIG, + participantId, + participantName, + onAllCompleted, +}) => { const [currentChallengeIndex, setCurrentChallengeIndex] = useState(0); const [completedChallenges, setCompletedChallenges] = useState(new Set()); const [submissionErrors, setSubmissionErrors] = useState({}); @@ -66,9 +76,21 @@ const TournamentGame = ({ participantId, participantName, onAllCompleted }) => { const [scenarioData, setScenarioData] = useState({}); const [loading, setLoading] = useState(true); - const challenge = TOURNAMENT_CONFIG.challenges[currentChallengeIndex]; + const challenge = tournamentConfig.challenges[currentChallengeIndex]; const allChallengesCompleted = - completedChallenges.size === TOURNAMENT_CONFIG.numChallenges; + tournamentConfig.numChallenges > 0 && + tournamentConfig.challenges.every((ch) => completedChallenges.has(ch.id)); + const enabledChallengeIds = useMemo( + () => new Set(tournamentConfig.challenges.map((ch) => ch.id)), + [tournamentConfig], + ); + const challengeIdByNumber = useMemo( + () => + new Map( + tournamentConfig.challenges.map((ch) => [Number(ch.number), ch.id]), + ), + [tournamentConfig], + ); // Load all challenge data and ground truth useEffect(() => { @@ -76,7 +98,7 @@ const TournamentGame = ({ participantId, participantName, onAllCompleted }) => { const gtData = {}; const scData = {}; - for (const ch of TOURNAMENT_CONFIG.challenges) { + for (const ch of tournamentConfig.challenges) { try { const filePath = `/processed_data/${ch.dataPath}/${ch.location}_${ch.fileSuffix}`; const response = await fetch(filePath); @@ -120,7 +142,7 @@ const TournamentGame = ({ participantId, participantName, onAllCompleted }) => { }; loadChallengeData(); - }, []); + }, [tournamentConfig]); // Load completed challenges for this participant useEffect(() => { @@ -128,12 +150,19 @@ const TournamentGame = ({ participantId, participantName, onAllCompleted }) => { if (!participantId) return; try { - const data = await getParticipant(participantId); + const data = await getParticipant(participantId, tournamentConfig); const completed = new Set(); data.submissions.forEach((sub) => { - if (sub.forecasts && sub.forecasts.length > 0) { - completed.add(sub.challengeNum - 1); // Convert to 0-indexed + const challengeId = + sub.challengeId || + challengeIdByNumber.get(Number(sub.challengeNum)); + if ( + sub.forecasts && + sub.forecasts.length > 0 && + enabledChallengeIds.has(challengeId) + ) { + completed.add(challengeId); } }); @@ -144,7 +173,12 @@ const TournamentGame = ({ participantId, participantName, onAllCompleted }) => { }; loadCompletedChallenges(); - }, [participantId]); + }, [ + participantId, + enabledChallengeIds, + challengeIdByNumber, + tournamentConfig, + ]); const latestObservationValue = useMemo(() => { if (!challenge || !scenarioData[challenge.number]) return 1000; @@ -312,7 +346,12 @@ const TournamentGame = ({ participantId, participantName, onAllCompleted }) => { try { // Submit to backend - await submitForecast(participantId, challenge.number, forecastEntries); + await submitForecast( + participantId, + challenge.number, + forecastEntries, + tournamentConfig, + ); // Calculate scores const gtData = groundTruthData[challenge.number]; @@ -338,13 +377,11 @@ const TournamentGame = ({ participantId, participantName, onAllCompleted }) => { }); // Load leaderboard to compare with other participants - const leaderboard = await getLeaderboard(); + const leaderboard = await getLeaderboard(tournamentConfig); setLeaderboardData(leaderboard); // Mark as completed - setCompletedChallenges( - (prev) => new Set([...prev, currentChallengeIndex]), - ); + setCompletedChallenges((prev) => new Set([...prev, challenge.id])); // Move to scoring view setInputMode("scoring"); @@ -395,31 +432,31 @@ const TournamentGame = ({ participantId, participantName, onAllCompleted }) => {
- {TOURNAMENT_CONFIG.name} + {tournamentConfig.name} {participantName}
Challenge {currentChallengeIndex + 1}/ - {TOURNAMENT_CONFIG.numChallenges} + {tournamentConfig.numChallenges}
{/* Progress Stepper */} - {TOURNAMENT_CONFIG.challenges.map((ch, idx) => ( + {tournamentConfig.challenges.map((ch, idx) => ( ) : undefined } - color={completedChallenges.has(idx) ? "green" : undefined} + color={completedChallenges.has(ch.id) ? "green" : undefined} /> ))} @@ -564,7 +601,10 @@ const TournamentGame = ({ participantId, participantName, onAllCompleted }) => { leaderboardData={leaderboardData} visibleRankings={visibleRankings} onNextChallenge={() => { - if (currentChallengeIndex < TOURNAMENT_CONFIG.numChallenges - 1) { + if ( + currentChallengeIndex < + tournamentConfig.challenges.length - 1 + ) { setCurrentChallengeIndex((prev) => prev + 1); } }} @@ -608,7 +648,7 @@ const ScoreDisplay = ({ // Add other participants if available if (leaderboardData) { leaderboardData.forEach((p) => { - const submission = p.submissions?.[challenge.number]; + const submission = getSubmissionForecasts(p.submissions, challenge); // Skip current participant by checking participantId if ( submission && diff --git a/app/src/components/tournament/TournamentLeaderboard.jsx b/app/src/components/tournament/TournamentLeaderboard.jsx index 992b77cf..72c53e43 100644 --- a/app/src/components/tournament/TournamentLeaderboard.jsx +++ b/app/src/components/tournament/TournamentLeaderboard.jsx @@ -25,7 +25,15 @@ const addWeeksToDate = (dateString, weeks) => { return base.toISOString().slice(0, 10); }; -const TournamentLeaderboard = ({ participantId }) => { +const getSubmissionForecasts = (submissions, challenge) => { + if (!submissions) return null; + return submissions[challenge.id] || submissions[challenge.number] || null; +}; + +const TournamentLeaderboard = ({ + tournamentConfig = TOURNAMENT_CONFIG, + participantId, +}) => { const [leaderboard, setLeaderboard] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -36,7 +44,7 @@ const TournamentLeaderboard = ({ participantId }) => { const loadGroundTruth = async () => { const gtData = {}; - for (const challenge of TOURNAMENT_CONFIG.challenges) { + for (const challenge of tournamentConfig.challenges) { try { const filePath = `/processed_data/${challenge.dataPath}/${challenge.location}_${challenge.fileSuffix}`; const response = await fetch(filePath); @@ -60,7 +68,7 @@ const TournamentLeaderboard = ({ participantId }) => { return null; }); - gtData[challenge.number] = groundTruthForHorizons; + gtData[challenge.id] = groundTruthForHorizons; } catch (error) { console.error( `Failed to load ground truth for challenge ${challenge.number}:`, @@ -73,13 +81,13 @@ const TournamentLeaderboard = ({ participantId }) => { }; loadGroundTruth(); - }, []); + }, [tournamentConfig]); // Load leaderboard and calculate scores useEffect(() => { const loadAndScoreLeaderboard = async () => { try { - const data = await getLeaderboard(); + const data = await getLeaderboard(tournamentConfig); // Calculate WIS for each participant const scoredLeaderboard = data.map((participant) => { @@ -89,46 +97,54 @@ const TournamentLeaderboard = ({ participantId }) => { let totalUnderprediction = 0; let totalOverprediction = 0; let validChallenges = 0; - - // Score each challenge - Object.entries(participant.submissions || {}).forEach( - ([challengeNum, forecasts]) => { - const challengeNumber = parseInt(challengeNum); - const groundTruth = groundTruthData[challengeNumber]; - - if (!groundTruth || forecasts.length === 0) return; - - // Convert forecasts to the format expected by scoreUserForecast - const forecastEntries = forecasts.map((f) => ({ - horizon: f.horizon, - median: f.median, - lower50: f.q25, - upper50: f.q75, - lower95: f.q025, - upper95: f.q975, - })); - - // Calculate WIS with components - const scoreResult = scoreUserForecast( - forecastEntries, - groundTruth, + const activeCompletedChallenges = tournamentConfig.challenges.reduce( + (count, challenge) => { + const forecasts = getSubmissionForecasts( + participant.submissions, + challenge, ); - if (scoreResult.wis !== null) { - challengeScores[challengeNumber] = { - wis: scoreResult.wis, - dispersion: scoreResult.dispersion, - underprediction: scoreResult.underprediction, - overprediction: scoreResult.overprediction, - }; - totalWIS += scoreResult.wis; - totalDispersion += scoreResult.dispersion; - totalUnderprediction += scoreResult.underprediction; - totalOverprediction += scoreResult.overprediction; - validChallenges++; - } + return forecasts && forecasts.length > 0 ? count + 1 : count; }, + 0, ); + // Score each active challenge + tournamentConfig.challenges.forEach((challenge) => { + const forecasts = getSubmissionForecasts( + participant.submissions, + challenge, + ); + const groundTruth = groundTruthData[challenge.id]; + + if (!groundTruth || !forecasts || forecasts.length === 0) return; + + // Convert forecasts to the format expected by scoreUserForecast + const forecastEntries = forecasts.map((f) => ({ + horizon: f.horizon, + median: f.median, + lower50: f.q25, + upper50: f.q75, + lower95: f.q025, + upper95: f.q975, + })); + + // Calculate WIS with components + const scoreResult = scoreUserForecast(forecastEntries, groundTruth); + if (scoreResult.wis !== null) { + challengeScores[challenge.id] = { + wis: scoreResult.wis, + dispersion: scoreResult.dispersion, + underprediction: scoreResult.underprediction, + overprediction: scoreResult.overprediction, + }; + totalWIS += scoreResult.wis; + totalDispersion += scoreResult.dispersion; + totalUnderprediction += scoreResult.underprediction; + totalOverprediction += scoreResult.overprediction; + validChallenges++; + } + }); + const avgWIS = validChallenges > 0 ? totalWIS / validChallenges : null; const avgDispersion = @@ -146,14 +162,17 @@ const TournamentLeaderboard = ({ participantId }) => { avgUnderprediction, avgOverprediction, validChallenges, + activeCompletedChallenges, challengeScores, }; }); // Sort participants: completed first (by avgWIS), then incomplete (by completed count) scoredLeaderboard.sort((a, b) => { - const aCompleted = a.completed === TOURNAMENT_CONFIG.numChallenges; - const bCompleted = b.completed === TOURNAMENT_CONFIG.numChallenges; + const aCompleted = + a.validChallenges === tournamentConfig.numChallenges; + const bCompleted = + b.validChallenges === tournamentConfig.numChallenges; // Both completed: sort by avgWIS (lower is better) if (aCompleted && bCompleted) { @@ -167,7 +186,7 @@ const TournamentLeaderboard = ({ participantId }) => { if (bCompleted) return 1; // Both incomplete: sort by number of challenges completed (descending) - return b.completed - a.completed; + return b.activeCompletedChallenges - a.activeCompletedChallenges; }); setLeaderboard(scoredLeaderboard); @@ -186,14 +205,14 @@ const TournamentLeaderboard = ({ participantId }) => { // Poll for updates const interval = setInterval( loadAndScoreLeaderboard, - TOURNAMENT_CONFIG.leaderboard.updateFrequency, + tournamentConfig.leaderboard.updateFrequency, ); return () => clearInterval(interval); } - }, [groundTruthData]); + }, [groundTruthData, tournamentConfig]); const getMedalEmoji = (rank) => { - return TOURNAMENT_CONFIG.ui.medals[rank] || ""; + return tournamentConfig.ui.medals[rank] || ""; }; if (loading) { @@ -250,7 +269,7 @@ const TournamentLeaderboard = ({ participantId }) => { Participant Avg WIS Total WIS - {TOURNAMENT_CONFIG.challenges.map((ch) => ( + {tournamentConfig.challenges.map((ch) => ( Ch {ch.number} @@ -310,7 +329,7 @@ const TournamentLeaderboard = ({ participantId }) => { return ( @@ -363,18 +382,15 @@ const TournamentLeaderboard = ({ participantId }) => { : "—"} - {TOURNAMENT_CONFIG.challenges.map((ch) => ( + {tournamentConfig.challenges.map((ch) => ( - {entry.challengeScores[ch.number]?.wis?.toFixed(1) || - "—"} + {entry.challengeScores[ch.id]?.wis?.toFixed(1) || "—"} ))} @@ -446,12 +462,14 @@ const TournamentLeaderboard = ({ participantId }) => { - {entry.completed}/{TOURNAMENT_CONFIG.numChallenges} + {entry.activeCompletedChallenges}/ + {tournamentConfig.numChallenges} diff --git a/app/src/components/tournament/TournamentRegistration.jsx b/app/src/components/tournament/TournamentRegistration.jsx index 931fcb0c..17d25a95 100644 --- a/app/src/components/tournament/TournamentRegistration.jsx +++ b/app/src/components/tournament/TournamentRegistration.jsx @@ -13,7 +13,10 @@ import { IconUserPlus, IconAlertCircle } from "@tabler/icons-react"; import { registerParticipant } from "../../utils/tournamentAPI"; import { TOURNAMENT_CONFIG } from "../../config"; -const TournamentRegistration = ({ onSuccess }) => { +const TournamentRegistration = ({ + tournamentConfig = TOURNAMENT_CONFIG, + onSuccess, +}) => { const [name, setName] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -30,7 +33,7 @@ const TournamentRegistration = ({ onSuccess }) => { setLoading(true); try { - const data = await registerParticipant(name); + const data = await registerParticipant(name, tournamentConfig); onSuccess(data.participantId, name); } catch (err) { setError(err.message || "Registration failed. Please try again."); @@ -52,10 +55,10 @@ const TournamentRegistration = ({ onSuccess }) => { Join the Tournament - {TOURNAMENT_CONFIG.name} + {tournamentConfig.name} - {TOURNAMENT_CONFIG.description} + {tournamentConfig.description}
@@ -106,7 +109,8 @@ const TournamentRegistration = ({ onSuccess }) => { How it works: - • Complete 3 forecasting challenges + • Complete {tournamentConfig.numChallenges} forecasting challenge + {tournamentConfig.numChallenges === 1 ? "" : "s"}
• Predict hospitalization counts for different diseases and locations diff --git a/app/src/config/index.js b/app/src/config/index.js index 57be3e2a..d104d392 100644 --- a/app/src/config/index.js +++ b/app/src/config/index.js @@ -25,6 +25,10 @@ import { FORECASTLE_CONFIG } from "./forecastle"; // Tournament settings import { TOURNAMENT_CONFIG, + TOURNAMENT_REGISTRY, + ENABLED_TOURNAMENTS, + getTournamentById, + getTournamentByPath, getChallengeById, getChallengeByNumber, areAllChallengesCompleted, @@ -39,6 +43,10 @@ export { APP_CONFIG }; export { FORECASTLE_CONFIG }; export { TOURNAMENT_CONFIG, + TOURNAMENT_REGISTRY, + ENABLED_TOURNAMENTS, + getTournamentById, + getTournamentByPath, getChallengeById, getChallengeByNumber, areAllChallengesCompleted, diff --git a/app/src/config/tournament.js b/app/src/config/tournament.js index 4ec09361..b7b9f95b 100644 --- a/app/src/config/tournament.js +++ b/app/src/config/tournament.js @@ -1,167 +1,238 @@ /** - * Tournament Configuration - * Settings for the Epidemics 10 Forecasting Tournament + * Tournament registry. + * + * To add a new tournament such as CSTE2026, add one object to + * TOURNAMENT_REGISTRY. Routes, navigation, storage keys, enabled challenge + * counts, and leaderboard API selection are derived from this file. */ -export const TOURNAMENT_CONFIG = { - // Tournament ID - id: "epidemics-10", +const parseList = (value) => { + if (!value) return []; + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +}; - // Tournament metadata - name: "Epidemics 10 Forecasting Tournament", - description: - "Compete in 3 epidemic forecasting challenges and climb the leaderboard", +const createStorageKeys = (prefix) => ({ + participantId: `${prefix}_participant_id`, + participantName: `${prefix}_participant_name`, + submissions: `${prefix}_submissions`, + lastSync: `${prefix}_last_sync`, +}); - // Google Sheets integration - // IMPORTANT: Replace with your deployed Google Apps Script Web App URL - apiUrl: - import.meta.env.VITE_TOURNAMENT_API_URL || - "https://script.google.com/macros/s/AKfycbwB7LnE8DSk9S7ACLs20j65iB-9ryCXAiih2FlMwpeWDDE4pLZ1zF3RQilfrm6_byLU7w/exec", - sheetId: "17J5KWUrVuqmqqBcVJg2A-dfVdrL4LjXTvlztCDpS0g0", - // Challenges are always active (no date restrictions as per requirements) +const DEFAULT_TOURNAMENT_SETTINGS = { challengesAlwaysActive: true, - - // Number of challenges - numChallenges: 3, - - // Challenge configuration - // Each challenge shows 1, 2, and 3 week ahead forecasts (like Forecastle) - // Using historical dates where ground truth is already available - challenges: [ - { - id: "ch-1", - number: 1, - title: "California Influenza Forecast", - description: - "Predict California flu hospitalizations for 1, 2, and 3 weeks ahead", - dataset: "flu", - datasetKey: "flusight", - dataPath: "flusight", - fileSuffix: "flu.json", - location: "CA", - displayName: "California", - target: "wk inc flu hosp", - horizons: [1, 2, 3], - forecastDate: "2023-11-11", - }, - { - id: "ch-2", - number: 2, - title: "Colorado Influenza Forecast", - description: - "Predict Colorado flu hospitalizations for 1, 2, and 3 weeks ahead", - dataset: "flu", - datasetKey: "flusight", - dataPath: "flusight", - fileSuffix: "flu.json", - location: "CO", - displayName: "Colorado", - target: "wk inc flu hosp", - horizons: [1, 2, 3], - forecastDate: "2025-01-18", - }, - { - id: "ch-3", - number: 3, - title: "North Carolina COVID-19 Forecast", - description: - "Predict North Carolina COVID hospitalizations for 1, 2, and 3 weeks ahead", - dataset: "covid", - datasetKey: "covid19", - dataPath: "covid19forecasthub", - fileSuffix: "covid19.json", - location: "NC", - displayName: "North Carolina", - target: "wk inc covid hosp", - horizons: [1, 2, 3], - forecastDate: "2025-09-13", - }, - ], - - // Scoring configuration scoring: { - method: "WIS", // Weighted Interval Score - lowerIsBetter: true, // Lower WIS is better - intervals: [50, 95], // 50% and 95% prediction intervals + method: "WIS", + lowerIsBetter: true, + intervals: [50, 95], }, - - // Leaderboard settings leaderboard: { - updateFrequency: 30000, // 30 seconds (polling interval) - showRealNames: true, // Display participant names - showScoreBreakdown: true, // Show detailed WIS breakdown - onlyShowCompleted: false, // Show all participants, not just those who completed all challenges - rankingMethod: "avgWIS", // Rank by average WIS across all challenges + updateFrequency: 30000, + showRealNames: true, + showScoreBreakdown: true, + onlyShowCompleted: false, + rankingMethod: "avgWIS", }, - - // UI settings ui: { - // Chart configuration for challenges chartHeight: 380, showIntervals: true, zoomedView: false, - - // Progress indicators showProgress: true, - progressStyle: "dots", // 'dots' or 'bar' - - // Medals for top 3 + progressStyle: "dots", medals: { 1: "🥇", 2: "🥈", 3: "🥉", }, }, - - // Storage keys for localStorage - storageKeys: { - participantId: "tournament_participant_id", - participantName: "tournament_participant_name", - submissions: "tournament_submissions", - lastSync: "tournament_last_sync", - }, - - // Feature flags features: { - allowResubmit: false, // Don't allow participants to update their forecasts - showOtherForecasts: false, // Don't show other participants' forecasts - showModelComparisons: false, // Don't show model forecasts in challenges - enableSocialSharing: true, // Enable sharing results + allowResubmit: false, + showOtherForecasts: false, + showModelComparisons: false, + enableSocialSharing: true, }, }; -/** - * Get challenge by ID - * @param {string} challengeId - Challenge ID - * @returns {Object|null} Challenge configuration or null if not found - */ -export const getChallengeById = (challengeId) => { - return TOURNAMENT_CONFIG.challenges.find((c) => c.id === challengeId) || null; +export const TOURNAMENT_REGISTRY = [ + { + id: "epidemics-10", + enabled: true, + path: "/epidemics10", + navLabel: "Epidemics10", + storageKeyPrefix: "epidemics10", + name: "Epidemics 10 Forecasting Tournament", + description: + "Compete in 3 epidemic forecasting challenges and climb the leaderboard", + apiUrl: + import.meta.env.VITE_EPIDEMICS10_TOURNAMENT_API_URL || + import.meta.env.VITE_TOURNAMENT_API_URL || + "https://script.google.com/macros/s/AKfycbwB7LnE8DSk9S7ACLs20j65iB-9ryCXAiih2FlMwpeWDDE4pLZ1zF3RQilfrm6_byLU7w/exec", + sheetId: "17J5KWUrVuqmqqBcVJg2A-dfVdrL4LjXTvlztCDpS0g0", + challenges: [ + { + id: "ch-1", + enabled: true, + number: 1, + title: "California Influenza Forecast", + description: + "Predict California flu hospitalizations for 1, 2, and 3 weeks ahead", + dataset: "flu", + datasetKey: "flusight", + dataPath: "flusight", + fileSuffix: "flu.json", + location: "CA", + displayName: "California", + target: "wk inc flu hosp", + horizons: [1, 2, 3], + forecastDate: "2023-11-11", + }, + { + id: "ch-2", + enabled: true, + number: 2, + title: "Colorado Influenza Forecast", + description: + "Predict Colorado flu hospitalizations for 1, 2, and 3 weeks ahead", + dataset: "flu", + datasetKey: "flusight", + dataPath: "flusight", + fileSuffix: "flu.json", + location: "CO", + displayName: "Colorado", + target: "wk inc flu hosp", + horizons: [1, 2, 3], + forecastDate: "2025-01-18", + }, + { + id: "ch-3", + enabled: true, + number: 3, + title: "North Carolina COVID-19 Forecast", + description: + "Predict North Carolina COVID hospitalizations for 1, 2, and 3 weeks ahead", + dataset: "covid", + datasetKey: "covid19", + dataPath: "covid19forecasthub", + fileSuffix: "covid19.json", + location: "NC", + displayName: "North Carolina", + target: "wk inc covid hosp", + horizons: [1, 2, 3], + forecastDate: "2025-09-13", + }, + ], + }, +]; + +const normalizeTournament = (tournament) => { + const envKey = `VITE_${tournament.id + .replace(/[^a-zA-Z0-9]/g, "_") + .toUpperCase()}_ENABLED_CHALLENGES`; + const configuredChallengeIds = parseList(import.meta.env[envKey]); + const enabledChallengeIds = + configuredChallengeIds.length > 0 ? new Set(configuredChallengeIds) : null; + const challenges = tournament.challenges.filter((challenge) => { + if (enabledChallengeIds) { + return enabledChallengeIds.has(challenge.id); + } + return challenge.enabled !== false; + }); + const storageKeyPrefix = tournament.storageKeyPrefix || tournament.id; + + return { + ...DEFAULT_TOURNAMENT_SETTINGS, + ...tournament, + path: tournament.path || `/${tournament.id}`, + navLabel: tournament.navLabel || tournament.name, + storageKeys: { + ...createStorageKeys(storageKeyPrefix), + ...(tournament.storageKeys || {}), + }, + challenges, + numChallenges: challenges.length, + }; }; -/** - * Get challenge by number - * @param {number} challengeNumber - Challenge number (1-5) - * @returns {Object|null} Challenge configuration or null if not found - */ -export const getChallengeByNumber = (challengeNumber) => { +export const ENABLED_TOURNAMENTS = TOURNAMENT_REGISTRY.filter( + (tournament) => tournament.enabled !== false, +).map(normalizeTournament); + +export const getTournamentById = (tournamentId) => + ENABLED_TOURNAMENTS.find((tournament) => tournament.id === tournamentId) || + null; + +export const getTournamentByPath = (pathname) => + ENABLED_TOURNAMENTS.find((tournament) => + pathname.startsWith(tournament.path), + ) || null; + +export const TOURNAMENT_CONFIG = + getTournamentById(import.meta.env.VITE_DEFAULT_TOURNAMENT_ID) || + ENABLED_TOURNAMENTS[0] || + normalizeTournament({ + id: "none", + enabled: false, + path: "/epidemics10", + navLabel: "Tournament", + name: "Tournament", + description: "", + apiUrl: "", + sheetId: "", + challenges: [], + }); + +export const getChallengeById = ( + challengeId, + tournamentConfig = TOURNAMENT_CONFIG, +) => { return ( - TOURNAMENT_CONFIG.challenges.find((c) => c.number === challengeNumber) || - null + tournamentConfig.challenges.find( + (challenge) => challenge.id === challengeId, + ) || null ); }; -/** - * Check if all challenges are completed - * @param {Array} submissions - Array of submission objects - * @returns {boolean} True if all challenges have submissions - */ -export const areAllChallengesCompleted = (submissions) => { - if (!submissions || submissions.length === 0) return false; +export const getChallengeByNumber = ( + challengeNumber, + tournamentConfig = TOURNAMENT_CONFIG, +) => { + return ( + tournamentConfig.challenges.find( + (challenge) => Number(challenge.number) === Number(challengeNumber), + ) || null + ); +}; +export const areAllChallengesCompleted = ( + submissions, + tournamentConfig = TOURNAMENT_CONFIG, +) => { + if ( + !submissions || + submissions.length === 0 || + tournamentConfig.numChallenges === 0 + ) { + return false; + } + + const challengeIdByNumber = new Map( + tournamentConfig.challenges.map((challenge) => [ + Number(challenge.number), + challenge.id, + ]), + ); const completedChallenges = new Set( - submissions.map((sub) => sub.challengeNum), + submissions + .map( + (sub) => + sub.challengeId || challengeIdByNumber.get(Number(sub.challengeNum)), + ) + .filter(Boolean), ); - return completedChallenges.size === TOURNAMENT_CONFIG.numChallenges; + return tournamentConfig.challenges.every((challenge) => + completedChallenges.has(challenge.id), + ); }; diff --git a/app/src/utils/tournamentAPI.js b/app/src/utils/tournamentAPI.js index 198e25da..08274e0f 100644 --- a/app/src/utils/tournamentAPI.js +++ b/app/src/utils/tournamentAPI.js @@ -3,20 +3,25 @@ * Handles communication with Google Sheets backend via Apps Script */ -import { TOURNAMENT_CONFIG } from "../config"; +import { TOURNAMENT_CONFIG, getChallengeByNumber } from "../config"; /** * Make a GET request to the tournament API * @param {string} action - API action * @param {Object} params - Query parameters + * @param {Object} tournamentConfig - Tournament configuration * @returns {Promise} Response data */ -const apiGet = async (action, params = {}) => { - const apiUrl = TOURNAMENT_CONFIG.apiUrl; +const apiGet = async ( + action, + params = {}, + tournamentConfig = TOURNAMENT_CONFIG, +) => { + const apiUrl = tournamentConfig.apiUrl; if (!apiUrl) { throw new Error( - "Tournament API URL not configured. Please set VITE_TOURNAMENT_API_URL in .env", + `Tournament API URL not configured for ${tournamentConfig.id}`, ); } @@ -53,14 +58,15 @@ const apiGet = async (action, params = {}) => { /** * Make a POST request to the tournament API * @param {Object} payload - Request payload + * @param {Object} tournamentConfig - Tournament configuration * @returns {Promise} Response data */ -const apiPost = async (payload) => { - const apiUrl = TOURNAMENT_CONFIG.apiUrl; +const apiPost = async (payload, tournamentConfig = TOURNAMENT_CONFIG) => { + const apiUrl = tournamentConfig.apiUrl; if (!apiUrl) { throw new Error( - "Tournament API URL not configured. Please set VITE_TOURNAMENT_API_URL in .env", + `Tournament API URL not configured for ${tournamentConfig.id}`, ); } @@ -94,25 +100,33 @@ const apiPost = async (payload) => { /** * Register a new participant or login existing participant * @param {string} name - Participant's recognizable name + * @param {Object} tournamentConfig - Tournament configuration * @returns {Promise} Participant data {participantId, message} */ -export const registerParticipant = async (name) => { +export const registerParticipant = async ( + name, + tournamentConfig = TOURNAMENT_CONFIG, +) => { if (!name) { throw new Error("Name is required"); } - const data = await apiPost({ - action: "register", - name: name.trim(), - }); + const data = await apiPost( + { + action: "register", + tournamentId: tournamentConfig.id, + name: name.trim(), + }, + tournamentConfig, + ); // Store in localStorage localStorage.setItem( - TOURNAMENT_CONFIG.storageKeys.participantId, + tournamentConfig.storageKeys.participantId, data.participantId, ); localStorage.setItem( - TOURNAMENT_CONFIG.storageKeys.participantName, + tournamentConfig.storageKeys.participantName, name.trim(), ); @@ -122,26 +136,33 @@ export const registerParticipant = async (name) => { /** * Submit a forecast for a challenge * @param {string} participantId - Participant ID - * @param {number} challengeNum - Challenge number (1-5) + * @param {number} challengeNum - Stable enabled challenge number * @param {Array|Object} forecasts - Array of forecast entries (one per horizon) or single forecast object for backward compatibility + * @param {Object} tournamentConfig - Tournament configuration * @returns {Promise} Submission data {submissionId, message} */ export const submitForecast = async ( participantId, challengeNum, forecasts, + tournamentConfig = TOURNAMENT_CONFIG, ) => { if (!participantId) { throw new Error("Participant ID is required"); } - if ( - !challengeNum || - challengeNum < 1 || - challengeNum > TOURNAMENT_CONFIG.numChallenges - ) { + const challenge = getChallengeByNumber( + Number(challengeNum), + tournamentConfig, + ); + if (!challengeNum || !challenge) { + const enabledChallengeNumbers = tournamentConfig.challenges + .map((enabledChallenge) => enabledChallenge.number) + .join(", "); throw new Error( - `Challenge number must be between 1 and ${TOURNAMENT_CONFIG.numChallenges}`, + enabledChallengeNumbers + ? `Challenge number must be one of: ${enabledChallengeNumbers}` + : "No tournament challenges are enabled", ); } @@ -173,17 +194,22 @@ export const submitForecast = async ( q975: f.q975 || f.upper95, })); - const data = await apiPost({ - action: "submitForecast", - participantId, - challengeNum, - forecasts: formattedForecasts, - // No WIS - scoring is done on frontend - }); + const data = await apiPost( + { + action: "submitForecast", + tournamentId: tournamentConfig.id, + participantId, + challengeNum, + challengeId: challenge.id, + forecasts: formattedForecasts, + // No WIS - scoring is done on frontend + }, + tournamentConfig, + ); // Update last sync time localStorage.setItem( - TOURNAMENT_CONFIG.storageKeys.lastSync, + tournamentConfig.storageKeys.lastSync, new Date().toISOString(), ); @@ -192,24 +218,37 @@ export const submitForecast = async ( /** * Get the leaderboard + * @param {Object} tournamentConfig - Tournament configuration * @returns {Promise} Leaderboard data */ -export const getLeaderboard = async () => { - const data = await apiGet("getLeaderboard"); +export const getLeaderboard = async (tournamentConfig = TOURNAMENT_CONFIG) => { + const data = await apiGet( + "getLeaderboard", + { tournamentId: tournamentConfig.id }, + tournamentConfig, + ); return data.leaderboard || []; }; /** * Get participant data including submissions * @param {string} participantId - Participant ID + * @param {Object} tournamentConfig - Tournament configuration * @returns {Promise} Participant data {participant, submissions} */ -export const getParticipant = async (participantId) => { +export const getParticipant = async ( + participantId, + tournamentConfig = TOURNAMENT_CONFIG, +) => { if (!participantId) { throw new Error("Participant ID is required"); } - const data = await apiGet("getParticipant", { participantId }); + const data = await apiGet( + "getParticipant", + { participantId, tournamentId: tournamentConfig.id }, + tournamentConfig, + ); return { participant: data.participant, @@ -219,34 +258,44 @@ export const getParticipant = async (participantId) => { /** * Get participant ID from localStorage + * @param {Object} tournamentConfig - Tournament configuration * @returns {string|null} Participant ID or null if not found */ -export const getStoredParticipantId = () => { - return localStorage.getItem(TOURNAMENT_CONFIG.storageKeys.participantId); +export const getStoredParticipantId = ( + tournamentConfig = TOURNAMENT_CONFIG, +) => { + return localStorage.getItem(tournamentConfig.storageKeys.participantId); }; /** * Get participant name from localStorage + * @param {Object} tournamentConfig - Tournament configuration * @returns {string|null} Participant name or null if not found */ -export const getStoredParticipantName = () => { - return localStorage.getItem(TOURNAMENT_CONFIG.storageKeys.participantName); +export const getStoredParticipantName = ( + tournamentConfig = TOURNAMENT_CONFIG, +) => { + return localStorage.getItem(tournamentConfig.storageKeys.participantName); }; /** * Clear participant data from localStorage (logout) + * @param {Object} tournamentConfig - Tournament configuration */ -export const clearParticipantData = () => { - localStorage.removeItem(TOURNAMENT_CONFIG.storageKeys.participantId); - localStorage.removeItem(TOURNAMENT_CONFIG.storageKeys.participantName); - localStorage.removeItem(TOURNAMENT_CONFIG.storageKeys.submissions); - localStorage.removeItem(TOURNAMENT_CONFIG.storageKeys.lastSync); +export const clearParticipantData = (tournamentConfig = TOURNAMENT_CONFIG) => { + localStorage.removeItem(tournamentConfig.storageKeys.participantId); + localStorage.removeItem(tournamentConfig.storageKeys.participantName); + localStorage.removeItem(tournamentConfig.storageKeys.submissions); + localStorage.removeItem(tournamentConfig.storageKeys.lastSync); }; /** * Check if participant is registered + * @param {Object} tournamentConfig - Tournament configuration * @returns {boolean} True if participant ID is stored */ -export const isParticipantRegistered = () => { - return !!getStoredParticipantId(); +export const isParticipantRegistered = ( + tournamentConfig = TOURNAMENT_CONFIG, +) => { + return !!getStoredParticipantId(tournamentConfig); };