Compare commits

...

11 Commits

Author SHA1 Message Date
49026fbab2 migrate to vite
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 4m15s
2026-03-06 20:46:40 +01:00
8df9c8eaee remove defaultAppData showing
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 3m28s
2026-03-05 21:37:00 +01:00
df8ebbfd47 move fetch to useEffect
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 6m25s
2026-03-04 22:40:33 +01:00
b0b8968fb2 use client for fetch data
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 5m51s
2026-03-04 22:23:49 +01:00
5a116eabe7 update request model rewiring
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 3m46s
2026-03-01 16:45:30 +01:00
3004545400 fix FL points defaulting to 1
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 4m3s
2026-03-01 15:07:45 +01:00
8b3d8bc817 Configure env variables for Gitea Actions build step
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 3m26s
Add secrets to build step in deploy.yaml:
- NEXT_PUBLIC_API_BASE
- SERVER_ID
- ACCESS_TOKEN

These are baked into static export during build, not needed by Cloudflare.
2026-03-01 14:50:45 +01:00
fb339c57de Use non-public env variables for sensitive credentials
- NEXT_PUBLIC_API_BASE: Keep public (just the endpoint URL)
- SERVER_ID: Remove NEXT_PUBLIC_ prefix (private identifier)
- ACCESS_TOKEN: Remove NEXT_PUBLIC_ prefix (sensitive credential)

Since fetching is server-side, credentials never reach the browser.
Only API_BASE needs to be public if accessed from client in future.
2026-03-01 14:50:25 +01:00
2da1b150ed Move leaderboard fetching to server-side
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 4m3s
- Create src/lib/api.ts with fetchLeaderboard function for server use
- Update page.tsx to async component that fetches data at build/request time
- Pass fetched data to ClientPage as initialAppData prop
- Remove client-side useEffect and state management from ClientPage
- Reduce client bundle size (4.07kb → 2.99kb)
- Improve security by keeping API credentials server-only
2026-03-01 14:28:31 +01:00
a096075d8c Extract client logic to separate ClientPage component
- Create ClientPage.tsx with 'use client' and all stateful logic
- Simplify page.tsx to just import and render ClientPage
- Allows easy switching between client/server rendering strategies
- Maintains same functionality with cleaner separation of concerns
2026-03-01 14:26:15 +01:00
6c9a5ea7a6 Port to Next.js with static export for Cloudflare Pages
- Create Next.js app with TypeScript, Tailwind, and Postcss
- Extract API logic, types, and utilities to lib/
- Build React components for Race Results, Driver Standings, Points Key, and D3 Chart
- Use environment variables for API credentials (NEXT_PUBLIC_*)
- Static export (output: 'export') for Cloudflare Pages deployment
- D3 chart rendered client-side with dynamic import
- Update deploy workflow to build and deploy ./out directory
- Remove original public/index.html
2026-03-01 14:21:02 +01:00
24 changed files with 5885 additions and 645 deletions

3
.env.local.example Executable file
View File

@@ -0,0 +1,3 @@
VITE_API_BASE=https://acc-api.jurmanovic.com/v1/api
VITE_SERVER_ID=your-server-id-here
VITE_ACCESS_TOKEN=your-access-token-here

13
.gitea/workflows/deploy.yaml Normal file → Executable file
View File

@@ -12,10 +12,21 @@ jobs:
uses: https://github.com/actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
VITE_API_BASE: ${{ secrets.API_BASE }}
VITE_SERVER_ID: ${{ secrets.SERVER_ID }}
VITE_ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- name: Publish to Cloudflare
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./public --project-name=grid-gladiators-leaderboard
command: pages deploy ./dist --project-name=grid-gladiators-leaderboard

16
.gitignore vendored Executable file
View File

@@ -0,0 +1,16 @@
# Dependencies
node_modules/
npm-debug.log
yarn-error.log
# Build output
dist/
.next/
out/
# Environment
.env.local
.env.*.local
# OS
.DS_Store

24
eslint.config.js Normal file
View File

