Compare commits

...

82 Commits

Author SHA1 Message Date
7434165ab0 Pocketbase: Update workflow to v0.33.0
Some checks failed
Build Formula11 Docker Image / pocketbase-docker (push) Has been cancelled
Build Pocketbase Docker Image / pocketbase-docker (push) Successful in 10s
2025-11-23 12:57:26 +01:00
cae1457081 Pocketbase: Update schema (fix incorrect DNF points calculation with multiple DNFs)
Some checks failed
Build Formula11 Docker Image / pocketbase-docker (push) Has been cancelled
Build Pocketbase Docker Image / pocketbase-docker (push) Successful in 3m28s
2025-11-23 12:48:47 +01:00
0afc4c2e80 Pocketbase: Update image to v0.33.0 2025-11-23 12:48:30 +01:00
b58ed8e0f0 Env: Update browser-list-db 2025-11-23 12:48:13 +01:00
9ff333f90e Env: Update flake 2025-11-23 12:47:41 +01:00
d31c6b6735 Lib: Fix f1.com scraping after the keks changed their site
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 55s
2025-06-21 22:35:06 +02:00
cb48ee68e5 Data/Official: Add warning toast to official data fetch button 2025-06-21 22:34:42 +02:00
f3c75fc921 Lib: Update toast.ts to allow action toasts 2025-06-21 22:33:47 +02:00
eb0de24754 Statistics: Fix nullpointer issue 2025-06-07 22:46:56 +02:00
5adc05e1bb Statistics: Implement statistics page with team/driver standings + driver cumsum chart
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 31s
2025-06-07 22:44:41 +02:00
f049805124 Leaderboard: Use chart options generator function 2025-06-07 22:44:24 +02:00
e4be7c4830 Data/Official: Update scraped data "depends" name 2025-06-07 22:44:01 +02:00
f0950d3241 Lib: Add chart options generator function 2025-06-07 22:43:30 +02:00
454b77e778 Lib: Update fetcher + schema after database update 2025-06-07 22:43:21 +02:00
35c0003159 Data/Official: Disable scrape button for official data clientside
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 30s
2025-06-07 20:40:59 +02:00
5a6156adb7 Skeleton: Subscribe to scraped_startinggrids collection 2025-06-07 20:40:19 +02:00
c14958e5bf Data/Official: Display starting grid data
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 30s
2025-06-07 20:34:28 +02:00
9d6e4e1b0b API/Scrape: Scrape starting grid data 2025-06-07 20:34:17 +02:00
f5d8f56330 Lib: Implement starting grid scraper 2025-06-07 20:34:02 +02:00
3ca967591e Leaderboard: Implement simple points cumsum chart
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 1m3s
2025-06-07 19:59:39 +02:00
6e6ce020a3 Env: Add carbon-charts dep 2025-06-07 19:59:22 +02:00
d54ee01227 Lib: Update fetchers and schema after database changes 2025-06-07 19:58:34 +02:00
3339ffaa5f Seasonpicks: Invert table background colors 2025-06-06 16:26:53 +02:00
78ee291795 Racepicks: Invert table background colors 2025-06-06 16:26:46 +02:00
77895d9057 Seasonpicks: Decrease table margins
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 49s
2025-06-06 16:08:58 +02:00
0cd87c6222 Racepicks: Decrease table margins 2025-06-06 16:08:58 +02:00
59ece62947 Lib: Fix driver id lookup by selected chips in RaceResultCard
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 52s
2025-04-16 03:03:37 +02:00
9cabe902a0 Pocketbase: Update schema
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 30s
2025-03-27 14:40:57 +01:00
c8016509ea Season/Teams: Remove border from team color badge
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 29s
2025-03-27 13:28:26 +01:00
82941d4a2a Season/Drivers: Remove border from team color badge 2025-03-27 13:28:19 +01:00
a9ac49f6fa Season/Drivers: Display started_active in table
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 33s
2025-03-27 13:17:30 +01:00
0cd4129a6e Seasonpicks: Filter drivers (podiums/teamwinners) by started_active 2025-03-27 13:17:20 +01:00
eeae9fea6e Lib: Set started_active field for new driver in TeamSwitchCard 2025-03-27 13:17:05 +01:00
35af66c222 Lib: Add started_active field to Driver schema 2025-03-27 13:16:17 +01:00
707ba0f156 Season/Drivers: Add "Switch Team" button
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 48s
2025-03-27 13:00:12 +01:00
a1a9166c73 Skeleton: Register TeamSwitchCard component 2025-03-27 13:00:01 +01:00
310d2c1bd3 Lib: Implement TeamSwitchCard 2025-03-27 12:59:52 +01:00
7d49da3492 Lib: Prevent DriverCard from updating driver teams (use team switch) 2025-03-27 12:59:37 +01:00
edbcf5e5ab Lib: Update SubstitutionCard promises 2025-03-27 12:59:01 +01:00
f66f6783d2 Skeleton: Link to roadmap in navbar
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 29s
2025-03-25 20:33:36 +01:00
415c9e3ae1 Lib: Add newtab option to Button component 2025-03-25 20:33:28 +01:00
cb4a2140f7 Skeleton: Move data drawer "Official" button
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 34s
2025-03-25 20:12:04 +01:00
7ab818dd48 Api/Scrape: Implement serverside api route to fetch official f1.com data
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 37s
2025-03-25 20:09:54 +01:00
1d7dff1b53 Seasonpicks: Update depended data 2025-03-25 19:51:37 +01:00
18efab45d3 Racepicks: Update depended data 2025-03-25 19:51:31 +01:00
f75c880c25 Data/Raceresults: Update depended data 2025-03-25 19:51:23 +01:00
20dfc45f89 Data/Official: Implement driverstandings page 2025-03-25 19:45:37 +01:00
3b710bd846 Data/Official: Implement raceresults page 2025-03-25 19:45:30 +01:00
5485425213 Data/Official: Implement teamstandings page 2025-03-25 19:45:23 +01:00
a9252e92f7 Skeleton: Subscribe to official/scraped pocketbase tables 2025-03-25 19:45:15 +01:00
baaa5f1c61 Data/Official: Add official data page skeleton 2025-03-25 19:32:00 +01:00
6239cdef5d Skeleton: Add data drawer button for official data 2025-03-25 19:31:39 +01:00
a05c0e6882 Data/Users: Update table height 2025-03-25 19:31:25 +01:00
756004476d Season/Teams: Update table height 2025-03-25 19:31:20 +01:00
0e0203c4f7 Season/Substitutions: Update table height 2025-03-25 19:31:13 +01:00
7e24a43312 Season/Races: Update table height 2025-03-25 19:31:06 +01:00
4bfa15f4aa Season/Drivers: Update table height 2025-03-25 19:31:01 +01:00
f3eb710403 Data/Raceresults: Update table height 2025-03-25 19:30:43 +01:00
bc158b6060 Lib: Add scraper library functions for results/team standings/driver standings 2025-03-25 19:30:10 +01:00
2fa2d7006b Lib: Add ScrapedDriverStanding + ScrapedTeamStanding to schema 2025-03-25 19:29:48 +01:00
36b4aea1c2 Lib: Add pocketbase fetchers for scraped data 2025-03-25 19:29:32 +01:00
4a497aefb4 Lib: Add optional height classes to component + make thead sticky 2025-03-25 19:29:16 +01:00
22a6da55fa Lib: Add ScrapedRaceResult to schema 2025-03-25 18:00:58 +01:00
b0859ff147 Env: Add cheerio node dep 2025-03-25 18:00:46 +01:00
cdf11fc2ba Raceresults: Check for 0 pxxs in results table
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 54s
2025-03-25 18:00:26 +01:00
8f27828384 Lib: Don't force 7 pxxs in RaceResultCard 2025-03-25 18:00:04 +01:00
e40c284726 Lib: Disable race select filtering in RaceResultCard when editing an existing result
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 28s
2025-03-23 19:16:18 +01:00
80fa0216fb Lib: Only show races without result present in RaceResultCard race dropdown
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-03-23 19:10:37 +01:00
ec56925dca Lib: Add placeholder to RaceCard, hint to end name with emoji
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-03-23 19:03:17 +01:00
243a020e00 Racepicks: Rework racename display on mobile (fixes emoji bug)
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 40s
2025-03-23 19:00:48 +01:00
45d4cc1557 Env: Add runes2 npm dep for emoji support 2025-03-23 19:00:34 +01:00
23d01672de Lib: Check for empty string in format_date
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 28s
2025-03-23 18:17:46 +01:00
ab32cfc49a Racepicks: Fix race result popup clipping with avatar table header
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 29s
2025-03-23 18:12:41 +01:00
2faa57489d Pocketbase: Update schema
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 29s
2025-03-23 17:22:08 +01:00
c732ff2014 Leaderboard: Implement first primitive leaderboard page (only total points)
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 29s
2025-03-23 17:17:07 +01:00
7fb38874d9 Leaderboard: Fetch points data 2025-03-23 17:16:51 +01:00
ce2c7af35c Env: Add sqlite/sqlitebrowser to flake 2025-03-23 17:16:30 +01:00
9ec0bf0bd5 Lib: Add fetchers for RacePickPoints and RacePickPointsAcc 2025-03-23 17:16:10 +01:00
f249205cd8 Lib: Add RacePickPoints and RacePickPointsAcc to schema 2025-03-23 17:15:59 +01:00
25e50fd64b Racepicks: Fix incorrect column min-width
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 19s
2025-03-23 08:47:46 +01:00
67164cff09 Racepicks: Make table scroll on both axes, always show race+avatars
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 28s
2025-03-18 18:54:19 +01:00
cb87f336a3 Seasonpicks: Remove obsolete styling 2025-03-18 18:53:43 +01:00
49 changed files with 4588 additions and 365 deletions

2
.envrc
View File

@ -1,2 +0,0 @@
use flake
layout node

View File

@ -20,6 +20,6 @@ jobs:
username: ${{ secrets.CONTAINER_REGISTRY_USER }}
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build Pocketbase Docker Image
run: docker build --build-arg PB_VERSION=0.25.0 --file pocketbase.dockerfile --tag gitea.vps.chriphost.de/christoph/pocketbase:0.25.0 .
run: docker build --build-arg PB_VERSION=0.33.0 --file pocketbase.dockerfile --tag gitea.vps.chriphost.de/christoph/pocketbase:0.33.0 .
- name: Push Pocketbase Docker Image
run: docker push gitea.vps.chriphost.de/christoph/pocketbase:0.25.0
run: docker push gitea.vps.chriphost.de/christoph/pocketbase:0.33.0

120
flake.lock generated
View File

