Compare commits

...

2 Commits

Author SHA1 Message Date
7a5e9244a7 wip 2024-05-23 08:18:54 +04:00
4ce9f62117 adding game player scores init sync 2024-03-23 19:46:17 +00:00
130 changed files with 11956 additions and 1792 deletions

0
.cursorrules Normal file
View File

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
**/node_modules
go.work
go.work.sum

33
Dockerfile Normal file
View File

@ -0,0 +1,33 @@
ARG GO_VERSION=1.22.1
FROM oven/bun as node-builder
WORKDIR /app
COPY front/package.json front/bun.lockb ./
RUN bun install --frozen-lockfile
COPY front/ ./
RUN bun run build
FROM golang:${GO_VERSION}-alpine as go-builder
RUN apk add --update ca-certificates git tzdata
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY ./ ./
RUN rm -rf ./cmd/server/dist
COPY --from=node-builder /app/dist ./cmd/server/dist
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /server ./cmd/server/.
FROM busybox:glibc
RUN mkdir /app
RUN addgroup -S server && adduser -S -s /bin/false -G server server
WORKDIR /app
COPY --from=go-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=go-builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=go-builder /server /app/server
RUN chown -R server:server /app
USER server
EXPOSE 8080
ENTRYPOINT ["/app/server"]

View File

@ -1,38 +0,0 @@
BINARY_NAME=bin/sorarebuddy
BIN_DIR=$(shell dirname $(BINARY_NAME))
include .env
export $(shell sed 's/=.*//' .env)
ensure-bin-dir:
@mkdir -p $(BIN_DIR)
build: ensure-bin-dir
@echo "Building..."
go build -o $(BINARY_NAME) cmd/console/root.go
run: build
@echo "Running..."
$(info JWTTOKEN is $(JWTTOKEN))
$(info JWTAUDIENCE is $(JWTAUDIENCE))
./$(BINARY_NAME) --jwtaudience $(JWTAUDIENCE) --jwttoken $(JWTTOKEN) --dbhost $(DBHOST) --dbport $(DBPORT) --dbuser $(DBUSER) --dbpass $(DBPASS) --dbname $(DBNAME) --verbose
clean:
@echo "Cleaning..."
go clean
rm -f $(BINARY_NAME)
downdb:
goose -dir ./db/migrations postgres "host=$(DBHOST) port=$(DBPORT) user=$(DBUSER) password=$(DBPASS) dbname=$(DBNAME) sslmode=disable" down
updb:
goose -dir ./db/migrations postgres "host=$(DBHOST) port=$(DBPORT) user=$(DBUSER) password=$(DBPASS) dbname=$(DBNAME) sslmode=disable" up
resetdb:
make downdb
make updb
.PHONY: build run clean ensure-bin-dir resetdb downdb updb
.DEFAULT_GOAL := all
all: build run clean ensure-bin-dir

7
build.ps1 Normal file
View File

@ -0,0 +1,7 @@
docker build --rm -t registry.lehouerou.net/sorareplus:latest .
if($?)
{
docker push registry.lehouerou.net/sorareplus:latest
ssh srv03 'docker pull registry.lehouerou.net/sorareplus; cd services/sorare; docker compose up -d sorareplus'
}

View File

@ -4,19 +4,22 @@ import (
"context"
"fmt"
"os"
"runtime"
"strings"
"time"
"git.lehouerou.net/laurent/sorare"
"git.lehouerou.net/laurent/sorare/graphql"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pkg/errors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"git.lehouerou.net/laurent/sorarebuddy/model"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/extra/bundebug"
)
func InitParams(cmd *cobra.Command) {
@ -116,6 +119,7 @@ func InitLog() {
}
func InitSorare(ctx context.Context) (*sorare.Sorare, error) {
audience := viper.GetString("jwtaudience")
if audience == "" {
return nil, errors.New("jwtaudience is required")
@ -150,7 +154,7 @@ func InitSorare(ctx context.Context) (*sorare.Sorare, error) {
return s, nil
}
func InitDb(ctx context.Context) (*model.Queries, error) {
func InitDb(ctx context.Context) (*bun.DB, error) {
host := viper.GetString("dbhost")
if host == "" {
return nil, errors.New("dbhost is required")
@ -172,25 +176,32 @@ func InitDb(ctx context.Context) (*model.Queries, error) {
return nil, errors.New("dbname is required")
}
conn, err := pgx.Connect(
ctx,
fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host,
port,
user,
password,
dbname,
),
maxOpenConns := 4 * runtime.GOMAXPROCS(0)
pgxconfig, err := pgx.ParseConfig(
fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", user, password, host, port, dbname),
)
if err != nil {
return nil, errors.Wrap(err, "connecting to database")
return nil, errors.Wrap(err, "parsing pgx config")
}
sqldb := stdlib.OpenDB(*pgxconfig)
db := bun.NewDB(sqldb, pgdialect.New())
db.SetMaxOpenConns(maxOpenConns)
tracedb := viper.GetBool("tracedb")
if tracedb {
db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
}
queries := model.New(conn)
// Test the connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.DB.PingContext(ctx); err != nil {
return nil, errors.Wrap(err, "pinging database")
}
log.Info().Msgf("connected to database %s@%s:%s/%s", user, host, port, dbname)
return queries, nil
return db, nil
}
type contextKey string

View File

@ -4,9 +4,10 @@ import (
"git.lehouerou.net/laurent/sorare"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/cmd/common"
"git.lehouerou.net/laurent/sorarebuddy/model"
"git.lehouerou.net/laurent/sorarebuddy/db"
"git.lehouerou.net/laurent/sorarebuddy/sorare_utils"
)
@ -35,17 +36,34 @@ func run(cmd *cobra.Command, _ []string) error {
return errors.New("sorare not found in context")
}
db, ok := ctx.Value(common.DbContextKey).(*model.Queries)
dbconn, ok := ctx.Value(common.DbContextKey).(*bun.DB)
if !ok {
return errors.New("db not found in context")
}
us := sorare_utils.NewUpdateService(s, db)
err := us.InitSyncDatabase(ctx)
us := sorare_utils.NewUpdateService(s, db.NewClient(dbconn))
err := us.UpdateLastClosedStartedAndOpenedFixtures(ctx)
if err != nil {
return errors.Wrap(err, "initializing database")
return errors.Wrap(err, "syncing database for last updated fixtures")
}
// err := us.SyncStartedFixture(ctx)
// if err != nil {
// return errors.Wrap(err, "syncing database")
// }
// err := us.UpdatePlayers(ctx, []string{"joshua-kimmich", "leon-goretzka"})
// if err != nil {
// return errors.Wrap(err, "updating players")
// }
// err := us.UpdateAllPlayers(ctx)
// if err != nil {
// return errors.Wrap(err, "updating all players")
// }
// err := us.UpdateCurrentlyPlayingGames(ctx)
// if err != nil {
// return errors.Wrap(err, "initializing database")
// }
// log.Debug().Msg("start sequence completed. waiting for shutdown request")
// <-ctx.Done()
// log.Debug().Msg("shutdown requested")

129
cmd/fbrefscraper/root.go Normal file
View File

@ -0,0 +1,129 @@
package main
import (
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/gocolly/colly"
"github.com/hashicorp/go-retryablehttp"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"github.com/spf13/cobra"
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/cmd/common"
"git.lehouerou.net/laurent/sorarebuddy/db"
)
var Cmd = &cobra.Command{
Use: "console",
Short: "console",
Long: `console`,
RunE: run,
PersistentPreRunE: common.CmdPreRunE,
}
func main() {
common.Start(Cmd)
}
func init() {
common.InitParams(Cmd)
}
func run(cmd *cobra.Command, _ []string) error {
httpClient := retryablehttp.NewClient()
ctx := cmd.Context()
dbconn, ok := ctx.Value(common.DbContextKey).(*bun.DB)
if !ok {
return errors.New("db not found in context")
}
dbclient := db.NewClient(dbconn)
players, err := dbclient.Players.GetAll(ctx)
if err != nil {
return errors.Wrap(err, "")
}
for _, p := range lo.Chunk(players, 100)[0] {
name := strings.ReplaceAll(p.DisplayName, " ", "+")
_, url, err := goQueryFromURL(httpClient.StandardClient(),
fmt.Sprintf("https://fbref.com/fr/search/search.fcgi?hint=&search=%s", name),
)
if err != nil {
return errors.Wrap(err, "")
}
log.Debug().Msgf("%s -> %s", p.DisplayName, url)
}
return nil
}
func goQueryFromURL(httpClient *http.Client, url string) (*goquery.Document, string, error) {
res, err := httpClient.Get(url)
if err != nil {
return nil, "", errors.Wrap(err, "requesting url")
}
defer res.Body.Close()
log.Debug().Int("status", res.StatusCode).Str("url", url).Msg("HTTP request completed")
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, "", errors.Wrapf(err, "requesting failed at the http level: %d %s", res.StatusCode, res.Status)
}
doc, err := goquery.NewDocumentFromReader(res.Body)
if err != nil {
return nil, "", errors.Wrap(err, "parsing html")
}
return doc, res.Request.URL.String(), nil
}
func scrapePlayers() {
c := colly.NewCollector(
colly.AllowedDomains("fbref.com"),
colly.Async(true),
)
players := make(map[string]bool)
mutex := &sync.Mutex{}
c.Limit(&colly.LimitRule{
DomainGlob: "fbref.com",
Parallelism: 2,
Delay: 1 * time.Second,
})
c.OnHTML("a[href]", func(e *colly.HTMLElement) {
link := e.Attr("href")
if matched, _ := regexp.MatchString(`^/fr/joueurs/[a-z0-9]{8}/[a-zA-Z-]+$`, link); matched {
splitLink := strings.Split(link, "/")
playerName := splitLink[len(splitLink)-1]
mutex.Lock()
if _, ok := players[playerName]; !ok {
players[playerName] = true
mutex.Unlock()
link = strings.Join(splitLink[:len(splitLink)-1], "/") + "/scout/365_m2/Rapport-de-scouting-" + playerName
c.Visit(e.Request.AbsoluteURL(link))
} else {
mutex.Unlock()
}
}
})
c.OnRequest(func(r *colly.Request) {
log.Debug().Str("url", r.URL.String()).Msg("")
})
c.Visit("https://fbref.com/fr/joueurs/df69b544/Antoine-Griezmann")
c.Wait()
for playerName := range players {
log.Debug().Str("player_name", playerName).Msg("")
}
}

362
cmd/server/root.go Normal file
View File

@ -0,0 +1,362 @@
package main
import (
"context"
"embed"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"git.lehouerou.net/laurent/sorare"
"git.lehouerou.net/laurent/sorare/football"
"git.lehouerou.net/laurent/sorare/types"
"github.com/dustin/go-humanize"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/pkg/errors"
"github.com/robfig/cron/v3"
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"github.com/sourcegraph/conc"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/cmd/common"
"git.lehouerou.net/laurent/sorarebuddy/db"
"git.lehouerou.net/laurent/sorarebuddy/model"
"git.lehouerou.net/laurent/sorarebuddy/sorare_utils"
)
var (
//go:embed all:dist
dist embed.FS
//go:embed dist/index.html
indexHTML embed.FS
distDirFS = echo.MustSubFS(dist, "dist/assets")
distIndexHtml = echo.MustSubFS(indexHTML, "dist")
)
var Cmd = &cobra.Command{
Use: "console",
Short: "console",
Long: `console`,
RunE: run,
PersistentPreRunE: common.CmdPreRunE,
}
func main() {
common.Start(Cmd)
}
func init() {
common.InitParams(Cmd)
Cmd.PersistentFlags().Bool("devmode", false, "Dev mode")
_ = viper.BindPFlag("devmode", Cmd.PersistentFlags().Lookup("devmode"))
viper.SetDefault("devmode", false)
}
func errorResponse(err error) map[string]interface{} {
return map[string]interface{}{"error": err.Error()}
}
func run(cmd *cobra.Command, _ []string) error {
var wg conc.WaitGroup
ctx := cmd.Context()
dbconn, ok := ctx.Value(common.DbContextKey).(*bun.DB)
if !ok {
return errors.New("db not found in context")
}
sorareClient, ok := ctx.Value(common.SorareContextKey).(*sorare.Sorare)
if !ok {
return errors.New("sorare not found in context")
}
devmode := viper.GetBool("devmode")
dbclient := db.NewClient(dbconn)
e := echo.New()
e.FileFS("/*", "index.html", distIndexHtml)
e.StaticFS("/assets", distDirFS)
e.Use(middleware.Recover())
e.HTTPErrorHandler = func(err error, c echo.Context) {
statusCode := http.StatusInternalServerError
if he, ok := err.(*echo.HTTPError); ok {
statusCode = he.Code
}
log.Error().
Err(err).
Str("method", c.Request().Method).
Str("url", c.Request().URL.String()).
Int("status", statusCode).
Msg("HTTP error encountered")
if statusCode == http.StatusInternalServerError {
if err := c.JSON(statusCode, errorResponse(errors.New("internal server error"))); err != nil {
log.Error().Err(err).Msg("error while sending error response")
}
} else {
if err := c.JSON(statusCode, errorResponse(err)); err != nil {
log.Error().Err(err).Msg("error while sending error response")
}
}
}
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
err := next(c)
latency := time.Since(start)
latencyDisplay := fmt.Sprintf("%dms", latency.Milliseconds())
if latency > time.Second {
latencyDisplay = fmt.Sprintf("%.2fs", latency.Seconds())
}
clientIP := c.RealIP()
log.Info().Msgf(
"%d %4s | %s | %6s | <- %6s | -> %6s | %s",
c.Response().Status,
c.Request().Method,
clientIP,
latencyDisplay,
humanize.Bytes(uint64(c.Request().ContentLength)),
humanize.Bytes(uint64(c.Response().Size)),
c.Request().RequestURI,
)
return err
}
})
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
Level: 5,
}))
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"https://www.sorareplus.xyz", "http://localhost:5173"},
}))
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
XFrameOptions: "DENY",
ContentTypeNosniff: "nosniff",
XSSProtection: "1; mode=block",
ContentSecurityPolicy: "default-src 'self'; img-src 'self' https://assets.sorare.com https://frontend-assets.sorare.com; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'",
HSTSMaxAge: 31536000,
ReferrerPolicy: "no-referrer",
}))
s := &ApiServer{
sorare: sorareClient,
db: dbclient,
e: e,
}
api := e.Group("api")
api.POST("/search/multi", s.SearchMulti)
fixture := api.Group("/fixture")
fixture.GET("/all", s.GetAllFixtures)
fixture.GET("/:slug/games", s.GetFixtureGames)
player := api.Group("/player")
player.GET("/:slug", s.GetPlayer)
player.GET("/:slug/games", s.GetPlayerGames)
api.GET("/zone/all", s.GetAllZones)
api.GET("/competition/club", s.GetAllClubCompetitions)
api.POST("/rankings/single", s.SingleRankings)
api.GET("/user/:slug/cards", s.GetUserCards)
wg.Go(func() {
defer func() {
log.Debug().Msg("http server stopped")
}()
err := e.Start(":8080")
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error().Err(err).Msg("running api http server")
}
})
c := cron.New()
if !devmode {
_, err := c.AddFunc("0 6 * * *", func() {
us := sorare_utils.NewUpdateService(sorareClient, db.NewClient(dbconn))
err := us.UpdateLastClosedStartedAndOpenedFixtures(ctx)
if err != nil {
log.Error().Err(err).Msg("syncing database")
}
})
if err != nil {
return errors.Wrap(err, "adding database sync job")
}
var updateMutex sync.Mutex
_, err = c.AddFunc("*/1 * * * *", func() {
if updateMutex.TryLock() {
defer updateMutex.Unlock()
us := sorare_utils.NewUpdateService(sorareClient, db.NewClient(dbconn))
err := us.UpdateCurrentlyPlayingGames(ctx)
if err != nil {
log.Error().Err(err).Msg("syncing database")
}
} else {
log.Info().Msg("Previous update still running, skipping this cycle.")
}
})
if err != nil {
return errors.Wrap(err, "adding database sync job")
}
}
c.Start()
log.Info().Msgf("%d scheduled jobs", len(c.Entries()))
for _, entry := range c.Entries() {
log.Info().Msgf("next @ %s", entry.Next)
}
<-ctx.Done()
c.Stop()
ctxhttp, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := e.Shutdown(ctxhttp)
if err != nil {
log.Error().Err(err).Msg("shutting down api http server")
}
wg.Wait()
return nil
}
type ApiServer struct {
sorare *sorare.Sorare
db *db.Client
e *echo.Echo
}
func (s *ApiServer) GetAllZones(c echo.Context) error {
ctx := c.Request().Context()
res, err := s.db.Zones.GetAll(ctx)
if err != nil {
return err
}
return c.JSON(http.StatusOK, res)
}
func (s *ApiServer) GetPlayer(c echo.Context) error {
ctx := c.Request().Context()
slug := c.Param("slug")
res, err := s.db.Players.GetOne(ctx, slug)
if err != nil {
return err
}
return c.JSON(http.StatusOK, res)
}
func (s *ApiServer) SingleRankings(c echo.Context) error {
ctx := c.Request().Context()
var opts db.SingleRankingsOptions
if err := c.Bind(&opts); err != nil {
return errors.Wrap(err, "parsing ranking request options")
}
res, err := s.db.Players.SingleRankings(ctx, opts)
if err != nil {
return errors.Wrap(err, "getting rankings")
}
return c.JSON(http.StatusOK, res)
}
func (s *ApiServer) GetAllClubCompetitions(c echo.Context) error {
ctx := c.Request().Context()
zones := c.QueryParam("zones")
zoneArray := lo.Filter(strings.Split(zones, ","), func(s string, _ int) bool {
return s != ""
})
res, err := s.db.Competitions.GetAllClubCompetitions(ctx, lo.Map(zoneArray, func(s string, _ int) int {
return lo.Must(strconv.Atoi(s))
}))
if err != nil {
return err
}
return c.JSON(http.StatusOK, res)
}
func (s *ApiServer) GetAllFixtures(c echo.Context) error {
ctx := c.Request().Context()
res, err := s.db.Fixtures.GetAll(ctx)
if err != nil {
return err
}
return c.JSON(http.StatusOK, res)
}
func (s *ApiServer) GetFixtureGames(c echo.Context) error {
ctx := c.Request().Context()
slug := c.Param("slug")
res, err := s.db.Games.GetByFixture(ctx, slug)
if err != nil {
return err
}
return c.JSON(http.StatusOK, res)
}
func (s *ApiServer) GetUserCards(c echo.Context) error {
ctx := c.Request().Context()
slug := c.Param("slug")
res, err := s.sorare.Users.FootballCards(slug).Get(ctx, football.CardsParams{
Rarities: []types.Rarity{types.RarityLimited, types.RarityRare, types.RaritySuperRare, types.RarityUnique},
})
if err != nil {
return err
}
return c.JSON(http.StatusOK, lo.Map(res, func(card football.Card, _ int) model.Card {
return model.NewCardFromSorare(card)
}))
}
func (s *ApiServer) GetPlayerGames(c echo.Context) error {
ctx := c.Request().Context()
slug := c.Param("slug")
res, err := s.db.GamePlayers.GetByPlayer(ctx, slug)
if err != nil {
return err
}
return c.JSON(http.StatusOK, res)
}
func (s *ApiServer) SearchMulti(c echo.Context) error {
ctx := c.Request().Context()
var searchText struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if err := c.Bind(&searchText); err != nil {
return c.JSON(http.StatusBadRequest, errorResponse(err))
}
var res struct {
Players []model.Player `json:"players"`
Teams []model.Team `json:"teams"`
}
players, err := s.db.Players.SearchByDisplayName(ctx, searchText.Query, searchText.Limit)
if err != nil {
return err
}
res.Players = players
teams, err := s.db.Teams.SearchByDisplayName(ctx, searchText.Query, searchText.Limit)
if err != nil {
return err
}
res.Teams = teams
return c.JSON(http.StatusOK, res)
}

17
db/card_supply.go Normal file
View File

@ -0,0 +1,17 @@
package db
import (
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/model"
)
type CardSupplyRepository struct {
*Repository[model.CardSupply]
}
func NewCardSupplyRepository(db *bun.DB) *CardSupplyRepository {
return &CardSupplyRepository{
Repository: NewRepository[model.CardSupply](db, []string{"player_slug", "season_start_year"}),
}
}

41
db/client.go Normal file
View File

@ -0,0 +1,41 @@
package db
import (
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/model"
)
type Client struct {
db *bun.DB
CardSupplies *CardSupplyRepository
Memberships *MembershipRepository
Countries *CountryRepository
Fixtures *FixtureRepository
Competitions *CompetitionRepository
Teams *TeamRepository
Games *GameRepository
Players *PlayerRepository
GamePlayers *GamePlayerRepository
GamePlayerScores *Repository[model.GamePlayerScore]
Zones *ZoneRepository
}
func NewClient(db *bun.DB) *Client {
return &Client{
db: db,
CardSupplies: NewCardSupplyRepository(db),
Memberships: NewMembershipRepository(db),
Countries: NewCountryRepository(db),
Fixtures: NewFixtureRepository(db),
Competitions: NewCompetitionRepository(db),
Teams: NewTeamRepository(db),
Games: NewGameRepository(db),
Players: NewPlayerRepository(db),
GamePlayers: NewGamePlayerRepository(db),
GamePlayerScores: NewRepository[model.GamePlayerScore](db, []string{"game_id", "player_slug"}),
Zones: NewZoneRepository(db),
}
}

50
db/competittion.go Normal file
View File

@ -0,0 +1,50 @@
package db
import (
"context"
"github.com/samber/lo"
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/model"
)
type CompetitionRepository struct {
*Repository[model.Competition]
}
func NewCompetitionRepository(db *bun.DB) *CompetitionRepository {
return &CompetitionRepository{
Repository: NewRepository[model.Competition](db, []string{"slug"}),
}
}
func (r *CompetitionRepository) GetAllClubCompetitions(ctx context.Context, zones []int) ([]model.Competition, error) {
var res []model.Competition
request := r.db.NewSelect().Model(&res).
Where("competition_type = ?", "CLUB").
Relation("Zone")
if len(zones) > 0 {
request = request.Where("zone_id IN (?)", bun.In(zones))
}
if err := request.Scan(ctx); err != nil {
return nil, err
}
return res, nil
}
func (r *CompetitionRepository) GetCompetitionSlugsNotInDb(
ctx context.Context,
competitionSlugs []string,
) ([]string, error) {
var competitions []model.Competition
err := r.db.NewSelect().Model(&competitions).Where("slug IN (?)", bun.In(competitionSlugs)).Scan(ctx)
if err != nil {
return nil, err
}
diff, _ := lo.Difference(
competitionSlugs,
lo.Map(competitions, func(c model.Competition, index int) string { return c.Slug }),
)
return diff, nil
}

40
db/country.go Normal file
View File

