mmcux commited on
Commit
8ea79fc
·
1 Parent(s): 377dd10

Collapse the ranking boards to ten rows and add a jump menu beside them

Browse files
src/app/globals.css CHANGED
@@ -126,3 +126,14 @@ body {
126
  background: #f9fafb;
127
  font-weight: 600;
128
  }
 
 
 
 
 
 
 
 
 
 
 
 
126
  background: #f9fafb;
127
  font-weight: 600;
128
  }
129
+
130
+ /*
131
+ * In-page anchor jumps (the rankings page section menu) glide rather than
132
+ * teleport, so the reader keeps their bearings. Motion is opt-out: readers who
133
+ * have asked for less of it keep the instant jump.
134
+ */
135
+ @media (prefers-reduced-motion: no-preference) {
136
+ html {
137
+ scroll-behavior: smooth;
138
+ }
139
+ }
src/app/page.tsx CHANGED
@@ -5,6 +5,7 @@ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
5
  import { Info } from 'lucide-react';
6
  import Breadcrumbs from '@/src/components/Breadcrumbs';
7
  import RankingTableElo from '@/src/components/RankingTableElo';
 
8
  import TimeSeriesChart from '@/src/components/TimeSeriesChart';
9
  import { getFilteredRankings, getRankingFilters, collectSqlEligible, ModelRanking, FilterOptions, ChallengeDefinition } from '@/src/services/modelService';
10
  import { getDefinitionRounds } from '@/src/services/definitionService';
@@ -12,6 +13,33 @@ import { getDefinitionRounds } from '@/src/services/definitionService';
12
  const DEFINITION_ID:number = parseInt(process.env.NEXT_PUBLIC_DEFINITION_ID || '225');
13
  const SERIES_ID:number = parseInt(process.env.NEXT_PUBLIC_SERIES_ID || '1373');
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  interface RankingsData {
16
  overall: ModelRanking[];
17
  byDefinition: Record<number, ModelRanking[]>;
@@ -295,11 +323,15 @@ export default function Home() {
295
  fetchAllData();
296
  }, [selectedCalculationDate, isMounted]);
297
 
