Compare commits
2 Commits
12cf5dd6e1
...
7a5e9244a7
Author | SHA1 | Date | |
---|---|---|---|
7a5e9244a7 | |||
4ce9f62117 |
0
.cursorrules
Normal file
0
.cursorrules
Normal file
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@ -0,0 +1,4 @@
|
||||
**/node_modules
|
||||
|
||||
go.work
|
||||
go.work.sum
|
33
Dockerfile
Normal file
33
Dockerfile
Normal 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"]
|
||||
|
38
Makefile
38
Makefile
@ -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
7
build.ps1
Normal 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'
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
129
cmd/fbrefscraper/root.go
Normal 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
362
cmd/server/root.go
Normal 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
17
db/card_supply.go
Normal 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
41
db/client.go
Normal 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
50
db/competittion.go
Normal 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
40
db/country.go
Normal 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
69
db/fixture.go
Normal 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
110
db/game.go
Normal 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
37
db/game_player.go
Normal 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
17
db/membership.go
Normal 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"}),
|
||||
}
|
||||
}
|
9
db/migrations/00002_index_optimizations.sql
Normal file
9
db/migrations/00002_index_optimizations.sql
Normal 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;
|
14
db/migrations/00003_card_supply.sql
Normal file
14
db/migrations/00003_card_supply.sql
Normal 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;
|
14
db/migrations/00004_club_memberships.sql
Normal file
14
db/migrations/00004_club_memberships.sql
Normal 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;
|
7
db/migrations/00005_player_activenationalteam.sql
Normal file
7
db/migrations/00005_player_activenationalteam.sql
Normal 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;
|
||||
|
||||
|
11
db/migrations/00006_rename_club_membership.sql
Normal file
11
db/migrations/00006_rename_club_membership.sql
Normal 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;
|
||||
|
5
db/migrations/00007_membership_indexes.sql
Normal file
5
db/migrations/00007_membership_indexes.sql
Normal 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;
|
6
db/migrations/00008_indexes_on_games.sql
Normal file
6
db/migrations/00008_indexes_on_games.sql
Normal 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;
|
35
db/migrations/00009_player_search.sql
Normal file
35
db/migrations/00009_player_search.sql
Normal 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;
|
||||
|
116
db/migrations/00010_avgs.sql
Normal file
116
db/migrations/00010_avgs.sql
Normal 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
366
db/player.go
Normal 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
35
db/repository.go
Normal 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
47
db/team.go
Normal 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
28
db/zone.go
Normal 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
18
front/.eslintrc.cjs
Normal 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
24
front/.gitignore
vendored
Normal 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
13
front/.prettierrc
Normal 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
30
front/README.md
Normal 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
BIN
front/bun.lockb
Normal file
Binary file not shown.
13
front/index.html
Normal file
13
front/index.html
Normal 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
4802
front/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
front/package.json
Normal file
46
front/package.json
Normal 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
6
front/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
BIN
front/public/favicon.ico
Normal file
BIN
front/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 158 KiB |
18
front/src/App.tsx
Normal file
18
front/src/App.tsx
Normal 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
86
front/src/api/api.ts
Normal 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
9
front/src/api/card.ts
Normal 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
|
||||
}
|
11
front/src/api/competition.ts
Normal file
11
front/src/api/competition.ts
Normal 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
9
front/src/api/fixture.ts
Normal 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
16
front/src/api/game.ts
Normal 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
17
front/src/api/player.ts
Normal 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
74
front/src/api/rankings.ts
Normal 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
9
front/src/api/zone.ts
Normal 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"
|
||||
});
|
||||
}
|
||||
|
51
front/src/components/checkbox.tsx
Normal file
51
front/src/components/checkbox.tsx
Normal 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>
|
||||
)
|
||||
}
|
13
front/src/components/error.tsx
Normal file
13
front/src/components/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
29
front/src/components/input_wrapper.tsx
Normal file
29
front/src/components/input_wrapper.tsx
Normal 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;
|
25
front/src/components/loader.tsx
Normal file
25
front/src/components/loader.tsx
Normal 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>
|
||||
)
|
||||
}
|
33
front/src/components/numeric_input.tsx
Normal file
33
front/src/components/numeric_input.tsx
Normal 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>
|
||||
);
|
||||
}
|
123
front/src/components/searchBox.tsx
Normal file
123
front/src/components/searchBox.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
35
front/src/components/withdatafetching.tsx
Normal file
35
front/src/components/withdatafetching.tsx
Normal 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
14
front/src/global.css
Normal 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
12
front/src/main.tsx
Normal 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>
|
||||
)
|
35
front/src/pages/header.tsx
Normal file
35
front/src/pages/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
13
front/src/pages/layout.tsx
Normal file
13
front/src/pages/layout.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
import Header from "./header"
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
374
front/src/pages/live/index.tsx
Normal file
374
front/src/pages/live/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
79
front/src/pages/player/club_history.tsx
Normal file
79
front/src/pages/player/club_history.tsx
Normal 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>
|
||||
)
|
||||
}
|
25
front/src/pages/player/components/card_icon.tsx
Normal file
25
front/src/pages/player/components/card_icon.tsx
Normal 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
|
117
front/src/pages/player/components/days_selection.tsx
Normal file
117
front/src/pages/player/components/days_selection.tsx
Normal 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>
|
||||
)
|
||||
}
|
65
front/src/pages/player/components/filter_select.tsx
Normal file
65
front/src/pages/player/components/filter_select.tsx
Normal 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[])
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
23
front/src/pages/player/components/scorebox.tsx
Normal file
23
front/src/pages/player/components/scorebox.tsx
Normal 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>
|
||||
)
|
||||
}
|
56
front/src/pages/player/index.tsx
Normal file
56
front/src/pages/player/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
184
front/src/pages/player/score_graph.tsx
Normal file
184
front/src/pages/player/score_graph.tsx
Normal 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>
|
||||
)
|
||||
}
|
363
front/src/pages/player/score_table.tsx
Normal file
363
front/src/pages/player/score_table.tsx
Normal 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>
|
||||
)
|
||||
}
|
36
front/src/pages/player/scores.tsx
Normal file
36
front/src/pages/player/scores.tsx
Normal 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>
|
||||
)
|
||||
}
|
57
front/src/pages/player/sidebar.tsx
Normal file
57
front/src/pages/player/sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
136
front/src/pages/singlerankings/competition_select.tsx
Normal file
136
front/src/pages/singlerankings/competition_select.tsx
Normal 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
|
67
front/src/pages/singlerankings/components/progress_bar.tsx
Normal file
67
front/src/pages/singlerankings/components/progress_bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
671
front/src/pages/singlerankings/index.tsx
Normal file
671
front/src/pages/singlerankings/index.tsx
Normal 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">
|
||||
±{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
|
42
front/src/pages/singlerankings/position_select.tsx
Normal file
42
front/src/pages/singlerankings/position_select.tsx
Normal 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>
|
||||
);
|
||||
}
|
51
front/src/pages/singlerankings/zone_select.tsx
Normal file
51
front/src/pages/singlerankings/zone_select.tsx
Normal 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>
|
||||
);
|
||||
}
|
205
front/src/pages/xpcenter/index.tsx
Normal file
205
front/src/pages/xpcenter/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
50
front/src/router/router.tsx
Normal file
50
front/src/router/router.tsx
Normal 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'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
27
front/src/types/card.ts
Normal 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
|
||||
}
|
15
front/src/types/competition.ts
Normal file
15
front/src/types/competition.ts
Normal 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
|
||||
}
|
11
front/src/types/country.ts
Normal file
11
front/src/types/country.ts
Normal 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;
|
||||
}
|
||||
|
8
front/src/types/fixture.ts
Normal file
8
front/src/types/fixture.ts
Normal 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
177
front/src/types/game.ts
Normal 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
41
front/src/types/player.ts
Normal 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
15
front/src/types/team.ts
Normal 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
4
front/src/types/zone.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export default interface Zone {
|
||||
id: number;
|
||||
displayName: string;
|
||||
}
|
1
front/src/vite-env.d.ts
vendored
Normal file
1
front/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
100
front/tailwind.config.js
Normal file
100
front/tailwind.config.js
Normal 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
25
front/tsconfig.json
Normal 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
11
front/tsconfig.node.json
Normal 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
21
front/vite.config.ts
Normal 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
52
go.mod
@ -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
174
go.sum
@ -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=
|
||||
|
516
model/batch.go
516
model/batch.go
@ -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
65
model/card.go
Normal 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
15
model/card_supply.go
Normal 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
15
model/competition.go
Normal 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"`
|
||||
}
|
@ -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
12
model/country.go
Normal 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"`
|
||||
}
|
@ -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
Loading…
Reference in New Issue
Block a user