@ -0,0 +1,40 @@
package db
import (
"context"
"github.com/pkg/errors"
"github.com/samber/lo"
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/model"
)
type CountryRepository struct {
*Repository[model.Country]
}
func NewCountryRepository(db *bun.DB) *CountryRepository {
return &CountryRepository{
Repository: NewRepository[model.Country](db, []string{"slug"}),
}
}
func (r *CountryRepository) GetBySlug(ctx context.Context, slug string) (model.Country, error) {
var res model.Country
err := r.db.NewSelect().Model(&res).Where("slug = ?", slug).Scan(ctx)
if err != nil {
return model.Country{}, errors.Wrapf(err, "getting country by slug : %s", slug)
}
return res, nil
}
func (r *CountryRepository) GetCountrySlugsNotInDb(ctx context.Context, countrySlugs []string) ([]string, error) {
var countries []model.Country
err := r.db.NewSelect().Model(&countries).Where("slug IN (?)", bun.In(countrySlugs)).Scan(ctx)
if err != nil {
return nil, err
}
diff, _ := lo.Difference(countrySlugs, lo.Map(countries, func(c model.Country, index int) string { return c.Slug }))
return diff, nil
}

69
db/fixture.go Normal file
View File

@ -0,0 +1,69 @@
package db
import (
"context"
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/model"
)
type FixtureRepository struct {
*Repository[model.Fixture]
}
func NewFixtureRepository(db *bun.DB) *FixtureRepository {
return &FixtureRepository{
Repository: NewRepository[model.Fixture](db, []string{"slug"}),
}
}
func (r *FixtureRepository) GetByGameWeek(ctx context.Context, gameweeks ...int) ([]model.Fixture, error) {
var fixtures []model.Fixture
err := r.db.NewSelect().
Model(&fixtures).
Where("game_week IN (?)", bun.In(gameweeks)).
Scan(ctx)
return fixtures, err
}
func (r *FixtureRepository) GetBySlug(ctx context.Context, slug string) (model.Fixture, error) {
var fixture model.Fixture
err := r.db.NewSelect().
Model(&fixture).
Where("slug = ?", slug).
Scan(ctx)
return fixture, err
}
func (r *FixtureRepository) GetStarted(ctx context.Context) ([]model.Fixture, error) {
var fixtures []model.Fixture
err := r.db.NewSelect().
Model(&fixtures).
Where("state = ?", "started").
Scan(ctx)
return fixtures, err
}
func (r *FixtureRepository) GetOpened(ctx context.Context) ([]model.Fixture, error) {
var fixtures []model.Fixture
err := r.db.NewSelect().
Model(&fixtures).
Where("state = ?", "opened").
Scan(ctx)
return fixtures, err
}
func (r *FixtureRepository) GetLastClosed(ctx context.Context) (model.Fixture, error) {
var fixture model.Fixture
err := r.db.NewSelect().
Model(&fixture).
Where("state IN (?)", bun.In([]string{"closed", "computed"})).
Order("game_week DESC").
Limit(1).
Scan(ctx)
return fixture, err
}

110
db/game.go Normal file
View File

@ -0,0 +1,110 @@
package db
import (
"context"
"time"
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/model"
)
type GameRepository struct {
*Repository[model.Game]
}
func NewGameRepository(db *bun.DB) *GameRepository {
return &GameRepository{
Repository: NewRepository[model.Game](db, []string{"id"}),
}
}
func (r *GameRepository) GetByFixture(ctx context.Context, fixtureSlug string) ([]model.Game, error) {
var games []model.Game
err := r.db.NewSelect().
Model(&games).
Relation("HomeTeam").
Relation("AwayTeam").
Relation("WinnerTeam").
Relation("Competition").
Relation("Competition.Country").
Relation("Competition.Zone").
Relation("GamePlayers").
Relation("GamePlayers.Player").
Relation("GamePlayers.Score").
Where("fixture_slug = ?", fixtureSlug).
Scan(ctx)
return games, err
}
func (r *GameRepository) GetByIds(ctx context.Context, ids []string) ([]model.Game, error) {
var games []model.Game
err := r.db.NewSelect().
Model(&games).
Relation("HomeTeam").
Relation("AwayTeam").
Relation("WinnerTeam").
Relation("Competition").
Relation("Competition.Country").
Relation("Competition.Zone").
Relation("GamePlayers").
Relation("GamePlayers.Player").
Relation("GamePlayers.Score").
Where("id IN (?)", bun.In(ids)).
Scan(ctx)
return games, err
}
func (r *GameRepository) CurrentlyPlayingGames(ctx context.Context) ([]model.Game, error) {
var games []model.Game
if err := r.db.NewSelect().
Model(&games).
Relation("HomeTeam").
Relation("AwayTeam").
Relation("WinnerTeam").
Relation("Competition").
Relation("Competition.Country").
Relation("Competition.Zone").
Relation("GamePlayers").
Relation("GamePlayers.Player").
Relation("GamePlayers.Score").
Join("INNER JOIN fixtures AS f ON f.slug = game.fixture_slug").
Where("f.state = 'started' AND (game.status = 'playing' OR (game.status = 'played' AND game.date > now() - interval '3 hours') OR (game.status = 'scheduled' AND game.date < now() + interval '1 hour'))").
OrderExpr("game.date ASC").
Scan(ctx); err != nil {
return nil, err
}
return games, nil
}
func (r *GameRepository) GetFutureGameIdsWithoutFormation(
ctx context.Context,
startingIn time.Duration,
minPlayers int,
) ([]string, error) {
var gameIds []string
subQueryHome := r.db.NewSelect().
ColumnExpr("COUNT(*)").
Model((*model.GamePlayer)(nil)).
Where("game_id = game.id").
Where("team_slug = game.home_team_slug")
subQueryAway := r.db.NewSelect().
ColumnExpr("COUNT(*)").
Model((*model.GamePlayer)(nil)).
Where("game_id = game.id").
Where("team_slug = game.away_team_slug")
if err := r.db.NewSelect().
Model((*model.Game)(nil)).
Column("id").
Where("date > ?", time.Now().Add(-3*time.Hour)).
Where("date < ?", time.Now().Add(startingIn)).
Where(`(?) < ? OR (?) < ?`,
subQueryHome, minPlayers,
subQueryAway, minPlayers).
Scan(ctx, &gameIds); err != nil {
return nil, err
}
return gameIds, nil
}

37
db/game_player.go Normal file
View File

@ -0,0 +1,37 @@
package db
import (
"context"
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/model"
)
type GamePlayerRepository struct {
*Repository[model.GamePlayer]
}
func NewGamePlayerRepository(db *bun.DB) *GamePlayerRepository {
return &GamePlayerRepository{
Repository: NewRepository[model.GamePlayer](db, []string{"game_id", "player_slug"}),
}
}
func (r *GamePlayerRepository) GetByPlayer(ctx context.Context, playerSlug string) ([]model.GamePlayer, error) {
var res []model.GamePlayer
err := r.db.NewSelect().
Model(&res).
Relation("Game").
Relation("Game.HomeTeam").
Relation("Game.AwayTeam").
Relation("Game.WinnerTeam").
Relation("Game.Fixture").
Relation("Score").
Where("game_player.player_slug = ?", playerSlug).
Scan(ctx)
if err != nil {
return nil, err
}
return res, nil
}

17
db/membership.go Normal file
View File

@ -0,0 +1,17 @@
package db
import (
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/model"
)
type MembershipRepository struct {
*Repository[model.Membership]
}
func NewMembershipRepository(db *bun.DB) *MembershipRepository {
return &MembershipRepository{
Repository: NewRepository[model.Membership](db, []string{"id"}),
}
}

View File

@ -0,0 +1,9 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS idx_games_date ON public.games(date);
CREATE INDEX IF NOT EXISTS idx_competitions_zone_id ON public.competitions(zone_id);
CREATE INDEX IF NOT EXISTS idx_player_field_position ON public.players(field_position);
-- +goose Down
DROP INDEX IF EXISTS idx_games_date;
DROP INDEX IF EXISTS idx_competitions_zone_id;
DROP INDEX IF EXISTS idx_player_field_position;

View File

@ -0,0 +1,14 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS card_supplies (
player_slug TEXT NOT NULL,
season_start_year INTEGER NOT NULL,
PRIMARY KEY (player_slug, season_start_year),
"limited" INTEGER NOT NULL,
"rare" INTEGER NOT NULL,
"super_rare" INTEGER NOT NULL,
"unique" INTEGER NOT NULL,
"last_updated" TIMESTAMPTZ NOT NULL
);
-- +goose Down
DROP TABLE IF EXISTS card_supplies;

View File

@ -0,0 +1,14 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS club_memberships(
id TEXT PRIMARY KEY,
player_slug TEXT NOT NULL REFERENCES players(slug),
club_slug TEXT NOT NULL REFERENCES teams(slug),
start_date DATE NOT NULL,
end_date DATE
);
CREATE INDEX IF NOT EXISTS club_memberships_player_slug_start_date_end_date_idx ON club_memberships(player_slug, start_date, end_date);
-- +goose Down
DROP INDEX IF EXISTS club_memberships_player_slug_start_date_end_date_idx;
DROP TABLE IF EXISTS club_memberships;

View File

@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE players ADD COLUMN active_national_team_slug TEXT;
-- +goose Down
ALTER TABLE players DROP COLUMN active_national_team_slug;

View File

@ -0,0 +1,11 @@
-- +goose Up
ALTER TABLE club_memberships RENAME TO memberships;
ALTER TABLE memberships RENAME COLUMN club_slug TO team_slug;
ALTER TABLE memberships ADD COLUMN membership_type TEXT NOT NULL DEFAULT 'club';
-- +goose Down
ALTER TABLE memberships RENAME TO club_memberships;
ALTER TABLE club_memberships RENAME COLUMN team_slug TO club_slug;
ALTER TABLE club_memberships DROP COLUMN membership_type;

View File

@ -0,0 +1,5 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS idx_memberships_on_player_slug_start_date_end_date ON memberships(player_slug, start_date, end_date);
-- +goose Down
DROP INDEX IF EXISTS idx_memberships_on_player_slug_start_date_end_date;

View File

@ -0,0 +1,6 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS idx_games_away_team_slug_date ON games(away_team_slug, date);
CREATE INDEX IF NOT EXISTS idx_games_home_team_slug_date ON games(home_team_slug, date);
-- +goose Down
DROP INDEX IF EXISTS idx_games_away_team_slug_date;
DROP INDEX IF EXISTS idx_games_home_team_slug_date;

View File