298
- // `loading` covers the rankings only, and is applied to the ranking sections
299
- // below rather than to the whole page. Returning a different tree here would
300
- // unmount TimeSeriesChart every time the flag moves, throwing away the data
301
- // it had already fetched and making it start over. Nothing in the chart
302
- // depends on the rankings, so it renders on its own schedule.
 
 
 
 
303
  return (
304
  <div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
305
  <main className="max-w-7xl mx-auto">
@@ -313,192 +345,199 @@ export default function Home() {
313
  </p>
314
  </div>
315
 
316
- {/* Time Series Chart Section */}
317
- {roundLoading ? (
318
- <div className="bg-white rounded-lg shadow-md p-4 sm:p-6 lg:p-8 mb-8">
319
- <div className="flex items-center justify-center">
320
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
321
- <span className="ml-3 text-gray-600">Loading time series data...</span>
322
- </div>
323
- </div>
324
- ) : oldestActiveRound ? (
325
- <div className="mb-8">
326
- <TimeSeriesChart
327
- challengeId={oldestActiveRound.id}
328
- challengeName={oldestActiveRound.name || oldestActiveRound.round_name}
329
- challengeDescription={oldestActiveRound.description}
330
- frequency={oldestActiveRound.frequency}
331
- horizon={oldestActiveRound.horizon}
332
- seriesId={SERIES_ID}
333
- on_title_page={true}
334
- definitionId={DEFINITION_ID}
335
- />
336
- </div>
337
- ) : (
338
- <div className="bg-white rounded-lg shadow-md p-4 sm:p-6 lg:p-8 text-center text-gray-500 mb-8">
339
- <p>No active rounds available at the moment.</p>
340
- </div>
341
- )}
342
-
343
- {/* The three ranking sections share one loading state. Only this
344
- subtree swaps while the rankings load — the chart above keeps
345
- its mount and its in-flight requests. */}
346
- {loading ? (
347
- <div className="text-center py-12">
348
- <div className="text-lg text-gray-600">Loading ranking...</div>
349
- </div>
350
- ) : (
351
- <>
352
- {/* Overall Ranking (Full Table) */}
353
- <div className="mb-8">
354
- <div className="flex items-center justify-between mb-1">
355
- <h2 className="text-2xl font-semibold text-gray-900">Overall Ranking</h2>
356
-
357
- {/* Calculation Month Filter */}
358
- {/* `relative` sits here, not on the Popover: the Popover box is only as
359
- wide as its 16px icon, so a `right-0` panel anchored to it starts
360
- off the left edge of a phone screen. Anchoring to this row instead
361
- gives the panel the full card width to grow into. `group` stays on
362
- the Popover so hover still only triggers from the icon. */}
363
- <div className="relative flex items-center gap-2">
364
- <label className="text-xs text-gray-500">Period</label>
365
- <Popover className="group">
366
- {({ open }) => (
367
- <>
368
- <PopoverButton
369
- className="flex items-center justify-center w-11 h-11 -m-[14px] rounded-full text-gray-400 hover:text-gray-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
370
- aria-label="About the calculation period"
371
- >
372
- <Info className="w-4 h-4" aria-hidden="true" />
373
- </PopoverButton>
374
- <PopoverPanel
375
- static
376
- className={`absolute right-0 top-6 w-72 max-w-[calc(100vw-2rem)] p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg transition-all duration-200 z-10 ${
377
- open ? 'opacity-100 visible' : 'opacity-0 invisible group-hover:opacity-100 group-hover:visible'
378
- }`}
379
- >
380
- <div className="font-medium mb-1">Calculation Period</div>
381
- <div className="text-gray-200 space-y-1">
382
- <p>Rankings are recalculated multiple times a day as new ground truth data arrives.</p>
383
- <p>Historical standings are snapshotted once per month. Select a month to view that period&apos;s snapshot.</p>
384
- <p>&ldquo;Recent&rdquo; shows today&apos;s current ranking.</p>
385
- </div>
386
- </PopoverPanel>
387
- </>
388
- )}
389
- </Popover>
390
- <select
391
- value={selectedCalculationDate}
392
- onChange={(e) => setSelectedCalculationDate(e.target.value)}
393
- className="px-3 py-1 text-base sm: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 cursor-pointer"
394
- >
395
- {monthOptions.map((option) => (
396
- <option key={option.value} value={option.value}>
397
- {option.label}
398
- </option>
399
- ))}
400
- </select>
401
- </div>
402
- </div>
403
- <p className="text-sm text-gray-600 mb-4">
404
- Aggregated scores across all challenge definitions and time series. ELO: higher is
405
- better. MASE and SQL: lower is better. SQL is shown only for models that submit
406
- quantile forecasts. Updated multiple times a day.
407
- </p>
408
- <RankingTableElo
409
- rankings={rankingsData.overall}
410
- sqlEligibleModelIds={rankingsData.sqlEligible.overall}
411
- />
412
- </div>
413
 
414
- {/* Rankings by Challenge Definition */}
415
- <div className="mb-8">
416
- <div className="flex flex-wrap items-center justify-between gap-3 mb-2">
417
- <h2 className="text-2xl font-semibold text-gray-900">Rankings by Challenge</h2>
418
- <select
419
- value={effectiveDefinitionId ?? ''}
420
- onChange={(e) => setSelectedDefinitionId(Number(e.target.value))}
421
- disabled={filterOptions.definitions.length === 0}
422
- className="px-3 py-2 text-base sm:text-sm bg-white border border-gray-200 rounded-md text-gray-700 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 cursor-pointer max-w-full sm:max-w-sm disabled:opacity-50"
423
- >
424
- {filterOptions.definitions.map((def) => (
425
- <option key={def.id} value={def.id}>
426
- {def.name}
427
- </option>
428
- ))}
429
- </select>
430
- </div>
431
- <p className="text-sm text-gray-600 mb-4">
432
- Rankings evaluated for the selected challenge definition.
433
- </p>
434
- {selectedDefinition ? (
435
- <RankingTableElo
436
- key={selectedDefinition.id}
437
- rankings={rankingsData.byDefinition[selectedDefinition.id] || []}
438
- sqlEligibleModelIds={rankingsData.sqlEligible.byDefinition[selectedDefinition.id]}
439
- limit={10}
440
- title={selectedDefinition.name}
441
- definitionId={selectedDefinition.id}
442
- />
443
- ) : (
444
- <p className="text-sm text-gray-500">No challenge definitions available.</p>
445
- )}
446
- </div>
447
 
448
- {/* Rankings by Frequency/Horizon */}
449
- <div className="mb-8">
450
- <div className="flex flex-wrap items-center justify-between gap-3 mb-2">
451
- <h2 className="text-2xl font-semibold text-gray-900">Rankings by Frequency & Horizon Combinations</h2>
452
- <div className="flex flex-wrap items-center gap-3">
453
- <label className="flex items-center gap-1.5 text-sm text-gray-600">
454
- <span>Frequency</span>
455
- <select
456
- value={effectiveFrequency ?? ''}
457
- onChange={(e) => setSelectedFrequency(e.target.value)}
458
- disabled={frequencyOptions.length === 0}
459
- className="px-3 py-2 text-base sm:text-sm bg-white border border-gray-200 rounded-md text-gray-700 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 cursor-pointer disabled:opacity-50"
460
- >
461
- {frequencyOptions.map((f) => (
462
- <option key={f} value={f}>
463
- {formatFrequency(f)}
464
- </option>
465
- ))}
466
- </select>
467
- </label>
468
- <label className="flex items-center gap-1.5 text-sm text-gray-600">
469
- <span>Horizon</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  <select
471
- value={effectiveHorizon ?? ''}
472
- onChange={(e) => setSelectedHorizon(e.target.value)}
473
- disabled={horizonOptions.length === 0}
474
- className="px-3 py-2 text-base sm:text-sm bg-white border border-gray-200 rounded-md text-gray-700 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 cursor-pointer disabled:opacity-50"
475
  >
476
- {horizonOptions.map((h) => (
477
- <option key={h} value={h}>
478
- {h}
479
  </option>
480
  ))}
481
  </select>
482
- </label>
483
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  </div>
485
- <p className="text-sm text-gray-600 mb-4">
486
- Rankings for the selected forecast frequency / horizon combination.
487
- </p>
488
- {selectedFh ? (
489
- <RankingTableElo
490
- key={selectedFh}
491
- rankings={rankingsData.byFrequencyHorizon[selectedFh] || []}
492
- sqlEligibleModelIds={rankingsData.sqlEligible.byFrequencyHorizon[selectedFh]}
493
- limit={10}
494
- title={formatFrequencyHorizon(selectedFh)}
495
- />
496
- ) : (
497
- <p className="text-sm text-gray-500">No frequency / horizon combinations available.</p>
498
- )}
499
  </div>
500
- </>
501
- )}
502
  </main>
503
  </div>
504
  );
 
5
  import { Info } from 'lucide-react';
6
  import Breadcrumbs from '@/src/components/Breadcrumbs';
7
  import RankingTableElo from '@/src/components/RankingTableElo';
8
+ import SectionNav, { NavSection } from '@/src/components/SectionNav';
9
  import TimeSeriesChart from '@/src/components/TimeSeriesChart';
10
  import { getFilteredRankings, getRankingFilters, collectSqlEligible, ModelRanking, FilterOptions, ChallengeDefinition } from '@/src/services/modelService';
11
  import { getDefinitionRounds } from '@/src/services/definitionService';
 
13
  const DEFINITION_ID:number = parseInt(process.env.NEXT_PUBLIC_DEFINITION_ID || '225');
14
  const SERIES_ID:number = parseInt(process.env.NEXT_PUBLIC_SERIES_ID || '1373');
15
 
16
+ /**
17
+ * Jump-menu entries, in page order — the `id` of each has to match the
18
+ * corresponding `<section>` below. Module scope, not an inline literal:
19
+ * SectionNav re-subscribes its observer whenever this array's identity changes.
20
+ */
21
+ const PAGE_SECTIONS: NavSection[] = [
22
+ { id: 'live-forecast', label: 'Live Forecast' },
23
+ { id: 'overall-ranking', label: 'Overall Ranking' },
24
+ { id: 'rankings-by-challenge', label: 'By Challenge' },
25
+ { id: 'rankings-by-frequency-horizon', label: 'By Frequency & Horizon' },
26
+ ];
27
+
28
+ /**
29
+ * Rows each board shows before the reader expands it. The three sections used to
30
+ * run to full length, which pushed the two lower ones out of sight entirely.
31
+ */
32
+ const COLLAPSED_ROWS = 10;
33
+
34
+ /** Stands in for a board while its rankings are in flight. */
35
+ function RankingPlaceholder() {
36
+ return (
37
+ <div className="bg-white rounded-lg shadow-md p-8 text-center text-gray-500">
38
+ Loading ranking...
39
+ </div>
40
+ );
41
+ }
42
+
43
  interface RankingsData {
44
  overall: ModelRanking[];
45
  byDefinition: Record<number, ModelRanking[]>;
 
323
  fetchAllData();
324
  }, [selectedCalculationDate, isMounted]);