@@ -0,0 +1,24 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
export default tseslint.config(
{ ignores: ['dist', 'node_modules'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
);

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ACC Championship Leaderboard</title>
<meta name="description" content="Grid Gladiators League Leaderboard - Race results and driver standings" />
<meta property="og:title" content="ACC Championship Leaderboard" />
<meta property="og:description" content="Grid Gladiators League Leaderboard - Race results and driver standings" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4760
package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

33
package.json Executable file
View File

@@ -0,0 +1,33 @@
{
"name": "grid-gladiators-results",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src/"
},
"dependencies": {
"react": "^19",
"react-dom": "^19",
"d3": "^7"
},
"devDependencies": {
"@eslint/js": "^9",
"@types/d3": "^7",
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitejs/plugin-react": "^4",
"autoprefixer": "^10",
"eslint": "^9",
"eslint-plugin-react-hooks": "^5",
"eslint-plugin-react-refresh": "^0.4",
"postcss": "^8",
"tailwindcss": "^3",
"typescript": "^5",
"typescript-eslint": "^8",
"vite": "^5"
}
}

9
postcss.config.mjs Executable file
View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -1,644 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ACC Championship Leaderboard</title>
<script src="https://cdn.tailwindcss.com" defer></script>
<script src="https://d3js.org/d3.v7.min.js" defer></script>
<style>
/* --- Custom Styles --- */
body {
font-family: 'Inter', sans-serif;
background-color: #111827; /* gray-900 */
}
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1f2937; /* gray-800 */
}
::-webkit-scrollbar-thumb {
background: #4b5563; /* gray-600 */
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280; /* gray-500 */
}
/* Podium Styles */
.podium-1 {
background: linear-gradient(145deg, #fde047, #facc15);
color: #422006;
font-weight: 700;
border-left: 4px solid #f59e0b;
}
.podium-2 {
background: linear-gradient(145deg, #9ca3af, #6b7280); /* gray-400, gray-500 */
color: #1f2937;
font-weight: 600;
border-left: 4px solid #9ca3af; /* gray-400 */
}
.podium-3 {
background: linear-gradient(145deg, #f0c0a0, #e69a6a);
color: #431407;
font-weight: 600;
border-left: 4px solid #c2410c;
}
/* Points Table Key Styles */
.points-fl { background-color: #8b5cf6; color: #000000; } /* violet-500 with black text */
.points-new {
background-color: #374151; /* gray-700 */
color: #e5e7eb; /* gray-200 */
}
/* Style for the (+1) badge in the POINTS TABLE */
.fl-bg-text {
background-color: #8b5cf6; /* violet-500 */
color: #000000; /* Black text */
font-weight: 700;
padding: 0.125rem 0.5rem; /* px-2 py-0.5 */
border-radius: 0.375rem; /* rounded-md */
margin-left: 0.5rem; /* ml-2 */
flex-shrink: 0;
line-height: 1.25;
}
/* Style for the (+1) text-only in the RESULTS TABLE */
.fl-text-only {
color: #000000; /* Black text */
font-weight: 700;
padding: 0.125rem 0.5rem; /* px-2 py-0.5 */
margin-left: 0.5rem; /* ml-2 */
flex-shrink: 0;
line-height: 1.25;
/* No background, no border-radius */
}
/* D3 Chart Styles */
.chart-axis path,
.chart-axis line {
stroke: #9ca3af;
}
.chart-axis text {
fill: #d1d5db;
font-size: 12px;
}
.chart-title {
fill: #f9fafb;
font-size: 1.25rem;
font-weight: 600;
text-anchor: middle;
}
.chart-grid line {
stroke: #374151;
stroke-opacity: 0.7;
shape-rendering: crispEdges;
}
.chart-line {
fill: none;
stroke-width: 2.5px;
}
.chart-dot {}
/* Sticky header for table */
#race-table-head th {
position: sticky;
top: 0;
z-index: 10;
background-color: #111827;
}
/* Sticky first column */
#race-table-body td:first-child,
#race-table-head th:first-child {
position: sticky;
left: 0;
z-index: 11;
background-color: #111827;
min-width: 200px;
}
#race-table-body td:first-child {
z-index: 1;
background-color: #1f2937;
display: flex;
justify-content: space-between;
align-items: center;
text-align: left;
}
#race-table-head th:first-child {
text-align: left;
padding-left: 1rem;
}
/* Sticky second column */
#race-table-body td:nth-child(2),
#race-table-head th:nth-child(2) {
position: sticky;
left: 200px; /* Must match min-width of first col */
z-index: 11;
background-color: #111827;
min-width: 100px;
font-weight: 600;
color: #a855f7;
}
#race-table-body td:nth-child(2) {
z-index: 1;
background-color: #1f2937;
}
</style>
</head>
<body class="bg-gray-900 text-gray-200 p-4 md:p-8">
<div class="container mx-auto max-w-7xl space-y-12">
<header class="text-center">
<h1 class="text-4xl md:text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 mb-4">
Grid Gladiators League Leaderboard
</h1>
</header>
<main class="space-y-12">
<section>
<h2 class="text-2xl font-bold text-white mb-4">Race Results</h2>
<div id="table-container" class="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50 overflow-x-auto">
<table id="race-results-table" class="w-full min-w-[1200px] border-collapse text-center">
<thead id="race-table-head"></thead>
<tbody id="race-table-body" class="divide-y divide-gray-700"></tbody>
</table>
</div>
</section>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<section class="lg:col-span-2">
<h2 class="text-2xl font-bold text-white mb-4">Driver Standings</h2>
<div class="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50">
<table class="w-full">
<thead class="border-b border-gray-600">
<tr>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-300">Pos</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-300">Color</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-300">Driver</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-300">Initials</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-300">Points</th>
</tr>
</thead>
<tbody id="standings-body"></tbody>
</table>
</div>
</section>
<section class="lg:col-span-1">
<h2 class="text-2xl font-bold text-white mb-4">Points Table:</h2>
<div class="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50">
<table class="w-full text-center font-bold">
<tbody id="points-key-body">
<!-- JS will populate this -->
</tbody>
</table>
</div>
</section>
</div>
</main>
<section>
<h2 class="text-2xl font-bold text-white mb-6 text-center">Cumulative Points per Race</h2>
<div id="combined-chart-container" class="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50"></div>
</section>
<script type="module">
// --- API CONFIG ---
const API_BASE = 'https://acc-api.jurmanovic.com/v1/api';
const SERVER_ID = '2ea19460-f3b6-47ae-a667-c26876425268';
const ACCESS_TOKEN = 'EB41048A021F7A7707ED4E5EF37052C401F9ACD5DCDBC94BF16D0456FAAF111A'
const defaultColors = [
"#1f77b4", "#ff7f0e", "#2ca02c", "#81c784", "#a7d9ef", "#8c564b",
"#e377c2", "#7f7f0f", "#bcbd22", "#17becf", "#aec7e8"
];
const defaultAppData = {
drivers: [
{ name: "Filip Makarijevski", color: defaultColors[0], initials: "F.M." },
{ name: "Karlo Steinbruckner", color: defaultColors[1], initials: "K.S." },
{ name: "Fran Jurmanović", color: defaultColors[2], initials: "F.J." },
{ name: "Borna Bevanda", color: defaultColors[3], initials: "B.B." },
{ name: "Matej Jugović", color: defaultColors[4], initials: "M.J." },
{ name: "Lovo Bravić", color: defaultColors[5], initials: "L.B." },
{ name: "Dominik Dejanović", color: defaultColors[6], initials: "D.D." },
{ name: "Josip Kompanović", color: defaultColors[7], initials: "J.K." },
{ name: "Tin Vidmar", color: defaultColors[8], initials: "T.V." },
{ name: "Ivan Ivezić", color: defaultColors[9], initials: "I.I." },
{ name: "Tomislav Glavaš", color: defaultColors[10], initials: "T.G." }
],
pointsTable: [
{ points: 10, label: "1st", color: "#fde047", textColor: "#422006", priority: 1 },
{ points: 7, label: "2nd", color: "#9ca3af", textColor: "#1f2937", priority: 2 },
{ points: 5, label: "3rd", color: "#f0c0a0", textColor: "#431407", priority: 3 },
{ points: 2, label: "4th", color: "#81c784", textColor: "#1F2937", priority: 4 },
{ points: 1, label: "5th", color: "#a7d9ef", textColor: "#1F2937", priority: 5 },
],
flPoints: { points: 1, label: "FL +1", color: "#8b5cf6", textColor: "#000000", priority: 6 },
tracks: [
{ name: "Hungaroring", results: [], fastestLapInitials: "" },
{ name: "Watkins Glen", results: [], fastestLapInitials: "" },
{ name: "Paul Ricard", results: [], fastestLapInitials: "" },
{ name: "Silverstone", results: [], fastestLapInitials: "" },
{ name: "Zandvoort", results: [], fastestLapInitials: "" },
{ name: "Brands Hatch", results: [], fastestLapInitials: "" },
{ name: "Circuit Ricardo Tormo (Valencia)", results: [], fastestLapInitials: "" },
{ name: "Mount Panorama Circuit", results: [], fastestLapInitials: "" },
{ name: "Autodromo Internazionale Enzo e Dino Ferrari - Imola", results: [], fastestLapInitials: "" },
{ name: "Barcelona", results: [, 0, 0], fastestLapInitials: "" },
{ name: "Indianapolis", results: [], fastestLapInitials: "" },
{ name: "WeatherTech Raceway Laguna Seca", results: [], fastestLapInitials: "" },
{ name: "Zolder", results: [], fastestLapInitials: "" },
{ name: "Suzuka Circuit", results: [], fastestLapInitials: "" },
{ name: "Snetterton", results: [], fastestLapInitials: "" },
{ name: "Spa-Francochamps", results: [], fastestLapInitials: "" },
{ name: "Circuit of the Americas (COTA)", results: [], fastestLapInitials: "" },
{ name: "Donington Park", results: [], fastestLapInitials: "" },
{ name: "Monza", results: [], fastestLapInitials: "" },
{ name: "Misano", results: [], fastestLapInitials: "" },
{ name: "Kyalami Grand Prix Circuit", results: [], fastestLapInitials: "" },
{ name: "Oulton Park", results: [], fastestLapInitials: "" },
{ name: "Red Bull Ring", results: [], fastestLapInitials: "" },
{ name: "Nürburgring", results: [], fastestLapInitials: "" }
]
};
let appData = null;
// --- SAVE/LOAD FUNCTIONS ---
async function loadData() {
try {
const res = await fetch(`${API_BASE}/server/${SERVER_ID}/leaderboard`, {
headers: {
"Access-Key": ACCESS_TOKEN
}
});
if (!res.ok) return null;
const data = await res.json();
// Treat empty leaderboard (no drivers) as null to fall back to defaults
if (!data || !data.drivers || data.drivers.length === 0) return null;
return data;
} catch (e) {
console.error("Failed to load leaderboard from API:", e);
return null;
}
}
// --- CORE FUNCTIONS ---
// Find styling by point value
function findStyleByPoints(points) {
return appData.pointsTable.find(p => p.points === points) || { class: 'points-new', color: '#374151', textColor: '#e5e7eb', label: '???' };
}
// Function to re-render UI from memory
function rerender() {
renderRaceTable();
const standingsData = calculateStandingsData();
renderStandings(standingsData);
renderCombinedChart(standingsData);
renderPointsKey();
}
async function initializeApp() {
const savedData = await loadData();
if (savedData) {
appData = savedData;
} else {
appData = JSON.parse(JSON.stringify(defaultAppData));
}
rerender();
}
function parsePoints(value) {
const num = parseInt(value, 10);
return isNaN(num) ? 0 : num;
}
function calculateStandingsData() {
const standings = appData.drivers.map((driver, driverIndex) => {
let totalPoints = 0;
let cumulativePoints = 0;
const raceHistory = [];
const driverInitials = driver.initials;
appData.tracks.forEach((track, trackIndex) => {
let result = track.results[driverIndex];
// DNS fallback: if result is undefined, null, or empty, treat as DNS
if (result === undefined || result === null || result === '') {
result = 'DNS';
}
let racePoints = parsePoints(result);
if (track.fastestLapInitials && track.fastestLapInitials === driverInitials) {
racePoints += appData.flPoints.points || 1;
}
totalPoints += racePoints;
cumulativePoints += racePoints;
raceHistory.push({
trackName: track.name,
points: racePoints,
cumulativePoints: cumulativePoints
});
});
return {
name: driver.name,
color: driver.color,
initials: driver.initials,
totalPoints: totalPoints,
raceHistory: raceHistory,
originalIndex: driverIndex
};
});
standings.sort((a, b) => b.totalPoints - a.totalPoints);
return standings;
}
// --- RENDER FUNCTIONS ---
function renderRaceTable() {
const head = document.getElementById('race-table-head');
const body = document.getElementById('race-table-body');
let headHTML = '<tr><th scope="col" class="py-3 px-4 min-w-[200px]">Track</th>';
headHTML += '<th scope="col" class="py-3 px-4 min-w-[100px]">FL</th>';
appData.drivers.forEach((driver, index) => {
headHTML += `
<th scope="col" class="py-3 px-4 min-w-[170px]" style="text-align: center;">
<span>${driver.name}</span>
</th>`;
});
headHTML += '</tr>';
head.innerHTML = headHTML;
let bodyHTML = '';
appData.tracks.forEach((track, trackIndex) => {
bodyHTML += '<tr>';
bodyHTML += `
<td class="font-semibold py-3 px-4 whitespace-nowrap min-w-[200px]">
<span>${track.name}</span>
</td>`;
bodyHTML += `<td class="py-3 px-4 font-mono min-w-[100px]">${track.fastestLapInitials || ''}</td>`;
appData.drivers.forEach((driver, driverIndex) => {
const driverInitials = driver.initials;
let result = track.results[driverIndex];
// DNS fallback
if (result === undefined || result === null || result === '') {
result = 'DNS';
}
const pointsNum = parsePoints(result);
const style = findStyleByPoints(pointsNum);
let cellStyles = `background-color: ${style.color}; color: ${style.textColor};`;
let fastestLapBadge = '';
if (track.fastestLapInitials && track.fastestLapInitials === driverInitials) {
fastestLapBadge = `<span class="fl-text-only" style="color: ${appData.flPoints.textColor};">(+1)</span>`;
const flColor = appData.flPoints.color;
if (pointsNum === 0) {
cellStyles = `background-color: ${flColor}; color: ${appData.flPoints.textColor};`;
} else {
cellStyles = `background: linear-gradient(135deg, ${style.color} 50%, ${flColor} 50%); color: ${style.textColor};`;
}
}
let cellContent;
if (fastestLapBadge) {
cellStyles += `display: flex; justify-content: space-between; align-items: center; flex-direction: row; padding-left: 0.75rem; padding-right: 0.75rem;`;
cellContent = `
<span class="flex-grow" style="text-align: left;">${result}</span>
${fastestLapBadge}
`;
} else {
cellStyles += `text-align: center;`;
cellContent = `<span>${result}</span>`;
}
bodyHTML += `
<td class="py-3 px-4 font-mono" style="${cellStyles}">
${cellContent}
</td>`;
});
bodyHTML += '</tr>';
});
body.innerHTML = bodyHTML;
}
function renderStandings(standingsData) {
const body = document.getElementById('standings-body');
let bodyHTML = '';
standingsData.forEach((driver, index) => {
const pos = index + 1;
let podiumClass = '';
if (pos === 1) podiumClass = 'podium-1';
else if (pos === 2) podiumClass = 'podium-2';
else if (pos === 3) podiumClass = 'podium-3';
bodyHTML += `
<tr class="border-b border-gray-700 ${podiumClass}">
<td class="px-3 py-3 font-semibold">${pos}</td>
<td class="px-3 py-3">
<div class="w-10 h-6 rounded" style="background-color: ${driver.color};"></div>
</td>
<td class="px-3 py-3">${driver.name}</td>
<td class="px-3 py-3 font-mono">${driver.initials}</td>
<td class="px-3 py-3 font-bold">${driver.totalPoints}</td>
</tr>
`;
});
body.innerHTML = bodyHTML;
}
function renderPointsKey() {
const body = document.getElementById('points-key-body');
const sortedPoints = [...appData.pointsTable].sort((a, b) => (a.priority || 99) - (b.priority || 99));
let bodyHTML = '';
sortedPoints.forEach((style, index) => {
const labelContent = `
<span class="points-table-label">${style.label}</span>
<span class="points-table-points">${style.points}</span>
`;
bodyHTML += `
<tr>
<td class="px-3 py-2" style="background-color: ${style.color}; color: ${style.textColor}; display: flex; justify-content: center; align-items: center; flex-direction: row;">
<div>${labelContent}</div>
</td>
</tr>`;
});
// Add the non-editable FL row
const flStyle = appData.flPoints;
const flLabel = `FL <span class="fl-bg-text" style="color: ${flStyle.textColor};">(+${flStyle.points})</span>`;
bodyHTML += `
<tr>
<td class="px-3 py-2 points-fl">
<div style="display: flex; justify-content: center; align-items: center; flex-direction: row;">
${flLabel}
</div>
</td>
</tr>`;
body.innerHTML = bodyHTML;
}
function renderCombinedChart(standingsData) {
const container = document.getElementById('combined-chart-container');
if (!container) return;
container.innerHTML = '';
const margin = { top: 40, right: 20, bottom: 200, left: 40 };
const containerWidth = container.getBoundingClientRect().width;
if (containerWidth < 50) return;
const width = containerWidth - margin.left - margin.right;
const height = 500 - margin.top - margin.bottom;
const trackNames = appData.tracks.map(t => t.name);
const svg = d3.select(container)
.append("svg")
.attr("width", "100%")
.attr("height", "100%")
.attr("viewBox", `0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`)
.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
const colorScale = d3.scaleOrdinal()
.domain(standingsData.map(d => d.name))
.range(standingsData.map(d => d.color));
const xScale = d3.scalePoint()
.domain(trackNames.length > 0 ? trackNames : ["No Tracks"])
.range([0, width])
.padding(0.5);
const maxY = d3.max(standingsData, driver =>
d3.max(driver.raceHistory, race => race.cumulativePoints)
);
const yScale = d3.scaleLinear()
.domain([0, (maxY !== undefined && maxY > 0) ? maxY : 10])
.range([height, 0])
.nice();
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
svg.append("g")
.attr("class", "chart-grid")
.call(d3.axisLeft(yScale).ticks(5).tickSize(-width).tickFormat(""));
svg.append("g")
.attr("class", "chart-axis")
.attr("transform", `translate(0, ${height})`)
.call(xAxis)
.selectAll("text")
.style("text-anchor", "end")
.attr("dx", "-.8em")
.attr("dy", ".15em")
.attr("transform", "rotate(-45)");
svg.append("g")
.attr("class", "chart-axis")
.call(yAxis);
const line = d3.line()
.x(d => xScale(d.trackName))
.y(d => yScale(d.cumulativePoints))
.curve(d3.curveMonotoneX);
const driverGroups = svg.selectAll('.driver-group')
.data(standingsData)
.enter()
.append('g')
.attr('class', 'driver-group');
driverGroups.append("path")
.attr("class", "chart-line")
.style("stroke", d => colorScale(d.name))
.attr("d", d => line(d.raceHistory));
driverGroups.selectAll(".chart-dot")
.data(d => d.raceHistory)
.enter().append("circle")
.attr("class", "chart-dot")
.attr("cx", d => xScale(d.trackName))
.attr("cy", d => yScale(d.cumulativePoints))
.attr("r", 3)
.style("fill", (d, i, nodes) => {
return colorScale(d3.select(nodes[i].parentNode).datum().name);
});
svg.append("text")
.attr("class", "chart-title")
.attr("x", width / 2)
.attr("y", 0 - (margin.top / 2))
.text("Cumulative Points per Race");
const legend = svg.append('g')
.attr('class', 'chart-legend')
.attr('transform', `translate(0, ${height + 120})`);
const legendItemWidth = 150;
const numCols = Math.floor(width / legendItemWidth);
const colWidth = (numCols > 0) ? (width / numCols) : width;
const maxCols = Math.max(1, numCols);
const legendItem = legend.selectAll('.legend-item')
.data(standingsData)
.enter()
.append('g')
.attr('class', 'legend-item')
.attr('transform', (d, i) => {
const xPos = (i % maxCols) * colWidth;
const yPos = Math.floor(i / maxCols) * 20;
return `translate(${xPos}, ${yPos})`;
});
legendItem.append('rect')
.attr('width', 12)
.attr('height', 12)
.style('fill', d => colorScale(d.name));
legendItem.append('text')
.attr('x', 16)
.attr('y', 10)
.style('fill', '#d1d5db')
.style('font-size', '12px')
.text(d => d.name);
}
// --- INITIALIZATION ---
document.addEventListener('DOMContentLoaded', async () => {
await initializeApp();
}); // --- END of DOMContentLoaded listener ---
</script>
</body>
</html>