@ -0,0 +1,35 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE OR REPLACE FUNCTION immutable_unaccent(regdictionary, TEXT)
RETURNS TEXT
LANGUAGE c
IMMUTABLE PARALLEL SAFE STRICT AS
'$libdir/unaccent',
'unaccent_dict';
CREATE OR REPLACE FUNCTION f_unaccent(TEXT) RETURNS TEXT
immutable
strict
parallel safe
language sql
as
$$
SELECT public.immutable_unaccent(regdictionary 'public.unaccent', $1)
$$;
CREATE INDEX IF NOT EXISTS players_unaccent_display_name_trgm_idx
ON players USING gin (f_unaccent(display_name::text) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS teams_unaccent_display_name_trgm_idx
ON teams USING gin (f_unaccent(display_name::text) gin_trgm_ops);
-- +goose Down
DROP INDEX IF EXISTS players_unaccent_display_name_trgm_idx;
DROP INDEX IF EXISTS teams_unaccent_display_name_trgm_idx;
DROP FUNCTION IF EXISTS f_unaccent;
DROP FUNCTION IF EXISTS immutable_unaccent;
DROP EXTENSION IF EXISTS unaccent;
DROP EXTENSION IF EXISTS pg_trgm;

View File

@ -0,0 +1,116 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS player_averages (
player_slug VARCHAR NOT NULL,
fixture_slug VARCHAR NOT NULL,
PRIMARY KEY (player_slug, fixture_slug),
l5 INTEGER,
l5r INTEGER,
l15 INTEGER,
l15r INTEGER,
l40 INTEGER,
l40r INTEGER,
gameweek INTEGER,
ds5 INTEGER,
ds15 INTEGER,
ds40 INTEGER,
aa5 INTEGER,
aa15 INTEGER,
aa40 INTEGER,
minutes15 INTEGER
);
CREATE INDEX player_averages_player_slug_gameweek_index ON player_averages (player_slug ASC, gameweek DESC);
CREATE OR REPLACE PROCEDURE calc_stats (IN starting_gameweek INTEGER) LANGUAGE plpgsql AS $$
DECLARE
fixture RECORD;
player RECORD;
startdate TIMESTAMP WITH TIME ZONE;
l5 INTEGER;
l15 INTEGER;
l40 INTEGER;
l5r INTEGER;
l15r INTEGER;
l40r INTEGER;
ds5 INTEGER;
ds15 INTEGER;
ds40 INTEGER;
aa5 INTEGER;
aa15 INTEGER;
aa40 INTEGER;
minutes15 INTEGER;
BEGIN
FOR fixture IN
(SELECT * FROM fixtures WHERE game_week >= starting_gameweek ORDER BY game_week)
LOOP
RAISE NOTICE 'Processing fixture: %, Game week: %', fixture.slug, fixture.game_week;
SELECT start_date FROM fixtures WHERE game_week = fixture.game_week - 1 INTO startdate;
IF startdate IS NULL THEN
startdate = fixture.start_date - INTERVAL '3 days';
END IF;
RAISE NOTICE 'Start date for calculations: %', startdate;
FOR player IN
(SELECT * FROM players)
LOOP
RAISE NOTICE 'Calculating averages for player: %', player.slug;
WITH subquery AS (SELECT row_number() OVER (ORDER BY g.date DESC) AS rn,
score,
decisive_score,
all_around_score,
minutes
FROM game_player_scores AS gps
JOIN games AS g ON g.id = gps.game_id
WHERE player_slug = player.slug
AND g.date < startdate)
select coalesce(round(avg(score) filter ( where rn <= 5 )), 0),
coalesce(round(avg(score) filter ( where rn <= 15 )), 0),
coalesce(round(avg(score) filter ( where rn <= 40 )), 0),
coalesce(round(avg(score) filter ( where rn <= 5 and minutes > 0 )), 0),
coalesce(round(avg(score) filter ( where rn <= 15 and minutes > 0 )), 0),
coalesce(round(avg(score) filter ( where rn <= 40 and minutes > 0 )), 0),
coalesce(round(avg(decisive_score) filter ( where rn <= 5 and minutes > 0 )), 0),
coalesce(round(avg(decisive_score) filter ( where rn <= 15 and minutes > 0 )), 0),
coalesce(round(avg(decisive_score) filter ( where rn <= 40 and minutes > 0 )), 0),
coalesce(round(avg(all_around_score) filter ( where rn <= 5 and minutes > 0 )), 0),
coalesce(round(avg(all_around_score) filter ( where rn <= 15 and minutes > 0 )), 0),
coalesce(round(avg(all_around_score) filter ( where rn <= 40 and minutes > 0 )), 0),
coalesce(round(avg(minutes) filter ( where rn <= 15 )), 0)
from subquery
into l5r, l15r, l40r, l5, l15, l40, ds5, ds15, ds40, aa5, aa15, aa40, minutes15;
RAISE NOTICE 'Inserting/updating averages for player: %, Fixture: %, Game week: %, l15: %', player.slug, fixture.slug, fixture.game_week, l15;
INSERT INTO player_averages (player_slug, fixture_slug, gameweek, l5, l5r, l15, l15r, l40, l40r, ds5, ds15, ds40, aa5, aa15, aa40, minutes15)
VALUES (player.slug, fixture.slug, fixture.game_week, l5, l5r, l15, l15r, l40, l40r, ds5, ds15, ds40, aa5, aa15, aa40, minutes15)
ON CONFLICT (player_slug, fixture_slug) DO UPDATE SET l5 = EXCLUDED.l5,
l5r = EXCLUDED.l5r,
l15 = EXCLUDED.l15,
l15r = EXCLUDED.l15r,
l40 = EXCLUDED.l40,
l40r = EXCLUDED.l40r,
ds5 = EXCLUDED.ds5,
ds15 = EXCLUDED.ds15,
ds40 = EXCLUDED.ds40,
aa5 = EXCLUDED.aa5,
aa15 = EXCLUDED.aa15,
aa40 = EXCLUDED.aa40,
minutes15 = EXCLUDED.minutes15;
END LOOP;
COMMIT;
END LOOP;
END;
$$;
-- +goose Down
DROP PROCEDURE IF EXISTS calc_stats;
DROP INDEX IF EXISTS player_averages_player_slug_gameweek_index;
DROP TABLE IF EXISTS player_averages;

366
db/player.go Normal file
View File

@ -0,0 +1,366 @@
package db
import (
"context"
"time"
"git.lehouerou.net/laurent/sorare/types"
"github.com/pkg/errors"
"github.com/samber/lo"
"github.com/shopspring/decimal"
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/model"
)
type PlayerRepository struct {
*Repository[model.Player]
}
func NewPlayerRepository(db *bun.DB) *PlayerRepository {
return &PlayerRepository{
Repository: NewRepository[model.Player](db, []string{"slug"}),
}
}
func (r *PlayerRepository) SearchByDisplayName(
ctx context.Context,
displayName string,
limit int,
) ([]model.Player, error) {
var players []model.Player
err := r.db.NewSelect().
Model(&players).
Relation("Team").
Relation("DomesticLeague").
Relation("DomesticLeague.Zone").
Where("f_unaccent(player.display_name) ILIKE ?", "%"+displayName+"%").
Limit(limit).
Scan(ctx)
return players, err
}
func (r *PlayerRepository) GetOne(ctx context.Context, slug string) (model.Player, error) {
var player model.Player
err := r.db.NewSelect().
Model(&player).
Where("player.slug = ?", slug).
Relation("CardSupply").
Relation("ClubMembership").
Relation("ClubMembership.Team").
Relation("ClubMembership.Team.Country").
Relation("ClubMembership.Team.DomesticLeague").
Relation("ClubMembership.Team.DomesticLeague.Zone").
Relation("Country").
Relation("Team").
Relation("DomesticLeague").
Relation("DomesticLeague.Zone").
Scan(ctx)
return player, err
}
func (r *PlayerRepository) GetMany(ctx context.Context, slugs ...string) ([]model.Player, error) {
var players []model.Player
err := r.db.NewSelect().
Model(&players).
Where("player.slug IN (?)", bun.In(slugs)).
Relation("CardSupply").
Relation("ClubMembership").
Relation("Country").
Relation("Team").
Relation("DomesticLeague").
Relation("DomesticLeague.Zone").
Scan(ctx)
return players, err
}
func (r *PlayerRepository) GetAll(ctx context.Context) ([]model.Player, error) {
var players []model.Player
err := r.db.NewSelect().
Model(&players).
Scan(ctx)
return players, err
}
func (r *PlayerRepository) GetPlayerSlugsNotInDb(ctx context.Context, playerSlugs []string) ([]string, error) {
var players []model.Player
err := r.db.NewSelect().
Model(&players).
Where("slug IN (?)", bun.In(playerSlugs)).
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "getting players not in db")
}
diff, _ := lo.Difference(playerSlugs, lo.Map(players, func(p model.Player, index int) string { return p.Slug }))
return diff, nil
}
type SingleRankingsOptions struct {
StartDate time.Time `json:"startDate"`
Position types.Position `json:"position"`
Competitions []string `json:"competitions"`
Zones []int `json:"zones"`
OnlyClubGames bool `json:"onlyClubGames"`
OnlyStarting bool `json:"onlyStarting"`
MinGameCount int `json:"minGameCount"`
MinTeamGamesPlayedPercentage int `json:"minTeamGamesPlayedPercentage"`
Order string `json:"order"`
Limit int `json:"limit"`
Rarity types.Rarity `json:"rarity"`
U23 bool `json:"u23"`
MinTotalMinutes int `json:"minTotalMinutes"`
MinAge int `json:"minAge"`
MaxAge int `json:"maxAge"`
HasGameInNextGw bool `json:"hasGameInNextGw"`
}
type SingleRanking struct {
PlayerSlug string `bun:"player_slug" json:"playerSlug"`
TeamSlug string `bun:"team_slug" json:"teamSlug"`
AvgTeamGoalsFor decimal.Decimal `bun:"avg_team_goals_for" json:"avgTeamGoalsFor"`
AvgTeamGoalsAgainst decimal.Decimal `bun:"avg_team_goals_against" json:"avgTeamGoalsAgainst"`
TotalAvgScore decimal.Decimal `bun:"total_avg_score" json:"totalAvgScore"`
DecisiveAvgScore decimal.Decimal `bun:"decisive_avg_score" json:"decisiveAvgScore"`
AllAroundAvgScore decimal.Decimal `bun:"all_around_avg_score" json:"allAroundAvgScore"`
MinScore decimal.Decimal `bun:"min_score" json:"minScore"`
MaxScore decimal.Decimal `bun:"max_score" json:"maxScore"`
TotalStddevScore decimal.Decimal `bun:"total_stddev_score" json:"totalStddevScore"`
GameUsedInStatsCount int `bun:"game_used_in_stats_count" json:"gameUsedInStatsCount"`
GameStartedCount int `bun:"game_started_count" json:"gameStartedCount"`
GameBenchedPlayedCount int `bun:"game_benched_played_count" json:"gameBenchedPlayedCount"`
GameBenchedUnplayedCount int `bun:"game_benched_unplayed_count" json:"gameBenchedUnplayedCount"`
TeamsGameCount int `bun:"teams_game_count" json:"teamsGameCount"`
TotalPossibleMinutes int `bun:"total_possible_minutes" json:"totalPossibleMinutes"`
PercentageMinutesPlayed decimal.Decimal `bun:"percentage_minutes_played" json:"percentageMinutesPlayed"`
StackPlayPercentage decimal.Decimal `bun:"stack_play_percentage" json:"stackPlayPercentage"`
Floor decimal.Decimal `bun:"floor" json:"floor"`
MoreThan80Score int `bun:"more_than_80_score" json:"moreThan80Score"`
MoreThan70Score int `bun:"more_than_70_score" json:"moreThan70Score"`
MoreThan60Score int `bun:"more_than_60_score" json:"moreThan60Score"`
MoreThan50Score int `bun:"more_than_50_score" json:"moreThan50Score"`
DecisiveCount int `bun:"decisive_count" json:"decisiveCount"`
MoreThan40AA int `bun:"more_than_40_aa" json:"moreThan40AA"`
MoreThan30AA int `bun:"more_than_30_aa" json:"moreThan30AA"`
MoreThan20AA int `bun:"more_than_20_aa" json:"moreThan20AA"`
MoreThan10AA int `bun:"more_than_10_aa" json:"moreThan10AA"`
MoreThan80ScorePercentage decimal.Decimal `bun:"more_than_80_score_percentage" json:"moreThan80ScorePercentage"`
MoreThan70ScorePercentage decimal.Decimal `bun:"more_than_70_score_percentage" json:"moreThan70ScorePercentage"`
MoreThan60ScorePercentage decimal.Decimal `bun:"more_than_60_score_percentage" json:"moreThan60ScorePercentage"`
MoreThan50ScorePercentage decimal.Decimal `bun:"more_than_50_score_percentage" json:"moreThan50ScorePercentage"`
DecisiveCountPercentage decimal.Decimal `bun:"decisive_percentage" json:"decisivePercentage"`
MoreThan40AAPercentage decimal.Decimal `bun:"more_than_40_aa_percentage" json:"moreThan40AAPercentage"`
MoreThan30AAPercentage decimal.Decimal `bun:"more_than_30_aa_percentage" json:"moreThan30AAPercentage"`
MoreThan20AAPercentage decimal.Decimal `bun:"more_than_20_aa_percentage" json:"moreThan20AAPercentage"`
MoreThan10AAPercentage decimal.Decimal `bun:"more_than_10_aa_percentage" json:"moreThan10AAPercentage"`
TotalMinutes decimal.Decimal `bun:"total_minutes" json:"totalMinutes"`
TotalAA decimal.Decimal `bun:"total_aa" json:"totalAA"`
AAPerMin decimal.Decimal `bun:"aa_per_min" json:"aaPerMin"`
Scores []*decimal.Decimal `bun:"scores,array" json:"scores"`
Player model.Player `bun:"-" json:"player"`
}
func CalculateCutoffBirthdate(age int) time.Time {
// Get the current time.
now := time.Now()
// Subtract the age from the current year.
// Also subtract 6 months to reverse the original SQL operation.
birthYear := now.Year() - age
if now.Month() < 7 { // If before July, subtract an additional year.
birthYear--
}
// Construct the birth date using the calculated birth year.
// The month and day are set to the same as the current date for simplicity.
// Adjust these values as needed for your specific requirements.
birthDate := time.Date(birthYear, now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
return birthDate
}
func (r *PlayerRepository) SingleRankings(ctx context.Context, opts SingleRankingsOptions) ([]SingleRanking, error) {
minAgeBirthdate := CalculateCutoffBirthdate(opts.MinAge)
maxAgeBirthdate := CalculateCutoffBirthdate(opts.MaxAge)
// Return the list of team slugs for teams that have games in the currently opened game week.
NextGWGameCountRequest := r.db.NewSelect().
ColumnExpr("t.slug").
TableExpr("teams AS t").
Join("JOIN games AS g ON g.away_team_slug = t.slug OR g.home_team_slug = t.slug").
Join("JOIN fixtures AS f ON f.slug = g.fixture_slug").
Where("f.state = 'opened'").
Group("t.slug")
FilteredGames := r.db.NewSelect().
ColumnExpr("g.*").
TableExpr("games AS g").
Where("g.date >= ?", opts.StartDate)
if opts.OnlyClubGames {
FilteredGames.Join("JOIN competitions AS c ON c.slug = g.competition_slug")
FilteredGames.Where("c.competition_type = 'CLUB'")
}
// Return for each player the sum of supplies over all seasons
PlayersSupplies := r.db.NewSelect().
ColumnExpr("cs.player_slug").
ColumnExpr("SUM(cs.limited) AS limited").
ColumnExpr("SUM(cs.rare) AS rare").
ColumnExpr("SUM(cs.super_rare) AS super_rare").
ColumnExpr("SUM(cs.unique) AS unique").
TableExpr("card_supplies AS cs").
Group("player_slug")
FilteredPlayers := r.db.NewSelect().
ColumnExpr("p.*").
TableExpr("players AS p").
Join("JOIN \"Supplies\" AS s ON s.player_slug = p.slug").
Join("JOIN competitions AS dc ON dc.slug = p.domestic_league_slug").
Where("p.field_position = ?", opts.Position).
Where("p.birth_date >= ?", maxAgeBirthdate).
Where("p.birth_date <= ?", minAgeBirthdate).
Where("dc.zone_id IS NOT NULL")
if opts.U23 {
FilteredPlayers.Where("p.birth_date >= ?", CalculateCutoffBirthdate(23))
}
if len(opts.Competitions) > 0 {
FilteredPlayers.Where("dc.slug IN (?)", bun.In(opts.Competitions))
}
if len(opts.Zones) > 0 {
FilteredPlayers.Where("dc.zone_id IN (?)", bun.In(opts.Zones))
}
if opts.HasGameInNextGw {
FilteredPlayers.Join("JOIN \"NextGWGameCount\" AS ngc ON ngc.slug = p.team_slug")
}
PlayerPossibleGameCount := r.db.NewSelect().
ColumnExpr("p.slug").
ColumnExpr("COUNT(DISTINCT g.id) AS total_game_count").
ColumnExpr("SUM(g.minutes) AS total_minutes").
ColumnExpr("ARRAY_AGG(COALESCE(fs.score, -1) ORDER BY g.date DESC) AS scores").
ColumnExpr("COUNT(DISTINCT CASE WHEN gp.status = 'starting' THEN gp.game_id ELSE NULL END) AS game_started_count").
ColumnExpr("COUNT(DISTINCT CASE WHEN gp.status = 'bench' AND fs.minutes_played > 0 THEN gp.game_id ELSE NULL END) AS game_benched_played_count").
ColumnExpr("COUNT(DISTINCT CASE WHEN gp.status = 'bench' AND fs.minutes_played = 0 THEN gp.game_id ELSE NULL END) AS game_benched_unplayed_count").
ColumnExpr("SUM(fs.minutes_played) * 100 / SUM(g.minutes) AS percentage_minutes_played").
TableExpr("\"FilteredPlayers\" AS p").
Join("JOIN memberships AS m ON m.player_slug = p.slug AND m.start_date <= now() AND (m.end_date IS NULL OR m.end_date >= ?)", opts.StartDate).
Join("JOIN games AS g ON (g.away_team_slug = m.team_slug OR g.home_team_slug = m.team_slug) AND g.date >= GREATEST(m.start_date, ?) AND (g.date <= m.end_date OR m.end_date IS NULL) AND g.date < DATE(now())", opts.StartDate).
Join("LEFT JOIN game_players AS gp ON gp.player_slug = p.slug AND gp.game_id = g.id").
Join("LEFT JOIN game_player_scores AS fs ON fs.player_slug = p.slug AND fs.game_id = g.id").
Group("p.slug")
if opts.OnlyClubGames {
PlayerPossibleGameCount.Where("m.membership_type = 'club'")
}
// Return the list of games played by players filtered out with params
gamePlayersRequest := r.db.NewSelect().
ColumnExpr("gp.player_slug").
ColumnExpr("gp.game_id").
ColumnExpr("gp.team_slug").
ColumnExpr("CASE WHEN gp.team_slug = g.home_team_slug THEN g.home_goals ELSE g.away_goals END as team_goals_for").
ColumnExpr("CASE WHEN gp.team_slug = g.home_team_slug THEN g.away_goals ELSE g.home_goals END as team_goals_against").
ColumnExpr("CASE WHEN gp.team_slug = g.home_team_slug THEN 'H' ELSE 'A' END AS home_away").
TableExpr("\"FilteredGames\" AS g").
Join("JOIN game_players AS gp ON g.id = gp.game_id").
Join("JOIN \"FilteredPlayers\" AS p ON p.slug = gp.player_slug")
if opts.OnlyStarting {
gamePlayersRequest.Where("gp.status = 'starting'")
}
scoresRequest := r.db.NewSelect().
ColumnExpr("gp.player_slug").
ColumnExpr("round(AVG(gp.team_goals_for), 2) AS avg_team_goals_for").
ColumnExpr("round(AVG(gp.team_goals_against), 2) AS avg_team_goals_against").
ColumnExpr("round(AVG(fs.score), 2) AS total_avg_score").
ColumnExpr("round(AVG(fs.decisive_score), 2) AS decisive_avg_score").
ColumnExpr("round(AVG(fs.all_around_score), 2) AS all_around_avg_score").
ColumnExpr("min(fs.score) as min_score").
ColumnExpr("max(fs.score) as max_score").
ColumnExpr("COALESCE(round(stddev(fs.score),2), 0) AS total_stddev_score").
ColumnExpr("COUNT(*) AS game_used_in_stats_count").
ColumnExpr("COUNT(CASE WHEN fs.score > 80 THEN 1 ELSE NULL END) AS more_than_80_score").
ColumnExpr("COUNT(CASE WHEN fs.score > 70 THEN 1 ELSE NULL END) AS more_than_70_score").
ColumnExpr("COUNT(CASE WHEN fs.score > 60 THEN 1 ELSE NULL END) AS more_than_60_score").
ColumnExpr("COUNT(CASE WHEN fs.score > 50 THEN 1 ELSE NULL END) AS more_than_50_score").
ColumnExpr("COUNT(CASE WHEN fs.decisive_score > 60 THEN 1 ELSE NULL END) AS decisive_count").
ColumnExpr("COUNT(CASE WHEN fs.all_around_score > 40 THEN 1 ELSE NULL END) AS more_than_40_aa").
ColumnExpr("COUNT(CASE WHEN fs.all_around_score > 30 THEN 1 ELSE NULL END) AS more_than_30_aa").
ColumnExpr("COUNT(CASE WHEN fs.all_around_score > 20 THEN 1 ELSE NULL END) AS more_than_20_aa").
ColumnExpr("COUNT(CASE WHEN fs.all_around_score > 10 THEN 1 ELSE NULL END) AS more_than_10_aa").
ColumnExpr("SUM(fs.minutes_played) as total_minutes").
ColumnExpr("SUM(fs.all_around_score) as total_aa").
ColumnExpr("COALESCE(ROUND(SUM(fs.all_around_score) / NULLIF(SUM(fs.minutes_played), 0), 3), 0) AS aa_per_min").
TableExpr("\"FilteredGamePlayers\" AS gp").
Join("JOIN game_player_scores AS fs ON gp.player_slug = fs.player_slug AND gp.game_id = fs.game_id").
Group("gp.player_slug")
filteredScoresRequest := r.db.NewSelect().
ColumnExpr("s.*").
ColumnExpr("ROUND(((s.game_used_in_stats_count::float / ppgc.total_game_count::float) * 100)::numeric, 2) AS stack_play_percentage").
ColumnExpr("ROUND(((s.more_than_80_score::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_80_score_percentage").
ColumnExpr("ROUND(((s.more_than_70_score::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_70_score_percentage").
ColumnExpr("ROUND(((s.more_than_60_score::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_60_score_percentage").
ColumnExpr("ROUND(((s.more_than_50_score::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_50_score_percentage").
ColumnExpr("ROUND(((s.decisive_count::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS decisive_percentage").
ColumnExpr("ROUND(((s.more_than_40_aa::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_40_aa_percentage").
ColumnExpr("ROUND(((s.more_than_30_aa::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_30_aa_percentage").
ColumnExpr("ROUND(((s.more_than_20_aa::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_20_aa_percentage").
ColumnExpr("ROUND(((s.more_than_10_aa::float / s.game_used_in_stats_count::float) * 100)::numeric, 2) AS more_than_10_aa_percentage").
ColumnExpr("ppgc.game_started_count").
ColumnExpr("ppgc.game_benched_played_count").
ColumnExpr("ppgc.game_benched_unplayed_count").
ColumnExpr("ppgc.total_game_count AS teams_game_count").
ColumnExpr("ppgc.scores").
ColumnExpr("ppgc.total_minutes AS total_possible_minutes").
ColumnExpr("ppgc.percentage_minutes_played").
TableExpr("\"Scores\" AS s").
Join("JOIN \"PlayerPossibleGameCount\" AS ppgc ON ppgc.slug = s.player_slug").
Where("s.game_used_in_stats_count >= ?", opts.MinGameCount).
Where("s.total_minutes >= ?", opts.MinTotalMinutes)
mainRequest := r.db.NewSelect().
With("Supplies", PlayersSupplies).
With("NextGWGameCount", NextGWGameCountRequest).
With("FilteredGames", FilteredGames).
With("FilteredPlayers", FilteredPlayers).
With("PlayerPossibleGameCount", PlayerPossibleGameCount).
With("FilteredGamePlayers", gamePlayersRequest).
With("Scores", scoresRequest).
With("FilteredScores", filteredScoresRequest).
ColumnExpr("fs.*").
TableExpr("\"FilteredScores\" AS fs").
Where("fs.stack_play_percentage >= ?", opts.MinTeamGamesPlayedPercentage).
Order(opts.Order).
Limit(opts.Limit)
var res []SingleRanking
err := mainRequest.Scan(ctx, &res)
if err != nil {
return nil, errors.Wrap(err, "selecting single rankings")
}
playerSlugs := lo.Map(res, func(r SingleRanking, _ int) string {
return r.PlayerSlug
})
if len(playerSlugs) == 0 {
return res, nil
}
players, err := r.GetMany(ctx, playerSlugs...)
if err != nil {
return nil, errors.Wrap(err, "selecting players")
}
for i, re := range res {
for _, p := range players {
if re.PlayerSlug == p.Slug {
res[i].Player = p
break
}
}
}
return res, nil
}

35
db/repository.go Normal file
View File

@ -0,0 +1,35 @@
package db
import (
"context"
"fmt"
"strings"
"github.com/uptrace/bun"
)
type Repository[T any] struct {
db *bun.DB
pkFieldList []string
}
func NewRepository[T any](db *bun.DB, pkFieldList []string) *Repository[T] {
return &Repository[T]{db: db, pkFieldList: pkFieldList}
}
func (r *Repository[T]) CreateOrUpdateMany(ctx context.Context, list []T) error {
if len(list) == 0 {
return nil
}
_, err := r.db.NewInsert().Model(&list).
On(fmt.Sprintf("CONFLICT (%s) DO UPDATE", strings.Join(r.pkFieldList, ","))).
Exec(ctx)
return err
}
func (r *Repository[T]) GetAll(ctx context.Context) ([]T, error) {
var list []T
err := r.db.NewSelect().Model(&list).Scan(ctx)
return list, err
}

47
db/team.go Normal file
View File

@ -0,0 +1,47 @@
package db
import (
"context"
"github.com/pkg/errors"
"github.com/samber/lo"
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/model"
)
type TeamRepository struct {
*Repository[model.Team]
}
func NewTeamRepository(db *bun.DB) *TeamRepository {
return &TeamRepository{
Repository: NewRepository[model.Team](db, []string{"slug"}),
}
}
func (r *TeamRepository) GetTeamSlugsNotInDb(ctx context.Context, teamSlugs []string) ([]string, error) {
var teams []model.Team
err := r.db.NewSelect().
Model(&teams).
Where("slug IN (?)", bun.In(teamSlugs)).
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "getting players not in db")
}
diff, _ := lo.Difference(teamSlugs, lo.Map(teams, func(t model.Team, index int) string { return t.Slug }))
return diff, nil
}
func (r *TeamRepository) SearchByDisplayName(ctx context.Context, displayName string, limit int) ([]model.Team, error) {
var teams []model.Team
err := r.db.NewSelect().
Model(&teams).
Relation("Country").
Relation("DomesticLeague").
Relation("DomesticLeague.Zone").
Where("f_unaccent(team.display_name) ILIKE ?", "%"+displayName+"%").
Limit(limit).
Scan(ctx)
return teams, err
}

28
db/zone.go Normal file
View File

@ -0,0 +1,28 @@
package db
import (
"context"
"github.com/uptrace/bun"
"git.lehouerou.net/laurent/sorarebuddy/model"
)
type ZoneRepository struct {
db *bun.DB
}
func NewZoneRepository(db *bun.DB) *ZoneRepository {
return &ZoneRepository{db: db}
}
func (r *ZoneRepository) GetAll(ctx context.Context) ([]model.Zone, error) {
var res []model.Zone
if err := r.db.NewSelect().Model(&res).Scan(ctx); err != nil {
return nil, err
}
return res, nil
}
//initializing sorare client: getting current user: querying records: Message: Unauthorized: Signature has expired, Locations: []"

18
front/.eslintrc.cjs Normal file
View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
front/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

13
front/.prettierrc Normal file
View File

@ -0,0 +1,13 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": false,
"singleQuote": false,
"useTabs": false,
"plugins": [
"@ianvs/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss"
],
"pluginSearchDirs": ["."]
}

30
front/README.md Normal file
View File

@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

BIN
front/bun.lockb Normal file

Binary file not shown.

13
front/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sorare Buddy</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4802
front/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
front/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "sorarebuddy",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"export": "tsc && vite build --outDir ../cmd/server/dist --emptyOutDir",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/exo-2": "^5.0.18",
"@fontsource/roboto": "^5.0.12",
"@tanstack/react-query": "^5.28.9",
"@tanstack/react-query-devtools": "^5.28.10",
"date-fns": "^3.6.0",
"moment": "^2.30.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.1.0",
"react-router": "^6.22.3",
"react-router-dom": "^6.22.3",
"react-select": "^5.8.0",
"recharts": "^2.12.5"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.2.1",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.13",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
}

6
front/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
front/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

18
front/src/App.tsx Normal file
View File

@ -0,0 +1,18 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import RouterOutlet from "./router/router";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: true,
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterOutlet />
</QueryClientProvider>
);
}

86
front/src/api/api.ts Normal file
View File

@ -0,0 +1,86 @@
import Player from "../types/player"
import Team from "../types/team"
interface GetParams {
endpoint: string
params?: Record<string, string | number | boolean>
}
interface PostParams {
endpoint: string
body: unknown
}
export function Get<T>({ endpoint, params }: GetParams): Promise<T> {
return apiRequest<T>({ method: "GET", endpoint, params })
}
export function Post<T>({ endpoint, body }: PostParams): Promise<T> {
return apiRequest<T>({ method: "POST", endpoint, body })
}
interface ApiRequestParams {
method: "GET" | "POST"
endpoint: string
body?: unknown
params?: Record<string, string | number | boolean>
}
async function apiRequest<T>({
method,
endpoint,
body,
params,
}: ApiRequestParams): Promise<T> {
const options: RequestInit = {
method,
}
if (method === "POST") {
options.headers = { "Content-Type": "application/json" }
if (body) {
options.body = JSON.stringify(body)
}
}
const queryParams = params
? "?" +
Object.entries(params)
.map(
([key, value]) =>
encodeURIComponent(key) + "=" + encodeURIComponent(value)
)
.join("&")
: ""
let resp: Response
try {
resp = await fetch(endpoint + queryParams, options)
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error("Network error: " + error.message)
} else {
throw new Error("An unknown error occurred")
}
}
if (!resp.ok) {
const data = await resp.json()
throw new Error(data.error || "Something went wrong")
}
if (resp.status === 204) return null as unknown as T
return await resp.json()
}
interface SearchMultiResponse {
players: Player[]
teams: Team[]
}
export async function SearchMulti(query: string): Promise<SearchMultiResponse> {
return await Post<SearchMultiResponse>({
endpoint: "/api/search/multi",
body: {
query: query,
limit: 10,
},
})
}

9
front/src/api/card.ts Normal file
View File

@ -0,0 +1,9 @@
import Card from "../types/card"
import { Get } from "./api"
export async function GetUserCards(userSlug: string): Promise<Card[]> {
const raw = await Get<Card[]>({
endpoint: `/api/user/${userSlug}/cards`,
})
return raw
}

View File

@ -0,0 +1,11 @@
import Competition from "../types/competition";
import { Get } from "./api";
export async function GetClubCompetitions(
zones: number[],
): Promise<Competition[]> {
return await Get<Competition[]>({
endpoint: `/api/competition/club`,
params: { zones: zones.join(",") },
});
}

9
front/src/api/fixture.ts Normal file
View File

@ -0,0 +1,9 @@
import { Fixture } from "../types/fixture"
import { Get } from "./api"
export async function GetAvailableFixtures(): Promise<Fixture[]> {
const raw = await Get<Fixture[]>({
endpoint: "/api/fixture/all",
})
return raw.sort((a, b) => b.gameWeek - a.gameWeek)
}

16
front/src/api/game.ts Normal file
View File

@ -0,0 +1,16 @@
import { Game } from "../types/game"
import { Get } from "./api"
export async function GetFixtureGames(fixtureSlug: string): Promise<Game[]> {
const raw = await Get<Game[]>({
endpoint: `/api/fixture/${fixtureSlug}/games`,
})
return raw
.sort(
(a: Game, b: Game) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
)
.filter((game: Game) => {
return game.awayTeam.slug !== "" && game.homeTeam.slug !== ""
})
}

17
front/src/api/player.ts Normal file
View File

@ -0,0 +1,17 @@
import { GamePlayer } from "../types/game"
import Player from "../types/player"
import { Get } from "./api"
export async function GetPlayer(slug: string): Promise<Player> {
const raw = await Get<Player>({
endpoint: `/api/player/${slug}`,
})
return raw
}
export async function GetPlayerGames(slug: string): Promise<GamePlayer[]> {
const raw = await Get<GamePlayer[]>({
endpoint: `/api/player/${slug}/games`,
})
return raw
}

74
front/src/api/rankings.ts Normal file
View File

@ -0,0 +1,74 @@
import Player from "../types/player"
import { Post } from "./api"
export interface SingleRankingsParams {
startDate: Date
position: string
competitions: string[]
zones: number[]
onlyClubGames: boolean
onlyStarting: boolean
minGameCount: number
minTeamGamesPlayedPercentage: number
order: string
limit: number
rarity: string
u23: boolean
minTotalMinutes: number
minAge: number
maxAge: number
hasGameInNextGw: boolean
}
export interface SingleRanking {
playerSlug: string
teamSlug: string
avgTeamGoalsFor: number
avgTeamGoalsAgainst: number
totalAvgScore: number
decisiveAvgScore: number
allAroundAvgScore: number
minScore: number
maxScore: number
totalStddevScore: number
gameUsedInStatsCount: number
gameStartedCount: number
gameBenchedPlayedCount: number
gameBenchedUnplayedCount: number
teamsGameCount: number
totalPossibleMinutes: number
percentageMinutesPlayed: number
stackPlayPercentage: number
floor: number
moreThan80Score: number
moreThan70Score: number
moreThan60Score: number
moreThan50Score: number
decisiveCount: number
moreThan40AA: number
moreThan30AA: number
moreThan20AA: number
moreThan10AA: number
moreThan80ScorePercentage: number
moreThan70ScorePercentage: number
moreThan60ScorePercentage: number
moreThan50ScorePercentage: number
decisiveCountPercentage: number
moreThan40AAPercentage: number
moreThan30AAPercentage: number
moreThan20AAPercentage: number
moreThan10AAPercentage: number
aaPerMin: number
totalMinutes: number
scores: number[]
player: Player
}
export async function GetSingleRankings(
options: SingleRankingsParams
): Promise<SingleRanking[]> {
return await Post<SingleRanking[]>({
endpoint: "/api/rankings/single",
body: options,
})
}

9
front/src/api/zone.ts Normal file
View File

@ -0,0 +1,9 @@
import Zone from "../types/zone";
import { Get } from "./api";
export async function GetAllZones(): Promise<Zone[]> {
return await Get({
endpoint: "/api/zone/all"
});
}

View File

@ -0,0 +1,51 @@
import { ChangeEventHandler } from "react"
interface CheckboxProps {
checked: boolean
label: string
onChange: ChangeEventHandler<HTMLInputElement>
id: string
}
export default function Checkbox({
checked,
label,
onChange,
id,
}: CheckboxProps) {
return (
<div className="mb-2 block text-xs font-bold text-gray-700">
<label htmlFor={id} className="flex cursor-pointer items-center">
<div className="relative">
<input
id={id}
type="checkbox"
className="sr-only"
checked={checked}
onChange={onChange}
/>
<div className="block h-6 w-6 rounded-md border-2 border-gray-300 bg-white ">
<div
className={`${
checked ? "opacity-100" : "opacity-0"
} absolute inset-0 flex h-full w-full items-center justify-center`}
>
<svg
className="h-4 w-4 text-blue-600"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
</div>
<span className="ml-2">{label}</span>
</label>
</div>
)
}

View File

@ -0,0 +1,13 @@
export default function ErrorBlock({ error }: { error: Error }) {
return (
<div className="flex h-screen flex-col items-center justify-center">
<span>Error: {error.message}</span>
<button
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-white"
onClick={() => window.location.reload()}
>
Retry
</button>
</div>
)
}