325
 
326
+ // `loading` covers the rankings only, and is applied to the boards inside the
327
+ // ranking sections rather than to the whole page. Returning a different tree
328
+ // here would unmount TimeSeriesChart every time the flag moves, throwing away
329
+ // the data it had already fetched and making it start over. Nothing in the
330
+ // chart depends on the rankings, so it renders on its own schedule.
331
+ //
332
+ // The section wrappers are outside the flag as well, so their ids exist from
333
+ // the first paint — SectionNav resolves its targets on mount and would find
334
+ // nothing to observe if the sections appeared only once the boards had loaded.
335
  return (
336
  <div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
337
  <main className="max-w-7xl mx-auto">
 
345
  </p>
346
  </div>
347
 
348
+ {/* The jump menu sits beside the content, not above it, so the boards
349
+ further down the page are visible as destinations from the top. */}
350
+ <div className="lg:grid lg:grid-cols-[13rem_minmax(0,1fr)] lg:gap-x-8 lg:items-start">
351
+ <SectionNav sections={PAGE_SECTIONS} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
+ <div className="min-w-0 lg:col-start-2 lg:row-start-1">
354
+ {/* Time Series Chart Section */}
355
+ <section id="live-forecast" className="mb-8 scroll-mt-20 lg:scroll-mt-8">
356
+ {roundLoading ? (
357
+ <div className="bg-white rounded-lg shadow-md p-4 sm:p-6 lg:p-8">
358
+ <div className="flex items-center justify-center">
359
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
360
+ <span className="ml-3 text-gray-600">Loading time series data...</span>
361
+ </div>
362
+ </div>
363
+ ) : oldestActiveRound ? (
364
+ <TimeSeriesChart
365
+ challengeId={oldestActiveRound.id}
366
+ challengeName={oldestActiveRound.name || oldestActiveRound.round_name}
367
+ challengeDescription={oldestActiveRound.description}
368
+ frequency={oldestActiveRound.frequency}
369
+ horizon={oldestActiveRound.horizon}
370
+ seriesId={SERIES_ID}
371
+ on_title_page={true}
372
+ definitionId={DEFINITION_ID}
373
+ />
374
+ ) : (
375
+ <div className="bg-white rounded-lg shadow-md p-4 sm:p-6 lg:p-8 text-center text-gray-500">
376
+ <p>No active rounds available at the moment.</p>
377
+ </div>
378
+ )}
379
+ </section>
 
 
 
 
 
 
380
 
381
+ {/* Overall Ranking */}
382
+ <section id="overall-ranking" className="mb-8 scroll-mt-20 lg:scroll-mt-8">
383
+ <div className="flex items-center justify-between mb-1">
384
+ <h2 className="text-2xl font-semibold text-gray-900">Overall Ranking</h2>
385
+
386
+ {/* Calculation Month Filter */}
387
+ {/* `relative` sits here, not on the Popover: the Popover box is only as
388
+ wide as its 16px icon, so a `right-0` panel anchored to it starts
389
+ off the left edge of a phone screen. Anchoring to this row instead
390
+ gives the panel the full card width to grow into. `group` stays on
391
+ the Popover so hover still only triggers from the icon. */}
392
+ <div className="relative flex items-center gap-2">
393
+ <label className="text-xs text-gray-500">Period</label>
394
+ <Popover className="group">
395
+ {({ open }) => (
396
+ <>
397
+ <PopoverButton
398
+ className="flex items-center justify-center w-11 h-11 -m-[14px] rounded-full text-gray-400 hover:text-gray-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
399
+ aria-label="About the calculation period"
400
+ >
401
+ <Info className="w-4 h-4" aria-hidden="true" />
402
+ </PopoverButton>
403
+ <PopoverPanel
404
+ static
405
+ className={`absolute right-0 top-6 w-72 max-w-[calc(100vw-2rem)] p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg transition-all duration-200 z-10 ${
406
+ open ? 'opacity-100 visible' : 'opacity-0 invisible group-hover:opacity-100 group-hover:visible'
407
+ }`}
408
+ >
409
+ <div className="font-medium mb-1">Calculation Period</div>
410
+ <div className="text-gray-200 space-y-1">
411
+ <p>Rankings are recalculated multiple times a day as new ground truth data arrives.</p>
412
+ <p>Historical standings are snapshotted once per month. Select a month to view that period&apos;s snapshot.</p>
413
+ <p>&ldquo;Recent&rdquo; shows today&apos;s current ranking.</p>
414
+ </div>
415
+ </PopoverPanel>
416
+ </>
417
+ )}
418
+ </Popover>
419
+ <select
420
+ value={selectedCalculationDate}
421
+ onChange={(e) => setSelectedCalculationDate(e.target.value)}
422
+ disabled={monthOptions.length === 0}
423
+ className="px-3 py-1 text-base sm: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 cursor-pointer disabled:opacity-50"
424
+ >
425
+ {monthOptions.map((option) => (
426
+ <option key={option.value} value={option.value}>
427
+ {option.label}
428
+ </option>
429
+ ))}
430
+ </select>
431
+ </div>
432
+ </div>
433
+ <p className="text-sm text-gray-600 mb-4">
434
+ Aggregated scores across all challenge definitions and time series. ELO: higher is
435
+ better. MASE and SQL: lower is better. SQL is shown only for models that submit
436
+ quantile forecasts. Updated multiple times a day.
437
+ </p>
438
+ {loading ? (
439
+ <RankingPlaceholder />
440
+ ) : (
441
+ <RankingTableElo
442
+ rankings={rankingsData.overall}
443
+ sqlEligibleModelIds={rankingsData.sqlEligible.overall}
444
+ limit={COLLAPSED_ROWS}
445
+ />
446
+ )}
447
+ </section>
448
+
449
+ {/* Rankings by Challenge Definition */}
450
+ <section id="rankings-by-challenge" className="mb-8 scroll-mt-20 lg:scroll-mt-8">
451
+ <div className="flex flex-wrap items-center justify-between gap-3 mb-2">
452
+ <h2 className="text-2xl font-semibold text-gray-900">Rankings by Challenge</h2>
453
  <select
454
+ value={effectiveDefinitionId ?? ''}
455
+ onChange={(e) => setSelectedDefinitionId(Number(e.target.value))}
456
+ disabled={filterOptions.definitions.length === 0}
457
+ className="px-3 py-2 text-base sm:text-sm bg-white border border-gray-200 rounded-md text-gray-700 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 cursor-pointer max-w-full sm:max-w-sm disabled:opacity-50"
458
  >
459
+ {filterOptions.definitions.map((def) => (
460
+ <option key={def.id} value={def.id}>
461
+ {def.name}
462
  </option>
463
  ))}
464
  </select>
465
+ </div>
466
+ <p className="text-sm text-gray-600 mb-4">
467
+ Rankings evaluated for the selected challenge definition.
468
+ </p>
469
+ {loading ? (
470
+ <RankingPlaceholder />
471
+ ) : selectedDefinition ? (
472
+ <RankingTableElo
473
+ key={selectedDefinition.id}
474
+ rankings={rankingsData.byDefinition[selectedDefinition.id] || []}
475
+ sqlEligibleModelIds={rankingsData.sqlEligible.byDefinition[selectedDefinition.id]}
476
+ limit={COLLAPSED_ROWS}
477
+ title={selectedDefinition.name}
478
+ definitionId={selectedDefinition.id}
479
+ />
480
+ ) : (
481
+ <p className="text-sm text-gray-500">No challenge definitions available.</p>
482
+ )}
483
+ </section>
484
+
485
+ {/* Rankings by Frequency/Horizon */}
486
+ <section id="rankings-by-frequency-horizon" className="mb-8 scroll-mt-20 lg:scroll-mt-8">
487
+ <div className="flex flex-wrap items-center justify-between gap-3 mb-2">
488
+ <h2 className="text-2xl font-semibold text-gray-900">Rankings by Frequency &amp; Horizon Combinations</h2>
489
+ <div className="flex flex-wrap items-center gap-3">
490
+ <label className="flex items-center gap-1.5 text-sm text-gray-600">
491
+ <span>Frequency</span>
492
+ <select
493
+ value={effectiveFrequency ?? ''}
494
+ onChange={(e) => setSelectedFrequency(e.target.value)}
495
+ disabled={frequencyOptions.length === 0}
496
+ className="px-3 py-2 text-base sm:text-sm bg-white border border-gray-200 rounded-md text-gray-700 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 cursor-pointer disabled:opacity-50"
497
+ >
498
+ {frequencyOptions.map((f) => (
499
+ <option key={f} value={f}>
500
+ {formatFrequency(f)}
501
+ </option>
502
+ ))}
503
+ </select>
504
+ </label>
505
+ <label className="flex items-center gap-1.5 text-sm text-gray-600">
506
+ <span>Horizon</span>
507
+ <select
508
+ value={effectiveHorizon ?? ''}
509
+ onChange={(e) => setSelectedHorizon(e.target.value)}
510
+ disabled={horizonOptions.length === 0}
511
+ className="px-3 py-2 text-base sm:text-sm bg-white border border-gray-200 rounded-md text-gray-700 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 cursor-pointer disabled:opacity-50"
512
+ >
513
+ {horizonOptions.map((h) => (
514
+ <option key={h} value={h}>
515
+ {h}
516
+ </option>
517
+ ))}
518
+ </select>
519
+ </label>
520
+ </div>
521
+ </div>
522
+ <p className="text-sm text-gray-600 mb-4">
523
+ Rankings for the selected forecast frequency / horizon combination.
524
+ </p>
525
+ {loading ? (
526
+ <RankingPlaceholder />
527
+ ) : selectedFh ? (
528
+ <RankingTableElo
529
+ key={selectedFh}
530
+ rankings={rankingsData.byFrequencyHorizon[selectedFh] || []}
531
+ sqlEligibleModelIds={rankingsData.sqlEligible.byFrequencyHorizon[selectedFh]}
532
+ limit={COLLAPSED_ROWS}
533
+ title={formatFrequencyHorizon(selectedFh)}
534
+ />
535
+ ) : (
536
+ <p className="text-sm text-gray-500">No frequency / horizon combinations available.</p>
537
+ )}
538
+ </section>
539
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  </div>
 
 
541
  </main>
542
  </div>
543
  );