147
src/App.css Normal file
View File

@@ -0,0 +1,147 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* --- Custom Styles --- */
body {
font-family: 'Inter', sans-serif;
background-color: #111827; /* gray-900 */
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1f2937; /* gray-800 */
}
::-webkit-scrollbar-thumb {
background: #4b5563; /* gray-600 */
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280; /* gray-500 */
}
/* Podium Styles */
.podium-1 {
background: linear-gradient(145deg, #fde047, #facc15);
color: #422006;
font-weight: 700;
border-left: 4px solid #f59e0b;
}
.podium-2 {
background: linear-gradient(145deg, #9ca3af, #6b7280); /* gray-400, gray-500 */
color: #1f2937;
font-weight: 600;
border-left: 4px solid #9ca3af; /* gray-400 */
}
.podium-3 {
background: linear-gradient(145deg, #f0c0a0, #e69a6a);
color: #431407;
font-weight: 600;
border-left: 4px solid #c2410c;
}
/* Points Table Key Styles */
.points-fl { background-color: #8b5cf6; color: #000000; } /* violet-500 with black text */
.points-new {
background-color: #374151; /* gray-700 */
color: #e5e7eb; /* gray-200 */
}
/* Style for the (+1) badge in the POINTS TABLE */
.fl-bg-text {
background-color: #8b5cf6; /* violet-500 */
color: #000000; /* Black text */
font-weight: 700;
padding: 0.125rem 0.5rem; /* px-2 py-0.5 */
border-radius: 0.375rem; /* rounded-md */
margin-left: 0.5rem; /* ml-2 */
flex-shrink: 0;
line-height: 1.25;
}
/* Style for the (+1) text-only in the RESULTS TABLE */
.fl-text-only {
color: #000000; /* Black text */
font-weight: 700;
padding: 0.125rem 0.5rem; /* px-2 py-0.5 */
margin-left: 0.5rem; /* ml-2 */
flex-shrink: 0;
line-height: 1.25;
/* No background, no border-radius */
}
/* D3 Chart Styles */
.chart-axis path,
.chart-axis line {
stroke: #9ca3af;
}
.chart-axis text {
fill: #d1d5db;
font-size: 12px;
}
.chart-title {
fill: #f9fafb;
font-size: 1.25rem;
font-weight: 600;
text-anchor: middle;
}
.chart-grid line {
stroke: #374151;
stroke-opacity: 0.7;
shape-rendering: crispEdges;
}
.chart-line {
fill: none;
stroke-width: 2.5px;
}
.chart-dot {}
/* Sticky header for table */
#race-table-head th {
position: sticky;
top: 0;
z-index: 10;
background-color: #111827;
}
/* Sticky first column */
#race-table-body td:first-child,
#race-table-head th:first-child {
position: sticky;
left: 0;
z-index: 11;
background-color: #111827;
min-width: 200px;
}
#race-table-body td:first-child {
z-index: 1;
background-color: #1f2937;
display: flex;
justify-content: space-between;
align-items: center;
text-align: left;
}
#race-table-head th:first-child {
text-align: left;
padding-left: 1rem;
}
/* Sticky second column */
#race-table-body td:nth-child(2),
#race-table-head th:nth-child(2) {
position: sticky;
left: 200px; /* Must match min-width of first col */
z-index: 11;
background-color: #111827;
min-width: 100px;
font-weight: 600;
color: #a855f7;
}
#race-table-body td:nth-child(2) {
z-index: 1;
background-color: #1f2937;
}