View File

@ -0,0 +1,29 @@
import React from "react";
interface InputWrapperProps {
label: string;
id: string;
children: React.ReactNode;
className?: string;
}
function InputWrapper({
label,
id,
children,
className = "",
}: InputWrapperProps) {
return (
<div className={className}>
<label
className="block text-neutral-700 text-xs font-bold mb-1 pl-1"
htmlFor={id}
>
{label}
</label>
{children}
</div>
);
}
export default InputWrapper;

View File

@ -0,0 +1,25 @@
export default function Loader() {
return (
<div className="my-8 flex items-center justify-center">
<div role="status" className="flex items-center space-x-2">
<svg
aria-hidden="true"
className="mr-2 h-8 w-8 animate-spin fill-blue-600 text-gray-200 dark:text-gray-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
</div>
)
}

View File

@ -0,0 +1,33 @@
import InputWrapper from "./input_wrapper";
interface NumericInputProps {
value: number;
onChange: (value: number) => void;
label: string;
placeholder?: string;
id: string;
classname?: string;
}
export default function NumericInput({
value,
onChange,
label,
placeholder = label,
id,
classname = "",
}: NumericInputProps) {
return (
<InputWrapper label={label} id={id} className={classname}>
<input
defaultValue={value}
className="bg-neutral-50 shadow border border-neutral-300 rounded-lg w-40 py-2 pl-3 pr-2 text-neutral-900 text-sm focus:ring-primary-blue-300 focus:border-primary-blue-300"
id={id}
type="number"
placeholder={placeholder}
onChange={(e) => {
onChange(parseInt(e.target.value));
}}
/>
</InputWrapper>
);
}

View File

@ -0,0 +1,123 @@
import { useCallback, useState } from "react"
import { useNavigate } from "react-router-dom"
import AsyncSelect from "react-select/async"
import { SearchMulti } from "../api/api"
import Player from "../types/player"
import Team from "../types/team"
type SearchOption = {
value: string
label: string
imageUrl: string
type: "player" | "team"
data: Player | Team
}
type GroupedOptionType = {
label: string
options: SearchOption[]
}
export function SearchBox() {
const [selectedOption, setSelectedOption] = useState<SearchOption | null>(
null
)
const [searchTimeout, setSearchTimeout] = useState<number | null>(null)
const navigate = useNavigate()
const handleSearch = useCallback(
(inputValue: string, callback: (res: GroupedOptionType[]) => void) => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
setSearchTimeout(
setTimeout(() => {
SearchMulti(inputValue).then((res) => {
const playerOptions = res.players
? res.players.map((player) => ({
value: player.slug,
label:
player.displayName + " (" + player.team?.displayName + ")",
type: "player" as const,
data: player,
imageUrl: player.avatarUrl,
}))
: []
const teamOptions = res.teams
? res.teams.map((team) => ({
value: team.slug,
label: team.displayName,
type: "team" as const,
data: team,
imageUrl: team.pictureUrl,
}))
: []
callback([
{ label: "Players", options: playerOptions },
{ label: "Teams", options: teamOptions },
])
})
}, 200)
)
},
[searchTimeout]
)
const handleSelect = useCallback(
async (option: SearchOption | null) => {
if (option) {
setSelectedOption(null)
navigate("/" + option.type + "/" + option.value)
}
},
[navigate]
)
const formatGroupLabel = (data: GroupedOptionType) => (
<div>
<span>{data.label}</span>
</div>
)
const formatOptionLabel = ({ imageUrl, type, data }: SearchOption) => {
return (
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-primary-100">
<img
className="h-full w-auto max-w-full rounded-full p-0.5"
src={imageUrl}
alt=""
/>
</div>
<span className="text-md">{data.displayName}</span>
</div>
{type === "player" ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs">
{(data as Player).team?.displayName}
</span>
<img
className="max-w-6"
src={(data as Player).team?.pictureUrl}
alt=""
/>
</div>
) : null}
</div>
)
}
return (
<AsyncSelect
value={selectedOption}
placeholder={"Search..."}
loadOptions={handleSearch}
onChange={handleSelect}
formatGroupLabel={formatGroupLabel}
formatOptionLabel={formatOptionLabel}
/>
)
}

View File

@ -0,0 +1,35 @@
import { useQuery } from "@tanstack/react-query"
import ErrorBlock from "./error"
import Loader from "./loader"
interface WithDataFetchingProps<T> {
queryKey: string[]
queryFn: () => Promise<T>
refetchInterval?: number
refetchOnWindowFocus?: boolean
enabled?: boolean
children: (data: T) => React.ReactNode
}
export default function WithDataFetching<T>({
queryKey,
queryFn,
refetchInterval,
refetchOnWindowFocus,
enabled,
children,
}: WithDataFetchingProps<T>) {
const { data, isLoading, isError, error } = useQuery<T, Error>({
queryKey: queryKey,
queryFn: queryFn,
refetchInterval: refetchInterval,
refetchOnWindowFocus: refetchOnWindowFocus,
enabled: enabled,
})
if (isLoading) return <Loader />
if (isError) return <ErrorBlock error={error} />
if (!data) return ErrorBlock({ error: new Error("Data not found") })
return <>{children(data)}</>
}

14
front/src/global.css Normal file
View File

@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn {
@apply px-4 py-2 rounded-md text-neutral-50 text-sm font-semibold hover:shadow-md;
}
}
body {
@apply font-sans;
}

12
front/src/main.tsx Normal file
View File

@ -0,0 +1,12 @@
import "@fontsource/roboto/latin.css"
import "@fontsource-variable/exo-2"
import React from "react"
import ReactDOM from "react-dom/client"
import App from "./App.tsx"
import "./global.css"
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@ -0,0 +1,35 @@
import { Link, useLocation } from "react-router-dom"
import { SearchBox } from "../components/searchBox"
interface HeaderLinkButtonProps {
to: string
label: string
}
const HeaderLinkButton = ({ to, label }: HeaderLinkButtonProps) => {
const location = useLocation()
const isActive = location.pathname === to
return (
<Link
to={to}
className={`w-36 rounded-lg px-4 py-2 text-white hover:bg-primary-600 hover:shadow-lg ${isActive ? "bg-primary-700" : "bg-primary-500"}`}
>
<span className="font-secondary text-lg font-semibold">{label}</span>
</Link>
)
}
export default function Header() {
return (
<div className="relative z-50 grid grid-cols-[auto_1fr] items-center gap-3 border-b border-neutral-300 bg-primary-200 p-3 shadow-md ">
<div className="w-96">
<SearchBox />
</div>
<div className="flex items-center justify-end gap-3 ">
<HeaderLinkButton to="/" label="Rankings" />
<HeaderLinkButton to="/live" label="Live" />
<HeaderLinkButton to="/xpcenter" label="XP Center" />
</div>
</div>
)
}

View File

@ -0,0 +1,13 @@
import { Outlet } from "react-router-dom"
import Header from "./header"
export default function Layout() {
return (
<>
<Header />
<main>
<Outlet />
</main>
</>
)
}

View File

@ -0,0 +1,374 @@
import { useEffect, useMemo, useState } from "react"
import { GetAvailableFixtures } from "../../api/fixture"
import { GetFixtureGames } from "../../api/game"
import WithDataFetching from "../../components/withdatafetching"
import { Fixture } from "../../types/fixture"
import { Game } from "../../types/game"
export default function Live() {
return (
<WithDataFetching<Fixture[]>
queryKey={["availableFixtures"]}
queryFn={GetAvailableFixtures}
refetchInterval={60 * 60000}
>
{(data) => <FixtureListLayout fixtures={data} />}
</WithDataFetching>
)
}
function FixtureListLayout({ fixtures }: { fixtures: Fixture[] }) {
const [currentIndex, setCurrentIndex] = useState(() => {
const startedFixtureIndex = fixtures?.findIndex(
(fixture) => fixture.fixtureState === "started"
)
return startedFixtureIndex !== -1 ? startedFixtureIndex : 0
})
useEffect(() => {
if (currentIndex < 0) {
setCurrentIndex(0)
} else if (currentIndex >= fixtures.length) {
setCurrentIndex(fixtures.length - 1)
}
}, [currentIndex, fixtures?.length])
const selectedFixture = fixtures[currentIndex]
const handlePrevious = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
}
}
const handleNext = () => {
if (currentIndex < fixtures.length - 1) {
setCurrentIndex(currentIndex + 1)
}
}
return (
<>
<div className="container mx-auto flex flex-col gap-3 border-x-2 border-neutral-300 bg-neutral-100 px-3 py-6">
<div className="flex items-center justify-center gap-6">
{currentIndex < fixtures.length - 1 ? (
<button onClick={handleNext}>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
) : (
<div className="w-[24px]"></div>
)}
<div className="flex items-center gap-2">
<span className="text-lg">Gameweek</span>
<span className="text-lg font-bold">
{selectedFixture ? selectedFixture.gameWeek : ""}
</span>
</div>
{currentIndex > 0 ? (
<button onClick={handlePrevious}>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M4 11h12.17l-5.59-5.59L12 4l8 8-8 8-1.41-1.41L16.17 13H4v-2z" />
</svg>
</button>
) : (
<div className="w-[24px]"></div>
)}
</div>
{selectedFixture && (
<WithDataFetching<Game[]>
queryKey={["fixtureGames", selectedFixture.slug]}
queryFn={() => GetFixtureGames(selectedFixture.slug)}
refetchInterval={10000}
enabled={!!selectedFixture}
>
{(data) => <FixtureGames fixture={selectedFixture} games={data} />}
</WithDataFetching>
)}
</div>
</>
)
}
function areDatesOnSameDay(date1: Date, date2: Date) {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
)
}
type TranslationKey = "today" | "tomorrow" | "yesterday"
type LanguageCode =
| "en"
| "zh"
| "hi"
| "es"
| "ar"
| "bn"
| "pt"
| "ru"
| "ja"
| "pa"
| "jv"
| "de"
| "fr"
| "it"
| "tr"
| "ko"
| "fa"
| "uk"
| "pl"
| "ro"
| "nl"
| "th"
| "el"
| "sv"
| "cs"
type Translations = Record<LanguageCode, Record<TranslationKey, string>>
function getLocalizedWord(word: TranslationKey): string {
const translations: Translations = {
en: { today: "Today", tomorrow: "Tomorrow", yesterday: "Yesterday" },
zh: { today: "今天", tomorrow: "明天", yesterday: "昨天" },
hi: { today: "आज", tomorrow: "कल", yesterday: "कल" },
es: { today: "Hoy", tomorrow: "Mañana", yesterday: "Ayer" },
ar: { today: "اليوم", tomorrow: "غدًا", yesterday: "أمس" },
bn: { today: "আজ", tomorrow: "আগামীকাল", yesterday: "গতকাল" },
pt: { today: "Hoje", tomorrow: "Amanhã", yesterday: "Ontem" },
ru: { today: "Сегодня", tomorrow: "Завтра", yesterday: "Вчера" },
ja: { today: "今日", tomorrow: "明日", yesterday: "昨日" },
pa: { today: "ਅੱਜ", tomorrow: "ਕਲ", yesterday: "ਕੱਲ੍ਹ" },
jv: { today: "Dina iki", tomorrow: "Esuk", yesterday: "Wingi" },
de: { today: "Heute", tomorrow: "Morgen", yesterday: "Gestern" },
fr: { today: "Aujourd'hui", tomorrow: "Demain", yesterday: "Hier" },
it: { today: "Oggi", tomorrow: "Domani", yesterday: "Ieri" },
tr: { today: "Bugün", tomorrow: "Yarın", yesterday: "Dün" },
ko: { today: "오늘", tomorrow: "내일", yesterday: "어제" },
fa: { today: "امروز", tomorrow: "فردا", yesterday: "دیروز" },
uk: { today: "Сьогодні", tomorrow: "Завтра", yesterday: "Вчора" },
pl: { today: "Dziś", tomorrow: "Jutro", yesterday: "Wczoraj" },
ro: { today: "Astăzi", tomorrow: "Mâine", yesterday: "Ieri" },
nl: { today: "Vandaag", tomorrow: "Morgen", yesterday: "Gisteren" },
th: { today: "วันนี้", tomorrow: "พรุ่งนี้", yesterday: "เมื่อวาน" },
el: { today: "Σήμερα", tomorrow: "Αύριο", yesterday: "Χθες" },
sv: { today: "Idag", tomorrow: "Imorgon", yesterday: "Igår" },
cs: { today: "Dnes", tomorrow: "Zítra", yesterday: "Včera" },
}
const lang = navigator.language.slice(0, 2)
if ((translations as Record<string, unknown>)[lang]) {
return translations[lang as LanguageCode][word] || word
}
return word
}
function getDayString(day: Date) {
const today = new Date()
const todayDate = today.getDate()
const dayDate = day.getDate()
if (areDatesOnSameDay(day, today)) {
return getLocalizedWord("today")
}
if (
dayDate === todayDate + 1 &&
day.getMonth() === today.getMonth() &&
day.getFullYear() === today.getFullYear()
) {
return getLocalizedWord("tomorrow")
}
if (
dayDate === todayDate - 1 &&
day.getMonth() === today.getMonth() &&
day.getFullYear() === today.getFullYear()
) {
return getLocalizedWord("yesterday")
}
const dayName = new Intl.DateTimeFormat(navigator.language, {
weekday: "long",
}).format(day)
return dayName.charAt(0).toUpperCase() + dayName.slice(1)
}
function FixtureGames({ fixture, games }: { fixture: Fixture; games: Game[] }) {
const [showOnlyStartedAndScheduled, setShowOnlyStartedAndScheduled] =
useState(false)
const toggleFilter = () => {
setShowOnlyStartedAndScheduled((prevState) => !prevState)
}
const [selectedDay, setSelectedDay] = useState<Date>(new Date())
useEffect(() => {
let initialDate = new Date()
if (
initialDate > new Date(fixture.endDate) ||
initialDate < new Date(fixture.startDate)
) {
initialDate = new Date(fixture.startDate)
}
initialDate.setHours(0, 0, 0, 0)
setSelectedDay(initialDate)
}, [fixture])
const uniqueDays = useMemo(() => {
const daysSet = new Set<string>()
games?.forEach((game) => {
const d = new Date(game.date)
const dateString = `${d.getFullYear()}-${String(
d.getMonth() + 1
).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
daysSet.add(dateString)
})
return Array.from(daysSet).map((dateString) => new Date(dateString))
}, [games])
const filteredGames = useMemo(() => {
let res = games?.filter((game: Game) =>
areDatesOnSameDay(new Date(game.date), selectedDay)
)
if (showOnlyStartedAndScheduled) {
res = res?.filter(
(game: Game) => game.status === "playing" || game.status === "scheduled"
)
}
return res?.sort((a: Game, b: Game) => {
const time1 = new Date(a.date).getTime()
const time2 = new Date(b.date).getTime()
if (time1 === time2) {
return a.id > b.id ? 1 : -1
}
return time1 - time2
})
}, [games, showOnlyStartedAndScheduled, selectedDay])
return (
<div className="container mx-auto flex flex-col justify-center">
<div className="flex items-center justify-center gap-4">
{uniqueDays
.sort((a: Date, b: Date) => a.getTime() - b.getTime())
.map((day) => (
<div className="flex flex-col items-center justify-center gap-1">
<button
key={day.getDate()}
onClick={() => setSelectedDay(day)}
className={`flex flex-col rounded-lg border bg-primary-100 px-5 py-1 text-neutral-800 shadow-sm ${
areDatesOnSameDay(day, selectedDay)
? "bg-primary-200 hover:bg-primary-200"
: "hover:border hover:border-primary-300 hover:bg-primary-200"
} `}
>
<span>{getDayString(day)}</span>
<span className="text-sm">
{day.toLocaleDateString(navigator.language, {
month: "short",
day: "numeric",
})}
</span>
</button>
</div>
))}
</div>
<div className="flex justify-end gap-4 p-4">
<label
className="inline-block pl-[0.15rem] hover:cursor-pointer"
htmlFor="flexSwitchChecked"
>
Only started and scheduled
</label>
<input
className="checked:focus:bg-primary dark:checked:bg-primary dark:checked:after:bg-primary mr-2 mt-[0.3rem] h-3.5 w-8 appearance-none rounded-[0.4375rem] bg-neutral-300 before:pointer-events-none before:absolute before:h-3.5 before:w-3.5 before:rounded-full before:bg-transparent before:content-[''] after:absolute after:z-[2] after:-mt-[0.1875rem] after:h-5 after:w-5 after:rounded-full after:border-none after:bg-neutral-100 after:shadow-[0_0px_3px_0_rgb(0_0_0_/_7%),_0_2px_2px_0_rgb(0_0_0_/_4%)] after:transition-[background-color_0.2s,transform_0.2s] after:content-[''] checked:bg-blue-500 checked:after:absolute checked:after:z-[2] checked:after:-mt-[3px] checked:after:ml-[1.0625rem] checked:after:h-5 checked:after:w-5 checked:after:rounded-full checked:after:border-none checked:after:shadow-[0_3px_1px_-2px_rgba(0,0,0,0.2),_0_2px_2px_0_rgba(0,0,0,0.14),_0_1px_5px_0_rgba(0,0,0,0.12)] checked:after:transition-[background-color_0.2s,transform_0.2s] checked:after:content-[''] hover:cursor-pointer focus:outline-none focus:ring-0 focus:before:scale-100 focus:before:opacity-[0.12] focus:before:shadow-[3px_-1px_0px_13px_rgba(0,0,0,0.6)] focus:before:transition-[box-shadow_0.2s,transform_0.2s] focus:after:absolute focus:after:z-[1] focus:after:block focus:after:h-5 focus:after:w-5 focus:after:rounded-full focus:after:content-[''] checked:focus:border-gray-300 checked:focus:before:ml-[1.0625rem] checked:focus:before:scale-100 checked:focus:before:shadow-[3px_-1px_0px_13px_#3b71ca] checked:focus:before:transition-[box-shadow_0.2s,transform_0.2s] dark:bg-neutral-600 dark:after:bg-neutral-400 dark:focus:before:shadow-[3px_-1px_0px_13px_rgba(255,255,255,0.4)] dark:checked:focus:before:shadow-[3px_-1px_0px_13px_#3b71ca]"
type="checkbox"
role="switch"
id="flexSwitchChecked"
checked={showOnlyStartedAndScheduled}
onChange={toggleFilter}
/>
</div>
<div className="grid grid-cols-[0.3fr_1fr_150px_1fr_0.3fr] items-center space-y-2">
{filteredGames.map((game) => (
<>
<div className=" px-3 py-1 text-sm">
{game.status === "playing" ? (
game.periodType === "HALF_TIME" ? (
<span>HT</span>
) : (
<span>{game.minutes}'</span>
)
) : game.status === "played" ? (
<span>FT</span>
) : game.status === "scheduled" ? (
<span className={`text-gray-700 `}>
{new Date(game.date).toLocaleString(navigator.language, {
hour: "numeric",
minute: "numeric",
})}
</span>
) : null}
</div>
<div
className={`text-md flex items-center justify-end gap-3 ${game.status === "played" && game.winnerTeam?.slug === game.homeTeam.slug ? "font-semibold" : ""}`}
>
{game.homeTeam.shortName}
<div className="flex w-8 justify-center">
<img className="h-8 " src={game.homeTeam.pictureUrl} alt="" />
</div>
</div>
<div className="flex items-center justify-center px-6">
<div>
<div className="flex items-center justify-end gap-1">
{game.homePenaltyScore > 0 || game.awayPenaltyScore > 0 ? (
<span>({game.homePenaltyScore})</span>
) : null}
<span
className={`px-2 text-right text-xl ${game.status === "played" && game.winnerTeam?.slug === game.homeTeam.slug ? "font-semibold" : ""}`}
>
{game.status !== "scheduled" ? game.homeGoals : "-"}
</span>
</div>
</div>
{game.status !== "scheduled" && <span>-</span>}
<div>
<div className="flex items-center justify-start gap-1">
<span
className={`px-2 text-right text-xl ${game.status === "played" && game.winnerTeam?.slug === game.awayTeam.slug ? "font-semibold" : ""}`}
>
{game.status !== "scheduled" ? game.awayGoals : "-"}
</span>
{game.awayPenaltyScore > 0 || game.homePenaltyScore > 0 ? (
<span>({game.awayPenaltyScore})</span>
) : null}
</div>
</div>
</div>
<div
className={`px-2 py-1 ${game.status === "played" && game.winnerTeam?.slug === game.awayTeam.slug ? "font-semibold" : ""}`}
>
<div className="flex items-center gap-3">
<div className="flex w-8 justify-center">
<img className="h-8 " src={game.awayTeam.pictureUrl} alt="" />
</div>
{game.awayTeam.shortName}
</div>
</div>
<div className="flex items-center justify-end gap-3">
<div className="text-right text-2xs">
{game.competition.displayName}
</div>
<div>
<img
className="h-4 w-6 border border-neutral-300 shadow-sm"
src={game.competition.country?.flagFlat32Url}
alt={`${game.competition.displayName} (${game.competition.country?.displayName})`}
/>
</div>
</div>
</>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,79 @@
import { useMemo } from "react"
import Player from "../../types/player"
export default function ClubHistory({ player }: { player: Player }) {
const clubMemberships = useMemo(
() =>
player.clubMembership
.filter((membership) => membership.membershipType === "club")
.sort((a, b) => {
if (!a.endDate) return -1
if (!b.endDate) return 1
if (new Date(a.endDate).getTime() === new Date(b.endDate).getTime()) {
return (
new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
)
}
return new Date(b.endDate).getTime() - new Date(a.endDate).getTime()
}),
[player.clubMembership]
)
return (
<div className="grid grid-cols-[auto_auto_3fr_1fr_1fr] items-center gap-3">
{clubMemberships.map((membership) => (
<>
<div className="flex justify-center border shadow-md">
<img
className="w-6"
src={membership.team?.country?.flagFlat32Url}
title={membership.team?.domesticLeague?.displayName}
/>
</div>
<div className="flex justify-center">
<img className="h-10" src={membership.team?.pictureUrl} />
</div>
<div>{membership.team?.displayName}</div>
<div className="flex items-baseline gap-1">
<span>
{new Date(membership.startDate).toLocaleDateString(undefined, {
month: "numeric",
year: "numeric",
})}
</span>
<span>{membership.endDate ? "->" : ""}</span>
<span>
{membership.endDate
? new Date(membership.endDate).toLocaleDateString(undefined, {
month: "numeric",
year: "numeric",
})
: ""}
</span>
</div>
<div>
{(() => {
const endDate = membership.endDate
? new Date(membership.endDate)
: new Date()
const startDate = new Date(membership.startDate)
const diffDays = Math.floor(
(endDate.getTime() - startDate.getTime()) /
(1000 * 60 * 60 * 24)
)
const diffYears = Math.floor(diffDays / 365)
const remainingDays = diffDays % 365
if (diffYears > 1) {
return `${diffYears} years ${remainingDays} days`
} else if (diffYears === 1) {
return `1 year ${remainingDays} days`
} else {
return `${diffDays} days`
}
})()}
</div>
</>
))}
</div>
)
}

View File

@ -0,0 +1,25 @@
import React from "react"
interface CardIconProps {
size?: number
color?: string
}
const CardIcon: React.FC<CardIconProps> = ({
size = 16,
color = "currentColor",
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 18 24"
fill={color}
>
<rect x="2" y="2" width="14" height="20" rx="2" ry="2" />
</svg>
)
}
export default CardIcon

View File

@ -0,0 +1,117 @@
import { useState } from "react"
function extractDateWithOffset(date: Date, offsetDays: number) {
const year = date.getFullYear()
const month = date.getMonth()
const day = date.getDate()
const resultDate = new Date(year, month, day)
resultDate.setDate(resultDate.getDate() + offsetDays)
return resultDate
}
const CustomRadioButton = ({
value,
currentValue,
onClick,
label,
}: {
value: string
currentValue: string
onClick: (value: string) => void
label: string
}) => {
const isActive = value === currentValue
const bg = isActive ? "bg-primary-blue-500" : "bg-neutral-700"
const hoverBg = isActive
? "hover:bg-primary-blue-600"
: "hover:bg-neutral-600"
const activeBg = isActive
? "active:bg-primary-blue-700"
: "active:bg-neutral-500"
return (
<button
aria-label={label}
onClick={() => onClick(value)}
className={`btn py-1 text-white ${bg} ${hoverBg} ${activeBg}`}
>
{label}
</button>
)
}
function DaysSelector({
days,
setDays,
}: {
days: number
setDays: (days: number) => void
}) {
return (
<div className="flex space-x-3">
<CustomRadioButton
value={"3"}
currentValue={String(days)}
onClick={(value) => setDays(Number(value))}
label="3 days"
/>
<CustomRadioButton
value={"7"}
currentValue={String(days)}
onClick={(value) => setDays(Number(value))}
label="1 week"
/>
<CustomRadioButton
value={"30"}
currentValue={String(days)}
onClick={(value) => setDays(Number(value))}
label="1 month"
/>
<CustomRadioButton
value={"90"}
currentValue={String(days)}
onClick={(value) => setDays(Number(value))}
label="3 months"
/>
<CustomRadioButton
value={"180"}
currentValue={String(days)}
onClick={(value) => setDays(Number(value))}
label="6 months"
/>
<CustomRadioButton
value={"365"}
currentValue={String(days)}
onClick={(value) => setDays(Number(value))}
label="1 year"
/>
<CustomRadioButton
value={"0"}
currentValue={String(days)}
onClick={(value) => setDays(Number(value))}
label="All"
/>
</div>
)
}
export default function DaysSelectionWrapper({
children,
}: {
children: (startDate: Date, endDate: Date) => React.ReactNode
}) {
const [days, setDays] = useState(365)
const farEnoughDate = new Date(2000, 0, 1) // You can set this to an earlier date if needed
const startDate =
days === 0 ? farEnoughDate : extractDateWithOffset(new Date(), -days)
const endDate = extractDateWithOffset(new Date(), 1)
return (
<div className="flex flex-col items-center gap-12">
<DaysSelector days={days} setDays={setDays} />
<div className="w-full">{children(startDate, endDate)}</div>
</div>
)
}

View File

@ -0,0 +1,65 @@
import Select, { MultiValue, SingleValue } from "react-select"
interface FilterOption<T> {
value: T
label: string
}
interface FilterSelectProps<T> {
value: T
options: FilterOption<T>[]
onChange: (value: T) => void
isSearchable?: boolean
}
export function FilterSelect<T>({
value,
options,
onChange,
isSearchable = false,
}: FilterSelectProps<T>) {
return (
<Select
value={options.find((option: FilterOption<T>) => option.value === value)}
isSearchable={isSearchable}
options={options}
onChange={(option: SingleValue<FilterOption<T>>) => {
if (option) {
onChange(option.value)
}
}}
/>
)
}
interface FilterSelectMultiProps<T> {
values: T[]
options: FilterOption<T>[]
onChange: (value: T[]) => void
isSearchable?: boolean
placeHolder?: string
}
export function FilterSelectMulti<T>({
values,
options,
onChange,
isSearchable = false,
placeHolder = "Select",
}: FilterSelectMultiProps<T>) {
return (
<Select
value={options.filter((option: FilterOption<T>) =>
values.includes(option.value)
)}
isMulti
isSearchable={isSearchable}
options={options}
placeholder={placeHolder}
isDisabled={options.length === 0}
onChange={(options: MultiValue<FilterOption<T>>) => {
onChange(options.map((option: FilterOption<T>) => option.value) as T[])
}}
/>
)
}

View File

@ -0,0 +1,23 @@
import { GetScoreColor } from "../../../types/game"
export default function ScoreBox({
score,
colorfunc,
}: {
score: number
colorfunc?: (score: number, tailwind: boolean) => string
}) {
if (!colorfunc) colorfunc = GetScoreColor
return (
<div
className={`inline-flex h-8 w-10 items-center justify-center rounded-xl border p-4 text-center ${colorfunc(
score,
true
)}`}
>
<span className={"text-sm font-bold text-white"}>
{Number(score).toFixed(0)}
</span>
</div>
)
}

View File

@ -0,0 +1,56 @@
import { useState } from "react"
import { useParams } from "react-router"
import { GetPlayer } from "../../api/player"
import WithDataFetching from "../../components/withdatafetching"
import Player from "../../types/player"
import ClubHistory from "./club_history"
import Scores from "./scores"
import Sidebar from "./sidebar"
export default function PlayerPage() {
const { slug } = useParams()
if (!slug) {
return <div>No slug</div>
}
return (
<WithDataFetching
queryKey={["player", slug]}
queryFn={() => GetPlayer(slug)}
>
{(player) => <PlayerLayout player={player} />}
</WithDataFetching>
)
function PlayerLayout({ player }: { player: Player }) {
const [activeTab, setActiveTab] = useState("club_history")
const tabs = [
{ id: "club_history", title: "Club History" },
{ id: "scores", title: "Scores" },
]
return (
<div className="grid h-screen grid-cols-[260px_1fr]">
<Sidebar player={player}></Sidebar>
<div className="flex flex-col">
<div className="flex border-b">
{tabs.map((tab) => (
<button
key={tab.id}
className={`px-4 py-2 text-center text-sm font-medium ${activeTab === tab.id ? "border-b-2 border-blue-300 text-blue-400" : "text-gray-500 hover:text-gray-700"}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.title}
</button>
))}
</div>
<div className="p-6">
{activeTab === "club_history" && (
<ClubHistory player={player}></ClubHistory>
)}
{activeTab === "scores" && <Scores player={player}></Scores>}
</div>
</div>
</div>
)
}
}