@ -1,8 +1,33 @@
{
"nodes": {
"clj-nix": {
"inputs": {
"devshell": "devshell",
"nix-fetcher-data": "nix-fetcher-data",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1763549559,
"narHash": "sha256-w7qhicMuDyfm9/dJKs5+47XqhZmGXRfkZjyn8XjO+c0=",
"owner": "jlesquembre",
"repo": "clj-nix",
"rev": "a55b9fbce3da4aa35c94221f76d40c79e6de4d81",
"type": "github"
},
"original": {
"owner": "jlesquembre",
"repo": "clj-nix",
"type": "github"
}
},
"devshell": {
"inputs": {
"nixpkgs": "nixpkgs"
"nixpkgs": [
"clj-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1741473158,
@ -18,6 +43,24 @@
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1719745305,
"narHash": "sha256-xwgjVUpqSviudEkpQnioeez1Uo2wzrsMaJKJClh+Bls=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "c3c5ecc05edc7dafba779c6c1a61cd08ac6583e9",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
@ -36,43 +79,80 @@
"type": "github"
}
},
"nixpkgs": {
"nix-fetcher-data": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": [
"clj-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1722073938,
"narHash": "sha256-OpX0StkL8vpXyWOGUD6G+MA26wAXK6SpT94kLJXo6B4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e36e9f57337d0ff0cf77aceb58af4c805472bfae",
"lastModified": 1728229178,
"narHash": "sha256-p5Fx880uBYstIsbaDYN7sECJT11oHxZQKtHgMAVblWA=",
"owner": "jlesquembre",
"repo": "nix-fetcher-data",
"rev": "f3a73c34d28db49ef90fd7872a142bfe93120e55",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"owner": "jlesquembre",
"repo": "nix-fetcher-data",
"type": "github"
}
},
"nixpkgs_2": {
"nixpkgs": {
"locked": {
"lastModified": 1741865919,
"narHash": "sha256-4thdbnP6dlbdq+qZWTsm4ffAwoS8Tiq1YResB+RP6WE=",
"lastModified": 1763618868,
"narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "573c650e8a14b2faa0041645ab18aed7e60f0c9a",
"rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
"id": "nixpkgs",
"type": "indirect"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1717284937,
"narHash": "sha256-lIbdfCsf8LMFloheeE6N31+BMIeixqyQWbSr2vk79EQ=",
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz"
}
},
"root": {
"inputs": {
"devshell": "devshell",
"clj-nix": "clj-nix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1763865987,
"narHash": "sha256-DJpzM8Jz3B0azJcAoF+YFHr8rEbxYLJ0wy1kWZ29HOw=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "042d905c01a6eec3bcae8530dacb19cda9758a63",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {

388
flake.nix
View File

@ -1,110 +1,328 @@
{
description = "Svelte F1 Guessgame";
rec {
description = "Formula11";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.devshell.url = "github:numtide/devshell";
inputs = {
nixpkgs.url = "nixpkgs"; # Use nixpkgs from system registry
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
clj-nix.url = "github:jlesquembre/clj-nix";
clj-nix.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = {
self,
nixpkgs,
flake-utils,
devshell,
rust-overlay,
clj-nix,
}:
# Create a shell (and possibly package) for each possible system, not only x86_64-linux
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [devshell.overlays.default];
};
timple = pkgs.python312Packages.buildPythonPackage rec {
pname = "timple";
version = "0.1.8";
src = pkgs.python312Packages.fetchPypi {
inherit pname version;
hash = "sha256-u8EgMA8BA6OpPlSg0ASRxLcIcv5psRIEcBpIicagXw8=";
};
doCheck = false;
pyproject = true;
# Build time deps
nativeBuildInputs = with pkgs.python312Packages; [
setuptools
];
# Run time deps
dependencies = with pkgs.python312Packages; [
matplotlib
numpy
overlays = [
rust-overlay.overlays.default
];
};
inherit (pkgs) lib stdenv;
fastf1 = pkgs.python312Packages.buildPythonPackage rec {
pname = "fastf1";
version = "3.4.4";
# ===========================================================================================
# Define custom dependencies
# ===========================================================================================
src = pkgs.python312Packages.fetchPypi {
inherit pname version;
hash = "sha256-nELQtvzlLsUYyVaPe1KqvMmzHy5l5W7u1I6m8r8md/4=";
};
# Python package example
# typed-ffmpeg = pkgs.python313Packages.buildPythonPackage rec {
# pname = "typed_ffmpeg";
# version = "3.6";
#
# src = pkgs.python313Packages.fetchPypi {
# inherit pname version;
# hash = "sha256-YPspq/lqI/jx/9FCQntmQPw4lrPIsdxtHTUg0F0QbrM=";
# };
#
# pyproject = true;
# build-system = [
# pkgs.python313Packages.setuptools
# pkgs.python313Packages.setuptools-scm
# ];
# };
doCheck = false;
pyproject = true;
# python = pkgs.python313.withPackages (p:
# with p; [
# # numpy
# # matplotlib
# # typed-ffmpeg
# # pyside6
# ]);
# Build time deps
nativeBuildInputs = with pkgs.python312Packages; [
hatchling
hatch-vcs
];
# rust = pkgs.rust-bin.stable.latest.default.override {
# extensions = ["rust-src"]; # Include the Rust stdlib source (for IntelliJ)
# };
# Run time deps
dependencies = with pkgs.python312Packages; [
matplotlib
numpy
pandas
python-dateutil
requests
requests-cache
scipy
rapidfuzz
websockets
timple
];
};
in {
devShell = pkgs.devshell.mkShell {
name = "Formula11";
# 64 bit C/C++ compilers that don't collide (use the same libc)
# bintools = pkgs.wrapBintoolsWith {
# bintools = pkgs.bintools.bintools; # Unwrapped bintools
# libc = pkgs.glibc;
# };
# gcc = pkgs.hiPrio (pkgs.wrapCCWith {
# cc = pkgs.gcc.cc; # Unwrapped gcc
# libc = pkgs.glibc;
# bintools = bintools;
# });
# clang = pkgs.wrapCCWith {
# cc = pkgs.clang.cc; # Unwrapped clang
# libc = pkgs.glibc;
# bintools = bintools;
# };
packages = with pkgs; [
nodejs_23
# Multilib C/C++ compilers that don't collide (use the same libc)
# bintools_multilib = pkgs.wrapBintoolsWith {
# bintools = pkgs.bintools.bintools; # Unwrapped bintools
# libc = pkgs.glibc_multi;
# };
# gcc_multilib = pkgs.hiPrio (pkgs.wrapCCWith {
# cc = pkgs.gcc.cc; # Unwrapped gcc
# libc = pkgs.glibc_multi;
# bintools = bintools_multilib;
# });
# clang_multilib = pkgs.wrapCCWith {
# cc = pkgs.clang.cc; # Unwrapped clang
# libc = pkgs.glibc_multi;
# bintools = bintools_multilib;
# };
# ===========================================================================================
# Specify dependencies
# https://nixos.org/manual/nixpkgs/stable/#ssec-stdenv-dependencies-overview
# Just for a "nix develop" shell, buildInputs can be used for everything.
# ===========================================================================================
# Add dependencies to nativeBuildInputs if they are executed during the build:
# - Those which are needed on $PATH during the build, for example cmake and pkg-config
# - Setup hooks, for example makeWrapper
# - Interpreters needed by patchShebangs for build scripts (with the --build flag), which can be the case for e.g. perl
nativeBuildInputs = with pkgs; [
nodejs_24
pocketbase
sqlite # For sqlite console
sqlitebrowser # To check low-level pocketbase data
# Languages:
# python
# rust
# bintools
# gcc
# clang
# bintools_multilib
# gcc_multilib
# clang_multilib
# clojure
# jdk
# C/C++:
# gdb
# valgrind
# gnumake
# cmake
# pkg-config
# Clojure:
# leiningen
# clj-nix.packages.${system}.deps-lock
# Java:
# gradle
# Python:
# hatch
# py-spy
# Qt:
# qt6.wrapQtAppsHook # For the shellHook
];
# Use $1 for positional args
commands = [
{
name = "pb";
help = "Serve PocketBase";
command = "pocketbase serve --http 192.168.86.50:8090";
}
{
name = "dev";
help = "Serve Formula 11 (Dev)";
command = "npm run dev -- --host --port 5173";
}
{
name = "prod";
help = "Serve Formula 11 (Prod)";
command = "npm run build && npm run preview -- --host --port 5173";
}
{
name = "check";
help = "Continuously monitor for SvelteKit issues";
command = "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch";
}
# Add dependencies to buildInputs if they will end up copied or linked into the final output or otherwise used at runtime:
# - Libraries used by compilers, for example zlib
# - Interpreters needed by patchShebangs for scripts which are installed, which can be the case for e.g. perl
buildInputs = with pkgs; [
# C/C++:
# boost
# sfml
# Qt:
# qt6.qtbase
# qt6.full
];
# ===========================================================================================
# Define buildable + installable packages
# ===========================================================================================
# package = stdenv.mkDerivation {
# inherit nativeBuildInputs buildInputs;
# pname = "";
# version = "1.0.0";
# src = ./.;
#
# installPhase = ''
# mkdir -p $out/bin
# mv ./BINARY $out/bin
# '';
# };
# package = clj-nix.lib.mkCljApp {
# inherit pkgs;
# modules = [
# # Option list: https://jlesquembre.github.io/clj-nix/options/
# {
# name = "";
# version = "1.0.0";
# main-ns = "";
# projectSrc = ./.;
# withLeiningen = true;
# buildCommand = "lein uberjar"; # Requires "withLeiningen = true;"
# jdk = pkgs.jdk; # Default is pkgs.jdk_headless
# # customJdk.enable = true;
# # nativeImage.enable = true;
# }
# ];
# };
in rec {
# Provide package for "nix build"
# defaultPackage = package;
# defaultApp = flake-utils.lib.mkApp {
# drv = defaultPackage;
# };
# Provide environment for "nix develop"
devShell = pkgs.mkShell {
inherit nativeBuildInputs buildInputs;
name = description;
# =========================================================================================
# Define environment variables
# =========================================================================================
# Rust stdlib source:
# RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
# Custom dynamic libraries:
# LD_LIBRARY_PATH = builtins.concatStringsSep ":" [
# # Rust Bevy GUI app:
# # "${pkgs.xorg.libX11}/lib"
# # "${pkgs.xorg.libXcursor}/lib"
# # "${pkgs.xorg.libXrandr}/lib"
# # "${pkgs.xorg.libXi}/lib"
# # "${pkgs.libGL}/lib"
#
# # JavaFX app:
# # "${pkgs.libGL}/lib"
# # "${pkgs.gtk3}/lib"
# # "${pkgs.glib.out}/lib"
# # "${pkgs.xorg.libXtst}/lib"
# ];
# Dynamic libraries from buildinputs:
# LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath buildInputs;
# QT imports to use with "qmlls -E"
# QML_IMPORT_PATH = "${pkgs.qt6.full}/lib/qt-6/qml";
# Set PYTHONPATH
# PYTHONPATH = ".";
# Set matplotlib backend
# MPLBACKEND = "TkAgg";
# =========================================================================================
# Define shell environment
# =========================================================================================
# Setup the shell when entering the "nix develop" environment (bash script).
shellHook = let
mkCmakeScript = type: let
typeLower = lib.toLower type;
in
pkgs.writers.writeFish "cmake-${typeLower}.fish" ''
cd $FLAKE_PROJECT_ROOT
echo "Removing build directory ./cmake-build-${typeLower}/"
rm -rf ./cmake-build-${typeLower}
echo "Creating build directory"
mkdir cmake-build-${typeLower}
cd cmake-build-${typeLower}
echo "Running cmake"
cmake -G "Unix Makefiles" -DCMAKE_BUILD_TYPE="${type}" -DCMAKE_EXPORT_COMPILE_COMMANDS="On" ..
echo "Linking compile_commands.json"
cd ..
ln -sf ./cmake-build-${typeLower}/compile_commands.json ./compile_commands.json
'';
cmakeDebug = mkCmakeScript "Debug";
cmakeRelease = mkCmakeScript "Release";
mkBuildScript = type: let
typeLower = lib.toLower type;
in
pkgs.writers.writeFish "cmake-build.fish" ''
cd $FLAKE_PROJECT_ROOT/cmake-build-${typeLower}
echo "Running cmake"
cmake --build .
'';
buildDebug = mkBuildScript "Debug";
buildRelease = mkBuildScript "Release";
# Use this to specify commands that should be ran after entering fish shell
initProjectShell = pkgs.writers.writeFish "init-shell.fish" ''
echo "Entering \"${description}\" environment..."
# Determine the project root, used e.g. in cmake scripts
set -g -x FLAKE_PROJECT_ROOT (git rev-parse --show-toplevel)
# Rust Bevy:
# abbr -a build-release-windows "CARGO_FEATURE_PURE=1 cargo xwin build --release --target x86_64-pc-windows-msvc"
# C/C++:
# abbr -a cmake-debug "${cmakeDebug}"
# abbr -a cmake-release "${cmakeRelease}"
# abbr -a build-debug "${buildDebug}"
# abbr -a build-release "${buildRelease}"
# Clojure:
# abbr -a clojure-deps "deps-lock --lein"
# Python:
# abbr -a run "python ./app/main.py"
# abbr -a profile "py-spy record -o profile.svg -- python ./app/main.py && firefox profile.svg"
# abbr -a ptop "py-spy top -- python ./app/main.py"
# Formula11:
abbr -a pb "pocketbase serve --http 192.168.86.50:8090 --dev"
abbr -a dev "npm run dev -- --host --port 5173"
abbr -a prod "npm run build && npm run preview -- --host --port 5173"
abbr -a check "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
'';
in
builtins.concatStringsSep "\n" [
# Launch into pure fish shell
''
exec "$(type -p fish)" -C "source ${initProjectShell} && abbr -a menu '${pkgs.bat}/bin/bat "${initProjectShell}"'"
''
# Qt: Launch into wrapped fish shell
# https://nixos.org/manual/nixpkgs/stable/#sec-language-qt
# ''
# fishdir=$(mktemp -d)
# makeWrapper "$(type -p fish)" "$fishdir/fish" "''${qtWrapperArgs[@]}"
# exec "$fishdir/fish" -C "source ${initProjectShell} && abbr -a menu '${pkgs.bat}/bin/bat "${initProjectShell}"'"
# ''
];
};
});

1338
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@carbon/charts-svelte": "^1.22.18",
"@floating-ui/dom": "^1.6.13",
"@fsouza/prettierd": "^0.26.1",
"@skeletonlabs/skeleton": "^2.10.4",
@ -20,12 +21,14 @@
"@tailwindcss/forms": "^0.5.10",
"@types/node": "^22.13.10",
"autoprefixer": "^10.4.21",
"cheerio": "^1.0.0",
"date-fns": "^4.1.0",
"pocketbase": "^0.25.2",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"runes2": "^1.1.4",
"svelte": "^5.23.0",
"svelte-check": "^4.1.5",
"tailwindcss": "^3.4.17",

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
# because fucking Docker thinks "toomanyrequests". Fuckers
FROM docker.io/library/alpine:3.21.2
ARG PB_VERSION=0.25.0
ARG PB_VERSION=0.33.0
RUN apk add --no-cache \
unzip \

60
src/lib/chart.ts Normal file
View File

@ -0,0 +1,60 @@
import { type LineChartOptions, ScaleTypes } from "@carbon/charts-svelte";
export const make_chart_options = (
title: string,
bottom: string,
left: string,
group: string = "group",
width: string = "100%",
height: string = "400px",
): LineChartOptions => {
return {
title: title,
axes: {
bottom: {
mapsTo: bottom,
scaleType: ScaleTypes.LABELS,
},
left: {
mapsTo: left,
scaleType: ScaleTypes.LINEAR,
},
},
data: {
groupMapsTo: group,
},
curve: "curveMonotoneX",
// toolbar: {
// enabled: false,
// },
animations: true,
// canvasZoom: {
// enabled: false,
// },
grid: {
x: {
enabled: true,
alignWithAxisTicks: true,
},
y: {
enabled: true,
alignWithAxisTicks: true,
},
},
legend: {
enabled: true,
clickable: true,
position: "top",
},
points: {
enabled: true,
radius: 5,
},
tooltip: {
showTotal: false,
},
resizable: true,
width: width,
height: height,
};
};

View File

@ -8,17 +8,20 @@
/** The columns the table should have. */
columns: TableColumn[];
/** Optional height classes */
height?: string;
/** An optional function handling clicking on a table row */
handler?: (event: Event, id: string) => Promise<void>;
}
let { data, columns, handler = undefined }: TableProps = $props();
let { data, columns, height = "", handler = undefined }: TableProps = $props();
</script>
{#if data.length > 0}
<div class="table-container bg-white shadow">
<table class="table table-compact bg-white">
<thead>
<div class="table-container bg-white shadow {height}">
<table class="table table-compact !overflow-scroll bg-white">
<thead class="sticky top-0">
<tr class="bg-surface-500">
{#each columns as col}
<th class="!px-3">{col.label}</th>

View File

@ -121,6 +121,14 @@
toastStore.trigger(get_error_toast("Invalid driver id!"));
return;
}
// TODO: Not sure if we want to switch teams without creating a new driver
if (team_select_value !== driver.team) {
toastStore.trigger(
get_error_toast("Please use the team switch button to change teams!"),
);
return;
}
await pb.collection("drivers").update(driver.id, driver_data);
}
modalStore.close();

View File

@ -187,13 +187,21 @@
imgonclick={(event: Event) => modalStore.close()}
>
<div class="flex flex-col gap-2">
<!-- Driver name input -->
<Input bind:value={name_value} autocomplete="off" {labelwidth} {disabled} {required}>
<!-- Race name input -->
<Input
bind:value={name_value}
autocomplete="off"
placeholder="Has to end with an emoji"
{labelwidth}
{disabled}
{required}
>
Name
</Input>
<Input
bind:value={step_value}
autocomplete="off"
placeholder="The step in the race calendar"
type="number"
min={1}
max={24}
@ -206,6 +214,7 @@
<Input
bind:value={pxx_value}
autocomplete="off"
placeholder="The place to guess"
type="number"
min={1}
max={20}

View File

@ -8,7 +8,7 @@
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Button, Card, Dropdown } from "$lib/components";
import { Button, Card, Dropdown, type DropdownOption } from "$lib/components";
import type { Driver, Race, RaceResult, Substitution } from "$lib/schema";
import { get_by_value } from "$lib/database";
import { race_dropdown_options } from "$lib/dropdown";
@ -46,6 +46,9 @@
let substitutions: Substitution[] | undefined = $state(undefined);
data.substitutions.then((s: Substitution[]) => (substitutions = s));
let raceresults: RaceResult[] | undefined = $state(undefined);
data.raceresults.then((r: RaceResult[]) => (raceresults = r));
// Constants
const labelwidth: string = "70px";
@ -58,6 +61,11 @@
get_by_value<Race>(races ?? [], "id", race_select_value) ?? undefined,
);
let present_results: string[] = $derived.by(() => {
if (!raceresults || raceresults.length === 0) return [];
return raceresults.map((raceresult: RaceResult) => raceresult.race);
});
let pxxs_placeholder: string = $derived(
currentrace
? `Select P${(currentrace.pxx ?? -10) - 3} to P${(currentrace.pxx ?? -10) + 3}...`
@ -136,7 +144,7 @@
pxxs_input = "";
// Manage ids that are submitted via form
const id: string = get_by_value(drivers, "code", event.detail.value)?.id ?? "Invalid";
const id: string = get_by_value(drivers.filter((driver: Driver) => driver.active), "code", event.detail.value)?.id ?? "Invalid";
if (!pxxs_ids.includes(id)) {
pxxs_ids.push(id);
}
@ -157,7 +165,7 @@
dnfs_input = "";
// Manage ids that are submitted via form
const id: string = get_by_value(drivers, "code", event.detail.value)?.id ?? "Invalid";
const id: string = get_by_value(drivers.filter((driver: Driver) => driver.active), "code", event.detail.value)?.id ?? "Invalid";
if (!dnfs_ids.includes(id)) {
dnfs_ids.push(id);
}
@ -174,10 +182,11 @@
toastStore.trigger(get_error_toast("Please select a race!"));
return;
}
if (!pxxs_ids || pxxs_ids.length !== 7) {
toastStore.trigger(get_error_toast("Please select all 7 driver placements!"));
return;
}
// If enough drivers DNF/DSQ, theoretically pxxs_ids could be empty
// if (!pxxs_ids || pxxs_ids.length !== 7) {
// toastStore.trigger(get_error_toast("Please select all 7 driver placements!"));
// return;
// }
const raceresult_data = {
race: race_select_value,
@ -227,7 +236,9 @@
<Dropdown
name="race"
bind:value={race_select_value}
options={race_dropdown_options(races)}
options={race_dropdown_options(races).filter(
(option: DropdownOption) => result || !present_results.includes(option.value),
)}
{labelwidth}
{disabled}
{required}

View File

@ -113,10 +113,10 @@
};
</script>
{#await Promise.all([data.graphics, data.drivers]) then [graphics, drivers]}
{#await data.drivers then drivers}
<Card
imgsrc={get_by_value<Driver>(drivers, "id", substitution?.substitute ?? "")?.headshot_url ??
get_driver_headshot_template(graphics)}
imgsrc={get_by_value(drivers, "id", substitution?.substitute ?? "")?.headshot_url ??
get_driver_headshot_template(data.graphics)}
imgid="headshot_preview"
width="w-full sm:w-auto"
imgwidth={DRIVER_HEADSHOT_WIDTH}

View File

@ -0,0 +1,155 @@
<script lang="ts">
import {
getModalStore,
getToastStore,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Card, Button, Dropdown } from "$lib/components";
import type { Driver } from "$lib/schema";
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import { get_error_toast } from "$lib/toast";
import { pb, pbUser } from "$lib/pocketbase";
import type { PageData } from "../../../routes/data/season/drivers/$types";
import { driver_dropdown_options, team_dropdown_options } from "$lib/dropdown";
interface TeamCardProps {
/** Data from the page context */
data: PageData;
}
let { data }: TeamCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
}
const toastStore: ToastStore = getToastStore();
// Await promises
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
// Constants
const labelwidth: string = "110px";
// Reactive state
let required: boolean = true;
let disabled: boolean = $derived(!$pbUser?.admin);
let driver_value: string = $state("");
let team_value: string = $state("");
// Update preview
$effect(() => {
if (!drivers) return;
const src: string = get_by_value(drivers, "id", driver_value)?.headshot_url ?? "";
const img: HTMLImageElement = document.getElementById("headshot_preview") as HTMLImageElement;
if (img) img.src = src;
});
// Database actions
const update_driver = (): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!driver_value || driver_value === "") {
toastStore.trigger(get_error_toast("Please select a driver!"));
return;
}
const old_driver: Driver | undefined = get_by_value(await data.drivers, "id", driver_value);
if (!old_driver) {
toastStore.trigger(get_error_toast("Unable to lookup driver!"));
return;
}
if (!old_driver.headshot_url) {
toastStore.trigger(get_error_toast("Unable to lookup driver headshot!"));
return;
}
if (!team_value || team_value === "" || team_value === old_driver.team) {
toastStore.trigger(get_error_toast("Please select a new team!"));
return;
}
// Create a new driver
const headshot_response = await fetch(old_driver.headshot_url);
const headshot_blob = await headshot_response.blob();
let new_driver_data = {
code: old_driver.code,
firstname: old_driver.firstname,
lastname: old_driver.lastname,
headshot: headshot_blob, // NOTE: Duplicates the image, but no issue for low volume
team: team_value,
active: true,
started_active: false,
};
try {
await pb.collection("drivers").create(new_driver_data);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
return;
}
// Disable the old driver
let old_driver_data = {
active: false,
};
try {
await pb.collection("drivers").update(driver_value, old_driver_data);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
</script>
<Card
imgsrc={get_driver_headshot_template(data.graphics)}
imgid="headshot_preview"
width="w-full sm:w-auto"
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<div class="flex flex-col gap-2">
<!-- Driver select -->
{#await data.drivers then drivers}
<Dropdown
bind:value={driver_value}
options={driver_dropdown_options(drivers.filter((driver: Driver) => driver.active))}
{labelwidth}
{disabled}
{required}
>
Driver
</Dropdown>
{/await}
<!-- New team select -->
{#await data.teams then teams}
<Dropdown
bind:value={team_value}
options={team_dropdown_options(teams)}
{labelwidth}
{disabled}
{required}
>
New Team
</Dropdown>
{/await}
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
<Button onclick={update_driver()} color="tertiary" {disabled} width="w-full">
Switch Team
</Button>
</div>
</div>
</Card>

View File

@ -21,6 +21,9 @@
/** Make the button act as a link. */
href?: string;
/** Open the link inside a new tab. */
newtab?: boolean;
/** Add a width class to the button. */
width?: string;
@ -54,6 +57,7 @@
color = undefined,
submit = false,
href = undefined,
newtab = false,
width = "w-auto",
activate = false,
activate_href = false,
@ -70,6 +74,8 @@
{#if href}
<a
{href}
target={newtab ? "_blank" : undefined}
rel={newtab ? "noopener noreferrer" : undefined}
class="btn m-0 select-none px-2 py-2 {color ? `variant-filled-${color}` : ''} {width} {activate
? 'btn-hover'
: ''} {activate_href && is_at_path(href) ? 'btn-hover' : ''} {shadow

View File

@ -15,6 +15,7 @@ import RaceResultCard from "./cards/RaceResultCard.svelte";
import SeasonPickCard from "./cards/SeasonPickCard.svelte";
import SubstitutionCard from "./cards/SubstitutionCard.svelte";
import TeamCard from "./cards/TeamCard.svelte";
import TeamSwitchCard from "./cards/TeamSwitchCard.svelte";
import type { DropdownOption } from "./form/Dropdown";
import type { TableColumn } from "./Table";
@ -48,6 +49,7 @@ export {
SeasonPickCard,
SubstitutionCard,
TeamCard,
TeamSwitchCard,
// Types
type DropdownOption,

View File

@ -24,5 +24,8 @@ export const timeformat: string = "HH:mm";
* Format a [Date] object using a [date-fns] formatstring.
* This function uses localtime instead of UTC.
*/
export const format_date = <T extends Date | string>(date: T, formatstring: string): string =>
format(new Date(date), formatstring);
export const format_date = <T extends Date | string>(date: T, formatstring: string): string => {
if (!date) return "";
return format(new Date(date), formatstring);
};

View File

@ -7,7 +7,15 @@ import type {
Hottake,
Race,
RacePick,
RacePickPoints,
RacePickPointsAcc,
RacePickPointsTotal,
RaceResult,
ScrapedDriverStanding,
ScrapedRaceResult,
ScrapedRaceResultAcc,
ScrapedStartingGrid,
ScrapedTeamStanding,
SeasonPick,
SeasonPickedUser,
Substitution,
@ -261,3 +269,107 @@ export const fetch_seasonpickedusers = async (
return seasonpickedusers;
};
/**
* Fetch all [RacePickPoints] from the database
*/
export const fetch_racepickpoints = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePickPoints[]> => {
const racepickpoints: RacePickPoints[] = await pb
.collection("racepickpoints")
.getFullList({ fetch: fetch });
return racepickpoints;
};
/**
* Fetch all [RacePickPointsAcc] from the database, ordered ascendingly by step.
*/
export const fetch_racepickpointsacc = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePickPointsAcc[]> => {
const racepickpointsacc: RacePickPointsAcc[] = await pb
.collection("racepickpointsacc")
.getFullList({ fetch: fetch });
return racepickpointsacc;
};
/**
* Fetch all [RacePickPointsTotal] from the database, ordered descendingly by total points.
*/
export const fetch_racepickpointstotal = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePickPointsTotal[]> => {
const racepickpointstotal: RacePickPointsTotal[] = await pb
.collection("racepickpointstotal")
.getFullList({ fetch: fetch });
return racepickpointstotal;
};
/**
* Fetch all [ScrapedDriverStandings] from the database, ordered ascendingly by position.
*/
export const fetch_scraped_driverstandings = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedDriverStanding[]> => {
const scraped_driverstandings: ScrapedDriverStanding[] = await pb
.collection("scraped_driverstandings")
.getFullList({ fetch: fetch, sort: "+position" });
return scraped_driverstandings;
};
/**
* Fetch all [ScrapedTeamStandings] from the database, ordered ascendingly by position.
*/
export const fetch_scraped_teamstandings = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedTeamStanding[]> => {
const scraped_teamstandings: ScrapedTeamStanding[] = await pb
.collection("scraped_teamstandings")
.getFullList({ fetch: fetch, sort: "+position" });
return scraped_teamstandings;
};
/**
* Fetch all [ScrapedStartingGrids] from the database, ordered descendingly by race step.
*/
export const fetch_scraped_startinggrids = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedStartingGrid[]> => {
const scraped_startinggrids: ScrapedStartingGrid[] = await pb
.collection("scraped_startinggrids")
.getFullList({ fetch: fetch, sort: "-race_step,+position" });
return scraped_startinggrids;
};
/**
* Fetch all [ScrapedRaceResults] from the database, ordered descendingly by race step.
*/
export const fetch_scraped_raceresults = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedRaceResult[]> => {
const scraped_raceresults: ScrapedRaceResult[] = await pb
.collection("scraped_raceresults")
.getFullList({ fetch: fetch, sort: "-race_step,+position" });
return scraped_raceresults;
};
/**
* Fetch all [ScrapedRaceResultsAcc] from the database, ordered ascendingly by race step.
*/
export const fetch_scraped_raceresultsacc = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedRaceResultAcc[]> => {
const scraped_raceresultsacc: ScrapedRaceResultAcc[] = await pb
.collection("scraped_raceresultsacc")
.getFullList({ fetch: fetch, sort: "+race_step" });
return scraped_raceresultsacc;
};

View File

@ -41,6 +41,7 @@ export interface Driver {
headshot_url?: string;
team: string;
active: boolean;
started_active: boolean;
}
export interface Race {
@ -127,3 +128,71 @@ export interface SeasonPickedUser {
admin: boolean;
picked: string | null;
}
// Points Data
export interface RacePickPoints {
id: string;
user: string;
step: number;
pxx_points: number;
dnf_points: number;
}
export interface RacePickPointsAcc {
id: string;
user: string;
step: number;
acc_pxx_points: number;
acc_dnf_points: number;
acc_points: number;
}
export interface RacePickPointsTotal {
id: string;
user: string;
total_pxx_points: number;
total_dnf_points: number;
total_points: number;
total_points_per_pick: number;
}
// Scraped Data
export interface ScrapedStartingGrid {
id: string;
race_step: number; // This maps to races
driver_code: string; // This maps to drivers
position: number;
time: string;
}
export interface ScrapedRaceResult {
id: string;
race_step: number; // This maps to races
driver_code: string; // This maps to drivers
position: number;
status: string; // Either contains time to leader or DNF/DSQ...
points: number;
}
export interface ScrapedRaceResultAcc {
id: string;
race_step: number; // This maps to races
driver_code: string; // This maps to drivers
acc_points: number;
}
export interface ScrapedDriverStanding {
id: string;
driver_code: string; // This maps to drivers
position: number;
points: number;
}
export interface ScrapedTeamStanding {
id: string;
team_fullname: string; // TODO: This does NOT map to teams! Add fullname to team data!
position: number;
points: number;
}

181
src/lib/server/scrape.ts Normal file
View File

@ -0,0 +1,181 @@
import type {
ScrapedDriverStanding,
ScrapedStartingGrid,
ScrapedRaceResult,
ScrapedTeamStanding,
} from "$lib/schema";
import * as cheerio from "cheerio";
// TODO: Validate the generated stuff
export const base_url: string = "https://www.formula1.com/en/results/2025";
/**
* Returns a list of links to all past races of the season,
* based on official f1.com data.
*/
export const scrape_race_links = async (): Promise<string[]> => {
const races_response = await fetch(`${base_url}/races`);
const races_text = await races_response.text();
const $ = cheerio.load(races_text);
const race_links: string[] = [];
$("tbody > tr > td:first-child > p > a[href]", "table.f1-table").each((_, element) => {
const href: string = element.attribs["href"];
// Keks changed the link format, cut off the start
const substring: string = href.replace("/../../en/results/2025/", "");
race_links.push(substring);
});
console.log(`Found ${race_links.length} races...`);
console.log(race_links);
return race_links;
};
/**
* Returns a list of [ScrapedStartingGrids] for all races contained in [race_links],
* based on official f1.com data.
*/
export const scrape_starting_grids = async (
race_links: string[],
): Promise<ScrapedStartingGrid[]> => {
// Update the race_links to point to the qualifications
const starting_grid_links: string[] = race_links.map((link: string) =>
link.replace("/race-result", "/starting-grid"),
);
const starting_grids: ScrapedStartingGrid[] = [];
await Promise.all(
starting_grid_links.map(async (link: string, index: number) => {
console.log(`Fetching qualifying results from ${base_url}/${link}...`);
const starting_grids_response = await fetch(`${base_url}/${link}`);
const starting_grids_text = await starting_grids_response.text();
const $ = cheerio.load(starting_grids_text);
// Obtain the positions for this starting grid for each driver
$("tbody > tr", "table.f1-table").each((driver_index, element) => {
const $$ = cheerio.load(element);
let result: ScrapedStartingGrid = {
id: "",
race_step: index + 1,
driver_code: $$(
"td:nth-child(3) > p > span:first-child > span:last-child > span:last-child",
).text(),
position: driver_index + 1, // parseInt($$("td:nth-child(1) > p").text()),
time: $$("td:nth-child(5) > p").text(),
};
starting_grids.push(result);
});
}),
);
console.log(`Scraped ${starting_grids.length} starting grids...`);
// console.log(starting_grids);
return starting_grids;
};
/**
* Returns a list of [ScrapedRaceResults] for all races contained in [race_links],
* based on official f1.com data.
*/
export const scrape_race_results = async (race_links: string[]): Promise<ScrapedRaceResult[]> => {
const race_results: ScrapedRaceResult[] = [];
await Promise.all(
race_links.map(async (link: string, index: number) => {
console.log(`Fetching race results from ${base_url}/${link}...`);
const race_response = await fetch(`${base_url}/${link}`);
const race_text = await race_response.text();
const $ = cheerio.load(race_text);
// Obtain the results for this race for each driver
$("tbody > tr", "table.f1-table").each((driver_index, element) => {
const $$ = cheerio.load(element);
let result: ScrapedRaceResult = {
id: "",
race_step: index + 1,
driver_code: $$(
"td:nth-child(3) > p > span:first-child > span:last-child > span:last-child",
).text(),
position: driver_index + 1, // parseInt($$("td:nth-child(1) > p").text()),
status: $$("td:nth-child(6) > p").text(),
points: parseInt($$("td:nth-child(7) > p").text()),
};
// DSQ'd/DNF'd drivers have NaN positions
// if (Number.isNaN(result.position)) {
// result.position = driver_index;
// }
race_results.push(result);
});
}),
);
console.log(`Scraped ${race_results.length} race results...`);
// console.log(race_results);
return race_results;
};
/**
* Returns a list of [ScrapedDriverStandings], based on official f1.com data.
*/
export const scrape_driver_standings = async (): Promise<ScrapedDriverStanding[]> => {
const standings_response = await fetch(`${base_url}/drivers`);
const standings_text = await standings_response.text();
const $ = cheerio.load(standings_text);
const driver_standings: ScrapedDriverStanding[] = [];
$("tbody > tr", "table.f1-table").each((driver_index, element) => {
const $$ = cheerio.load(element);
let standing: ScrapedDriverStanding = {
id: "",
driver_code: $$("td:nth-child(2) > p > a > span:last-child > span:last-child").text(),
position: driver_index + 1,
points: parseInt($$("td:nth-child(5) > p").text()),
};
driver_standings.push(standing);
});
console.log(`Scraped ${driver_standings.length} driver standings...`);
// console.log(driver_standings);
return driver_standings;
};
/**
* Returns a list of [ScrapedTeamStandings], based on official f1.com data.
*/
export const scrape_team_standings = async (): Promise<ScrapedTeamStanding[]> => {
const standings_response = await fetch(`${base_url}/team`);
const standings_text = await standings_response.text();
const $ = cheerio.load(standings_text);
const team_standings: ScrapedTeamStanding[] = [];
$("tbody > tr", "table.f1-table").each((team_index, element) => {
const $$ = cheerio.load(element);
let standing: ScrapedTeamStanding = {
id: "",
team_fullname: $$("td:nth-child(2) > p > a").text(),
position: team_index + 1,
points: parseInt($$("td:nth-child(3) > p").text()),
};
team_standings.push(standing);
});
console.log(`Scraped ${team_standings.length} team standings...`);
// console.log(team_standings);
return team_standings;
};

View File

@ -1,28 +1,63 @@
import type { ToastSettings } from "@skeletonlabs/skeleton";
export const get_info_toast = (message: string, timeout: number = 2000): ToastSettings => {
return {
message,
hideDismiss: true,
timeout,
background: "variant-filled-tertiary",
};
};
export const get_info_toast = (
message: string,
timeout: number | null = 2000,
action_label: string | undefined = undefined,
action_response: (() => void) | undefined = undefined,
): ToastSettings =>
get_toast(message, "variant-filled-tertiary", timeout, action_label, action_response);
export const get_warning_toast = (message: string, timeout: number = 2000): ToastSettings => {
return {
message,
hideDismiss: true,
timeout,
background: "variant-filled-secondary",
};
};
export const get_warning_toast = (
message: string,
timeout: number | null = 2000,
action_label: string | undefined = undefined,
action_response: (() => void) | undefined = undefined,
): ToastSettings =>
get_toast(message, "variant-filled-secondary", timeout, action_label, action_response);
export const get_error_toast = (message: string, timeout: number = 2000): ToastSettings => {
export const get_error_toast = (
message: string,
timeout: number | null = 2000,
action_label: string | undefined = undefined,
action_response: (() => void) | undefined = undefined,
): ToastSettings =>
get_toast(message, "variant-filled-primary", timeout, action_label, action_response);
/**
* Utility function to create [ToastSettings].
* If [timeout] is defined, the toast will disappear automatically and the dismiss-button will be hidden.
* If [timeout] is undefined, the toast will stay until dismissed from the dismiss-button.
* If [action_label] and [action_response] are defined, a callback function will be executed after accepting.
* In this case, [timeout] behaves as if undefined.
*/
const get_toast = (
message: string,
background: string,
timeout: number | null = 2000,
action_label: string | undefined = undefined,
action_response: (() => void) | undefined = undefined,
): ToastSettings => {
return {
message,
hideDismiss: true,
timeout,
background: "variant-filled-primary",
background,
// If we have a timeout and no action, dismiss is hidden
hideDismiss: !!timeout && (!action_label || !action_response),
// If we have a timeout and no action, the timeout is used
timeout: !!timeout && (!action_label || !action_response) ? timeout : undefined,
// If we have a timeout and no action, autohide is true
autohide: !!timeout && (!action_label || !action_response),
// If we have a label and a response, use the action
action:
!!action_label && !!action_response
? {
label: action_label,
response: action_response,
}
: undefined,
};
};

View File

@ -19,6 +19,7 @@
RaceResultCard,
SeasonPickCard,
EMailIcon,
TeamSwitchCard,
} from "$lib/components";
import { get_avatar_preview_event_handler } from "$lib/image";
import {
@ -58,13 +59,14 @@
const modalStore: ModalStore = getModalStore();
const modalRegistry: Record<string, ModalComponent> = {
// Card data (e.g. team, driver etc.) is passed using $modalStore[0].meta
teamCard: { ref: TeamCard },
driverCard: { ref: DriverCard },
raceCard: { ref: RaceCard },
racePickCard: { ref: RacePickCard },
raceResultCard: { ref: RaceResultCard },
seasonPickCard: { ref: SeasonPickCard },
substitutionCard: { ref: SubstitutionCard },
teamCard: { ref: TeamCard },
teamSwitchCard: { ref: TeamSwitchCard },
};
// Toast config
@ -327,6 +329,10 @@
"seasonpicks",
"substitutions",
"teams",
"scraped_startinggrids",
"scraped_raceresults",
"scraped_driverstandings",
"scraped_teamstandings",
// The view collections do not receive realtime events
]),
@ -342,6 +348,10 @@
"seasonpicks",
"substitutions",
"teams",
"scraped_startinggrids",
"scraped_raceresults",
"scraped_driverstandings",
"scraped_teamstandings",
]),
);
</script>
@ -374,6 +384,16 @@
<Button href="/rules" onclick={close_drawer} color="surface" width="w-full" shadow>
Rules
</Button>
<Button
href="https://gitea.vps.chriphost.de/christoph/svelte-formula11/projects/1"
onclick={close_drawer}
color="surface"
width="w-full"
shadow
newtab
>
Roadmap
</Button>
</div>
{:else if $drawerStore.id === "data_drawer"}
<!-- Data Drawer -->
@ -392,6 +412,15 @@
>
Season
</Button>
<Button
href="/data/official/driverstandings"
onclick={close_drawer}
color="surface"
width="w-full"
shadow
>
Official
</Button>
<Button href="/data/users" onclick={close_drawer} color="surface" width="w-full" shadow>
Users
</Button>
@ -570,6 +599,14 @@
<Button href="/leaderboard" color="primary" activate_href>Leaderboard</Button>
<Button href="/statistics" color="primary" activate_href>Statistics</Button>
<Button href="/rules" color="primary" activate_href>Rules</Button>
<Button
href="https://gitea.vps.chriphost.de/christoph/svelte-formula11/projects/1"
color="primary"
activate_href
newtab
>
Roadmap
</Button>
</div>
<svelte:fragment slot="trail">

View File

@ -0,0 +1,164 @@
import {
fetch_scraped_driverstandings,
fetch_scraped_raceresults,
fetch_scraped_startinggrids,
fetch_scraped_teamstandings,
} from "$lib/fetch";
import { pb } from "$lib/pocketbase";
import type {
ScrapedDriverStanding,
ScrapedRaceResult,
ScrapedStartingGrid,
ScrapedTeamStanding,
} from "$lib/schema";
import {
scrape_driver_standings,
scrape_race_links,
scrape_race_results,
scrape_starting_grids,
scrape_team_standings,
} from "$lib/server/scrape";
import type { RequestHandler } from "./$types";
/**
* This route is available at /api/scrape.
* It will fetch current statistics from f1.com and insert them into the database.
*/
// TODO: If this function aborts, it will leave the official data in an inconsistent state...
// Would be nice to use transactions for this, do I need to implement this as PB extension?
export const POST: RequestHandler = async ({ request }) => {
console.log("Fetching race results from f1.com...");
// Obtain the results for each race
const racelinks: string[] = await scrape_race_links();
const startinggrids: ScrapedStartingGrid[] = await scrape_starting_grids(racelinks);
const raceresults: ScrapedRaceResult[] = await scrape_race_results(racelinks);
const driverstandings: ScrapedDriverStanding[] = await scrape_driver_standings();
const teamstandings: ScrapedTeamStanding[] = await scrape_team_standings();
// Clear existing PocketBase data
// TODO: Do I really have to fetch everything just to delete it???
let deleted: number = 0;
const scraped_startinggrids: ScrapedStartingGrid[] = await fetch_scraped_startinggrids(fetch);
for (const grid of scraped_startinggrids) {
try {
await pb.collection("scraped_startinggrids").delete(grid.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_startinggrids.length} starting grids`);
deleted = 0;
const scraped_raceresults: ScrapedRaceResult[] = await fetch_scraped_raceresults(fetch);
for (const result of scraped_raceresults) {
try {
await pb.collection("scraped_raceresults").delete(result.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_raceresults.length} race results.`);
deleted = 0;
const scraped_driverstandings: ScrapedDriverStanding[] =
await fetch_scraped_driverstandings(fetch);
for (const standing of scraped_driverstandings) {
try {
await pb.collection("scraped_driverstandings").delete(standing.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_driverstandings.length} driver standings.`);
deleted = 0;
const scraped_teamstandings: ScrapedTeamStanding[] = await fetch_scraped_teamstandings(fetch);
for (const standing of scraped_teamstandings) {
try {
await pb.collection("scraped_teamstandings").delete(standing.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_teamstandings.length} team standings.`);
// Submit new data to PocketBase
let submissions: number = 0;
for (const grid of startinggrids) {
try {
// TODO: Authenticate this
await pb.collection("scraped_startinggrids").create(grid);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this starting grid:");
console.log(grid);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${startinggrids.length} starting grids.`);
submissions = 0;
for (const result of raceresults) {
try {
// TODO: Authenticate this
await pb.collection("scraped_raceresults").create(result);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this race result:");
console.log(result);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${raceresults.length} race results.`);
submissions = 0;
for (const standing of driverstandings) {
try {
// TODO: Authenticate this
await pb.collection("scraped_driverstandings").create(standing);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this driver standing:");
console.log(standing);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${driverstandings.length} driver standings.`);
submissions = 0;
for (const standing of teamstandings) {
try {
// TODO: Authenticate this
await pb.collection("scraped_teamstandings").create(standing);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this team standing:");
console.log(standing);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${teamstandings.length} team standings.`);
return new Response(); // TODO: Return success
};

View File

@ -0,0 +1,58 @@
<script lang="ts">
import { invalidate } from "$app/navigation";
import { Button } from "$lib/components";
import { pbUser } from "$lib/pocketbase";
import { get_error_toast, get_warning_toast } from "$lib/toast";
import { getToastStore, type ToastStore } from "@skeletonlabs/skeleton";
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
const toastStore: ToastStore = getToastStore();
const scrape_message: string =
"This will clear and redownload all data from f1.com. Please don't refresh the page during the process.";
// This callback will be executed once the admin presses the "Proceed"-button in the warning toast
const scrape_callback = async () => {
const response: Response = await fetch("/api/scrape", { method: "POST" });
invalidate("data:official");
};
const scrape_official_data = async () => {
if (!$pbUser || !$pbUser.admin) {
toastStore.trigger(get_error_toast("Only admins may perform this action!"));
return;
}
// No timeout + action toast
toastStore.trigger(get_warning_toast(scrape_message, null, "Proceed", scrape_callback));
};
</script>
<div class="fixed left-0 right-0 top-14 z-10 flex justify-center">
<div
class="mx-2 flex w-full justify-center gap-2 bg-primary-500 pb-2 pt-3 shadow rounded-bl-container-token rounded-br-container-token"
>
<Button href="driverstandings" color="primary" activate_href>Drivers</Button>
<Button href="teamstandings" color="primary" activate_href>Teams</Button>
<Button href="startinggrids" color="primary" activate_href>Grids</Button>
<Button href="raceresults" color="primary" activate_href>Race Results</Button>
</div>
</div>
<!-- Each child's contents will be inserted here -->
<div style="margin-top: 56px;">
<div class="pb-2">
<Button
width="w-full"
color="tertiary"
onclick={scrape_official_data}
shadow
disabled={!$pbUser?.admin}
>
<span class="font-bold">Refresh All Data</span>
</Button>
</div>
{@render children()}
</div>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const standings_columns: TableColumn[] = $derived([
{
data_value_name: "driver_code",
label: "Driver",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Driver Standings</title>
</svelte:head>
{#await data.scraped_driverstandings then standings}
<Table
data={standings}
columns={standings_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,11 @@
import { fetch_drivers, fetch_scraped_driverstandings } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:official", "data:drivers");
return {
scraped_driverstandings: fetch_scraped_driverstandings(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -0,0 +1,55 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
import type { Race } from "$lib/schema";
let { data }: { data: PageData } = $props();
const results_columns: TableColumn[] = $derived([
{
data_value_name: "race_step",
label: "Race",
valuefun: async (value: string): Promise<string> => {
const racename: string = get_by_value(await data.races, "step", value)?.name ?? "Invalid";
return `<span class='badge variant-filled-surface'>${racename}</span>`;
},
},
{
data_value_name: "race_step",
label: "Step",
},
{
data_value_name: "driver_code",
label: "Driver",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "status",
label: "Status",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Race Results</title>
</svelte:head>
{#await data.scraped_raceresults then results}
<Table
data={results}
columns={results_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,12 @@
import { fetch_drivers, fetch_races, fetch_scraped_raceresults } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:official", "data:races", "data:drivers");
return {
scraped_raceresults: fetch_scraped_raceresults(fetch),
races: fetch_races(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -0,0 +1,49 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
import type { Race } from "$lib/schema";
let { data }: { data: PageData } = $props();
const grids_columns: TableColumn[] = $derived([
{
data_value_name: "race_step",
label: "Race",
valuefun: async (value: string): Promise<string> => {
const racename: string = get_by_value(await data.races, "step", value)?.name ?? "Invalid";
return `<span class='badge variant-filled-surface'>${racename}</span>`;
},
},
{
data_value_name: "race_step",
label: "Step",
},
{
data_value_name: "driver_code",
label: "Driver",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "time",
label: "Time",
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Starting Grids</title>
</svelte:head>
{#await data.scraped_startinggrids then grids}
<Table
data={grids}
columns={grids_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,12 @@
import { fetch_drivers, fetch_races, fetch_scraped_startinggrids } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:official", "data:races", "data:drivers");
return {
scraped_startinggrids: fetch_scraped_startinggrids(fetch),
races: fetch_races(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const standings_columns: TableColumn[] = $derived([
{
data_value_name: "team_fullname",
label: "Team",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Team Standings</title>
</svelte:head>
{#await data.scraped_teamstandings then standings}
<Table
data={standings}
columns={standings_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,11 @@
import { fetch_scraped_teamstandings, fetch_teams } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:official", "data:teams");
return {
scraped_teamstandings: fetch_scraped_teamstandings(fetch),
teams: fetch_teams(fetch),
};
};

View File

@ -54,6 +54,8 @@
data_value_name: "pxxs",
label: "Standing",
valuefun: async (value: string): Promise<string> => {
if (value.length === 0 || value === "") return "";
const pxxs_array: string[] = value.toString().split(",");
const pxxs_codes: string[] = await Promise.all(
pxxs_array.map(
@ -95,5 +97,10 @@
</Button>
</div>
{#await data.raceresults then results}
<Table data={results} columns={results_columns} handler={result_handler} />
<Table
data={results}
columns={results_columns}
handler={result_handler}
height="h-[calc(100vh-210px)] lg:h-[calc(100vh-126px)]"
/>
{/await}

View File

@ -2,7 +2,7 @@ import { fetch_drivers, fetch_raceresults, fetch_races, fetch_substitutions } fr
import type { PageLoad } from "../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:drivers", "data:races", "data:raceresults");
depends("data:drivers", "data:races", "data:raceresults", "data:substitutions");
return {
drivers: fetch_drivers(fetch),

View File

@ -24,6 +24,17 @@
modalStore.trigger(modalSettings);
};
const teamswitch_handler = async (event: Event, id?: string) => {
const modalSettings: ModalSettings = {
type: "component",
component: "teamSwitchCard",
meta: {
data,
},
};
modalStore.trigger(modalSettings);
};
const drivers_columns: TableColumn[] = $derived([
{
@ -39,7 +50,7 @@
label: "Team",
valuefun: async (value: string): Promise<string> => {
const team: Team | undefined = get_by_value(await data.teams, "id", value);
return `<span class='badge border mr-2' style='color: ${team?.color ?? "#FFFFFF"}; background: ${team?.color ?? "#FFFFFF"};'>C</span>${team?.name ?? "Invalid"}`;
return `<span class='badge mr-2' style='color: ${team?.color ?? "#FFFFFF"}; background: ${team?.color ?? "#FFFFFF"};'>C</span>${team?.name ?? "Invalid"}`;
},
},
{
@ -48,6 +59,12 @@
valuefun: async (value: boolean): Promise<string> =>
`<span class='badge variant-filled-${value ? "tertiary" : "primary"} text-center' style='width: 36px;'>${value ? "Yes" : "No"}</span>`,
},
{
data_value_name: "started_active",
label: "Started Active",
valuefun: async (value: boolean): Promise<string> =>
`<span class='badge variant-filled-${value ? "tertiary" : "primary"} text-center' style='width: 36px;'>${value ? "Yes" : "No"}</span>`,
},
]);
</script>
@ -55,11 +72,19 @@
<title>Formula 11 - Drivers</title>
</svelte:head>
<div class="pb-2">
<div class="flex gap-2 pb-2">
<Button width="w-full" color="tertiary" onclick={driver_handler} shadow>
<span class="font-bold">Create New Driver</span>
</Button>
<Button width="w-full" color="secondary" onclick={teamswitch_handler} shadow>
<span class="font-bold">Switch Driver Team</span>
</Button>
</div>
{#await data.drivers then drivers}
<Table data={drivers} columns={drivers_columns} handler={driver_handler} />
<Table
data={drivers}
columns={drivers_columns}
handler={driver_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -69,5 +69,10 @@
</Button>
</div>
{#await data.races then races}
<Table data={races} columns={races_columns} handler={race_handler} />
<Table
data={races}
columns={races_columns}
handler={race_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -69,5 +69,10 @@
</Button>
</div>
{#await data.substitutions then substitutions}
<Table data={substitutions} columns={substitutions_columns} handler={substitution_handler} />
<Table
data={substitutions}
columns={substitutions_columns}
handler={substitution_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -37,7 +37,7 @@
data_value_name: "color",
label: "Color",
valuefun: async (value: string): Promise<string> =>
`<span class='badge border mr-2' style='color: ${value}; background: ${value};'>C</span>`,
`<span class='badge mr-2' style='color: ${value}; background: ${value};'>C</span>`,
},
]);
</script>
@ -52,5 +52,10 @@
</Button>
</div>
{#await data.teams then teams}
<Table data={teams} columns={teams_columns} handler={team_handler} />
<Table
data={teams}
columns={teams_columns}
handler={team_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -43,4 +43,9 @@
<title>Formula 11 - Users</title>
</svelte:head>
<Table data={data.users} columns={users_columns} handler={users_handler} />
<Table
data={data.users}
columns={users_columns}
handler={users_handler}
height="h-[calc(100vh-160px)] lg:h-[calc(100vh-76px)]"
/>

View File

@ -1,3 +1,96 @@
<script lang="ts">
import { make_chart_options } from "$lib/chart";
import { Table, type TableColumn } from "$lib/components";
import { get_by_value } from "$lib/database";
import type { RacePickPoints, RacePickPointsAcc, User } from "$lib/schema";
import type { PageData } from "./$types";
import {
LineChart,
ScaleTypes,
type ChartTabularData,
type LineChartOptions,
} from "@carbon/charts-svelte";
import "@carbon/charts-svelte/styles.css";
let { data }: { data: PageData } = $props();
// Await promises
let users: User[] | undefined = $state(undefined);
data.users.then((u: User[]) => (users = u));
let racepickpoints: RacePickPoints[] | undefined = $state(undefined);
data.racepickpoints.then((r: RacePickPoints[]) => (racepickpoints = r));
let racepickpointsacc: RacePickPointsAcc[] | undefined = $state(undefined);
data.racepickpointsacc.then((r: RacePickPointsAcc[]) => (racepickpointsacc = r));
const leaderboard_columns: TableColumn[] = $derived([
{
data_value_name: "user",
label: "User",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${get_by_value(await data.users, "id", value)?.firstname ?? "Invalid"}</span>`,
},
{
data_value_name: "total_points",
label: "Total",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "total_pxx_points",
label: "PXX",
},
{
data_value_name: "total_dnf_points",
label: "DNF",
},
{
data_value_name: "total_points_per_pick",
label: "Per Pick",
valuefun: async (value: string): Promise<string> => Number.parseFloat(value).toFixed(2),
},
]);
const points_chart_data: ChartTabularData = $derived.by(() => {
if (!users || !racepickpointsacc) return [];
return users
.map((user: User) => {
return {
group: user.firstname,
step: "0",
points: 0,
};
})
.concat(
racepickpointsacc.map((points: RacePickPointsAcc) => {
return {
group: get_by_value(users ?? [], "id", points.user)?.firstname || "INVALID",
step: points.step.toString(),
points: points.acc_points,
};
}),
);
});
const points_chart_options: LineChartOptions = make_chart_options(
"I ❤️ CumSum",
"step",
"points",
);
</script>
<svelte:head>
<title>Formula 11 - Leaderboard</title>
</svelte:head>
<div class="card w-full bg-surface-100 p-2 shadow">
<LineChart data={points_chart_data} options={points_chart_options} />
</div>
<div class="mt-2">
{#await data.racepickpointstotal then racepickpointstotal}
<Table data={racepickpointstotal} columns={leaderboard_columns} />
{/await}
</div>

View File

@ -0,0 +1,18 @@
import {
fetch_users,
fetch_racepickpoints,
fetch_racepickpointsacc,
fetch_racepickpointstotal,
} from "$lib/fetch";
import type { PageLoad } from "../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:users", "data:raceresults");
return {
users: fetch_users(fetch),
racepickpoints: fetch_racepickpoints(fetch),
racepickpointsacc: fetch_racepickpointsacc(fetch),
racepickpointstotal: fetch_racepickpointstotal(fetch),
};
};

View File

@ -19,6 +19,7 @@
RACE_PICTOGRAM_HEIGHT,
RACE_PICTOGRAM_WIDTH,
} from "$lib/config";
import { runes, substring } from "runes2";
import { format_date, shortdatetimeformat } from "$lib/date";
import type { CurrentPickedUser, RacePick } from "$lib/schema";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
@ -58,7 +59,7 @@
return {
event: "click",
target: target,
placement: "right",
placement: "right-start",
};
};
</script>
@ -70,7 +71,12 @@
<!-- Only show the userguess if signed in and we have a next race -->
{#if $pbUser && data.currentrace}
{#await Promise.all( [data.drivers, data.currentpickedusers, pickedusers, outstandingusers], ) then [drivers, currentpicked, picked, outstanding]}
<Accordion class="card mx-auto bg-surface-500 shadow" regionPanel="pt-0" width="w-full">
<!-- HACK: relative was required to get the shadow to show up above the upper table occluder? -->
<Accordion
class="card relative z-20 mx-auto bg-surface-500 shadow"
regionPanel="pt-0"
width="w-full"
>
<AccordionItem>
<svelte:fragment slot="lead"><ChequeredFlagIcon /></svelte:fragment>
<svelte:fragment slot="summary">
@ -196,14 +202,20 @@
{/if}
<!-- The fookin table -->
<div class="flex {!$pbUser ? 'mt-[-8px]' : ''}">
<div>
<div
class="{!$pbUser
? 'mt-[-8px]'
: ''} relative h-[calc(100vh-200px)] w-[calc(100vw-16px)] scroll-pl-8 scroll-pt-[72px] flex-col overflow-scroll max-lg:hide-scrollbar lg:h-[calc(100vh-116px)] lg:scroll-pl-[152px]"
>
<div class="sticky top-0 z-[15] flex w-full min-w-fit">
<!-- Points color coding legend -->
<!-- Use mt-3/mt-4 to account for 2x padding around the avatar. -->
<div class="mt-4 h-10 w-7 lg:w-36">
<div
class="sticky left-0 z-20 h-16 w-7 min-w-7 max-w-7 bg-surface-50 pt-2 lg:w-36 lg:min-w-36 lg:max-w-36 lg:pt-4"
>
<div class="hidden h-5 text-sm font-bold lg:block">Points:</div>
<div
class="flex h-full flex-col overflow-hidden rounded-b-lg rounded-t-lg shadow lg:h-5 lg:flex-row lg:!rounded-l-lg lg:!rounded-r-lg lg:rounded-b-none lg:rounded-t-none"
class="flex h-full flex-col overflow-hidden rounded-b-lg rounded-t-lg lg:h-5 lg:flex-row lg:!rounded-l-lg lg:!rounded-r-lg lg:rounded-b-none lg:rounded-t-none"
>
<!-- Large Screens: -->
<span
@ -239,14 +251,49 @@
</div>
</div>
<!-- Avatars -->
<div class="flex h-16 w-full bg-surface-50">
{#await data.currentpickedusers then currentpicked}
{#each currentpicked as user}
<div
class="card ml-1 mt-2 w-full min-w-14 rounded-b-none bg-surface-400 py-2
{$pbUser && $pbUser.username === user.username ? '!bg-primary-400' : ''}"
>
<!-- Avatar + name display at the top -->
<div class="m-auto flex h-10 w-fit">
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 40px; width: 40px;"
imgclass="bg-surface-400 rounded-full"
tooltip={user.firstname}
/>
<div
style="height: 40px; line-height: 40px;"
class="ml-2 hidden text-nowrap text-center align-middle max-2xl:text-sm max-xl:text-xs lg:block"
>
{user.firstname}
</div>
</div>
</div>
{/each}
{/await}
</div>
</div>
<div class="flex w-full min-w-fit">
<!-- Race name display -->
<div
class="sticky left-0 z-10 w-7 min-w-7 max-w-7 bg-surface-50 lg:w-36 lg:min-w-36 lg:max-w-36"
>
{#await Promise.all( [data.races, data.raceresults, data.drivers], ) then [races, raceresults, drivers]}
{#each raceresults as result}
{@const race = get_by_value(races, "id", result.race)}
<!-- Race name display -->
<div
use:popup={race_popupsettings(race?.id ?? "Invalid")}
class="card mt-2 flex h-20 w-7 cursor-pointer flex-col !rounded-r-none bg-surface-300 p-2 shadow lg:w-36"
class="card mt-1 flex h-16 w-7 cursor-pointer flex-col !rounded-r-none bg-surface-400 lg:h-20 lg:w-36 lg:p-2"
>
<!-- For large screens -->
<span class="hidden text-sm font-bold lg:block">
@ -258,13 +305,14 @@
<span class="hidden text-sm lg:block">Guessed: P{race?.pxx}</span>
<!-- For small screens -->
<span class="block rotate-90 text-xs font-bold lg:hidden">
{race?.name.slice(0, 9)}{(race?.name.length ?? 9) > 9 ? "." : ""}
</span>
<!-- TODO: This requires the race name to end with an emoji, but this is never enforced -->
<div class="mx-[2px] my-[18px] block text-lg font-bold lg:hidden">
{runes(race?.name ?? "Invalid").at(-1)}
</div>
</div>
<!-- The race result popup is triggered on click on the race -->
<div data-popup={race?.id ?? "Invalid"} class="card z-50 p-2 shadow">
<div data-popup={race?.id ?? "Invalid"} class="card z-50 bg-surface-400 p-2 shadow">
<span class="font-bold">Result:</span>
<div class="mt-2 flex flex-col gap-1">
{#each result.pxxs as pxx, index}
@ -296,35 +344,14 @@
{/await}
</div>
<!-- TODO: Horizontal scrollbar missing in desktop chrome (fuck chrome)??? -->
<div class="flex w-full overflow-x-scroll pb-2">
<!-- Picks -->
<div class="flex w-full">
<!-- Not ideal but currentpickedusers contains all users, so we do not need to fetch the users separately -->
{#await Promise.all( [data.currentpickedusers, data.racepicks, data.races, data.drivers, data.raceresults], ) then [currentpicked, racepicks, races, drivers, raceresults]}
{#each currentpicked as user}
{@const picks = racepicks.filter((pick: RacePick) => pick.user === user.id)}
<div
class="card ml-1 mt-2 w-full min-w-12 overflow-hidden py-2 shadow lg:ml-2 lg:min-w-36
{$pbUser && $pbUser.username === user.username ? 'bg-primary-300' : ''}"
>
<!-- Avatar + name display at the top -->
<div class="mx-auto flex h-10 w-fit">
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 40px; width: 40px;"
imgclass="bg-surface-400 rounded-full"
tooltip={user.firstname}
/>
<div
style="height: 40px; line-height: 40px;"
class="ml-2 hidden text-nowrap text-center align-middle lg:block"
>
{user.firstname}
</div>
</div>
<div class="ml-1 w-full min-w-14">
{#each raceresults as result}
{@const race = get_by_value(races, "id", result.race)}
{@const pick = picks.filter((pick: RacePick) => pick.race === race?.id)[0]}
@ -333,16 +360,16 @@
result.dnfs.indexOf(pick?.dnf ?? "Invalid") >= 0 ? PXX_COLORS[3] : PXX_COLORS[-1]}
{#if pick}
<div class="mt-2 h-20 w-full border bg-surface-300 p-1 lg:p-2">
<div class="mt-1 h-16 w-full border bg-surface-200 px-1 py-2 lg:h-20 lg:px-2">
<div class="mx-auto flex h-full w-fit flex-col justify-evenly">
<span
class="w-10 p-1 text-center text-sm rounded-container-token"
class="w-10 p-1 text-center text-xs rounded-container-token lg:text-sm"
style="background: {pxxcolor};"
>
{get_by_value(drivers, "id", pick?.pxx ?? "")?.code}
</span>
<span
class="w-10 p-1 text-center text-sm rounded-container-token"
class="w-10 p-1 text-center text-xs rounded-container-token lg:text-sm"
style="background: {dnfcolor};"
>
{get_by_value(drivers, "id", pick?.dnf ?? "")?.code}
@ -350,11 +377,12 @@
</div>
</div>
{:else}
<div class="mt-2 h-20 w-full"></div>
<div class="mt-1 h-16 w-full px-1 py-2 lg:h-20 lg:px-2"></div>
{/if}
{/each}
</div>
{/each}
{/await}
</div>
</div>
</div>

View File

@ -12,12 +12,13 @@ import type { PageLoad } from "../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends(
"data:racepicks",
"data:user",
"data:racepicks",
"data:users",
"data:raceresults",
"data:drivers",
"data:races",
"data:substitutions",
);
return {

View File

@ -256,7 +256,7 @@
<div class="sticky top-0 z-[15] flex w-full min-w-fit">
<!-- TODO: Points popup? -->
<div
class="sticky left-0 z-20 h-16 w-7 min-w-7 max-w-7 rounded-br-none bg-surface-50 lg:w-36 lg:min-w-36 lg:max-w-36"
class="sticky left-0 z-20 h-16 w-7 min-w-7 max-w-7 bg-surface-50 lg:w-36 lg:min-w-36 lg:max-w-36"
></div>
<!-- Avatars -->
@ -264,8 +264,8 @@
{#await data.seasonpickedusers then seasonpicked}
{#each seasonpicked as user}
<div
class="card ml-1 mt-2 w-full min-w-36 rounded-b-none py-2 lg:ml-2
{$pbUser && $pbUser.username === user.username ? 'bg-primary-300' : ''}"
class="card ml-1 mt-2 w-full min-w-36 rounded-b-none bg-surface-400 py-2
{$pbUser && $pbUser.username === user.username ? '!bg-primary-400' : ''}"
>
<!-- Avatar + name display at the top -->
<div class="m-auto flex h-10 w-fit">
@ -296,7 +296,7 @@
class="sticky left-0 z-10 w-7 min-w-7 max-w-7 bg-surface-50 lg:w-36 lg:min-w-36 lg:max-w-36"
>
<!-- Hottake -->
<div class="card mt-1 flex h-32 w-7 flex-col !rounded-r-none p-2 lg:mt-2 lg:w-36">
<div class="card mt-1 flex h-32 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">Hottake</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">Hottake</span>
</div>
@ -304,13 +304,13 @@
{#await data.seasonpicks then seasonpicks}
{#if seasonpicks.length > 0}
<!-- Drivers Champion -->
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none p-2 lg:mt-2 lg:w-36">
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">Drivers<br />Champion</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">WDC</span>
</div>
<!-- Constructors Champion -->
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none p-2 lg:mt-2 lg:w-36">
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">
Constructors<br />Champion
</span>
@ -318,25 +318,25 @@
</div>
<!-- Overtakes -->
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none p-2 lg:mt-2 lg:w-36">
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">Most Overtakes</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">Overtakes</span>
</div>
<!-- DNFs -->
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none p-2 lg:mt-2 lg:w-36">
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">Most DNFs</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">DNFs</span>
</div>
<!-- Doohan Starts -->
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none p-2 lg:mt-2 lg:w-36">
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">Doohan Starts</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">Doohan</span>
</div>
<!-- Teamwinners -->
<div class="card mt-1 flex h-64 w-7 flex-col !rounded-r-none p-2 lg:mt-2 lg:w-36">
<div class="card mt-1 flex h-64 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block"
>Team-Battle<br />Winners</span
>
@ -344,7 +344,7 @@
</div>
<!-- Podiums -->
<div class="card mt-1 flex h-64 w-7 flex-col !rounded-r-none p-2 lg:mt-2 lg:w-36">
<div class="card mt-1 flex h-64 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">Podiums</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">Podiums</span>
</div>
@ -366,15 +366,15 @@
? get_by_value(drivers, "id", pick.mostovertakes)
: undefined}
{@const mostdnfs = pick ? get_by_value(drivers, "id", pick.mostdnfs) : undefined}
{@const drivers_sorted = drivers
.filter((driver: Driver) => driver.active)
{@const drivers_startedactive = drivers
.filter((driver: Driver) => driver.started_active)
.sort((a: Driver, b: Driver) => a.code.localeCompare(b.code))
.sort((a: Driver, b: Driver) => a.team.localeCompare(b.team))}
<div class="ml-1 w-full min-w-36 lg:ml-2">
<div class="ml-1 w-full min-w-36">
<!-- Hottake -->
<div
class="mt-1 h-32 w-full overflow-y-scroll border bg-surface-300 px-1 py-2 leading-3 lg:mt-2 lg:px-2"
class="mt-1 h-32 w-full overflow-y-scroll border bg-surface-200 px-1 py-2 leading-3 lg:px-2"
>
<div class="mx-auto w-fit text-xs font-bold lg:text-sm">
{hottake?.hottake ?? "?"}
@ -383,9 +383,7 @@
{#if seasonpicks.length > 0}
<!-- Drivers Champion -->
<div
class="mt-1 h-20 w-full border bg-surface-300 px-1 py-2 leading-3 lg:mt-2 lg:px-2"
>
<div class="mt-1 h-20 w-full border bg-surface-200 px-1 py-2 leading-3 lg:px-2">
<div class="mx-auto w-fit">
<!-- NOTE: The containerstyle should be 64x64, don't know why that doesn't fit... (also below) -->
<LazyImage
@ -400,9 +398,7 @@
</div>
<!-- Constructors Champion -->
<div
class="mt-1 h-20 w-full border bg-surface-300 p-1 px-1 py-2 leading-3 lg:mt-2 lg:px-2"
>
<div class="mt-1 h-20 w-full border bg-surface-200 p-1 px-1 py-2 leading-3 lg:px-2">
<div class="mx-auto w-fit">
<LazyImage
src={wccwinner?.banner_url ?? get_team_banner_template(data.graphics)}
@ -416,9 +412,7 @@
</div>
<!-- Most Overtakes -->
<div
class="mt-1 h-20 w-full border bg-surface-300 px-1 py-2 leading-3 lg:mt-2 lg:px-2"
>
<div class="mt-1 h-20 w-full border bg-surface-200 px-1 py-2 leading-3 lg:px-2">
<div class="mx-auto w-fit">
<LazyImage
src={mostovertakes?.headshot_url ?? get_driver_headshot_template(data.graphics)}
@ -434,9 +428,7 @@
</div>
<!-- Most DNFs -->
<div
class="mt-1 h-20 w-full border bg-surface-300 px-1 py-2 leading-3 lg:mt-2 lg:px-2"
>
<div class="mt-1 h-20 w-full border bg-surface-200 px-1 py-2 leading-3 lg:px-2">
<div class="mx-auto w-fit">
<LazyImage
src={mostdnfs?.headshot_url ?? get_driver_headshot_template(data.graphics)}
@ -450,9 +442,7 @@
</div>
<!-- Doohan Starts -->
<div
class="mt-1 h-20 w-full border bg-surface-300 p-1 px-1 py-2 leading-3 lg:mt-2 lg:px-2"
>
<div class="mt-1 h-20 w-full border bg-surface-200 p-1 px-1 py-2 leading-3 lg:px-2">
<div class="mx-auto w-fit text-xs lg:text-sm">
Jack Doohan startet <span class="font-bold">{pick?.doohanstarts ?? "?"}</span> mal.
</div>
@ -460,11 +450,11 @@
<!-- Teamwinners -->
<div
class="mt-1 h-64 w-full overflow-y-scroll border bg-surface-300 p-1 px-1 py-2 leading-3 lg:mt-2 lg:px-2"
class="mt-1 h-64 w-full overflow-y-scroll border bg-surface-200 p-1 px-1 py-2 leading-3 lg:px-2"
>
{#if pick && pick.teamwinners}
<div class="grid grid-cols-2 gap-1">
{#each drivers_sorted as driver}
{#each drivers_startedactive as driver}
{@const color: string = get_by_value(teams, "id", driver?.team ?? "")?.color ?? ""}
<div class="mx-auto w-fit text-xs lg:text-sm">
<div
@ -489,11 +479,11 @@
<!-- Podiums -->
<!-- TODO: Replace all style tags throughout the page with custom classes like height here -->
<div
class="mt-1 h-64 w-full overflow-y-scroll border bg-surface-300 p-1 px-1 py-2 leading-3 shadow lg:mt-2 lg:px-2"
class="mt-1 h-64 w-full overflow-y-scroll border bg-surface-200 p-1 px-1 py-2 leading-3 shadow lg:px-2"
>
{#if pick && pick.podiums}
<div class="grid grid-cols-2 gap-1">
{#each drivers_sorted as driver}
{#each drivers_startedactive as driver}
{@const color: string = get_by_value(teams, "id", driver?.team ?? "")?.color ?? ""}
<div class="mx-auto w-fit text-xs lg:text-sm">
<div

View File

@ -10,7 +10,7 @@ import {
import type { PageLoad } from "../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:teams", "data:drivers", "data:seasonpicks", "data:user");
depends("data:teams", "data:drivers", "data:seasonpicks", "data:user", "data:users");
return {
teams: fetch_teams(fetch),

View File

@ -1,3 +1,113 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { Driver, ScrapedRaceResultAcc } from "$lib/schema";
import {
LineChart,
ScaleTypes,
type ChartTabularData,
type LineChartOptions,
} from "@carbon/charts-svelte";
import "@carbon/charts-svelte/styles.css";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
import { make_chart_options } from "$lib/chart";
let { data }: { data: PageData } = $props();
// Await promises
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
let scraped_raceresultsacc: ScrapedRaceResultAcc[] | undefined = $state(undefined);
data.scraped_raceresultsacc.then((s: ScrapedRaceResultAcc[]) => (scraped_raceresultsacc = s));
const drivers_columns: TableColumn[] = $derived([
{
data_value_name: "position",
label: "Position",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "driver_code",
label: "Driver",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
const teams_columns: TableColumn[] = $derived([
{
data_value_name: "position",
label: "Position",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "team_fullname",
label: "team",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
const drivers_chart_data: ChartTabularData = $derived.by(() => {
if (!drivers || !scraped_raceresultsacc) return [];
const active_drivers: Driver[] = drivers.filter((driver: Driver) => driver.active);
const active_results: ScrapedRaceResultAcc[] = scraped_raceresultsacc.filter(
(result: ScrapedRaceResultAcc) =>
get_by_value(drivers ?? [], "code", result.driver_code)?.active,
);
return active_drivers
.map((driver: Driver) => {
return {
group: driver.code,
step: "0",
points: 0,
};
})
.concat(
active_results.map((result: ScrapedRaceResultAcc) => {
return {
group: result.driver_code,
step: result.race_step.toString(),
points: result.acc_points,
};
}),
);
});
const drivers_chart_options: LineChartOptions = make_chart_options("Drivers", "step", "points");
</script>
<svelte:head>
<title>Formula 11 - Statistics</title>
</svelte:head>
<div class="card w-full bg-surface-100 p-2 shadow">
<LineChart data={drivers_chart_data} options={drivers_chart_options} />
</div>
<div class="mt-2 grid w-full grid-cols-1 gap-2 lg:grid-cols-2">
<div class="w-full">
{#await data.scraped_driverstandings then driverstandings}
<Table data={driverstandings} columns={drivers_columns} />
{/await}
</div>
<div class="w-full">
{#await data.scraped_teamstandings then teamstandings}
<Table data={teamstandings} columns={teams_columns} />
{/await}
</div>
</div>

View File

@ -0,0 +1,23 @@
import {
fetch_drivers,
fetch_scraped_driverstandings,
fetch_scraped_raceresults,
fetch_scraped_raceresultsacc,
fetch_scraped_startinggrids,
fetch_scraped_teamstandings,
} from "$lib/fetch";
import type { PageLoad } from "../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:drivers", "data:official");
return {
drivers: fetch_drivers(fetch),
scraped_driverstandings: fetch_scraped_driverstandings(fetch),
scraped_teamstandings: fetch_scraped_teamstandings(fetch),
scraped_startinggrids: fetch_scraped_startinggrids(fetch),
scraped_raceresults: fetch_scraped_raceresults(fetch),
scraped_raceresultsacc: fetch_scraped_raceresultsacc(fetch),
};
};

View File

@ -3,6 +3,9 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [sveltekit()],
ssr: {
noExternal: process.env.NODE_ENV === "production" ? ["@carbon/charts"] : [],
},
build: {
rollupOptions: {
external: ["sharp"],