93
src/App.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { useEffect, useState, useMemo } from 'react';
import { fetchLeaderboard } from '@/lib/api';
import { AppData } from '@/lib/types';
import { calculateStandingsData } from '@/lib/leaderboard';
import RaceResultsTable from '@/components/RaceResultsTable';
import DriverStandings from '@/components/DriverStandings';
import CumulativeChart from '@/components/CumulativeChart';
import PointsKey from '@/components/PointsKey';
export default function App() {
const [appData, setAppData] = useState<AppData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = async () => {
setLoading(true);
setError(null);
const data = await fetchLeaderboard();
if (data) {
setAppData(data);
} else {
setError('Failed to load leaderboard data');
}
setLoading(false);
};
useEffect(() => {
fetchData();
}, []);
const standingsData = useMemo(
() => (appData ? calculateStandingsData(appData) : []),
[appData]
);
return (
<div className="bg-gray-900 text-gray-200 p-4 md:p-8 min-h-screen">
<div className="container mx-auto max-w-7xl space-y-12">
<header className="text-center">
<h1 className="text-4xl md:text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 mb-4">
Grid Gladiators League Leaderboard
</h1>
</header>
{loading ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-500"></div>
<p className="mt-4 text-lg text-gray-400">Loading leaderboard...</p>
</div>
) : error ? (
<div className="bg-red-900/20 border border-red-500 rounded-lg p-6 text-center">
<p className="text-red-300 mb-4">{error}</p>
<button
onClick={fetchData}
className="bg-purple-600 hover:bg-purple-700 text-white font-semibold py-2 px-6 rounded-lg transition"
>
Retry
</button>
</div>
) : appData ? (
<main className="space-y-12">
<section>
<h2 className="text-2xl font-bold text-white mb-4">Race Results</h2>
<RaceResultsTable appData={appData} />
</section>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<section className="lg:col-span-2">
<h2 className="text-2xl font-bold text-white mb-4">Driver Standings</h2>
<DriverStandings standingsData={standingsData} />
</section>
<section className="lg:col-span-1">
<h2 className="text-2xl font-bold text-white mb-4">Points Table:</h2>
<PointsKey
pointsTable={appData.pointsTable}
flPoints={appData.flPoints}
flColor={appData.flColor}
flTextColor={appData.flTextColor}
/>
</section>
</div>
<section>
<h2 className="text-2xl font-bold text-white mb-6 text-center">Cumulative Points per Race</h2>
<CumulativeChart standingsData={standingsData} tracks={appData.tracks} />
</section>
</main>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,193 @@
import { useEffect, useRef } from 'react';
import { DriverStanding, Track } from '@/lib/types';
interface CumulativeChartProps {
standingsData: DriverStanding[];
tracks: Track[];
}
export default function CumulativeChart({ standingsData, tracks }: CumulativeChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const selectionRef = useRef<any>(null);
useEffect(() => {
const renderChart = async () => {
if (!containerRef.current) return;
const d3 = await import('d3');
const container = containerRef.current;
if (selectionRef.current) {
selectionRef.current.remove();
}
container.innerHTML = '';
const margin = { top: 40, right: 20, bottom: 200, left: 40 };
const containerWidth = container.getBoundingClientRect().width;
if (containerWidth < 50) return;
const width = containerWidth - margin.left - margin.right;
const height = 500 - margin.top - margin.bottom;
const trackNames = tracks.map((t) => t.name);
const svg = d3
.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr(
'viewBox',
`0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`
)
.attr('role', 'img')
.attr('aria-label', 'Cumulative points per race chart showing driver progression across all races')
.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`);
selectionRef.current = d3.select(container).select('svg');
const colorScale = d3
.scaleOrdinal()
.domain(standingsData.map((d) => d.name))
.range(standingsData.map((d) => d.color));
const xScale = d3
.scalePoint()
.domain(trackNames.length > 0 ? trackNames : ['No Tracks'])
.range([0, width])
.padding(0.5);
const maxY = d3.max(standingsData, (driver: DriverStanding) =>
d3.max(driver.raceHistory, (race) => race.cumulativePoints)
);
const yScale = d3
.scaleLinear()
.domain([0, maxY !== undefined && maxY > 0 ? maxY : 10])
.range([height, 0])
.nice();
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
svg
.append('g')
.attr('class', 'chart-grid')
.call(
d3
.axisLeft(yScale)
.ticks(5)
.tickSize(-width)
.tickFormat(() => '')
);
svg
.append('g')
.attr('class', 'chart-axis')
.attr('transform', `translate(0, ${height})`)
.call(xAxis)
.selectAll('text')
.style('text-anchor', 'end')
.attr('dx', '-.8em')
.attr('dy', '.15em')
.attr('transform', 'rotate(-45)');
svg.append('g').attr('class', 'chart-axis').call(yAxis);
const line = d3
.line<(typeof standingsData)[0]['raceHistory'][0]>()
.x((d) => xScale(d.trackName) ?? 0)
.y((d) => yScale(d.cumulativePoints))
.curve(d3.curveMonotoneX);
const driverGroups = svg
.selectAll('.driver-group')
.data(standingsData)
.enter()
.append('g')
.attr('class', 'driver-group');
driverGroups
.append('path')
.attr('class', 'chart-line')
.style('stroke', (d) => colorScale(d.name) as string)
.attr('d', (d) => line(d.raceHistory));
driverGroups
.selectAll('.chart-dot')
.data((d: DriverStanding) => d.raceHistory)
.enter()
.append('circle')
.attr('class', 'chart-dot')
.attr('cx', (d) => xScale(d.trackName) ?? 0)
.attr('cy', (d) => yScale(d.cumulativePoints))
.attr('r', 3)
.style('fill', (_d, i: number, nodes: NodeListOf<Element>) => {
const parentNode = nodes[i].parentNode;
return colorScale((d3.select(parentNode).datum() as DriverStanding).name) as string;
});
svg
.append('text')
.attr('class', 'chart-title')
.attr('x', width / 2)
.attr('y', 0 - margin.top / 2)
.text('Cumulative Points per Race');
const legend = svg
.append('g')
.attr('class', 'chart-legend')
.attr('transform', `translate(0, ${height + 120})`);
const legendItemWidth = 150;
const numCols = Math.floor(width / legendItemWidth);
const colWidth = numCols > 0 ? width / numCols : width;
const maxCols = Math.max(1, numCols);
const legendItem = legend
.selectAll('.legend-item')
.data(standingsData)
.enter()
.append('g')
.attr('class', 'legend-item')
.attr('transform', (_d, i: number) => {
const xPos = (i % maxCols) * colWidth;
const yPos = Math.floor(i / maxCols) * 20;
return `translate(${xPos}, ${yPos})`;
});
legendItem
.append('rect')
.attr('width', 12)
.attr('height', 12)
.style('fill', (d) => colorScale(d.name) as string);
legendItem
.append('text')
.attr('x', 16)
.attr('y', 10)
.style('fill', '#d1d5db')
.style('font-size', '12px')
.text((d) => d.name);
};
renderChart();
return () => {
if (selectionRef.current) {
selectionRef.current.remove();
}
};
}, [standingsData, tracks]);
return (
<div
ref={containerRef}
className="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50"
/>
);
}

View File

@@ -0,0 +1,49 @@
import { DriverStanding } from '@/lib/types';
interface DriverStandingsProps {
standingsData: DriverStanding[];
}
export default function DriverStandings({ standingsData }: DriverStandingsProps) {
return (
<div className="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50">
<table className="w-full">
<thead className="border-b border-gray-600">
<tr>
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Pos</th>
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Color</th>
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Driver</th>
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Initials</th>
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Points</th>
</tr>
</thead>
<tbody>
{standingsData.map((driver, index) => {
const pos = index + 1;
let podiumClass = '';
if (pos === 1) podiumClass = 'podium-1';
else if (pos === 2) podiumClass = 'podium-2';
else if (pos === 3) podiumClass = 'podium-3';
return (
<tr key={driver.originalIndex} className={`border-b border-gray-700 ${podiumClass}`}>
<td className="px-3 py-3 font-semibold">{pos}</td>
<td className="px-3 py-3">
<div
className="w-10 h-6 rounded"
style={{ backgroundColor: driver.color }}
role="presentation"
aria-label={`${driver.name}'s color`}
></div>
</td>
<td className="px-3 py-3">{driver.name}</td>
<td className="px-3 py-3 font-mono">{driver.initials}</td>
<td className="px-3 py-3 font-bold">{driver.totalPoints}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { useMemo } from 'react';
import { PointStyle } from '@/lib/types';
interface PointsKeyProps {
pointsTable: PointStyle[];
flPoints: number;
flColor: string;
flTextColor: string;
}
export default function PointsKey({ pointsTable, flPoints, flColor, flTextColor }: PointsKeyProps) {
const sortedPoints = useMemo(
() => [...pointsTable].sort((a, b) => (a.priority || 99) - (b.priority || 99)),
[pointsTable]
);
return (
<div className="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50">
<table className="w-full text-center font-bold">
<thead>
<tr>
<th scope="col" className="px-3 py-2">Points</th>
</tr>
</thead>
<tbody>
{sortedPoints.map((style) => (
<tr key={style.id || `pt-${style.points}`}>
<td
className="px-3 py-2 flex justify-center items-center"
style={{
backgroundColor: style.color,
color: style.textColor,
}}
>
<div>
<span className="points-table-label">{style.label}</span>
<span className="points-table-points ml-2">{style.points}</span>
</div>
</td>
</tr>
))}
<tr>
<td
className="px-3 py-2 points-fl flex justify-center items-center"
style={{
backgroundColor: flColor,
color: flTextColor,
}}
>
<div className="flex justify-center items-center">
FL <span className="fl-bg-text" style={{ color: flTextColor }}>(+{flPoints})</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import { useMemo } from 'react';
import { AppData } from '@/lib/types';
import { parsePoints, findStyleByPoints } from '@/lib/leaderboard';
interface RaceResultsTableProps {
appData: AppData;
}
export default function RaceResultsTable({ appData }: RaceResultsTableProps) {
const driverById = useMemo(() => {
const map = new Map<string, (typeof appData.drivers)[0]>();
appData.drivers.forEach((driver) => {
map.set(driver.id, driver);
});
return map;
}, [appData]);
const resultsByTrackAndDriver = useMemo(() => {
const map = new Map<string, Map<string, number | null>>();
appData.tracks.forEach((track) => {
const driverMap = new Map<string, number | null>();
track.results.forEach((result) => {
driverMap.set(result.driverId, result.score);
});
map.set(track.id, driverMap);
});
return map;
}, [appData]);
return (
<div
id="table-container"
className="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50 overflow-x-auto"
>
<table
id="race-results-table"
className="w-full min-w-[1200px] border-collapse text-center"
>
<thead id="race-table-head">
<tr>
<th scope="col" className="py-3 px-4 min-w-[200px]">
Track
</th>
<th scope="col" className="py-3 px-4 min-w-[100px]">
FL
</th>
{appData.drivers.map((driver) => (
<th
key={driver.initials}
scope="col"
className="py-3 px-4 min-w-[170px]"
style={{ textAlign: 'center' }}
>
<span>{driver.name}</span>
</th>
))}
</tr>
</thead>
<tbody id="race-table-body" className="divide-y divide-gray-700">
{appData.tracks.map((track) => {
const flDriver = driverById.get(track.fastestLapDriverId);
return (
<tr key={track.id}>
<td className="font-semibold py-3 px-4 whitespace-nowrap min-w-[200px]">
<span>{track.name}</span>
</td>
<td className="py-3 px-4 font-mono min-w-[100px]">
{flDriver?.initials || ''}
</td>
{appData.drivers.map((driver) => {
const driverMap = resultsByTrackAndDriver.get(track.id);
const score = driverMap?.get(driver.id);
const result = score === null || score === undefined ? 'DNS' : score;
const pointsNum = parsePoints(result);
const style = findStyleByPoints(appData, pointsNum);
let cellStyles: React.CSSProperties = {
backgroundColor: style.color,
color: style.textColor,
};
const hasFL =
track.fastestLapDriverId === driver.id && appData.flPoints;
if (hasFL) {
const flColor = appData.flColor;
if (pointsNum === 0) {
cellStyles = {
backgroundColor: flColor,
color: appData.flTextColor,
};
} else {
cellStyles = {
background: `linear-gradient(135deg, ${style.color} 50%, ${flColor} 50%)`,
color: style.textColor,
};
}
}
if (hasFL) {
cellStyles = {
...cellStyles,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: 'row',
paddingLeft: '0.75rem',
paddingRight: '0.75rem',
};
} else {
cellStyles = {
...cellStyles,
textAlign: 'center',
};
}
return (
<td
key={driver.id}
className="py-3 px-4 font-mono"
style={cellStyles}
title={`${result} - ${style.label}`}
>
{hasFL ? (
<>
<span style={{ flexGrow: 1, textAlign: 'left' }}>
{result}
</span>
<span
className="fl-text-only"
style={{ color: appData.flTextColor }}
>
(+{appData.flPoints})
</span>
</>
) : (
<span>{result}</span>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}

35
src/lib/api.ts Executable file
View File

@@ -0,0 +1,35 @@
import { AppData } from './types';
import { defaultAppData } from './defaultData';
export async function fetchLeaderboard(): Promise<AppData | null> {
const API_BASE = import.meta.env.VITE_API_BASE || '';
const SERVER_ID = import.meta.env.VITE_SERVER_ID || '';
const ACCESS_TOKEN = import.meta.env.VITE_ACCESS_TOKEN || '';
if (!API_BASE || !SERVER_ID || !ACCESS_TOKEN) {
console.warn('Missing API configuration, using default data');
return defaultAppData;
}
try {
const res = await fetch(`${API_BASE}/server/${SERVER_ID}/leaderboard`, {
headers: {
'Access-Key': ACCESS_TOKEN,
},
});
if (!res.ok) {
console.warn('API request failed, using default data');
return defaultAppData;
}
const data = await res.json();
// Treat empty leaderboard (no drivers) as null to fall back to defaults
if (!data || !data.drivers || data.drivers.length === 0) {
console.warn('Empty API response, using default data');
return defaultAppData;
}
return data;
} catch (e) {
console.error('Failed to load leaderboard from API:', e);
return defaultAppData;
}
}

58
src/lib/defaultData.ts Executable file
View File

@@ -0,0 +1,58 @@
import { AppData } from './types';
const defaultColors = [
"#1f77b4", "#ff7f0e", "#2ca02c", "#81c784", "#a7d9ef", "#8c564b",
"#e377c2", "#7f7f0f", "#bcbd22", "#17becf", "#aec7e8"
];
export const defaultAppData: AppData = {
drivers: [
{ id: "550e8400-e29b-41d4-a716-446655440000", name: "Filip Makarijevski", color: defaultColors[0], initials: "F.M." },
{ id: "550e8400-e29b-41d4-a716-446655440001", name: "Karlo Steinbruckner", color: defaultColors[1], initials: "K.S." },
{ id: "550e8400-e29b-41d4-a716-446655440002", name: "Fran Jurmanović", color: defaultColors[2], initials: "F.J." },
{ id: "550e8400-e29b-41d4-a716-446655440003", name: "Borna Bevanda", color: defaultColors[3], initials: "B.B." },
{ id: "550e8400-e29b-41d4-a716-446655440004", name: "Matej Jugović", color: defaultColors[4], initials: "M.J." },
{ id: "550e8400-e29b-41d4-a716-446655440005", name: "Lovo Bravić", color: defaultColors[5], initials: "L.B." },
{ id: "550e8400-e29b-41d4-a716-446655440006", name: "Dominik Dejanović", color: defaultColors[6], initials: "D.D." },
{ id: "550e8400-e29b-41d4-a716-446655440007", name: "Josip Kompanović", color: defaultColors[7], initials: "J.K." },
{ id: "550e8400-e29b-41d4-a716-446655440008", name: "Tin Vidmar", color: defaultColors[8], initials: "T.V." },
{ id: "550e8400-e29b-41d4-a716-446655440009", name: "Ivan Ivezić", color: defaultColors[9], initials: "I.I." },
{ id: "550e8400-e29b-41d4-a716-44665544000a", name: "Tomislav Glavaš", color: defaultColors[10], initials: "T.G." }
],
pointsTable: [
{ id: "pt-1st", points: 10, label: "1st", color: "#fde047", textColor: "#422006", priority: 1 },
{ id: "pt-2nd", points: 7, label: "2nd", color: "#9ca3af", textColor: "#1f2937", priority: 2 },
{ id: "pt-3rd", points: 5, label: "3rd", color: "#f0c0a0", textColor: "#431407", priority: 3 },
{ id: "pt-4th", points: 2, label: "4th", color: "#81c784", textColor: "#1F2937", priority: 4 },
{ id: "pt-5th", points: 1, label: "5th", color: "#a7d9ef", textColor: "#1F2937", priority: 5 },
],
flPoints: 1,
flColor: "#8b5cf6",
flTextColor: "#000000",
tracks: [
{ id: "track-hungaroring", name: "Hungaroring", results: [], fastestLapDriverId: "" },
{ id: "track-watkins", name: "Watkins Glen", results: [], fastestLapDriverId: "" },
{ id: "track-paulricard", name: "Paul Ricard", results: [], fastestLapDriverId: "" },
{ id: "track-silverstone", name: "Silverstone", results: [], fastestLapDriverId: "" },
{ id: "track-zandvoort", name: "Zandvoort", results: [], fastestLapDriverId: "" },
{ id: "track-brands", name: "Brands Hatch", results: [], fastestLapDriverId: "" },
{ id: "track-valencia", name: "Circuit Ricardo Tormo (Valencia)", results: [], fastestLapDriverId: "" },
{ id: "track-bathurst", name: "Mount Panorama Circuit", results: [], fastestLapDriverId: "" },
{ id: "track-imola", name: "Autodromo Internazionale Enzo e Dino Ferrari - Imola", results: [], fastestLapDriverId: "" },
{ id: "track-barcelona", name: "Barcelona", results: [], fastestLapDriverId: "" },
{ id: "track-indianapolis", name: "Indianapolis", results: [], fastestLapDriverId: "" },
{ id: "track-laguna", name: "WeatherTech Raceway Laguna Seca", results: [], fastestLapDriverId: "" },
{ id: "track-zolder", name: "Zolder", results: [], fastestLapDriverId: "" },
{ id: "track-suzuka", name: "Suzuka Circuit", results: [], fastestLapDriverId: "" },
{ id: "track-snetterton", name: "Snetterton", results: [], fastestLapDriverId: "" },
{ id: "track-spa", name: "Spa-Francochamps", results: [], fastestLapDriverId: "" },
{ id: "track-cota", name: "Circuit of the Americas (COTA)", results: [], fastestLapDriverId: "" },
{ id: "track-donington", name: "Donington Park", results: [], fastestLapDriverId: "" },
{ id: "track-monza", name: "Monza", results: [], fastestLapDriverId: "" },
{ id: "track-misano", name: "Misano", results: [], fastestLapDriverId: "" },
{ id: "track-kyalami", name: "Kyalami Grand Prix Circuit", results: [], fastestLapDriverId: "" },
{ id: "track-oulton", name: "Oulton Park", results: [], fastestLapDriverId: "" },
{ id: "track-redbull", name: "Red Bull Ring", results: [], fastestLapDriverId: "" },
{ id: "track-nurburgring", name: "Nürburgring", results: [], fastestLapDriverId: "" }
]
};

77
src/lib/leaderboard.ts Executable file
View File

@@ -0,0 +1,77 @@
import { AppData, PointStyle, DriverStanding, RaceHistoryEntry } from './types';
export function parsePoints(value: number | string | null): number {
if (typeof value === 'number') return value;
if (typeof value === 'string') {
const num = parseInt(value, 10);
return isNaN(num) ? 0 : num;
}
return 0;
}
export function findStyleByPoints(
appData: AppData,
points: number,
): PointStyle {
return (
appData.pointsTable.find((p) => p.points === points) || {
id: 'unknown',
points: 0,
label: '???',
color: '#374151',
textColor: '#e5e7eb',
priority: 99,
}
);
}
export function calculateStandingsData(appData: AppData): DriverStanding[] {
const resultsByTrackAndDriver = new Map<
string,
Map<string, { score: number | null }>
>();
appData.tracks.forEach((track) => {
const driverMap = new Map<string, { score: number | null }>();
track.results.forEach((result) => {
driverMap.set(result.driverId, { score: parsePoints(result.score) });
});
resultsByTrackAndDriver.set(track.id, driverMap);
});
const standings = appData.drivers.map((driver, driverIndex) => {
let totalPoints = 0;
let cumulativePoints = 0;
const raceHistory: RaceHistoryEntry[] = [];
appData.tracks.forEach((track) => {
const driverMap = resultsByTrackAndDriver.get(track.id);
const resultObj = driverMap?.get(driver.id);
let racePoints = resultObj?.score ?? 0;
if (track.fastestLapDriverId === driver.id) {
racePoints += appData.flPoints ?? 1;
}
totalPoints += racePoints;
cumulativePoints += racePoints;
raceHistory.push({
trackName: track.name,
points: racePoints,
cumulativePoints: cumulativePoints,
});
});
return {
name: driver.name,
color: driver.color,
initials: driver.initials,
totalPoints,
raceHistory,
originalIndex: driverIndex,
};
});
standings.sort((a, b) => b.totalPoints - a.totalPoints);
return standings;
}

53
src/lib/types.ts Executable file
View File

@@ -0,0 +1,53 @@
export interface Driver {
id: string;
name: string;
color: string;
initials: string;
}
export interface PointStyle {
id?: string;
points: number;
label: string;
color: string;
textColor: string;
priority: number;
}
export interface RaceResult {
driverId: string;
score: number | null;
}
export interface Track {
id: string;
name: string;
results: RaceResult[];
fastestLapDriverId: string;
}
export interface RaceHistoryEntry {
trackName: string;
points: number;
cumulativePoints: number;
}
export interface DriverStanding {
name: string;
color: string;
initials: string;
totalPoints: number;
raceHistory: RaceHistoryEntry[];
originalIndex: number;
}
export interface AppData {
id?: string;
serverId?: string;
drivers: Driver[];
pointsTable: PointStyle[];
flPoints: number;
flColor: string;
flTextColor: string;
tracks: Track[];
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './App.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

14
tailwind.config.ts Executable file
View File

@@ -0,0 +1,14 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {},
},
plugins: [],
};
export default config;

49
tsconfig.json Executable file
View File

@@ -0,0 +1,49 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"allowJs": true,
"incremental": true,
"types": [
"vite/client"
]
},
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
],
"exclude": [
"node_modules"
]
}

12
tsconfig.node.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

13
vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});