View File

@ -0,0 +1,184 @@
import {
addMonths,
format,
isSameDay,
startOfMonth,
startOfYear,
subMonths,
} from "date-fns"
import moment from "moment/moment"
import { useMemo } from "react"
import {
Cell,
ComposedChart,
LabelList,
ReferenceLine,
ResponsiveContainer,
Scatter,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import {
Game,
GamePlayer,
GamePlayerScore,
GetScoreColor,
} from "../../types/game"
import Player from "../../types/player"
type DataPoint = {
date: Date
matchScore?: number
decisiveScore?: number
aaScore?: number
game?: Game
score?: GamePlayerScore
gameweek: number
dateAsNumber: number
}
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const dataPoint = payload[0].payload
const { gameweek, date } = dataPoint
const formattedDate = moment(date).format("DD MMM YYYY")
return (
<div className="rounded-md border border-gray-300 bg-white p-2">
<p className="text-black">{`Date: ${formattedDate}`}</p>
{gameweek && <p className="text-black">{`Gameweek: ${gameweek}`}</p>}
{payload.map((item: any) => (
<p
key={item.name}
style={{ color: item.color }}
>{`${item.name}: ${Math.round(item.value)}`}</p>
))}
</div>
)
}
return null
}
export default function ScoreGraph({
scores,
startDate,
endDate,
}: {
player: Player
scores: GamePlayer[]
startDate: Date
endDate: Date
}) {
const CustomXAxisTick = ({ x, y, payload }: any) => {
const date = new Date(payload.value)
const isFirstJan = isSameDay(date, startOfYear(date))
const isFirstOfMonth = isSameDay(date, startOfMonth(date))
const displayText = isFirstJan
? format(date, "yyyy")
: isFirstOfMonth
? format(date, "MMM")
: ""
return (
<g transform={`translate(${x},${y})`}>
<text x={0} y={0} dy={16} textAnchor="middle" fill="#666">
{displayText}
</text>
</g>
)
}
const adjustedStartDate = useMemo(() => {
const earliestScoreDate = scores.reduce((minDate, score) => {
const scoreDate = new Date(score.game?.date ?? "")
return minDate > scoreDate ? scoreDate : minDate
}, new Date())
return startDate > earliestScoreDate ? startDate : earliestScoreDate
}, [scores, startDate])
const filteredScores = useMemo(
() =>
scores
.filter(
(score) =>
new Date(score.game?.date ?? "") >= startDate &&
new Date(score.game?.date ?? "") <= endDate
)
.map<DataPoint>((score) => ({
date: score.game?.fixture.startDate ?? new Date(),
matchScore: score.score?.score,
decisiveScore: score.score?.decisiveScore,
aaScore: score.score?.allAroundScore,
game: score.game,
score: score.score,
gameweek: score.game?.fixture.gameWeek ?? 0,
dateAsNumber:
new Date(score.game?.fixture.startDate ?? new Date()).getTime() ??
0,
}))
.sort((a, b) => a.gameweek - b.gameweek),
[scores, startDate, endDate]
)
const ticks = useMemo(() => {
const start = startOfMonth(adjustedStartDate)
const end = startOfMonth(endDate)
let current = start
const dates = []
while (current <= end) {
dates.push(current.getTime())
current = addMonths(current, 1)
}
dates.unshift(subMonths(start, 1).getTime())
dates.push(addMonths(end, 1).getTime())
return dates
}, [adjustedStartDate, endDate])
return (
<div className="z-10 flex w-full flex-col gap-6">
<ResponsiveContainer width="100%" height={500}>
<ComposedChart data={filteredScores}>
<XAxis
dataKey="dateAsNumber"
domain={["dataMin", "dataMax"]}
name="Date"
type="number"
tick={<CustomXAxisTick />}
ticks={ticks}
/>
<YAxis domain={[-10, 110]} ticks={[0, 20, 40, 60, 80, 100]} />
<Scatter dataKey="matchScore" name="Score" isAnimationActive={false}>
{filteredScores?.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={GetScoreColor(entry.matchScore ?? 0, false)}
/>
))}
<LabelList
dataKey="matchScore"
position="top"
offset={10}
stroke={"#333"}
strokeWidth={0.3}
fontSize={12}
formatter={(value: number) => Math.round(value)}
/>
</Scatter>
{[0, 20, 40, 60, 80, 100].map((value) => (
<ReferenceLine
key={value}
y={value}
stroke="gray"
strokeWidth={0.3}
/>
))}
<Tooltip content={<CustomTooltip />} />
</ComposedChart>
</ResponsiveContainer>
</div>
)
}

View File