src/components/RankingTableElo.tsx CHANGED
@@ -1,6 +1,6 @@
1
  'use client';
2
 
3
- import { useMemo } from 'react';
4
  import { useRouter } from 'next/navigation';
5
  import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
6
  import {
@@ -12,13 +12,18 @@ import {
12
  flexRender,
13
  SortDirection,
14
  } from '@tanstack/react-table';
15
- import { Info } from 'lucide-react';
16
  import { ModelRanking } from '@/src/services/modelService';
17
 
18
  interface RankingTableEloProps {
19
  rankings: ModelRanking[];
20
  compact?: boolean;
21
  title?: string;
 
 
 
 
 
22
  limit?: number;
23
  definitionId?: number;
24
  /**
@@ -106,6 +111,13 @@ function HeaderWithInfo({
106
  );
107
  }
108
 
 
 
 
 
 
 
 
109
  /** Tooltip body for the SQL column. Kept next to the header that uses it. */
110
  function SqlExplanation() {
111
  return (
@@ -127,18 +139,17 @@ export default function RankingTableElo({
127
  sqlEligibleModelIds,
128
  }: RankingTableEloProps) {
129
  const router = useRouter();
 
130
 
131
  const handleRowClick = (modelId: string, modelName: string) => {
132
  router.push(`/models/${modelId}`);
133
  };
134
 
135
- // Apply limit if specified. Memoize so the `data` reference passed to
136
- // useReactTable is stable across renders — an unstable data ref each render
137
- // makes react-table's auto-reset loop infinitely (freezing the page).
138
- const displayedRankings = useMemo(
139
- () => (limit ? rankings.slice(0, limit) : rankings),
140
- [rankings, limit]
141
- );
142
 
143
  const fullColumns = useMemo<ColumnDef<ModelRanking>[]>(
144
  () => [
@@ -381,7 +392,7 @@ export default function RankingTableElo({
381
  const columns = compact ? compactColumns : fullColumns;
382
 
383
  const table = useReactTable({
384
- data: displayedRankings ?? [],
385
  columns,
386
  getCoreRowModel: getCoreRowModel(),
387
  getSortedRowModel: getSortedRowModel(),
@@ -396,6 +407,10 @@ export default function RankingTableElo({
396
  },
397
  });
398
 
 
 
 
 
399
  const getSortIcon = (isSorted: false | SortDirection) => {
400
  if (!isSorted) {
401
  return <span className="ml-1 text-gray-400">↕</span>;
@@ -423,7 +438,7 @@ export default function RankingTableElo({
423
  horizontal room, which pushes the ELO score (the point of the page)
424
  off-screen on a phone. */}
425
  <div className="md:hidden p-3 space-y-2">
426
- {table.getRowModel().rows.map((row) => {
427
  const model = row.original;
428
  const eloUpperDiff = model.elo_ci_upper - model.elo_rating_median;
429
  const eloLowerDiff = model.elo_rating_median - model.elo_ci_lower;
@@ -528,7 +543,7 @@ export default function RankingTableElo({
528
  ))}
529
  </thead>
530
  <tbody className="bg-white divide-y divide-gray-200">
531
- {table.getRowModel().rows.map((row) => (
532
  <tr
533
  key={row.id}
534
  className="hover:bg-gray-50 cursor-pointer transition-colors"
@@ -548,11 +563,30 @@ export default function RankingTableElo({
548
  </table>
549
  </div>
550
 
551
- {table.getRowModel().rows.length === 0 && (
552
  <div className="text-center py-8 text-gray-500">
553
  No rankings found.
554
  </div>
555
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  </div>
557
  );
558
  }
 
1
  'use client';
2
 
3
+ import { useMemo, useState } from 'react';
4
  import { useRouter } from 'next/navigation';
5
  import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
6
  import {
 
12
  flexRender,
13
  SortDirection,
14
  } from '@tanstack/react-table';
15
+ import { ChevronDown, Info } from 'lucide-react';
16
  import { ModelRanking } from '@/src/services/modelService';
17
 
18
  interface RankingTableEloProps {
19
  rankings: ModelRanking[];
20
  compact?: boolean;
21
  title?: string;
22
+ /**
23
+ * How many rows to show while collapsed. The board still holds every model —
24
+ * sorting and the column filters act on all of them, and a footer control
25
+ * expands to the full list. Omit to always show everything.
26
+ */
27
  limit?: number;
28
  definitionId?: number;
29
  /**
 
111
  );
112
  }
113
 
114
+ /**
115
+ * Stand-in for an absent board. Callers routinely write `rankings={map[id] || []}`,
116
+ * which mints a new empty array every render; funnelling those through one
117
+ * constant keeps the `data` reference react-table sees stable. See the memo below.
118
+ */
119
+ const NO_RANKINGS: ModelRanking[] = [];
120
+
121
  /** Tooltip body for the SQL column. Kept next to the header that uses it. */
122
  function SqlExplanation() {
123
  return (
 
139
  sqlEligibleModelIds,
140
  }: RankingTableEloProps) {
141
  const router = useRouter();
142
+ const [expanded, setExpanded] = useState(false);
143
 
144
  const handleRowClick = (modelId: string, modelName: string) => {
145
  router.push(`/models/${modelId}`);
146
  };
147
 
148
+ // The whole board goes to react-table; `limit` cuts the *rendered* rows further
149
+ // down, after sorting and filtering, so both act on every model rather than on
150
+ // the first ten. Memoized so the `data` reference is stable across renders — an
151
+ // unstable one makes react-table's auto-reset loop infinitely (freezing the page).
152
+ const data = useMemo(() => (rankings?.length ? rankings : NO_RANKINGS), [rankings]);
 
 
153
 
154
  const fullColumns = useMemo<ColumnDef<ModelRanking>[]>(
155
  () => [
 
392
  const columns = compact ? compactColumns : fullColumns;
393
 
394
  const table = useReactTable({
395
+ data,
396
  columns,
397
  getCoreRowModel: getCoreRowModel(),
398
  getSortedRowModel: getSortedRowModel(),
 
407
  },
408
  });
409
 
410
+ const allRows = table.getRowModel().rows;
411
+ const collapsible = limit !== undefined && allRows.length > limit;
412
+ const rows = collapsible && !expanded ? allRows.slice(0, limit) : allRows;
413
+
414
  const getSortIcon = (isSorted: false | SortDirection) => {
415
  if (!isSorted) {
416
  return <span className="ml-1 text-gray-400">↕</span>;
 
438
  horizontal room, which pushes the ELO score (the point of the page)
439
  off-screen on a phone. */}
440
  <div className="md:hidden p-3 space-y-2">
441
+ {rows.map((row) => {
442
  const model = row.original;
443
  const eloUpperDiff = model.elo_ci_upper - model.elo_rating_median;
444
  const eloLowerDiff = model.elo_rating_median - model.elo_ci_lower;
 
543
  ))}
544
  </thead>
545
  <tbody className="bg-white divide-y divide-gray-200">
546
+ {rows.map((row) => (
547
  <tr
548
  key={row.id}
549
  className="hover:bg-gray-50 cursor-pointer transition-colors"
 
563
  </table>
564
  </div>
565
 
566
+ {allRows.length === 0 && (
567
  <div className="text-center py-8 text-gray-500">
568
  No rankings found.
569
  </div>
570
  )}
571
+
572
+ {collapsible && (
573
+ <div className="border-t border-gray-200 bg-gray-50">
574
+ <button
575
+ type="button"
576
+ onClick={() => setExpanded((wasExpanded) => !wasExpanded)}
577
+ aria-expanded={expanded}
578
+ className="flex w-full min-h-[44px] items-center justify-center gap-1.5 px-6 text-sm font-medium text-blue-600 transition-colors hover:bg-gray-100 hover:text-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-400"
579
+ >
580
+ <span>
581
+ {expanded ? `Show top ${limit}` : `Show all ${allRows.length} models`}
582
+ </span>
583
+ <ChevronDown
584
+ className={`w-4 h-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
585
+ aria-hidden="true"
586
+ />
587
+ </button>
588
+ </div>
589
+ )}
590
  </div>
591
  );
592
  }
src/components/SectionNav.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ export interface NavSection {
6
+ /** `id` of the element on the page this entry jumps to. */
7
+ id: string;
8
+ label: string;
9
+ }
10
+
11
+ interface SectionNavProps {
12
+ /**
13
+ * The sections, in page order. Pass a value with a stable identity (a module
14
+ * constant, or memoized) — a fresh array each render re-subscribes the
15
+ * observer on every render.
16
+ */
17
+ sections: NavSection[];
18
+ }
19
+
20
+ /**
21
+ * In-page jump menu.
22
+ *
23
+ * The rankings page stacks three boards below a full-height chart, far enough
24
+ * down that readers were not finding the per-challenge and
25
+ * per-frequency/horizon ones at all. This lists them all above the fold.
26
+ *
27
+ * One component, two layouts: a rail in the left gutter from `lg` up, and a
28
+ * strip of chips across the top below it, where there is no gutter to put a
29
+ * rail in. Both stick while the reader scrolls the sections past them.
30
+ */
31
+ export default function SectionNav({ sections }: SectionNavProps) {
32
+ const [activeId, setActiveId] = useState<string>(sections[0]?.id ?? '');
33
+
34
+ useEffect(() => {
35
+ const elements = sections
36
+ .map((section) => document.getElementById(section.id))
37
+ .filter((element): element is HTMLElement => element !== null);
38
+ if (elements.length === 0) return;
39
+
40
+ // Ids currently in the band, kept across callbacks: an IntersectionObserver
41
+ // reports only what *changed*, so the full picture has to be accumulated.
42
+ const inBand = new Set<string>();
43
+
44
+ const observer = new IntersectionObserver(
45
+ (entries) => {
46
+ entries.forEach((entry) => {
47
+ if (entry.isIntersecting) inBand.add(entry.target.id);
48
+ else inBand.delete(entry.target.id);
49
+ });
50
+
51
+ // Later sections win: scrolling down, the one that just arrived at the
52
+ // top of the screen is the one the reader has moved to.
53
+ for (let i = sections.length - 1; i >= 0; i--) {
54
+ if (inBand.has(sections[i].id)) {
55
+ setActiveId(sections[i].id);
56
+ return;
57
+ }
58
+ }
59
+ },
60
+ // Squeeze the root to a band across the top fifth of the viewport, so
61
+ // "in view" means "at the top of the screen". Against the whole viewport
62
+ // a board as tall as these would stay active long after the next one had
63
+ // scrolled into place.
64
+ { rootMargin: '0px 0px -80% 0px' }
65
+ );
66
+
67
+ elements.forEach((element) => observer.observe(element));
68
+ return () => observer.disconnect();
69
+ }, [sections]);
70
+
71
+ return (
72
+ <nav
73
+ aria-label="Sections on this page"
74
+ className="sticky top-0 z-20 -mx-4 mb-6 border-b border-gray-200 bg-gray-50/95 px-4 py-2 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-0 lg:mb-0 lg:self-start lg:top-8 lg:border-0 lg:bg-transparent lg:px-0 lg:py-0 lg:backdrop-blur-none"
75
+ >
76
+ <p className="hidden lg:block pl-3 pb-2 text-xs font-semibold uppercase tracking-wider text-gray-400">
77
+ On this page
78
+ </p>
79
+ <ul className="flex gap-2 overflow-x-auto lg:flex-col lg:gap-0.5 lg:overflow-visible">
80
+ {sections.map((section) => {
81
+ const isActive = section.id === activeId;
82
+ return (
83
+ <li key={section.id} className="shrink-0 lg:shrink">
84
+ <a
85
+ href={`#${section.id}`}
86
+ aria-current={isActive ? 'true' : undefined}
87
+ className={`flex min-h-[44px] items-center whitespace-nowrap rounded-md px-3 text-sm transition-colors lg:min-h-0 lg:whitespace-normal lg:rounded-none lg:border-l-2 lg:py-2 ${
88
+ isActive
89
+ ? 'bg-blue-50 font-medium text-blue-700 lg:bg-transparent lg:border-blue-500'
90
+ : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 lg:border-transparent lg:hover:bg-transparent lg:hover:border-gray-300'
91
+ }`}
92
+ >
93
+ {section.label}
94
+ </a>
95
+ </li>
96
+ );
97
+ })}
98
+ </ul>
99
+ </nav>
100
+ );
101
+ }