@ -0,0 +1,363 @@
import moment from "moment"
import React from "react"
import { BiFootball } from "react-icons/bi"
import { BsHandThumbsUp } from "react-icons/bs"
import { FaHandSparkles } from "react-icons/fa"
import { GiFist, GiFootprint, GiLeg, GiWhistle } from "react-icons/gi"
import { TbHandStop } from "react-icons/tb"
import {
GamePlayer,
GetAAScoreColor,
GetDSScoreColor,
GetMinutesScoreColor,
GetScoreColor,
} from "../../types/game"
import Player from "../../types/player"
import CardIcon from "./components/card_icon"
import { FilterSelect } from "./components/filter_select"
import ScoreBox from "./components/scorebox"
type GameEventProps = {
count: number | undefined
icon: React.ElementType
positive?: boolean
label?: string
}
function GameEvent({
count,
icon: Icon,
positive = true,
label = "",
}: GameEventProps) {
if (!count || count === 0) {
return null
}
return (
<div title={label} className="flex flex-col items-center">
<span className="text-xs font-semibold">{count}</span>
<div className={`${positive ? "text-green-700" : "text-red-600"}`}>
<Icon />
</div>
</div>
)
}
function ScoreText({ score }: { score: number | string }) {
return (
<td className="text-center">
<span className="text-xs font-bold text-gray-600">{score}</span>
</td>
)
}
function ScoreHeader({ label, tooltip }: { label: string; tooltip: string }) {
return (
<th title={tooltip} className="text-center">
{label}
</th>
)
}
export default function ScoreTable({
player,
scores,
}: {
player: Player
scores: GamePlayer[]
}) {
const [statCategory, setStatCategory] = React.useState("def")
let availableStatCategory = [
{ value: "def", label: "Defensive" },
{ value: "off", label: "Offensive" },
{ value: "poss", label: "Possession" },
]
if (player.fieldPosition === "Goalkeeper") {
availableStatCategory = [
...availableStatCategory,
{ value: "goal", label: "Goalkeeping" },
]
}
return (
<div className="flex w-full flex-col gap-6">
<div className="flex w-full justify-end">
<FilterSelect
value={statCategory}
options={availableStatCategory}
onChange={setStatCategory}
/>
</div>
<table>
<thead className="bg-neutral-50">
<tr>
<th title="Game Week" className="th">
GW
</th>
<th>Date</th>
<th title="Minutes played" className="th text-center">
Minutes
</th>
<th className="text-right">Home</th>
<th></th>
<th></th>
<th></th>
<th>Away</th>
<th className="text-center">Score</th>
<th></th>
<th title="Decisive score" className="text-center">
DS
</th>
<th title="All-around score" className="text-center">
AA
</th>
{statCategory === "def" && (
<>
<ScoreHeader tooltip={"Clean sheet"} label={"CS"} />
<ScoreHeader tooltip={"Error lead to goal"} label={"ELG"} />
<ScoreHeader tooltip={"Goals conceded"} label={"GC"} />
<ScoreHeader tooltip={"Effective clearance"} label={"EC"} />
<ScoreHeader tooltip={"Won tackle"} label={"WT"} />
<ScoreHeader tooltip={"Blocked cross"} label={"BC"} />
<ScoreHeader tooltip={"Block"} label={"B"} />
</>
)}
{statCategory === "off" && (
<>
<ScoreHeader tooltip={"Shots on target"} label={"SOT"} />
<ScoreHeader tooltip={"Won contest"} label={"WC"} />
<ScoreHeader tooltip={"Big chance created"} label={"BCC"} />
<ScoreHeader tooltip={"Attempted assist"} label={"AA"} />
<ScoreHeader tooltip={"Penalty area entries"} label={"PAE"} />
<ScoreHeader tooltip={"Penalty kick missed"} label={"PKM"} />
<ScoreHeader tooltip={"Big chance missed"} label={"BCM"} />
</>
)}
{statCategory === "poss" && (
<>
<ScoreHeader tooltip={"Possession +won -lost"} label={"Poss"} />
<ScoreHeader tooltip={"Duel +won -lost"} label={"Duel"} />
<ScoreHeader tooltip={"Interceptions"} label={"Int"} />
<ScoreHeader tooltip={"Accurate pass"} label={"AP"} />
<ScoreHeader
tooltip={"Accurate final 3rd pass"}
label={"A3P"}
/>
<ScoreHeader tooltip={"Accurate long balls"} label={"ALB"} />
<ScoreHeader
tooltip={"Long pass into opposition"}
label={"LPO"}
/>
<ScoreHeader tooltip={"Missed pass"} label={"MP"} />
<ScoreHeader tooltip={"Pass accuracy"} label={"PA%"} />
</>
)}
{statCategory === "goal" && (
<>
<ScoreHeader tooltip={"Saves"} label={"S"} />
<ScoreHeader
tooltip={"Saved shot from inside the box"}
label={"SIB"}
/>
<ScoreHeader tooltip={"Good high claim"} label={"GHC"} />
<ScoreHeader tooltip={"Punches"} label={"P"} />
<ScoreHeader tooltip={"Diving save"} label={"DS"} />
<ScoreHeader tooltip={"Diving catch"} label={"DC"} />
<ScoreHeader tooltip={"Cross not claimed"} label={"CNC"} />
<ScoreHeader tooltip={"GK smother"} label={"GKS"} />
<ScoreHeader tooltip={"Keeper sweeper"} label={"KS"} />
</>
)}
</tr>
</thead>
<tbody>
{scores
?.sort((a, b) =>
new Date(a.game?.date ?? new Date()) <
new Date(b.game?.date ?? new Date())
? 1
: -1
)
.map((scoreData) => {
if (!scoreData.game || !scoreData.score) return null
return (
<tr key={scoreData.score.gameId}>
<td>{scoreData.game.fixture?.gameWeek}</td>
<td>{moment(scoreData.game.date).format("DD MMMM YYYY")}</td>
<td className="text-center">
<span
className={`w-10 rounded-md border-2 border-gray-300 p-1 text-white ${GetMinutesScoreColor(scoreData.score?.minutesPlayed ?? 0)}`}
>
{scoreData.score.minutesPlayed}
</span>
</td>
<td className="text-right">
<span className="text-xs">
{scoreData.game.homeTeam.displayName}
</span>
</td>
<td className="text-right">{scoreData.game.homeGoals}</td>
<td className="text-center">-</td>
<td>{scoreData.game.awayGoals}</td>
<td>
<span className="text-xs">
{scoreData.game.awayTeam.displayName}
</span>
</td>
<td className="text-center">
<ScoreBox
score={scoreData.score.score}
colorfunc={GetScoreColor}
/>
</td>
<td>
<div className="flex justify-end">
<GameEvent
count={scoreData.score.goal}
icon={BiFootball}
label="Goal"
/>
<GameEvent
count={scoreData.score.assist}
icon={GiFootprint}
label="Assist"
/>
<GameEvent
count={scoreData.score.penaltyWon}
icon={GiWhistle}
label="Penalty won"
/>
<GameEvent
count={scoreData.score.clearanceOffLine}
icon={TbHandStop}
label="Clearance off line"
/>
<GameEvent
count={
player.fieldPosition === "Goalkeeper" &&
scoreData.score.cleanSheet
? 1
: 0
}
icon={FaHandSparkles}
label="Clean sheet"
/>
<GameEvent
count={scoreData.score.penaltySave}
icon={GiFist}
label="Penalty saved"
/>
<GameEvent
count={scoreData.score.lastManTackle}
icon={GiLeg}
label="Last man tackle"
/>
<GameEvent
count={scoreData.score.redCard ? 1 : 0}
icon={() => <CardIcon color={"#FF0000"} />}
label="Red card"
/>
<GameEvent
count={scoreData.score.yellowCard ? 1 : 0}
icon={() => <CardIcon color={"#FFD700"} />}
label="Yellow card"
/>
</div>
</td>
<td className="text-center">
<ScoreBox
score={scoreData.score.decisiveScore}
colorfunc={GetDSScoreColor}
/>
</td>
<td className="text-center">
<ScoreBox
score={scoreData.score?.allAroundScore ?? 0}
colorfunc={GetAAScoreColor}
/>
</td>
{statCategory === "def" &&
(scoreData.score.minutesPlayed ?? 0) > 0 && (
<>
<td className="flex justify-center">
{scoreData.score.cleanSheet ? <BsHandThumbsUp /> : ""}
</td>
<ScoreText score={scoreData.score.errorLeadToGoal} />
<ScoreText score={scoreData.score.goalsConceded} />
<ScoreText score={scoreData.score.effectiveClearance} />
<ScoreText score={scoreData.score.wonTackle} />
<ScoreText score={scoreData.score.blockedCross} />
<ScoreText score={scoreData.score.block} />
</>
)}
{statCategory === "off" &&
scoreData.score.minutesPlayed > 0 && (
<>
<ScoreText score={scoreData.score.shotOnTarget} />
<ScoreText score={scoreData.score.wonContest} />
<ScoreText score={scoreData.score.bigChanceCreated} />
<ScoreText score={scoreData.score.attemptedAssist} />
<ScoreText score={scoreData.score.penaltyAreaEntries} />
<ScoreText score={scoreData.score.penaltyKickMissed} />
<ScoreText score={scoreData.score.bigChanceMissed} />
</>
)}
{statCategory === "poss" &&
scoreData.score.minutesPlayed > 0 && (
<>
<ScoreText
score={`+${scoreData.score.possessionWon} -${scoreData.score.possessionLost}`}
/>
<ScoreText
score={`+${scoreData.score.duelWon} -${scoreData.score.duelLost}`}
/>
<ScoreText score={scoreData.score.interception} />
<ScoreText score={scoreData.score.accuratePass} />
<ScoreText
score={scoreData.score.accurateFinalThirdPass}
/>
<ScoreText score={scoreData.score.accurateLongBall} />
<ScoreText
score={scoreData.score.longPassIntoOpposition}
/>
<ScoreText score={scoreData.score.missedPass} />
<ScoreText
score={
Number(
(scoreData.score.accuratePass /
(scoreData.score.accuratePass +
scoreData.score.missedPass)) *
100
).toFixed(1) + "%"
}
/>
</>
)}
{statCategory === "goal" &&
scoreData.score.minutesPlayed > 0 && (
<>
<ScoreText score={scoreData.score.saves} />
<ScoreText
score={scoreData.score.savedShotFromInsideBox}
/>
<ScoreText score={scoreData.score.goodHighClaim} />
<ScoreText score={scoreData.score.punches} />
<ScoreText score={scoreData.score.divingSave} />
<ScoreText score={scoreData.score.divingCatch} />
<ScoreText score={scoreData.score.crossNotClaimed} />
<ScoreText score={scoreData.score.goalkeeperSmother} />
<ScoreText score={scoreData.score.keeperSweeper} />
</>
)}
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@ -0,0 +1,36 @@
import { GetPlayerGames } from "../../api/player"
import WithDataFetching from "../../components/withdatafetching"
import Player from "../../types/player"
import DaysSelectionWrapper from "./components/days_selection"
import ScoreGraph from "./score_graph"
import ScoreTable from "./score_table"
export default function Scores({ player }: { player: Player }) {
return (
<DaysSelectionWrapper>
{(startDate, endDate) => {
return (
<WithDataFetching
queryKey={["playerScores", player.slug]}
queryFn={() => GetPlayerGames(player.slug)}
refetchOnWindowFocus={false}
>
{(scores) => {
return (
<div className="flex flex-col items-center gap-12">
<ScoreGraph
player={player}
scores={scores}
startDate={startDate}
endDate={endDate}
/>
<ScoreTable player={player} scores={scores} />
</div>
)
}}
</WithDataFetching>
)
}}
</DaysSelectionWrapper>
)
}

View File

@ -0,0 +1,57 @@
import Player from "../../types/player"
function Badge({
imgUrl,
name,
imgHeight = "full",
}: {
imgUrl?: string
name?: string
imgHeight?: string
}) {
return (
<div className="flex h-8 items-center gap-3 rounded-full border border-neutral-300 bg-gradient-to-r from-primary-blue-200 to-neutral-50 px-3 py-1 shadow-md">
<img src={imgUrl} className={`h-${imgHeight}`} />
<span className="text-sm">{name}</span>
</div>
)
}
export default function Sidebar({ player }: { player: Player }) {
return (
<div className="flex flex-col items-center gap-3 border-r border-r-neutral-300 bg-gradient-to-b from-neutral-100 to-neutral-50 p-3 text-neutral-700 shadow-md">
<div className="flex h-32 w-32 items-center justify-center overflow-hidden rounded-full border bg-neutral-50 p-3">
<img
src={player.avatarUrl}
className="h-full w-full scale-[1.16] object-contain"
/>
</div>
<span className="font-secondary text-lg font-semibold">
{player.displayName}
</span>
<div className="flex flex-col items-center justify-center gap-0.5">
<span className="text-sm">
{new Date().getFullYear() - new Date(player.birthDate).getFullYear()}
</span>
<span className="text-xs">
{new Date(player.birthDate).toLocaleDateString("en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})}
</span>
</div>
<Badge
imgUrl={player.country?.flagFlat64Url}
name={player.country?.displayName}
imgHeight="4"
/>
<Badge imgUrl={player.team?.pictureUrl} name={player.team?.shortName} />
<Badge
imgUrl={player.domesticLeague?.pictureUrl}
name={player.domesticLeague?.displayName}
/>
</div>
)
}

View File

@ -0,0 +1,136 @@
import { useMemo } from "react"
import { SingleRankingsParams } from "../../api/rankings"
import InputWrapper from "../../components/input_wrapper"
import Competition from "../../types/competition"
interface ZoneGroup {
label: string
options: Array<{ label: string; value: string }>
}
interface CompetitionsSelectProps {
competitions: Competition[]
params: SingleRankingsParams
setParams: (params: SingleRankingsParams) => void
}
function CompetitionsSelect({
competitions,
params,
setParams,
}: CompetitionsSelectProps) {
const zoneGroups = useMemo(() => {
const groups: Record<string, ZoneGroup> = {}
competitions.forEach((competition) => {
if (!competition.zone) return
if (!groups[competition.zone.id]) {
groups[competition.zone.id] = {
label: competition.zone?.displayName,
options: [],
}
}
groups[competition.zone.id].options.push({
label: competition.displayName,
value: competition.slug,
})
})
return groups
}, [competitions])
const groupedOptions = useMemo(() => {
const sortedGroups = Object.values(zoneGroups).sort((a, b) => {
if (a.label === "") return 1
if (b.label === "") return -1
return a.label.localeCompare(b.label)
})
const withoutZoneGroup = {
label: "Without zone",
options: sortedGroups.find((group) => group.label === "")?.options || [],
}
const filteredGroups = sortedGroups
.filter((group) => group.label !== "")
.map((group) => ({
...group,
options: group.options.sort((a, b) => a.label.localeCompare(b.label)),
}))
if (withoutZoneGroup.options.length > 0) {
filteredGroups.push(withoutZoneGroup)
}
return filteredGroups
}, [zoneGroups])
const selectedOptions = useMemo(() => {
return params.competitions.map((slug) => {
const competition = competitions.find((c) => c.slug === slug)
return { value: slug, label: competition?.displayName || "" }
})
}, [competitions, params.competitions])
return (
<InputWrapper label={"Competitions"} id={"competitions"}>
<select
// multiple={true}
value={selectedOptions.map((option) => option.value)}
onChange={(e) => {
const selectedValues = Array.from(
e.target.selectedOptions,
(option) => option.value
)
const nonEmptySelectedValues = selectedValues.filter(
(value) => value !== ""
)
setParams({
...params,
competitions:
nonEmptySelectedValues.length > 0 ? nonEmptySelectedValues : [],
})
}}
className="w-full rounded-lg border border-neutral-300 bg-neutral-50 py-2 pl-3 pr-2 text-sm text-neutral-900 shadow focus:border-primary-blue-300 focus:ring-primary-blue-300"
>
<option value="">No Selection</option>
{groupedOptions.map((group, groupIndex) => (
<optgroup key={groupIndex} label={group.label}>
{group.options.map((option, optionIndex) => (
<option key={optionIndex} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
{/* <Select
value={selectedOptions}
placeholder="Select competitions..."
options={groupedOptions}
isMulti
formatGroupLabel={(group) => (
<div className="bg-primary-blue-500 text-white font-bold px-2 py-1 rounded-md text-center border border-neutral-200 shadow-sm mb-3">
{group.label}
</div>
)}
formatOptionLabel={(option) => (
<div className="px-1 text-md">{option.label}</div>
)}
onChange={(newValue) => {
setParams({
...params,
competitions: newValue.map((option) => option.value),
});
}}
styles={{
control: (base) => ({
...base,
borderRadius: "0.5rem",
}),
}}
/> */}
</InputWrapper>
)
}
export default CompetitionsSelect

View File

@ -0,0 +1,67 @@
interface ProgressBarProps {
percentages: number[];
colors?: string[];
textColors?: string[];
displayPercentage?: boolean;
}
export default function ProgressBar({
percentages,
colors,
textColors,
displayPercentage = true,
}: ProgressBarProps) {
if (!colors) {
colors = ["bg-green-300", "bg-yellow-400", "bg-orange-500", "bg-red-600"];
}
if (!textColors) {
textColors = ["text-black", "text-black", "text-white", "text-white"];
}
const getColor = (index: number) => {
return colors ? colors[index] || "bg-neutral-100" : "bg-neutral-100";
};
const getTextColor = (index: number) => textColors?.[index] || "text-black";
const sortedPercentages = [...percentages].sort((a, b) => b - a);
return (
<div className="relative">
<div
className={`absolute top-0 left-0 h-4 bg-neutral-50 border-2 border-neutral-100`}
style={{ width: `100%` }}
></div>
{sortedPercentages.map((percentage, index) => {
const spanleft =
100 -
(((percentage - sortedPercentages[index + 1]) / 2) * 100) /
percentage;
return (
<div
key={index}
className={`absolute top-0 left-0 h-4 ${getColor(index)} group`}
style={{ width: `${percentage}%` }}
>
{displayPercentage &&
percentage > 0 &&
(spanleft < 96 || isNaN(spanleft)) && (
<span
className={`absolute opacity-0 group-hover:opacity-100 transition-opacity duration-300 px-1 text-xs ${getTextColor(
index,
)}`}
style={{
left: `${
index === sortedPercentages.length - 1 ? 50 : spanleft
}%`,
transform: "translateX(-50%)",
whiteSpace: "nowrap",
}}
>
{percentage}%
</span>
)}
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,671 @@
import { useQuery } from "@tanstack/react-query"
import { ChangeEvent, useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { GetClubCompetitions } from "../../api/competition"
import { GetSingleRankings, SingleRankingsParams } from "../../api/rankings"
import Checkbox from "../../components/checkbox"
import InputWrapper from "../../components/input_wrapper"
import Loader from "../../components/loader"
import NumericInput from "../../components/numeric_input"
import CompetitionsSelect from "./competition_select"
import ProgressBar from "./components/progress_bar"
import PositionSelect from "./position_select"
import ZoneSelect from "./zone_select"
function calculateAge(birthDate: Date): number {
const today = new Date()
let age = today.getFullYear() - birthDate.getFullYear()
const m = today.getMonth() - birthDate.getMonth()
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
age--
}
return age
}
// const rarities = [
// { value: "limited", label: "Limited" },
// { value: "rare", label: "Rare" },
// { value: "super_rare", label: "Super Rare" },
// { value: "unique", label: "Unique" },
// ];
const orders = [
{
value: "total_avg_score DESC",
label: "Score (High to Low)",
},
{
value: "decisive_avg_score DESC",
label: "Decisive (High to Low)",
},
{
value: "all_around_avg_score DESC",
label: "All Around (High to Low)",
},
{
value: "aa_per_min DESC",
label: "AA/Min (High to Low)",
},
{
value: "more_than_80_score_percentage DESC",
label: "Score Percentage > 80 (High to Low)",
},
{
value: "more_than_70_score_percentage DESC",
label: "Score Percentage > 70 (High to Low)",
},
{
value: "more_than_60_score_percentage DESC",
label: "Score Percentage > 60 (High to Low)",
},
{
value: "more_than_50_score_percentage DESC",
label: "Score Percentage > 50 (High to Low)",
},
{
value: "more_than_40_aa_percentage DESC",
label: "AA Percentage > 40 (High to Low)",
},
{
value: "more_than_30_aa_percentage DESC",
label: "AA Percentage > 30 (High to Low)",
},
{
value: "more_than_20_aa_percentage DESC",
label: "AA Percentage > 20 (High to Low)",
},
{
value: "more_than_10_aa_percentage DESC",
label: "AA Percentage > 10 (High to Low)",
},
]
function SingleRankingsPage() {
const [params, setParams] = useState<SingleRankingsParams>({
limit: 100,
startDate: new Date(new Date().setMonth(new Date().getMonth() - 3)),
competitions: [],
zones: [],
minGameCount: 5,
minTeamGamesPlayedPercentage: 70,
onlyClubGames: true,
onlyStarting: true,
order: "total_avg_score DESC",
rarity: "limited",
position: "Defender",
u23: false,
minTotalMinutes: 0,
minAge: 0,
maxAge: 40,
hasGameInNextGw: false,
})
const [usedParams, setUsedParams] = useState<SingleRankingsParams>(params)
const {
data,
isFetching,
refetch: refetchRankings,
} = useQuery({
queryKey: ["singleRankings"],
queryFn: async () => {
const rankings = await GetSingleRankings(params)
setUsedParams(params)
return rankings
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
})
const { data: competitions, refetch: refetchCompetitions } = useQuery({
queryKey: ["competitions", params.zones],
queryFn: async () => {
const competitions = await GetClubCompetitions(params.zones)
return competitions.sort((a, b) =>
a.displayName.localeCompare(b.displayName)
)
},
})
useEffect(() => {
setParams((prevParams) => ({
...prevParams,
competitions: [],
}))
refetchCompetitions()
}, [params.zones, refetchCompetitions])
return (
<>
<div className="flex items-center justify-between rounded border-b border-neutral-300 bg-primary-100 px-3 py-1 shadow-lg">
<div className="flex flex-col">
<div className="flex w-full items-center justify-center gap-3 p-3">
<PositionSelect params={params} setParams={setParams} />
<ZoneSelect params={params} setParams={setParams}></ZoneSelect>
</div>
<div className={"grid grid-cols-[1fr_auto_auto]"}>
<div className="flex w-full flex-wrap items-baseline justify-around gap-6 px-8 py-3">
<InputWrapper label={"Start Date"} id={"startDate"}>
<input
defaultValue={
params.startDate
? params.startDate.toISOString().slice(0, 10)
: ""
}
className="w-40 rounded-lg border border-neutral-300 bg-neutral-50 py-2 pl-3 pr-2 text-sm text-neutral-900 shadow focus:border-primary-blue-300 focus:ring-primary-blue-300"
id="startDate"
type="date"
placeholder="Start Date"
onChange={(e) => {
if (e.target.value) {
setParams({
...params,
startDate: new Date(e.target.value),
})
}
}}
/>
</InputWrapper>
<NumericInput
value={params.minGameCount}
label={"Min Game Count"}
id={"minGameCount"}
onChange={(value) => {
setParams({
...params,
minGameCount: value,
})
}}
/>
<NumericInput
value={params.minTeamGamesPlayedPercentage}
label={"Min Team Games Played Percentage"}
id={"minTeamGamesPlayedPercentage"}
onChange={(value) => {
setParams({
...params,
minTeamGamesPlayedPercentage: value,
})
}}
/>
<div className="w-[250px]">
<CompetitionsSelect
competitions={competitions ?? []}
params={params}
setParams={setParams}
/>
</div>
<NumericInput
value={params.minAge}
label={"Min Age"}
id={"minAge"}
onChange={(value) => {
setParams({
...params,
minAge: value,
})
}}
/>
<NumericInput
value={params.maxAge}
label={"Max Age"}
id={"maxAge"}
onChange={(value) => {
setParams({
...params,
maxAge: value,
})
}}
/>
<NumericInput
value={params.minTotalMinutes}
label={"Min Total Minutes"}
id={"minTotalMinutes"}
onChange={(value) => {
setParams({
...params,
minTotalMinutes: value,
})
}}
/>
</div>
</div>
</div>
<div className={"flex flex-col gap-2 p-3"}>
<Checkbox
id="onlyClubGamesCheckbox"
checked={params.onlyClubGames}
label={"Only club games"}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setParams({
...params,
onlyClubGames: e.target.checked,
})
}}
/>
<Checkbox
id="onlyStartingGamesCheckbox"
checked={params.onlyStarting}
label={"Only starting games"}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setParams({
...params,
onlyStarting: e.target.checked,
})
}}
/>
<Checkbox
id="u23Checkbox"
checked={params.u23}
label={"U23"}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setParams({
...params,
u23: e.target.checked,
})
}}
/>
<Checkbox
id="hasGameInNextGw"
checked={params.hasGameInNextGw}
label={"Has game in next GW"}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setParams({
...params,
hasGameInNextGw: e.target.checked,
})
}}
/>
</div>
<div className={"flex flex-col items-center justify-center gap-3 p-3"}>
<NumericInput
value={params.limit}
label={"Limit"}
id={"limit"}
onChange={(value) => {
setParams({
...params,
limit: value,
})
}}
/>
<InputWrapper label={"Order by"} id={"orders"}>
<select
defaultValue={params.order}
onChange={(e) => {
setParams({
...params,
order: e.target.value,
})
}}
className="w-40 rounded-lg border border-neutral-300 bg-neutral-50 py-2 pl-3 pr-2 text-sm text-neutral-900 shadow focus:border-primary-blue-300 focus:ring-primary-blue-300"
>
{orders.map(({ label, value }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</InputWrapper>
<button
className={`btn flex w-32 items-center justify-center gap-3 ${
isFetching
? "bg-primary-blue-300"
: "bg-primary-blue-500 hover:bg-primary-blue-700"
} font-bold`}
type="button"
onClick={() => refetchRankings()}
disabled={isFetching}
>
{isFetching ? (
<>
<>
<div
className="h-4 w-4 animate-spin rounded-full border-[1px] border-current border-t-transparent text-blue-600"
role="status"
aria-label="loading"
></div>
<span>Loading...</span>
</>
</>
) : (
<span>Apply Filters</span>
)}
</button>
</div>
</div>
{isFetching ? (
<Loader></Loader>
) : (
<div className="overflow-x-auto p-3">
<table className="divide-y divide-neutral-200">
<thead className="bg-neutral-50">
<tr>
<th></th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
Team
</th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
Name
</th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
Age
</th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
Score
</th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
Stddev
</th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
DS
</th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
AA
</th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
Games Played
</th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
% Minutes Played
</th>
{["Goalkeeper", "Defender"].includes(usedParams.position) && (
<th
className={
"px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500"
}
>
Avg Team GA
</th>
)}
{["Midfielder", "Forward"].includes(usedParams.position) && (
<th
className={
"px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500"
}
>
Avg Team GF
</th>
)}
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
AA/Min
</th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
Total Min
</th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
<div className="flex items-center justify-center gap-3">
<span>Score Percentages</span>
<div className="flex items-center justify-center gap-1">
<span className="rounded bg-red-600 p-1 text-2xs text-white">
{">80"}
</span>
<span className="rounded bg-orange-500 p-1 text-2xs text-white">
{">70"}
</span>
<span className="rounded bg-yellow-400 p-1 text-2xs text-black">
{">60"}
</span>
<span className="rounded bg-green-300 p-1 text-2xs text-black">
{">50"}
</span>
</div>
</div>
</th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-gray-500">
<div className="flex items-center justify-center gap-3">
<span>AA Percentages</span>
<span className="rounded bg-red-600 p-1 text-2xs text-white">
{">40"}
</span>
<span className="rounded bg-orange-500 p-1 text-2xs text-white">
{">30"}
</span>
<span className="rounded bg-yellow-400 p-1 text-2xs text-black">
{">20"}
</span>
<span className="rounded bg-green-300 p-1 text-2xs text-black">
{">10"}
</span>
</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 bg-neutral-50">
{data?.map((item) => {
return (
<tr key={item.player.slug}>
<td className="relative whitespace-nowrap px-3 py-4">
<div
className={"flex h-10 w-10 items-center justify-center"}
>
<a
href={`https://www.soraredata.com/playerSearch/${item.player.displayName}`}
target="_blank"
rel="noopener noreferrer"
>
<img
src={item.player.avatarUrl}
className={"w-[115px]"}
alt={""}
/>
</a>
</div>
</td>
<td className="relative whitespace-nowrap px-3 py-4">
<div
className={
"flex h-8 w-full items-center justify-center"
}
>
<a
href={`https://www.soraredata.com/playerSearch/${item.player.displayName}`}
target="_blank"
rel="noopener noreferrer"
>
{item.player.team?.pictureUrl ? (
<img
src={item.player.team.pictureUrl}
className={"w-8"}
alt={""}
title={item.player.team?.shortName}
/>
) : (
<span
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-neutral-500 bg-white text-black"
title={item.player.team?.shortName}
>
{item.player.team?.shortName?.substring(0, 3)}
</span>
)}
</a>
</div>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm font-semibold">
<Link to={`/player/${item.player.slug}`}>
{item.player.displayName}
</Link>
</td>
<td className="whitespace-nowrap px-3 py-4 text-xs">
<div className={"flex items-baseline justify-center"}>
<span>
{calculateAge(new Date(item.player.birthDate))}
</span>
</div>
</td>
<td className="w-80 whitespace-nowrap px-3 py-2">
<div
className={
"flex flex-col items-center justify-center gap-3"
}
>
<div
className={"flex items-baseline justify-center gap-3"}
>
<span
className={
"w-8 text-center text-xs text-neutral-500"
}
>
{item.minScore}
</span>
<span
className={"w-10 text-center text-sm font-semibold"}
>
{item.totalAvgScore}
</span>
<span
className={
"w-8 text-center text-xs text-neutral-500"
}
>
{item.maxScore}
</span>
</div>
<div className="grid w-full grid-cols-5">
{item.scores
.slice(0, 5)
.reverse()
.map((score, index) => (
<span
key={index}
className={`text-xs text-white ${index > 0 ? "border-l border-neutral-400" : ""} w-full px-1 py-1 text-center
${score >= 80 ? "bg-green-600" : score >= 60 ? "bg-green-500" : score >= 40 ? "bg-yellow-400" : score >= 20 ? "bg-orange-500" : "bg-red-500"}
${index === 0 ? "rounded-l-lg" : index === 4 ? "rounded-r-lg" : ""}`}
>
{Number(score) === -1
? "x"
: Number(score).toFixed(0)}
</span>
))}
</div>
</div>
</td>
<td className="whitespace-nowrap px-3 py-4 text-xs">
&plusmn;{item.totalStddevScore}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
{item.decisiveAvgScore}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
{item.allAroundAvgScore}
</td>
<td className="w-40 whitespace-nowrap px-3 py-4 text-sm">
<div className="flex w-full flex-col items-center gap-5">
<div className="w-full">
<ProgressBar
displayPercentage={false}
percentages={[
Math.round(
((item.gameStartedCount +
item.gameBenchedPlayedCount +
item.gameBenchedUnplayedCount) /
item.teamsGameCount) *
100
),
Math.round(
((item.gameBenchedPlayedCount +
item.gameBenchedUnplayedCount) /
item.teamsGameCount) *
100
),
Math.round(
(item.gameBenchedUnplayedCount /
item.teamsGameCount) *
100
),
]}
/>
</div>
<div className="flex w-full justify-between gap-3">
<span
className="rounded-md bg-orange-500 px-2 py-1 text-xs text-white"
title="Games Benched Unplayed"
>
{item.gameBenchedUnplayedCount}
</span>
<span
className="rounded-md bg-yellow-500 px-2 py-1 text-xs text-white"
title="Games Benched Played"
>
{item.gameBenchedPlayedCount}
</span>
<span
className="rounded-md bg-green-300 px-2 py-1 text-xs text-black"
title="Games Started"
>
{item.gameStartedCount}
</span>
<span
className="rounded-md bg-neutral-100 px-2 py-1 text-xs text-black"
title="Games Started"
>
{item.teamsGameCount}
</span>
</div>
</div>
</td>
<td className="whitespace-nowrap px-3 py-4 text-center text-sm">
{item.percentageMinutesPlayed}
</td>
{["Goalkeeper", "Defender"].includes(
usedParams.position
) && (
<td className="whitespace-nowrap px-3 py-4 text-sm">
{item.avgTeamGoalsAgainst}
</td>
)}
{["Midfielder", "Forward"].includes(
usedParams.position
) && (
<td className="whitespace-nowrap px-3 py-4 text-sm">
{item.avgTeamGoalsFor}
</td>
)}
<td className="whitespace-nowrap px-3 py-4 text-sm">
{item.aaPerMin}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
{item.totalMinutes}
</td>
<td className="whitespace-nowrap px-3 py-4">
<ProgressBar
percentages={[
item.moreThan80ScorePercentage,
item.moreThan70ScorePercentage,
item.moreThan60ScorePercentage,
item.moreThan50ScorePercentage,
]}
/>
</td>
<td className="whitespace-nowrap px-3 py-4">
<ProgressBar
percentages={[
item.moreThan40AAPercentage,
item.moreThan30AAPercentage,
item.moreThan20AAPercentage,
item.moreThan10AAPercentage,
]}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)
}
export default SingleRankingsPage

View File

@ -0,0 +1,42 @@
import { SingleRankingsParams } from "../../api/rankings";
const positions = ["Goalkeeper", "Defender", "Midfielder", "Forward"];
interface PositionSelectProps {
params: SingleRankingsParams;
setParams: (params: SingleRankingsParams) => void;
}
export default function PositionSelect({
params,
setParams,
}: PositionSelectProps) {
const handlePositionChange = (position: string) => {
setParams({
...params,
position,
});
};
return (
<div className="flex border-2 border-gray-300 rounded-xl overflow-hidden">
{positions.map((position, index) => (
<div
key={position}
className={`flex-auto ${index > 0 ? "border-l-2 border-gray-300" : ""}`}
>
<button
className={`w-full py-1 px-3 ${
params.position === position
? "bg-primary-blue-500 hover:bg-primary-blue-700 text-white"
: "bg-white hover:bg-neutral-100 text-neutral-800"
}`}
onClick={() => handlePositionChange(position)}
>
{position}
</button>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,51 @@
import { useQuery } from "@tanstack/react-query";
import { SingleRankingsParams } from "../../api/rankings";
import { GetAllZones } from "../../api/zone";
import Zone from "../../types/zone";
interface ZoneSelectProps {
params: SingleRankingsParams;
setParams: (params: SingleRankingsParams) => void;
}
export default function ZoneSelect({ params, setParams }: ZoneSelectProps) {
const { data: zones } = useQuery({
queryKey: ["zones"],
queryFn: async () => {
const zones = await GetAllZones();
return zones.sort((a, b) => a.displayName.localeCompare(b.displayName));
},
});
const handleZoneChange = (zone: Zone) => {
const newZones = params.zones.includes(zone.id)
? params.zones.filter((z) => z !== zone.id)
: [...params.zones, zone.id];
setParams({
...params,
zones: newZones,
});
};
return (
<div className="flex border-2 border-gray-300 rounded-xl overflow-hidden">
{zones?.map((zone, index) => (
<div
key={zone.id}
className={`flex-auto ${index > 0 ? "border-l-2 border-gray-300" : ""}`}
>
<button
className={`w-full py-1 px-3 ${
params.zones.includes(zone.id)
? "bg-primary-blue-500 hover:bg-primary-blue-700 text-white"
: "bg-white hover:bg-neutral-100 text-neutral-800"
}`}
onClick={() => handleZoneChange(zone)}
>
{zone.displayName}
</button>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,205 @@
import { useMemo, useState } from "react"
import { useParams } from "react-router"
import { Link } from "react-router-dom"
import { GetUserCards } from "../../api/card"
import WithDataFetching from "../../components/withdatafetching"
import Card from "../../types/card"
export default function XpCenter() {
const { slug = "gigiz22" } = useParams()
return (
<WithDataFetching<Card[]>
queryKey={["user", slug, "cards"]}
queryFn={() => GetUserCards(slug)}
refetchOnWindowFocus={false}
>
{(data) => <CardList cards={data} />}
</WithDataFetching>
)
}
function CardList({ cards }: { cards: Card[] }) {
const [sortConfig, setSortConfig] = useState<{
key: string
direction: "asc" | "desc"
} | null>(null)
const [hideMaxLevelUp, setHideMaxLevelUp] = useState(false)
const filteredCards = useMemo(() => {
return hideMaxLevelUp
? cards.filter(
(card) => card.levelUpAppliedCount < card.maxLevelUpAppliedCount
)
: cards
}, [cards, hideMaxLevelUp])
const sortedCards = useMemo(() => {
let sortableCards = filteredCards.map((card) => ({
...card,
xpNeeded:
card.xpNeededForNextGrade > card.xp
? card.xpNeededForNextGrade - card.xp
: "N/A",
xpPercentage:
card.xpNeededForNextGrade > card.xp
? ((card.xpNeededForNextGrade - card.xp) * 100) /
(card.xpNeededForNextGrade - card.xpNeededForCurrentGrade)
: "N/A",
}))
if (sortConfig !== null) {
sortableCards.sort((a, b) => {
const key = sortConfig.key as keyof typeof a
if (a[key] < b[key]) {
return sortConfig.direction === "asc" ? -1 : 1
}
if (a[key] > b[key]) {
return sortConfig.direction === "asc" ? 1 : -1
}
return 0
})
}
return sortableCards
}, [filteredCards, sortConfig])
const requestSort = (key: string) => {
let direction: "asc" | "desc" = "asc"
if (
sortConfig &&
sortConfig.key === key &&
sortConfig.direction === "asc"
) {
direction = "desc"
}
setSortConfig({ key, direction })
}
const getSortIndicator = (key: string) => {
if (!sortConfig || sortConfig.key !== key) return null
return sortConfig.direction === "asc" ? "↑" : "↓"
}
return (
<>
<div className="flex w-full items-center justify-end p-3">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={hideMaxLevelUp}
onChange={() => setHideMaxLevelUp(!hideMaxLevelUp)}
/>
<span className="ml-2">Hide cards with max level up applied</span>
</label>
</div>
<table className="min-w-full">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500"
></th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500"
>
Player
</th>
<th
scope="col"
className="w-auto cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
onClick={() => requestSort("grade")}
>
Grade{" "}
<span className="ml-1 align-middle">
{getSortIndicator("grade")}
</span>
</th>
<th
scope="col"
className="cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
onClick={() => requestSort("xp")}
>
XP{" "}
<span className="ml-1 align-middle">
{getSortIndicator("xp")}
</span>
</th>
<th
scope="col"
className="cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
onClick={() => requestSort("xpNeeded")}
>
XP Needed for Next Grade{" "}
<span className="ml-1 align-middle">
{getSortIndicator("xpNeeded")}
</span>
</th>
<th
scope="col"
className="cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
onClick={() => requestSort("xpPercentage")}
>
% Until next grade{" "}
<span className="ml-1 align-middle">
{getSortIndicator("xpPercentage")}
</span>
</th>
<th
scope="col"
className="cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
>
Level up applied
</th>
</tr>
</thead>
<tbody className="divide-y-2 divide-gray-200 bg-white">
{sortedCards.map((card) => (
<tr key={card.id}>
<td className="px-3 py-1">
<a
href={`https://sorare.com/fr/football/cards/${card.slug}`}
target="_blank"
rel="noopener noreferrer"
>
<img className="h-16" src={card.pictureUrl} alt={card.name} />
</a>
</td>
<td className="border-r-2 border-neutral-200 px-3 py-1">
<Link to={`/player/${card.playerSlug}`}>
<div className="flex items-center">
<div className="text-md font-secondary font-semibold text-neutral-900">
{card.playerDisplayName}
</div>
</div>
</Link>
</td>
<td className="w-auto border-r border-neutral-200 px-3 py-1 text-center">
<div className="text-sm text-neutral-900">{card.grade}</div>
</td>
<td className="border-r-2 border-neutral-200 px-3 py-1 text-center">
<div className="text-sm text-neutral-900">{card.xp}</div>
</td>
<td className="border-r-2 border-neutral-200 px-3 py-1 text-center">
<div className="text-sm text-neutral-900">{card.xpNeeded}</div>
</td>
<td className="border-r-2 border-neutral-200 px-3 py-1 text-center">
<div className="text-sm text-neutral-900">
{card.xpPercentage !== "N/A"
? `${Number(card.xpPercentage).toFixed(0)}%`
: "N/A"}
</div>
</td>
<td className="px-3 py-1 text-center">
<div className="text-sm text-neutral-900">
{card.levelUpAppliedCount} / {card.maxLevelUpAppliedCount}
</div>
</td>
</tr>
))}
</tbody>
</table>
</>
)
}

View File

@ -0,0 +1,50 @@
import { createBrowserRouter, Link, RouterProvider } from "react-router-dom"
import Layout from "../pages/layout"
import Live from "../pages/live"
import PlayerPage from "../pages/player"
import SingleRankingsPage from "../pages/singlerankings"
import XpCenter from "../pages/xpcenter"
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{ path: "/", element: <SingleRankingsPage /> },
{ path: "/live", element: <Live /> },
{ path: "/xpcenter", element: <XpCenter /> },
{ path: "/xpcenter/:slug", element: <XpCenter /> },
{ path: "/player/:slug", element: <PlayerPage /> },
],
},
{ path: "*", element: <NoMatch /> },
])
export default function RouterOutlet() {
return <RouterProvider router={router} />
}
function NoMatch() {
return (
<div className="flex h-screen w-full flex-col items-center justify-center text-center">
<div className="rounded-xl bg-white p-4 shadow-xl dark:bg-gray-700">
<h1 className="mb-4 text-4xl text-gray-800 md:text-6xl dark:text-gray-200">
404
</h1>
<h2 className="mb-8 text-2xl text-gray-800 md:text-4xl dark:text-gray-200">
Page Not Found
</h2>
<p className="text-md mb-8 text-gray-800 md:text-lg dark:text-gray-200">
The page you are looking for doesn&apos;t exist or has been moved.
</p>
<Link to="/">
<div className="inline-block">
<button className="btn bg-primary-blue-500 hover:bg-primary-blue-700">
Go Home
</button>
</div>
</Link>
</div>
</div>
)
}

27
front/src/types/card.ts Normal file
View File

@ -0,0 +1,27 @@
export default interface Card {
id: string
assetId: string
playerSlug: string
playerDisplayName: string
rarity: string
serialNumber: number
seasonStartYear: number
singleCivilYear: boolean
supply: number
teamSlug: string
name: string
pictureUrl: string
slug: string
power: number
powerMalusAfterTransfer: number
rivalsPower: number
grade: number
gradeAfterTransfer: number
xp: number
xpAfterTransfer: number
xpNeededForNextGrade: number
xpNeededForCurrentGrade: number
inSeasonEligible: boolean
levelUpAppliedCount: number
maxLevelUpAppliedCount: number
}

View File

@ -0,0 +1,15 @@
import Country from "./country"
import Zone from "./zone"
export default interface Competition {
slug: string
displayName: string
countrySlug: string
competitionFormat: string
competitionType: string
pictureUrl: string
logoUrl: string
zoneId?: number
zone?: Zone
country: Country
}

View File

@ -0,0 +1,11 @@
export default interface Country {
slug: string;
code: string;
displayName: string;
threeLetterCode: string;
flagFlat64Url: string;
flagFlat32Url: string;
flagRound64Url: string;
flagRound32Url: string;
}

View File

@ -0,0 +1,8 @@
export interface Fixture {
slug: string
displayName: string
fixtureState: string
startDate: Date
endDate: Date
gameWeek: number
}

177
front/src/types/game.ts Normal file
View File

@ -0,0 +1,177 @@
import Competition from "./competition"
import { Fixture } from "./fixture"
import Player from "./player"
import Team from "./team"
export interface Game {
id: string
date: Date
coverageStatus: string
lowCoverage: boolean
minutes: number
periodType: string
scored: boolean
status: string
competitionSlug: string
fixtureSlug: string
awayTeamSlug: string
awayGoals: number
awayExtraTimeScore: number
awayPenaltyScore: number
homeTeamSlug: string
homeGoals: number
homeExtraTimeScore: number
homePenaltyScore: number
winnerTeamSlug: string
competition: Competition
homeTeam: Team
awayTeam: Team
winnerTeam: Team
gamePlayers: GamePlayer[]
fixture: Fixture
}
export interface GamePlayer {
gameId: string
playerSlug: string
teamSlug: string
status: string
game?: Game
player?: Player
score?: GamePlayerScore
}
export interface GamePlayerScore {
gameId: string
playerSlug: string
score: number
decisiveScore: number
allAroundScore: number
minutesPlayed: number
gameStarted: boolean
formationPlace: number
live: boolean
onGameSheet: boolean
reviewed: boolean
goal: number
assist: number
penaltyWon: number
clearanceOffLine: number
lastManTackle: number
penaltySave: number
ownGoal: number
redCard: boolean
errorLeadToGoal: number
penaltyConceded: number
yellowCard: number
fouls: number
fouled: number
cleanSheet: boolean
doubleDouble: boolean
tripleDouble: boolean
tripleTriple: boolean
errorLeadToShot: number
saves: number
savedShotFromInsideBox: number
goodHighClaim: number
punches: number
divingSave: number
divingCatch: number
crossNotClaimed: number
goalkeeperSmother: number
sixSecondViolation: number
keeperSweeper: number
goalsConceded: number
effectiveClearance: number
wonTackle: number
blockedCross: number
block: number
possessionLost: number
possessionWon: number
duelLost: number
duelWon: number
interception: number
accuratePass: number
accurateFinalThirdPass: number
accurateLongBall: number
longPassIntoOpposition: number
missedPass: number
shotOnTarget: number
wonContest: number
bigChanceCreated: number
attemptedAssist: number
penaltyAreaEntries: number
penaltyKickMissed: number
bigChanceMissed: number
}
export function GetScoreColor(score: number, tailwind: boolean): string {
if (score < 15) {
return tailwind ? "bg-support-red-400" : "#FF0000" // red
} else if (score < 40) {
return tailwind ? "bg-orange-400" : "#FFA500" // orange
} else if (score < 60) {
return tailwind ? "bg-yellow-400" : "#FFFF00" // yellow
} else if (score < 75) {
return tailwind ? "bg-green-600" : "#32CD32" // lime green, a bit darker than light green
} else {
return tailwind ? "bg-green-700" : "#006400" // dark green
}
}
export function GetLScoreColor(score: number): string {
if (score < 40) {
return "bg-support-red-500"
} else if (score < 46) {
return "bg-orange-500"
} else if (score < 54) {
return "bg-yellow-500"
} else if (score < 66) {
return "bg-green-500"
} else {
return "bg-green-600"
}
}
export function GetDSScoreColor(score: number): string {
if (score < 34) {
return "bg-support-red-500"
} else if (score < 37) {
return "bg-orange-500"
} else if (score < 42) {
return "bg-yellow-500"
} else if (score < 52) {
return "bg-green-500"
} else {
return "bg-green-600"
}
}
export function GetAAScoreColor(score: number): string {
if (score < 7) {
return "bg-support-red-500"
} else if (score < 10) {
return "bg-orange-500"
} else if (score < 16) {
return "bg-yellow-500"
} else if (score < 25) {
return "bg-green-500"
} else {
return "bg-green-600"
}
}
export function GetMinutesScoreColor(score: number): string {
if (score < 38) {
return "bg-support-red-500"
} else if (score < 58) {
return "bg-orange-500"
} else if (score < 78) {
return "bg-yellow-500"
} else if (score < 90) {
return "bg-green-500"
} else {
return "bg-green-600"
}
}

41
front/src/types/player.ts Normal file
View File

@ -0,0 +1,41 @@
import Competition from "./competition"
import Country from "./country"
import Team from "./team"
interface CardSupply {
playerSlug: string
seasonStartYear: number
limited: number
rare: number
superRare: number
unique: number
lastUpdated: Date
}
interface Membership {
id: string
playerSlug: string
teamSlug: string
startDate: Date
endDate: Date
membershipType: string
team?: Team
}
export default interface Player {
slug: string
displayName: string
birthDate: Date
countrySlug: string
teamSlug?: string
domesticLeagueSlug?: string
avatarUrl: string
fieldPosition: string
status: string
shirtNumber: number
country?: Country
team?: Team
domesticLeague?: Competition
cardSupply: CardSupply[]
clubMembership: Membership[]
}

15
front/src/types/team.ts Normal file
View File

@ -0,0 +1,15 @@
import Competition from "./competition"
import Country from "./country"
export default interface Team {
slug: string
displayName: string
countrySlug: string
domesticLeagueSlug?: string
shortName: string
pictureUrl: string
teamType: string
country?: Country
domesticLeague?: Competition
}

4
front/src/types/zone.ts Normal file
View File

@ -0,0 +1,4 @@
export default interface Zone {
id: number;
displayName: string;
}

1
front/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

100
front/tailwind.config.js Normal file
View File

@ -0,0 +1,100 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
fontFamily: {
sans: ["Roboto", "sans-serif"],
secondary: ["\"Exo 2 Variable\"", "sans-serif"],
},
fontSize: {
"2xs": ".625rem",
},
colors: {
primary: {
'50': '#f3f7f8',
'100': '#e0e9ed',
'200': '#c4d5dd',
'300': '#9bb8c5',
'400': '#6b93a5',
'500': '#4b7083',
'600': '#456375',
'700': '#3c5362',
'800': '#374753',
'900': '#313e48',
'950': '#1d272f',
},
white: "#f5f2f1",
// neutral: {
// 50: "#F7F7F7",
// 100: "#E1E1E1",
// 200: "#CFCFCF",
// 300: "#B1B1B1",
// 400: "#9E9E9E",
// 500: "#7E7E7E",
// 600: "#626262",
// 700: "#515151",
// 800: "#3B3B3B",
// 900: "#222222",
// },
//white: "neutral.50",
black: "neutral.900",
"primary-yellow": {
50: "#FFFBEA",
100: "#FFF3C4",
200: "#FCE588",
300: "#FADB5F",
400: "#F7C948",
500: "#F0B429",
600: "#DE911D",
700: "#CB6E17",
800: "#B44D12",
900: "#8D2B0B",
},
"primary-blue": {
50: "#E3F8FF",
100: "#B3ECFF",
200: "#81DEFD",
300: "#5ED0FA",
400: "#40C3F7",
500: "#2BB0ED",
600: "#1992D4",
700: "#127FBF",
800: "#0B69A3",
900: "#035388",
},
"support-red": {
50: "#FFE3E3",
100: "#FFBDBD",
200: "#FF9B9B",
300: "#F86A6A",
400: "#EF4E4E",
500: "#E12D39",
600: "#CF1124",
700: "#AB091E",
800: "#8A041A",
900: "#610316",
},
"support-teal": {
50: "#EFFCF6",
100: "#C6F7E2",
200: "#8EEDC7",
300: "#65D6AD",
400: "#3EBD93",
500: "#27AB83",
600: "#199473",
700: "#147D64",
800: "#0C6B58",
900: "#014D40",
},
},
},
},
plugins: [],
};

25
front/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
front/tsconfig.node.json Normal file
View File

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

21
front/vite.config.ts Normal file
View File

@ -0,0 +1,21 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
secure: false,
changeOrigin: true,
},
},
},
});

52
go.mod
View File

@ -3,53 +3,83 @@ module git.lehouerou.net/laurent/sorarebuddy
go 1.22.1
require (
github.com/hashicorp/go-retryablehttp v0.7.5
github.com/jackc/pgx/v5 v5.5.5
github.com/llehouerou/go-graphql-client v0.9.6
github.com/shopspring/decimal v1.3.1
github.com/llehouerou/go-graphql-client v0.9.7
github.com/shopspring/decimal v1.4.0
github.com/sourcegraph/conc v0.3.0
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2
github.com/uptrace/bun v1.1.17
github.com/uptrace/bun/dialect/pgdialect v1.1.17
)
require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/antchfx/htmlquery v1.3.1 // indirect
github.com/antchfx/xmlquery v1.4.0 // indirect
github.com/antchfx/xpath v1.3.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.1 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/kennygrant/sanitize v1.2.4 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/temoto/robotstxt v1.1.2 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xlzd/gotp v0.1.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
nhooyr.io/websocket v1.8.7 // indirect
nhooyr.io/websocket v1.8.11 // indirect
)
require (
git.lehouerou.net/laurent/sorare v0.0.0-20240308104821-b99d85580526
git.lehouerou.net/laurent/sorare v0.1.9
github.com/PuerkitoBio/goquery v1.9.1
github.com/dustin/go-humanize v1.0.1
github.com/gocolly/colly v1.2.0
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/labstack/echo/v4 v4.11.4
github.com/pkg/errors v0.9.1
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.32.0
github.com/samber/lo v1.39.0
golang.org/x/crypto v0.21.0 // indirect
github.com/uptrace/bun/extra/bundebug v1.1.17
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/text v0.14.0 // indirect
)

174
go.sum
View File

@ -1,46 +1,47 @@
git.lehouerou.net/laurent/sorare v0.0.0-20240308104821-b99d85580526 h1:/qgeHqVliEYKynyyDEnS45e6I8uS7bAFkw1ucpQ3D8I=
git.lehouerou.net/laurent/sorare v0.0.0-20240308104821-b99d85580526/go.mod h1:s/kVH8wu+esjAd1Zw+8+UGovg5hVClZp+9lFu7ZXsbI=
git.lehouerou.net/laurent/sorare v0.1.9 h1:puSzj3KcX+BYZcIsR5DO1iZJEC8YR7i+aE09+VZoEA8=
git.lehouerou.net/laurent/sorare v0.1.9/go.mod h1:Si2h5uCc4ELc0qEZFgiYLhJIm9B9NGJAAkaYYTAE978=
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/antchfx/htmlquery v1.3.1 h1:wm0LxjLMsZhRHfQKKZscDf2COyH4vDYA3wyH+qZ+Ylc=
github.com/antchfx/htmlquery v1.3.1/go.mod h1:PTj+f1V2zksPlwNt7uVvZPsxpKNa7mlVliCRxLX6Nx8=
github.com/antchfx/xmlquery v1.4.0 h1:xg2HkfcRK2TeTbdb0m1jxCYnvsPaGY/oeZWTGqX/0hA=
github.com/antchfx/xmlquery v1.4.0/go.mod h1:Ax2aeaeDjfIw3CwXKDQ0GkwZ6QlxoChlIBP+mGnDFjI=
github.com/antchfx/xpath v1.3.0 h1:nTMlzGAK3IJ0bPpME2urTuFL76o4A96iYvoKFHRXJgc=
github.com/antchfx/xpath v1.3.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI=
github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/graph-gophers/graphql-go v1.5.0 h1:fDqblo50TEpD0LY7RXk/LFVYEVqo3+tXMNMPSVXA1yc=
@ -66,36 +67,30 @@ github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/llehouerou/go-graphql-client v0.9.6 h1:tZdCHrBz8p044KoxXwmEur9L4VNZ3luYeJmQZpLodPY=
github.com/llehouerou/go-graphql-client v0.9.6/go.mod h1:hyvnfe3diGLxMelSKF+cFPyAfxZLa1CGGBogmix4SyM=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/llehouerou/go-graphql-client v0.9.7 h1:cDDy69jNbVhcfA6Bmmk0tYeaLArsrIA/lohAQxA5D3Q=
github.com/llehouerou/go-graphql-client v0.9.7/go.mod h1:hyvnfe3diGLxMelSKF+cFPyAfxZLa1CGGBogmix4SyM=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -103,6 +98,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
@ -113,10 +110,12 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
@ -134,7 +133,6 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@ -142,38 +140,89 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun v1.1.17 h1:qxBaEIo0hC/8O3O6GrMDKxqyT+mw5/s0Pn/n6xjyGIk=
github.com/uptrace/bun v1.1.17/go.mod h1:hATAzivtTIRsSJR4B8AXR+uABqnQxr3myKDKEf5iQ9U=
github.com/uptrace/bun/dialect/pgdialect v1.1.17 h1:NsvFVHAx1Az6ytlAD/B6ty3cVE6j9Yp82bjqd9R9hOs=
github.com/uptrace/bun/dialect/pgdialect v1.1.17/go.mod h1:fLBDclNc7nKsZLzNjFL6BqSdgJzbj2HdnyOnLoDvAME=
github.com/uptrace/bun/extra/bundebug v1.1.17 h1:LcZ8DzyyGdXAmbUqmnCpBq7TPFegMp59FGy+uzEE21c=
github.com/uptrace/bun/extra/bundebug v1.1.17/go.mod h1:FOwNaBEGGChv3qBVh3pz3TPlUuikZ93qKjd/LJdl91o=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -181,11 +230,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0=
nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=

View File

@ -1,516 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.25.0
// source: batch.go
package model
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
var (
ErrBatchAlreadyClosed = errors.New("batch already closed")
)
const createOrUpdateCompetitions = `-- name: CreateOrUpdateCompetitions :batchexec
INSERT INTO competitions (
slug,
display_name,
country_slug,
competition_format,
competition_type,
picture_url,
logo_url
)
VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (slug)
DO
UPDATE
SET display_name = EXCLUDED.display_name,
competition_format = EXCLUDED.competition_format,
competition_type = EXCLUDED.competition_type,
picture_url = EXCLUDED.picture_url,
logo_url = EXCLUDED.logo_url,
country_slug = EXCLUDED.country_slug
`
type CreateOrUpdateCompetitionsBatchResults struct {
br pgx.BatchResults
tot int
closed bool
}
type CreateOrUpdateCompetitionsParams struct {
Slug string
DisplayName string
CountrySlug string
CompetitionFormat string
CompetitionType string
PictureUrl string
LogoUrl string
}
func (q *Queries) CreateOrUpdateCompetitions(ctx context.Context, arg []CreateOrUpdateCompetitionsParams) *CreateOrUpdateCompetitionsBatchResults {
batch := &pgx.Batch{}
for _, a := range arg {
vals := []interface{}{
a.Slug,
a.DisplayName,
a.CountrySlug,
a.CompetitionFormat,
a.CompetitionType,
a.PictureUrl,
a.LogoUrl,
}
batch.Queue(createOrUpdateCompetitions, vals...)
}
br := q.db.SendBatch(ctx, batch)
return &CreateOrUpdateCompetitionsBatchResults{br, len(arg), false}
}
func (b *CreateOrUpdateCompetitionsBatchResults) Exec(f func(int, error)) {
defer b.br.Close()
for t := 0; t < b.tot; t++ {
if b.closed {
if f != nil {
f(t, ErrBatchAlreadyClosed)
}
continue
}
_, err := b.br.Exec()
if f != nil {
f(t, err)
}
}
}
func (b *CreateOrUpdateCompetitionsBatchResults) Close() error {
b.closed = true
return b.br.Close()
}
const createOrUpdateCountries = `-- name: CreateOrUpdateCountries :batchexec
INSERT INTO countries (
slug,
code,
display_name,
three_letter_code,
flag_flat_64_url,
flag_flat_32_url,
flag_round_64_url,
flag_round_32_url
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (slug)
DO UPDATE
SET code = EXCLUDED.code,
display_name = EXCLUDED.display_name,
three_letter_code = EXCLUDED.three_letter_code,
flag_flat_64_url = EXCLUDED.flag_flat_64_url,
flag_flat_32_url = EXCLUDED.flag_flat_32_url,
flag_round_64_url = EXCLUDED.flag_round_64_url,
flag_round_32_url = EXCLUDED.flag_round_32_url
`
type CreateOrUpdateCountriesBatchResults struct {
br pgx.BatchResults
tot int
closed bool
}
type CreateOrUpdateCountriesParams struct {
Slug string
Code string
DisplayName string
ThreeLetterCode string
FlagFlat64Url string
FlagFlat32Url string
FlagRound64Url string
FlagRound32Url string
}
func (q *Queries) CreateOrUpdateCountries(ctx context.Context, arg []CreateOrUpdateCountriesParams) *CreateOrUpdateCountriesBatchResults {
batch := &pgx.Batch{}
for _, a := range arg {
vals := []interface{}{
a.Slug,
a.Code,
a.DisplayName,
a.ThreeLetterCode,
a.FlagFlat64Url,
a.FlagFlat32Url,
a.FlagRound64Url,
a.FlagRound32Url,
}
batch.Queue(createOrUpdateCountries, vals...)
}
br := q.db.SendBatch(ctx, batch)
return &CreateOrUpdateCountriesBatchResults{br, len(arg), false}
}
func (b *CreateOrUpdateCountriesBatchResults) Exec(f func(int, error)) {
defer b.br.Close()
for t := 0; t < b.tot; t++ {
if b.closed {
if f != nil {
f(t, ErrBatchAlreadyClosed)
}
continue
}
_, err := b.br.Exec()
if f != nil {
f(t, err)
}
}
}
func (b *CreateOrUpdateCountriesBatchResults) Close() error {
b.closed = true
return b.br.Close()
}
const createOrUpdateFixtures = `-- name: CreateOrUpdateFixtures :batchexec
INSERT INTO fixtures (slug, display_name, state, start_date, end_date, game_week)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (slug) DO UPDATE
SET display_name = $2, state = $3, start_date = $4, end_date = $5, game_week = $6
`
type CreateOrUpdateFixturesBatchResults struct {
br pgx.BatchResults
tot int
closed bool
}
type CreateOrUpdateFixturesParams struct {
Slug string
DisplayName string
State string
StartDate pgtype.Timestamptz
EndDate pgtype.Timestamptz
GameWeek int32
}
func (q *Queries) CreateOrUpdateFixtures(ctx context.Context, arg []CreateOrUpdateFixturesParams) *CreateOrUpdateFixturesBatchResults {
batch := &pgx.Batch{}
for _, a := range arg {
vals := []interface{}{
a.Slug,
a.DisplayName,
a.State,
a.StartDate,
a.EndDate,
a.GameWeek,
}
batch.Queue(createOrUpdateFixtures, vals...)
}
br := q.db.SendBatch(ctx, batch)
return &CreateOrUpdateFixturesBatchResults{br, len(arg), false}
}
func (b *CreateOrUpdateFixturesBatchResults) Exec(f func(int, error)) {
defer b.br.Close()
for t := 0; t < b.tot; t++ {
if b.closed {
if f != nil {
f(t, ErrBatchAlreadyClosed)
}
continue
}
_, err := b.br.Exec()
if f != nil {
f(t, err)
}
}
}
func (b *CreateOrUpdateFixturesBatchResults) Close() error {
b.closed = true
return b.br.Close()
}
const createOrUpdateGamePlayers = `-- name: CreateOrUpdateGamePlayers :batchexec
INSERT INTO game_players(
game_id,
player_slug,
status,
team_slug)
VALUES(
$1,
$2,
$3,
$4)
ON CONFLICT (game_id, player_slug) DO UPDATE
SET status = $3,
team_slug = $4
`
type CreateOrUpdateGamePlayersBatchResults struct {
br pgx.BatchResults
tot int
closed bool
}
type CreateOrUpdateGamePlayersParams struct {
GameID string
PlayerSlug string
Status string
TeamSlug string
}
func (q *Queries) CreateOrUpdateGamePlayers(ctx context.Context, arg []CreateOrUpdateGamePlayersParams) *CreateOrUpdateGamePlayersBatchResults {
batch := &pgx.Batch{}
for _, a := range arg {
vals := []interface{}{
a.GameID,
a.PlayerSlug,
a.Status,
a.TeamSlug,
}
batch.Queue(createOrUpdateGamePlayers, vals...)
}
br := q.db.SendBatch(ctx, batch)
return &CreateOrUpdateGamePlayersBatchResults{br, len(arg), false}
}
func (b *CreateOrUpdateGamePlayersBatchResults) Exec(f func(int, error)) {
defer b.br.Close()
for t := 0; t < b.tot; t++ {
if b.closed {
if f != nil {
f(t, ErrBatchAlreadyClosed)
}
continue
}
_, err := b.br.Exec()
if f != nil {
f(t, err)
}
}
}
func (b *CreateOrUpdateGamePlayersBatchResults) Close() error {
b.closed = true
return b.br.Close()
}
const createOrUpdateGames = `-- name: CreateOrUpdateGames :batchexec
INSERT INTO games (id, date, coverage_status, low_coverage, minutes, period_type, scored, status, competition_slug, fixture_slug, away_team_slug, away_goals, away_extra_time_score, away_penalty_score, home_team_slug, home_goals, home_extra_time_score, home_penalty_score, winner_team_slug)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (id) DO UPDATE
SET date = EXCLUDED.date, coverage_status = EXCLUDED.coverage_status, low_coverage = EXCLUDED.low_coverage, minutes = EXCLUDED.minutes, period_type = EXCLUDED.period_type, scored = EXCLUDED.scored, status = EXCLUDED.status, competition_slug = EXCLUDED.competition_slug, fixture_slug = EXCLUDED.fixture_slug, away_team_slug = EXCLUDED.away_team_slug, away_goals = EXCLUDED.away_goals, away_extra_time_score = EXCLUDED.away_extra_time_score, away_penalty_score = EXCLUDED.away_penalty_score, home_team_slug = EXCLUDED.home_team_slug, home_goals = EXCLUDED.home_goals, home_extra_time_score = EXCLUDED.home_extra_time_score, home_penalty_score = EXCLUDED.home_penalty_score, winner_team_slug = EXCLUDED.winner_team_slug
`
type CreateOrUpdateGamesBatchResults struct {
br pgx.BatchResults
tot int
closed bool
}
type CreateOrUpdateGamesParams struct {
ID string
Date pgtype.Timestamptz
CoverageStatus string
LowCoverage bool
Minutes int32
PeriodType string
Scored bool
Status string
CompetitionSlug string
FixtureSlug string
AwayTeamSlug string
AwayGoals int32
AwayExtraTimeScore int32
AwayPenaltyScore int32
HomeTeamSlug string
HomeGoals int32
HomeExtraTimeScore int32
HomePenaltyScore int32
WinnerTeamSlug *string
}
func (q *Queries) CreateOrUpdateGames(ctx context.Context, arg []CreateOrUpdateGamesParams) *CreateOrUpdateGamesBatchResults {
batch := &pgx.Batch{}
for _, a := range arg {
vals := []interface{}{
a.ID,
a.Date,
a.CoverageStatus,
a.LowCoverage,
a.Minutes,
a.PeriodType,
a.Scored,
a.Status,
a.CompetitionSlug,
a.FixtureSlug,
a.AwayTeamSlug,
a.AwayGoals,
a.AwayExtraTimeScore,
a.AwayPenaltyScore,
a.HomeTeamSlug,
a.HomeGoals,
a.HomeExtraTimeScore,
a.HomePenaltyScore,
a.WinnerTeamSlug,
}
batch.Queue(createOrUpdateGames, vals...)
}
br := q.db.SendBatch(ctx, batch)
return &CreateOrUpdateGamesBatchResults{br, len(arg), false}
}
func (b *CreateOrUpdateGamesBatchResults) Exec(f func(int, error)) {
defer b.br.Close()
for t := 0; t < b.tot; t++ {
if b.closed {
if f != nil {
f(t, ErrBatchAlreadyClosed)
}
continue
}
_, err := b.br.Exec()
if f != nil {
f(t, err)
}
}
}
func (b *CreateOrUpdateGamesBatchResults) Close() error {
b.closed = true
return b.br.Close()
}
const createOrUpdatePlayers = `-- name: CreateOrUpdatePlayers :batchexec
INSERT INTO players (slug, display_name, birth_date, country_slug, team_slug, domestic_league_slug, avatar_url, field_position, status, shirt_number)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (slug) DO UPDATE
SET display_name = $2, birth_date = $3, country_slug = $4, team_slug = $5, domestic_league_slug = $6, avatar_url = $7, field_position = $8, status = $9, shirt_number = $10
`
type CreateOrUpdatePlayersBatchResults struct {
br pgx.BatchResults
tot int
closed bool
}
type CreateOrUpdatePlayersParams struct {
Slug string
DisplayName string
BirthDate time.Time
CountrySlug string
TeamSlug *string
DomesticLeagueSlug *string
AvatarUrl string
FieldPosition string
Status string
ShirtNumber int32
}
func (q *Queries) CreateOrUpdatePlayers(ctx context.Context, arg []CreateOrUpdatePlayersParams) *CreateOrUpdatePlayersBatchResults {
batch := &pgx.Batch{}
for _, a := range arg {
vals := []interface{}{
a.Slug,
a.DisplayName,
a.BirthDate,
a.CountrySlug,
a.TeamSlug,
a.DomesticLeagueSlug,
a.AvatarUrl,
a.FieldPosition,
a.Status,
a.ShirtNumber,
}
batch.Queue(createOrUpdatePlayers, vals...)
}
br := q.db.SendBatch(ctx, batch)
return &CreateOrUpdatePlayersBatchResults{br, len(arg), false}
}
func (b *CreateOrUpdatePlayersBatchResults) Exec(f func(int, error)) {
defer b.br.Close()
for t := 0; t < b.tot; t++ {
if b.closed {
if f != nil {
f(t, ErrBatchAlreadyClosed)
}
continue
}
_, err := b.br.Exec()
if f != nil {
f(t, err)
}
}
}
func (b *CreateOrUpdatePlayersBatchResults) Close() error {
b.closed = true
return b.br.Close()
}
const createOrUpdateTeams = `-- name: CreateOrUpdateTeams :batchexec
INSERT INTO teams (slug, display_name, country_slug, domestic_league_slug, short_name, picture_url, team_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (slug) DO UPDATE
SET display_name = $2, country_slug = $3, domestic_league_slug = $4, short_name = $5, picture_url = $6, team_type = $7
`
type CreateOrUpdateTeamsBatchResults struct {
br pgx.BatchResults
tot int
closed bool
}
type CreateOrUpdateTeamsParams struct {
Slug string
DisplayName string
CountrySlug string
DomesticLeagueSlug *string
ShortName string
PictureUrl string
TeamType string
}
func (q *Queries) CreateOrUpdateTeams(ctx context.Context, arg []CreateOrUpdateTeamsParams) *CreateOrUpdateTeamsBatchResults {
batch := &pgx.Batch{}
for _, a := range arg {
vals := []interface{}{
a.Slug,
a.DisplayName,
a.CountrySlug,
a.DomesticLeagueSlug,
a.ShortName,
a.PictureUrl,
a.TeamType,
}
batch.Queue(createOrUpdateTeams, vals...)
}
br := q.db.SendBatch(ctx, batch)
return &CreateOrUpdateTeamsBatchResults{br, len(arg), false}
}
func (b *CreateOrUpdateTeamsBatchResults) Exec(f func(int, error)) {
defer b.br.Close()
for t := 0; t < b.tot; t++ {
if b.closed {
if f != nil {
f(t, ErrBatchAlreadyClosed)
}
continue
}
_, err := b.br.Exec()
if f != nil {
f(t, err)
}
}
}
func (b *CreateOrUpdateTeamsBatchResults) Close() error {
b.closed = true
return b.br.Close()
}

65
model/card.go Normal file
View File

@ -0,0 +1,65 @@
package model
import (
"git.lehouerou.net/laurent/sorare/football"
"git.lehouerou.net/laurent/sorare/types"
"github.com/shopspring/decimal"
)
type Card struct {
Id string `json:"id"`
AssetId string `json:"assetId"`
PlayerSlug string `json:"playerSlug"`
PlayerDisplayName string `json:"playerDisplayName"`
Rarity types.Rarity `json:"rarity"`
SerialNumber int `json:"serialNumber"`
SeasonStartYear int `json:"seasonStartYear"`
SingleCivilYear bool `json:"singleCivilYear"`
Supply int `json:"supply"`
TeamSlug string `json:"teamSlug"`
Name string `json:"name"`
PictureUrl string `json:"pictureUrl"`
Slug string `json:"slug"`
Power decimal.Decimal `json:"power"`
PowerMalusAfterTransfer decimal.Decimal `json:"powerMalusAfterTransfer"`
RivalsPower decimal.Decimal `json:"rivalsPower"`
Grade int `json:"grade"`
GradeAfterTransfer int `json:"gradeAfterTransfer"`
Xp int `json:"xp"`
XpAfterTransfer int `json:"xpAfterTransfer"`
XpNeededForNextGrade int `json:"xpNeededForNextGrade"`
XpNeededForCurrentGrade int `json:"xpNeededForCurrentGrade"`
InSeasonEligible bool `json:"inSeasonEligible"`
LevelUpAppliedCount int `json:"levelUpAppliedCount"`
MaxLevelUpAppliedCount int `json:"maxLevelUpAppliedCount"`
}
func NewCardFromSorare(card football.Card) Card {
return Card{
Id: card.Token.Id.Value,
AssetId: card.Token.AssetId,
PlayerSlug: card.Token.Metadata.Football.PlayerSlug,
PlayerDisplayName: card.Token.Metadata.Football.PlayerDisplayName,
Rarity: types.Rarity(card.Token.Metadata.Football.Rarity),
SerialNumber: card.Token.Metadata.Football.SerialNumber,
SeasonStartYear: card.Token.Metadata.Football.SeasonStartYear,
SingleCivilYear: card.Token.Metadata.Football.SingleCivilYear,
Supply: card.Token.Metadata.Football.Supply,
TeamSlug: card.Token.Metadata.Football.TeamSlug,
Name: card.Token.Name,
PictureUrl: card.Token.PictureUrl,
Slug: card.Token.Slug,
Power: card.Power,
PowerMalusAfterTransfer: card.PowerMalusAfterTransfer,
RivalsPower: card.RivalsPower,
Grade: card.Grade,
GradeAfterTransfer: card.GradeAfterTransfer,
Xp: card.Xp,
XpAfterTransfer: card.XpAfterTransfer,
XpNeededForNextGrade: card.XpNeededForNextGrade,
XpNeededForCurrentGrade: card.XpNeededForCurrentGrade,
InSeasonEligible: card.InSeasonEligible,
LevelUpAppliedCount: card.LevelUpAppliedCount,
MaxLevelUpAppliedCount: card.MaxLevelUpAppliedCount,
}
}

15
model/card_supply.go Normal file
View File

@ -0,0 +1,15 @@
package model
import (
"time"
)
type CardSupply struct {
PlayerSlug string `bun:"player_slug,pk" json:"playerSlug"`
SeasonStartYear int `bun:"season_start_year,pk" json:"seasonStartYear"`
Limited int `bun:"limited" json:"limited"`
Rare int `bun:"rare" json:"rare"`
SuperRare int `bun:"super_rare" json:"superRare"`
Unique int `bun:"unique" json:"unique"`
LastUpdated time.Time `bun:"last_updated" json:"lastUpdated"`
}

15
model/competition.go Normal file
View File

@ -0,0 +1,15 @@
package model
type Competition struct {
Slug string `bun:"slug,pk" json:"slug"`
DisplayName string `bun:"display_name" json:"displayName"`
CountrySlug string `bun:"country_slug" json:"countrySlug"`
CompetitionFormat string `bun:"competition_format" json:"competitionFormat"`
CompetitionType string `bun:"competition_type" json:"competitionType"`
PictureUrl string `bun:"picture_url" json:"pictureUrl"`
LogoUrl string `bun:"logo_url" json:"logoUrl"`
ZoneId *int `bun:"zone_id,scanonly" json:"zoneId"`
Country *Country `bun:"rel:has-one,join:country_slug=slug" json:"country"`
Zone *Zone `bun:"rel:has-one,join:zone_id=id" json:"zone"`
}

View File

@ -1,106 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.25.0
// source: competition.sql
package model
import (
"context"
)
const getAllCompetitions = `-- name: GetAllCompetitions :many
SELECT competitions.slug, competitions.display_name, competitions.country_slug, competitions.competition_format, competitions.competition_type, competitions.picture_url, competitions.logo_url, competitions.zone_id,
zones.id, zones.display_name,
countries.slug, countries.code, countries.display_name, countries.three_letter_code, countries.flag_flat_64_url, countries.flag_flat_32_url, countries.flag_round_64_url, countries.flag_round_32_url
FROM competitions
LEFT JOIN zones ON competitions.zone_id = zones.id
LEFT JOIN countries ON competitions.country_slug = countries.slug
`
type GetAllCompetitionsRow struct {
Competition Competition
Zone Zone
Country Country
}
func (q *Queries) GetAllCompetitions(ctx context.Context) ([]GetAllCompetitionsRow, error) {
rows, err := q.db.Query(ctx, getAllCompetitions)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllCompetitionsRow
for rows.Next() {
var i GetAllCompetitionsRow
if err := rows.Scan(
&i.Competition.Slug,
&i.Competition.DisplayName,
&i.Competition.CountrySlug,
&i.Competition.CompetitionFormat,
&i.Competition.CompetitionType,
&i.Competition.PictureUrl,
&i.Competition.LogoUrl,
&i.Competition.ZoneID,
&i.Zone.ID,
&i.Zone.DisplayName,
&i.Country.Slug,
&i.Country.Code,
&i.Country.DisplayName,
&i.Country.ThreeLetterCode,
&i.Country.FlagFlat64Url,
&i.Country.FlagFlat32Url,
&i.Country.FlagRound64Url,
&i.Country.FlagRound32Url,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getCompetitionBySlug = `-- name: GetCompetitionBySlug :one
SELECT competitions.slug, competitions.display_name, competitions.country_slug, competitions.competition_format, competitions.competition_type, competitions.picture_url, competitions.logo_url, competitions.zone_id,
zones.id, zones.display_name,
countries.slug, countries.code, countries.display_name, countries.three_letter_code, countries.flag_flat_64_url, countries.flag_flat_32_url, countries.flag_round_64_url, countries.flag_round_32_url
FROM competitions
LEFT JOIN zones ON competitions.zone_id = zones.id
LEFT JOIN countries ON competitions.country_slug = countries.slug
WHERE competitions.slug = $1
`
type GetCompetitionBySlugRow struct {
Competition Competition
Zone Zone
Country Country
}
func (q *Queries) GetCompetitionBySlug(ctx context.Context, slug string) (GetCompetitionBySlugRow, error) {
row := q.db.QueryRow(ctx, getCompetitionBySlug, slug)
var i GetCompetitionBySlugRow
err := row.Scan(
&i.Competition.Slug,
&i.Competition.DisplayName,
&i.Competition.CountrySlug,
&i.Competition.CompetitionFormat,
&i.Competition.CompetitionType,
&i.Competition.PictureUrl,
&i.Competition.LogoUrl,
&i.Competition.ZoneID,
&i.Zone.ID,
&i.Zone.DisplayName,
&i.Country.Slug,
&i.Country.Code,
&i.Country.DisplayName,
&i.Country.ThreeLetterCode,
&i.Country.FlagFlat64Url,
&i.Country.FlagFlat32Url,
&i.Country.FlagRound64Url,
&i.Country.FlagRound32Url,
)
return i, err
}

12
model/country.go Normal file
View File

@ -0,0 +1,12 @@
package model
type Country struct {
Slug string `bun:"slug,pk" json:"slug"`
Code string `bun:"code" json:"code"`
DisplayName string `bun:"display_name" json:"displayName"`
ThreeLetterCode string `bun:"three_letter_code" json:"threeLetterCode"`
FlagFlat64Url string `bun:"flag_flat_64_url" json:"flagFlat64Url"`
FlagFlat32Url string `bun:"flag_flat_32_url" json:"flagFlat32Url"`
FlagRound64Url string `bun:"flag_round_64_url" json:"flagRound64Url"`
FlagRound32Url string `bun:"flag_round_32_url" json:"flagRound32Url"`
}

View File

@ -1,80 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.25.0
// source: country.sql
package model
import (
"context"
)
const createOrUpdateCountry = `-- name: CreateOrUpdateCountry :exec
INSERT INTO countries (
slug,
code,
display_name,
three_letter_code,
flag_flat_64_url,
flag_flat_32_url,
flag_round_64_url,
flag_round_32_url
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (slug)
DO
UPDATE
SET code = EXCLUDED.code,
display_name = EXCLUDED.display_name,
three_letter_code = EXCLUDED.three_letter_code,
flag_flat_64_url = EXCLUDED.flag_flat_64_url,
flag_flat_32_url = EXCLUDED.flag_flat_32_url,
flag_round_64_url = EXCLUDED.flag_round_64_url,
flag_round_32_url = EXCLUDED.flag_round_32_url
`
type CreateOrUpdateCountryParams struct {
Slug string
Code string
DisplayName string
ThreeLetterCode string
FlagFlat64Url string
FlagFlat32Url string
FlagRound64Url string
FlagRound32Url string
}
func (q *Queries) CreateOrUpdateCountry(ctx context.Context, arg CreateOrUpdateCountryParams) error {
_, err := q.db.Exec(ctx, createOrUpdateCountry,
arg.Slug,
arg.Code,
arg.DisplayName,
arg.ThreeLetterCode,
arg.FlagFlat64Url,
arg.FlagFlat32Url,
arg.FlagRound64Url,
arg.FlagRound32Url,
)
return err
}
const getCountryBySlug = `-- name: GetCountryBySlug :one
SELECT slug, code, display_name, three_letter_code, flag_flat_64_url, flag_flat_32_url, flag_round_64_url, flag_round_32_url
FROM countries
WHERE slug = $1
`
func (q *Queries) GetCountryBySlug(ctx context.Context, slug string) (Country, error) {
row := q.db.QueryRow(ctx, getCountryBySlug, slug)
var i Country
err := row.Scan(
&i.Slug,
&i.Code,
&i.DisplayName,
&i.ThreeLetterCode,
&i.FlagFlat64Url,
&i.FlagFlat32Url,
&i.FlagRound64Url,
&i.FlagRound32Url,
)
return i, err
}

Some files were not shown because too many files have changed in this diff Show More