wip
This commit is contained in:
parent
7a5e9244a7
commit
8f6317be6e
@ -1,4 +1,4 @@
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
|
|
||||||
go.work
|
go.work
|
||||||
go.work.sum
|
go.work.sum
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -20,3 +20,6 @@ go.work.sum
|
|||||||
|
|
||||||
bin/
|
bin/
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
cmd/server/dist/
|
||||||
|
.vscode/launch.json
|
||||||
|
66
Dockerfile
66
Dockerfile
@ -1,33 +1,33 @@
|
|||||||
ARG GO_VERSION=1.22.1
|
ARG GO_VERSION=1.22.1
|
||||||
|
|
||||||
FROM oven/bun as node-builder
|
FROM oven/bun as node-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY front/package.json front/bun.lockb ./
|
COPY front/package.json front/bun.lockb ./
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
COPY front/ ./
|
COPY front/ ./
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
FROM golang:${GO_VERSION}-alpine as go-builder
|
FROM golang:${GO_VERSION}-alpine as go-builder
|
||||||
RUN apk add --update ca-certificates git tzdata
|
RUN apk add --update ca-certificates git tzdata
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY ./ ./
|
COPY ./ ./
|
||||||
RUN rm -rf ./cmd/server/dist
|
RUN rm -rf ./cmd/server/dist
|
||||||
COPY --from=node-builder /app/dist ./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/.
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /server ./cmd/server/.
|
||||||
|
|
||||||
|
|
||||||
FROM busybox:glibc
|
FROM busybox:glibc
|
||||||
RUN mkdir /app
|
RUN mkdir /app
|
||||||
RUN addgroup -S server && adduser -S -s /bin/false -G server server
|
RUN addgroup -S server && adduser -S -s /bin/false -G server server
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=go-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
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 /usr/share/zoneinfo /usr/share/zoneinfo
|
||||||
COPY --from=go-builder /server /app/server
|
COPY --from=go-builder /server /app/server
|
||||||
RUN chown -R server:server /app
|
RUN chown -R server:server /app
|
||||||
USER server
|
USER server
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/app/server"]
|
ENTRYPOINT ["/app/server"]
|
||||||
|
|
||||||
|
16
Makefile
Normal file
16
Makefile
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
build:
|
||||||
|
docker build --rm -t registry.lehouerou.net/sorareplus:latest .
|
||||||
|
|
||||||
|
push: build
|
||||||
|
docker push registry.lehouerou.net/sorareplus:latest
|
||||||
|
|
||||||
|
deploy: push
|
||||||
|
ssh srv03 'docker pull registry.lehouerou.net/sorareplus; cd services/sorare; docker compose up -d sorareplus'
|
||||||
|
|
||||||
|
dbup:
|
||||||
|
goose -dir=./db/migrations postgres "host=192.168.1.250 port=5436 user=sorare password=sorare dbname=sorare sslmode=disable" up
|
||||||
|
|
||||||
|
dbdown:
|
||||||
|
goose -dir=./db/migrations postgres "host=192.168.1.250 port=5436 user=sorare password=sorare dbname=sorare sslmode=disable" down
|
||||||
|
|
||||||
|
.PHONY: build push deploy dbup
|
14
build.ps1
14
build.ps1
@ -1,7 +1,7 @@
|
|||||||
docker build --rm -t registry.lehouerou.net/sorareplus:latest .
|
docker build --rm -t registry.lehouerou.net/sorareplus:latest .
|
||||||
if($?)
|
if($?)
|
||||||
{
|
{
|
||||||
docker push registry.lehouerou.net/sorareplus:latest
|
docker push registry.lehouerou.net/sorareplus:latest
|
||||||
ssh srv03 'docker pull registry.lehouerou.net/sorareplus; cd services/sorare; docker compose up -d sorareplus'
|
ssh srv03 'docker pull registry.lehouerou.net/sorareplus; cd services/sorare; docker compose up -d sorareplus'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -169,6 +169,12 @@ func run(cmd *cobra.Command, _ []string) error {
|
|||||||
player := api.Group("/player")
|
player := api.Group("/player")
|
||||||
player.GET("/:slug", s.GetPlayer)
|
player.GET("/:slug", s.GetPlayer)
|
||||||
player.GET("/:slug/games", s.GetPlayerGames)
|
player.GET("/:slug/games", s.GetPlayerGames)
|
||||||
|
|
||||||
|
team := api.Group("/team")
|
||||||
|
team.GET("/:slug", s.GetTeam)
|
||||||
|
team.GET("/:slug/roster", s.GetTeamRoster)
|
||||||
|
team.GET("/:slug/memberships", s.GetTeamMemberships)
|
||||||
|
|
||||||
api.GET("/zone/all", s.GetAllZones)
|
api.GET("/zone/all", s.GetAllZones)
|
||||||
api.GET("/competition/club", s.GetAllClubCompetitions)
|
api.GET("/competition/club", s.GetAllClubCompetitions)
|
||||||
api.POST("/rankings/single", s.SingleRankings)
|
api.POST("/rankings/single", s.SingleRankings)
|
||||||
@ -360,3 +366,66 @@ func (s *ApiServer) SearchMulti(c echo.Context) error {
|
|||||||
|
|
||||||
return c.JSON(http.StatusOK, res)
|
return c.JSON(http.StatusOK, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ApiServer) GetTeam(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
slug := c.Param("slug")
|
||||||
|
res, err := s.db.Teams.GetOne(ctx, slug, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ApiServer) GetTeamRoster(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
slug := c.Param("slug")
|
||||||
|
|
||||||
|
team, err := s.db.Teams.GetOne(ctx, slug, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
roster, err := s.db.Teams.GetRoster(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
roster = lo.Map(roster, func(player model.Player, _ int) model.Player {
|
||||||
|
player.GamePlayers = lo.Filter(player.GamePlayers, func(gp model.GamePlayer, _ int) bool {
|
||||||
|
return gp.TeamSlug == slug
|
||||||
|
})
|
||||||
|
return player
|
||||||
|
})
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Team model.Team `json:"team"`
|
||||||
|
Roster []model.Player `json:"roster"`
|
||||||
|
}
|
||||||
|
res.Team = team
|
||||||
|
res.Roster = roster
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ApiServer) GetTeamMemberships(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
slug := c.Param("slug")
|
||||||
|
memberships, err := s.db.Teams.GetMemberships(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
team, err := s.db.Teams.GetOne(ctx, slug, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Team model.Team `json:"team"`
|
||||||
|
Memberships []model.Membership `json:"memberships"`
|
||||||
|
}
|
||||||
|
res.Team = team
|
||||||
|
res.Memberships = memberships
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, res)
|
||||||
|
}
|
||||||
|
@ -34,7 +34,7 @@ func NewClient(db *bun.DB) *Client {
|
|||||||
Games: NewGameRepository(db),
|
Games: NewGameRepository(db),
|
||||||
Players: NewPlayerRepository(db),
|
Players: NewPlayerRepository(db),
|
||||||
GamePlayers: NewGamePlayerRepository(db),
|
GamePlayers: NewGamePlayerRepository(db),
|
||||||
GamePlayerScores: NewRepository[model.GamePlayerScore](db, []string{"game_id", "player_slug"}),
|
GamePlayerScores: NewRepository[model.GamePlayerScore](db, []string{"game_id", "player_slug", "game_date"}),
|
||||||
Zones: NewZoneRepository(db),
|
Zones: NewZoneRepository(db),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
-- +goose Up
|
-- +goose Up
|
||||||
CREATE INDEX IF NOT EXISTS idx_games_date ON public.games(date);
|
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_competitions_zone_id ON public.competitions(zone_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_player_field_position ON public.players(field_position);
|
CREATE INDEX IF NOT EXISTS idx_player_field_position ON public.players(field_position);
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
DROP INDEX IF EXISTS idx_games_date;
|
DROP INDEX IF EXISTS idx_games_date;
|
||||||
DROP INDEX IF EXISTS idx_competitions_zone_id;
|
DROP INDEX IF EXISTS idx_competitions_zone_id;
|
||||||
DROP INDEX IF EXISTS idx_player_field_position;
|
DROP INDEX IF EXISTS idx_player_field_position;
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
-- +goose Up
|
-- +goose Up
|
||||||
CREATE TABLE IF NOT EXISTS card_supplies (
|
CREATE TABLE IF NOT EXISTS card_supplies (
|
||||||
player_slug TEXT NOT NULL,
|
player_slug TEXT NOT NULL,
|
||||||
season_start_year INTEGER NOT NULL,
|
season_start_year INTEGER NOT NULL,
|
||||||
PRIMARY KEY (player_slug, season_start_year),
|
PRIMARY KEY (player_slug, season_start_year),
|
||||||
"limited" INTEGER NOT NULL,
|
"limited" INTEGER NOT NULL,
|
||||||
"rare" INTEGER NOT NULL,
|
"rare" INTEGER NOT NULL,
|
||||||
"super_rare" INTEGER NOT NULL,
|
"super_rare" INTEGER NOT NULL,
|
||||||
"unique" INTEGER NOT NULL,
|
"unique" INTEGER NOT NULL,
|
||||||
"last_updated" TIMESTAMPTZ NOT NULL
|
"last_updated" TIMESTAMPTZ NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
DROP TABLE IF EXISTS card_supplies;
|
DROP TABLE IF EXISTS card_supplies;
|
@ -1,14 +1,14 @@
|
|||||||
-- +goose Up
|
-- +goose Up
|
||||||
CREATE TABLE IF NOT EXISTS club_memberships(
|
CREATE TABLE IF NOT EXISTS club_memberships(
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
player_slug TEXT NOT NULL REFERENCES players(slug),
|
player_slug TEXT NOT NULL REFERENCES players(slug),
|
||||||
club_slug TEXT NOT NULL REFERENCES teams(slug),
|
club_slug TEXT NOT NULL REFERENCES teams(slug),
|
||||||
start_date DATE NOT NULL,
|
start_date DATE NOT NULL,
|
||||||
end_date DATE
|
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);
|
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
|
-- +goose Down
|
||||||
DROP INDEX IF EXISTS club_memberships_player_slug_start_date_end_date_idx;
|
DROP INDEX IF EXISTS club_memberships_player_slug_start_date_end_date_idx;
|
||||||
DROP TABLE IF EXISTS club_memberships;
|
DROP TABLE IF EXISTS club_memberships;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
-- +goose Up
|
-- +goose Up
|
||||||
ALTER TABLE players ADD COLUMN active_national_team_slug TEXT;
|
ALTER TABLE players ADD COLUMN active_national_team_slug TEXT;
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
ALTER TABLE players DROP COLUMN active_national_team_slug;
|
ALTER TABLE players DROP COLUMN active_national_team_slug;
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
-- +goose Up
|
-- +goose Up
|
||||||
ALTER TABLE club_memberships RENAME TO memberships;
|
ALTER TABLE club_memberships RENAME TO memberships;
|
||||||
ALTER TABLE memberships RENAME COLUMN club_slug TO team_slug;
|
ALTER TABLE memberships RENAME COLUMN club_slug TO team_slug;
|
||||||
ALTER TABLE memberships ADD COLUMN membership_type TEXT NOT NULL DEFAULT 'club';
|
ALTER TABLE memberships ADD COLUMN membership_type TEXT NOT NULL DEFAULT 'club';
|
||||||
|
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
ALTER TABLE memberships RENAME TO club_memberships;
|
ALTER TABLE memberships RENAME TO club_memberships;
|
||||||
ALTER TABLE club_memberships RENAME COLUMN team_slug TO club_slug;
|
ALTER TABLE club_memberships RENAME COLUMN team_slug TO club_slug;
|
||||||
ALTER TABLE club_memberships DROP COLUMN membership_type;
|
ALTER TABLE club_memberships DROP COLUMN membership_type;
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
-- +goose Up
|
-- +goose Up
|
||||||
CREATE INDEX IF NOT EXISTS idx_memberships_on_player_slug_start_date_end_date ON memberships(player_slug, start_date, end_date);
|
CREATE INDEX IF NOT EXISTS idx_memberships_on_player_slug_start_date_end_date ON memberships(player_slug, start_date, end_date);
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
DROP INDEX IF EXISTS idx_memberships_on_player_slug_start_date_end_date;
|
DROP INDEX IF EXISTS idx_memberships_on_player_slug_start_date_end_date;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
-- +goose Up
|
-- +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_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);
|
CREATE INDEX IF NOT EXISTS idx_games_home_team_slug_date ON games(home_team_slug, date);
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
DROP INDEX IF EXISTS idx_games_away_team_slug_date;
|
DROP INDEX IF EXISTS idx_games_away_team_slug_date;
|
||||||
DROP INDEX IF EXISTS idx_games_home_team_slug_date;
|
DROP INDEX IF EXISTS idx_games_home_team_slug_date;
|
||||||
|
@ -1,35 +1,35 @@
|
|||||||
-- +goose Up
|
-- +goose Up
|
||||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
CREATE EXTENSION IF NOT EXISTS unaccent;
|
CREATE EXTENSION IF NOT EXISTS unaccent;
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION immutable_unaccent(regdictionary, TEXT)
|
CREATE OR REPLACE FUNCTION immutable_unaccent(regdictionary, TEXT)
|
||||||
RETURNS TEXT
|
RETURNS TEXT
|
||||||
LANGUAGE c
|
LANGUAGE c
|
||||||
IMMUTABLE PARALLEL SAFE STRICT AS
|
IMMUTABLE PARALLEL SAFE STRICT AS
|
||||||
'$libdir/unaccent',
|
'$libdir/unaccent',
|
||||||
'unaccent_dict';
|
'unaccent_dict';
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION f_unaccent(TEXT) RETURNS TEXT
|
CREATE OR REPLACE FUNCTION f_unaccent(TEXT) RETURNS TEXT
|
||||||
immutable
|
immutable
|
||||||
strict
|
strict
|
||||||
parallel safe
|
parallel safe
|
||||||
language sql
|
language sql
|
||||||
as
|
as
|
||||||
$$
|
$$
|
||||||
SELECT public.immutable_unaccent(regdictionary 'public.unaccent', $1)
|
SELECT public.immutable_unaccent(regdictionary 'public.unaccent', $1)
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS players_unaccent_display_name_trgm_idx
|
CREATE INDEX IF NOT EXISTS players_unaccent_display_name_trgm_idx
|
||||||
ON players USING gin (f_unaccent(display_name::text) gin_trgm_ops);
|
ON players USING gin (f_unaccent(display_name::text) gin_trgm_ops);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS teams_unaccent_display_name_trgm_idx
|
CREATE INDEX IF NOT EXISTS teams_unaccent_display_name_trgm_idx
|
||||||
ON teams USING gin (f_unaccent(display_name::text) gin_trgm_ops);
|
ON teams USING gin (f_unaccent(display_name::text) gin_trgm_ops);
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
DROP INDEX IF EXISTS players_unaccent_display_name_trgm_idx;
|
DROP INDEX IF EXISTS players_unaccent_display_name_trgm_idx;
|
||||||
DROP INDEX IF EXISTS teams_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 f_unaccent;
|
||||||
DROP FUNCTION IF EXISTS immutable_unaccent;
|
DROP FUNCTION IF EXISTS immutable_unaccent;
|
||||||
DROP EXTENSION IF EXISTS unaccent;
|
DROP EXTENSION IF EXISTS unaccent;
|
||||||
DROP EXTENSION IF EXISTS pg_trgm;
|
DROP EXTENSION IF EXISTS pg_trgm;
|
||||||
|
|
||||||
|
@ -1,116 +0,0 @@
|
|||||||
-- +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;
|
|
||||||
|
|
10
db/migrations/00010_game_date_on_game_player_scores.sql
Normal file
10
db/migrations/00010_game_date_on_game_player_scores.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE game_player_scores ADD COLUMN game_date TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_player_scores_player_slug_game_date ON game_player_scores (player_slug, game_date);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE game_player_scores DROP COLUMN game_date;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_game_player_scores_player_slug_game_date;
|
12
db/migrations/00011_game_player_scores_hypertable.sql
Normal file
12
db/migrations/00011_game_player_scores_hypertable.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
ALTER TABLE game_player_scores DROP CONSTRAINT game_player_scores_pkey;
|
||||||
|
ALTER TABLE game_player_scores ADD PRIMARY KEY (game_id, player_slug, game_date);
|
||||||
|
SELECT create_hypertable('game_player_scores', 'game_date', migrate_data => true);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_players_player_slug_team_slug ON game_players(player_slug, team_slug);
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_game_players_player_slug_team_slug;
|
||||||
|
ALTER TABLE game_player_scores DROP CONSTRAINT game_player_scores_pkey;
|
||||||
|
ALTER TABLE game_player_scores ADD PRIMARY KEY (game_id, player_slug, game_date);
|
||||||
|
SELECT drop_hypertable('game_player_scores', cascade => true);
|
20
db/player.go
20
db/player.go
@ -32,8 +32,6 @@ func (r *PlayerRepository) SearchByDisplayName(
|
|||||||
err := r.db.NewSelect().
|
err := r.db.NewSelect().
|
||||||
Model(&players).
|
Model(&players).
|
||||||
Relation("Team").
|
Relation("Team").
|
||||||
Relation("DomesticLeague").
|
|
||||||
Relation("DomesticLeague.Zone").
|
|
||||||
Where("f_unaccent(player.display_name) ILIKE ?", "%"+displayName+"%").
|
Where("f_unaccent(player.display_name) ILIKE ?", "%"+displayName+"%").
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
Scan(ctx)
|
Scan(ctx)
|
||||||
@ -153,10 +151,15 @@ type SingleRanking struct {
|
|||||||
MoreThan20AAPercentage decimal.Decimal `bun:"more_than_20_aa_percentage" json:"moreThan20AAPercentage"`
|
MoreThan20AAPercentage decimal.Decimal `bun:"more_than_20_aa_percentage" json:"moreThan20AAPercentage"`
|
||||||
MoreThan10AAPercentage decimal.Decimal `bun:"more_than_10_aa_percentage" json:"moreThan10AAPercentage"`
|
MoreThan10AAPercentage decimal.Decimal `bun:"more_than_10_aa_percentage" json:"moreThan10AAPercentage"`
|
||||||
|
|
||||||
TotalMinutes decimal.Decimal `bun:"total_minutes" json:"totalMinutes"`
|
TotalMinutes decimal.Decimal `bun:"total_minutes" json:"totalMinutes"`
|
||||||
TotalAA decimal.Decimal `bun:"total_aa" json:"totalAA"`
|
TotalAA decimal.Decimal `bun:"total_aa" json:"totalAA"`
|
||||||
AAPerMin decimal.Decimal `bun:"aa_per_min" json:"aaPerMin"`
|
AAPerMin decimal.Decimal `bun:"aa_per_min" json:"aaPerMin"`
|
||||||
Scores []*decimal.Decimal `bun:"scores,array" json:"scores"`
|
DuelWonPer90Min decimal.Decimal `bun:"duel_won_per_90_min" json:"duelWonPer90Min"`
|
||||||
|
EffectiveClearancePer90Min decimal.Decimal `bun:"effective_clearance_per_90_min" json:"effectiveClearancePer90Min"`
|
||||||
|
ShotOnTargetPer90Min decimal.Decimal `bun:"shot_on_target_per_90_min" json:"shotOnTargetPer90Min"`
|
||||||
|
AccuratePassPer90Min decimal.Decimal `bun:"accurate_pass_per_90_min" json:"accuratePassPer90Min"`
|
||||||
|
WonContestPer90Min decimal.Decimal `bun:"won_contest_per_90_min" json:"wonContestPer90Min"`
|
||||||
|
Scores []*decimal.Decimal `bun:"scores,array" json:"scores"`
|
||||||
|
|
||||||
Player model.Player `bun:"-" json:"player"`
|
Player model.Player `bun:"-" json:"player"`
|
||||||
}
|
}
|
||||||
@ -294,6 +297,11 @@ func (r *PlayerRepository) SingleRankings(ctx context.Context, opts SingleRankin
|
|||||||
ColumnExpr("SUM(fs.minutes_played) as total_minutes").
|
ColumnExpr("SUM(fs.minutes_played) as total_minutes").
|
||||||
ColumnExpr("SUM(fs.all_around_score) as total_aa").
|
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").
|
ColumnExpr("COALESCE(ROUND(SUM(fs.all_around_score) / NULLIF(SUM(fs.minutes_played), 0), 3), 0) AS aa_per_min").
|
||||||
|
ColumnExpr("COALESCE(ROUND((SUM(fs.duel_won)::NUMERIC / NULLIF(SUM(fs.minutes_played), 0)) * 90, 2), 0) AS duel_won_per_90_min").
|
||||||
|
ColumnExpr("COALESCE(ROUND((SUM(fs.effective_clearance)::NUMERIC / NULLIF(SUM(fs.minutes_played), 0)) * 90, 2), 0) AS effective_clearance_per_90_min").
|
||||||
|
ColumnExpr("COALESCE(ROUND((SUM(fs.shot_on_target)::NUMERIC / NULLIF(SUM(fs.minutes_played), 0)) * 90, 2), 0) AS shot_on_target_per_90_min").
|
||||||
|
ColumnExpr("COALESCE(ROUND((SUM(fs.accurate_pass)::NUMERIC / NULLIF(SUM(fs.minutes_played), 0)) * 90, 2), 0) AS accurate_pass_per_90_min").
|
||||||
|
ColumnExpr("COALESCE(ROUND((SUM(fs.won_contest)::NUMERIC / NULLIF(SUM(fs.minutes_played), 0)) * 90, 2), 0) AS won_contest_per_90_min").
|
||||||
TableExpr("\"FilteredGamePlayers\" AS gp").
|
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").
|
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")
|
Group("gp.player_slug")
|
||||||
|
44
db/team.go
44
db/team.go
@ -20,6 +20,23 @@ func NewTeamRepository(db *bun.DB) *TeamRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *TeamRepository) GetOne(ctx context.Context, slug string, includeGames bool) (model.Team, error) {
|
||||||
|
var team model.Team
|
||||||
|
req := r.db.NewSelect().
|
||||||
|
Model(&team).
|
||||||
|
Where("team.slug = ?", slug).
|
||||||
|
Relation("Country").
|
||||||
|
Relation("DomesticLeague").
|
||||||
|
Relation("DomesticLeague.Zone")
|
||||||
|
if includeGames {
|
||||||
|
req = req.Relation("HomeGames").
|
||||||
|
Relation("AwayGames")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := req.Scan(ctx)
|
||||||
|
return team, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *TeamRepository) GetTeamSlugsNotInDb(ctx context.Context, teamSlugs []string) ([]string, error) {
|
func (r *TeamRepository) GetTeamSlugsNotInDb(ctx context.Context, teamSlugs []string) ([]string, error) {
|
||||||
var teams []model.Team
|
var teams []model.Team
|
||||||
err := r.db.NewSelect().
|
err := r.db.NewSelect().
|
||||||
@ -45,3 +62,30 @@ func (r *TeamRepository) SearchByDisplayName(ctx context.Context, displayName st
|
|||||||
Scan(ctx)
|
Scan(ctx)
|
||||||
return teams, err
|
return teams, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *TeamRepository) GetRoster(ctx context.Context, slug string) ([]model.Player, error) {
|
||||||
|
var players []model.Player
|
||||||
|
err := r.db.NewSelect().
|
||||||
|
Model(&players).
|
||||||
|
Where("player.team_slug = ?", slug).
|
||||||
|
Relation("CardSupply").
|
||||||
|
Relation("ClubMembership").
|
||||||
|
Relation("Country").
|
||||||
|
Relation("GamePlayers").
|
||||||
|
Relation("GamePlayers.Score").
|
||||||
|
Relation("GamePlayers.Game").
|
||||||
|
Scan(ctx)
|
||||||
|
return players, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TeamRepository) GetMemberships(ctx context.Context, slug string) ([]model.Membership, error) {
|
||||||
|
var memberships []model.Membership
|
||||||
|
err := r.db.NewSelect().
|
||||||
|
Model(&memberships).
|
||||||
|
Relation("Player").
|
||||||
|
Relation("Player.ClubMembership").
|
||||||
|
Relation("Player.ClubMembership.Team").
|
||||||
|
Where("membership.team_slug = ?", slug).
|
||||||
|
Scan(ctx)
|
||||||
|
return memberships, err
|
||||||
|
}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@ianvs/prettier-plugin-sort-imports",
|
"@ianvs/prettier-plugin-sort-imports",
|
||||||
"prettier-plugin-tailwindcss"
|
"prettier-plugin-tailwindcss"
|
||||||
],
|
],
|
||||||
"pluginSearchDirs": ["."]
|
"pluginSearchDirs": ["."]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BIN
front/bun.lockb
BIN
front/bun.lockb
Binary file not shown.
4802
front/package-lock.json
generated
4802
front/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,9 @@
|
|||||||
import Card from "../types/card"
|
import Card from "../types/card"
|
||||||
import { Get } from "./api"
|
import { Get } from "./api"
|
||||||
|
|
||||||
export async function GetUserCards(userSlug: string): Promise<Card[]> {
|
export async function GetUserCards(userSlug: string): Promise<Card[]> {
|
||||||
const raw = await Get<Card[]>({
|
const raw = await Get<Card[]>({
|
||||||
endpoint: `/api/user/${userSlug}/cards`,
|
endpoint: `/api/user/${userSlug}/cards`,
|
||||||
})
|
})
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import Competition from "../types/competition";
|
import Competition from "../types/competition";
|
||||||
import { Get } from "./api";
|
import { Get } from "./api";
|
||||||
|
|
||||||
export async function GetClubCompetitions(
|
export async function GetClubCompetitions(
|
||||||
zones: number[],
|
zones: number[],
|
||||||
): Promise<Competition[]> {
|
): Promise<Competition[]> {
|
||||||
return await Get<Competition[]>({
|
return await Get<Competition[]>({
|
||||||
endpoint: `/api/competition/club`,
|
endpoint: `/api/competition/club`,
|
||||||
params: { zones: zones.join(",") },
|
params: { zones: zones.join(",") },
|
||||||
});
|
});
|
||||||
}
|
}
|
@ -1,9 +1,9 @@
|
|||||||
import { Fixture } from "../types/fixture"
|
import { Fixture } from "../types/fixture"
|
||||||
import { Get } from "./api"
|
import { Get } from "./api"
|
||||||
|
|
||||||
export async function GetAvailableFixtures(): Promise<Fixture[]> {
|
export async function GetAvailableFixtures(): Promise<Fixture[]> {
|
||||||
const raw = await Get<Fixture[]>({
|
const raw = await Get<Fixture[]>({
|
||||||
endpoint: "/api/fixture/all",
|
endpoint: "/api/fixture/all",
|
||||||
})
|
})
|
||||||
return raw.sort((a, b) => b.gameWeek - a.gameWeek)
|
return raw.sort((a, b) => b.gameWeek - a.gameWeek)
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
import { Game } from "../types/game"
|
import { Game } from "../types/game"
|
||||||
import { Get } from "./api"
|
import { Get } from "./api"
|
||||||
|
|
||||||
export async function GetFixtureGames(fixtureSlug: string): Promise<Game[]> {
|
export async function GetFixtureGames(fixtureSlug: string): Promise<Game[]> {
|
||||||
const raw = await Get<Game[]>({
|
const raw = await Get<Game[]>({
|
||||||
endpoint: `/api/fixture/${fixtureSlug}/games`,
|
endpoint: `/api/fixture/${fixtureSlug}/games`,
|
||||||
})
|
})
|
||||||
return raw
|
return raw
|
||||||
.sort(
|
.sort(
|
||||||
(a: Game, b: Game) =>
|
(a: Game, b: Game) =>
|
||||||
new Date(a.date).getTime() - new Date(b.date).getTime()
|
new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||||
)
|
)
|
||||||
.filter((game: Game) => {
|
.filter((game: Game) => {
|
||||||
return game.awayTeam.slug !== "" && game.homeTeam.slug !== ""
|
return game.awayTeam.slug !== "" && game.homeTeam.slug !== ""
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import { GamePlayer } from "../types/game"
|
import { GamePlayer } from "../types/game"
|
||||||
import Player from "../types/player"
|
import Player from "../types/player"
|
||||||
import { Get } from "./api"
|
import { Get } from "./api"
|
||||||
|
|
||||||
export async function GetPlayer(slug: string): Promise<Player> {
|
export async function GetPlayer(slug: string): Promise<Player> {
|
||||||
const raw = await Get<Player>({
|
const raw = await Get<Player>({
|
||||||
endpoint: `/api/player/${slug}`,
|
endpoint: `/api/player/${slug}`,
|
||||||
})
|
})
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GetPlayerGames(slug: string): Promise<GamePlayer[]> {
|
export async function GetPlayerGames(slug: string): Promise<GamePlayer[]> {
|
||||||
const raw = await Get<GamePlayer[]>({
|
const raw = await Get<GamePlayer[]>({
|
||||||
endpoint: `/api/player/${slug}/games`,
|
endpoint: `/api/player/${slug}/games`,
|
||||||
})
|
})
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
@ -59,6 +59,11 @@ export interface SingleRanking {
|
|||||||
moreThan20AAPercentage: number
|
moreThan20AAPercentage: number
|
||||||
moreThan10AAPercentage: number
|
moreThan10AAPercentage: number
|
||||||
aaPerMin: number
|
aaPerMin: number
|
||||||
|
duelWonPer90Min: number
|
||||||
|
effectiveClearancePer90Min: number
|
||||||
|
shotOnTargetPer90Min: number
|
||||||
|
accuratePassPer90Min: number
|
||||||
|
wonContestPer90Min: number
|
||||||
totalMinutes: number
|
totalMinutes: number
|
||||||
scores: number[]
|
scores: number[]
|
||||||
player: Player
|
player: Player
|
||||||
|
36
front/src/api/team.ts
Normal file
36
front/src/api/team.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import Player, { Membership } from "../types/player"
|
||||||
|
import Team from "../types/team"
|
||||||
|
import { Get } from "./api"
|
||||||
|
|
||||||
|
export async function GetTeam(slug: string): Promise<Team> {
|
||||||
|
const raw = await Get<Team>({
|
||||||
|
endpoint: `/api/team/${slug}`,
|
||||||
|
})
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamRosterResponse {
|
||||||
|
team: Team
|
||||||
|
roster: Player[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GetTeamRoster(slug: string): Promise<TeamRosterResponse> {
|
||||||
|
const raw = await Get<TeamRosterResponse>({
|
||||||
|
endpoint: `/api/team/${slug}/roster`,
|
||||||
|
})
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMembershipsResponse {
|
||||||
|
team: Team
|
||||||
|
memberships: Membership[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GetTeamMemberships(
|
||||||
|
slug: string
|
||||||
|
): Promise<TeamMembershipsResponse> {
|
||||||
|
const raw = await Get<TeamMembershipsResponse>({
|
||||||
|
endpoint: `/api/team/${slug}/memberships`,
|
||||||
|
})
|
||||||
|
return raw
|
||||||
|
}
|
@ -1,9 +1,9 @@
|
|||||||
import Zone from "../types/zone";
|
import Zone from "../types/zone";
|
||||||
import { Get } from "./api";
|
import { Get } from "./api";
|
||||||
|
|
||||||
export async function GetAllZones(): Promise<Zone[]> {
|
export async function GetAllZones(): Promise<Zone[]> {
|
||||||
return await Get({
|
return await Get({
|
||||||
endpoint: "/api/zone/all"
|
endpoint: "/api/zone/all"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BIN
front/src/assets/man.png
Normal file
BIN
front/src/assets/man.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
27
front/src/components/badge.tsx
Normal file
27
front/src/components/badge.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Link } from "react-router-dom"
|
||||||
|
|
||||||
|
export default function Badge({
|
||||||
|
imgUrl,
|
||||||
|
name,
|
||||||
|
imgHeight = "full",
|
||||||
|
link = "",
|
||||||
|
}: {
|
||||||
|
imgUrl?: string
|
||||||
|
name?: string
|
||||||
|
imgHeight?: string
|
||||||
|
link?: string
|
||||||
|
}) {
|
||||||
|
return link ? (
|
||||||
|
<Link to={link}>
|
||||||
|
<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>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
@ -1,51 +1,51 @@
|
|||||||
import { ChangeEventHandler } from "react"
|
import { ChangeEventHandler } from "react"
|
||||||
|
|
||||||
interface CheckboxProps {
|
interface CheckboxProps {
|
||||||
checked: boolean
|
checked: boolean
|
||||||
label: string
|
label: string
|
||||||
onChange: ChangeEventHandler<HTMLInputElement>
|
onChange: ChangeEventHandler<HTMLInputElement>
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Checkbox({
|
export default function Checkbox({
|
||||||
checked,
|
checked,
|
||||||
label,
|
label,
|
||||||
onChange,
|
onChange,
|
||||||
id,
|
id,
|
||||||
}: CheckboxProps) {
|
}: CheckboxProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 block text-xs font-bold text-gray-700">
|
<div className="mb-2 block text-xs font-bold text-gray-700">
|
||||||
<label htmlFor={id} className="flex cursor-pointer items-center">
|
<label htmlFor={id} className="flex cursor-pointer items-center">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
<div className="block h-6 w-6 rounded-md border-2 border-gray-300 bg-white ">
|
<div className="block h-6 w-6 rounded-md border-2 border-gray-300 bg-white ">
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
checked ? "opacity-100" : "opacity-0"
|
checked ? "opacity-100" : "opacity-0"
|
||||||
} absolute inset-0 flex h-full w-full items-center justify-center`}
|
} absolute inset-0 flex h-full w-full items-center justify-center`}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4 text-blue-600"
|
className="h-4 w-4 text-blue-600"
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<path d="M5 13l4 4L19 7"></path>
|
<path d="M5 13l4 4L19 7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-2">{label}</span>
|
<span className="ml-2">{label}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,33 +1,33 @@
|
|||||||
import InputWrapper from "./input_wrapper";
|
import InputWrapper from "./input_wrapper";
|
||||||
|
|
||||||
interface NumericInputProps {
|
interface NumericInputProps {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
label: string;
|
label: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
id: string;
|
id: string;
|
||||||
classname?: string;
|
classname?: string;
|
||||||
}
|
}
|
||||||
export default function NumericInput({
|
export default function NumericInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
placeholder = label,
|
placeholder = label,
|
||||||
id,
|
id,
|
||||||
classname = "",
|
classname = "",
|
||||||
}: NumericInputProps) {
|
}: NumericInputProps) {
|
||||||
return (
|
return (
|
||||||
<InputWrapper label={label} id={id} className={classname}>
|
<InputWrapper label={label} id={id} className={classname}>
|
||||||
<input
|
<input
|
||||||
defaultValue={value}
|
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"
|
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}
|
id={id}
|
||||||
type="number"
|
type="number"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onChange(parseInt(e.target.value));
|
onChange(parseInt(e.target.value));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</InputWrapper>
|
</InputWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -118,6 +118,10 @@ export function SearchBox() {
|
|||||||
onChange={handleSelect}
|
onChange={handleSelect}
|
||||||
formatGroupLabel={formatGroupLabel}
|
formatGroupLabel={formatGroupLabel}
|
||||||
formatOptionLabel={formatOptionLabel}
|
formatOptionLabel={formatOptionLabel}
|
||||||
|
styles={{
|
||||||
|
menu: (base) => ({ ...base, maxHeight: "600px" }),
|
||||||
|
menuList: (base) => ({ ...base, maxHeight: "600px" }),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,35 +1,35 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import ErrorBlock from "./error"
|
import ErrorBlock from "./error"
|
||||||
import Loader from "./loader"
|
import Loader from "./loader"
|
||||||
|
|
||||||
interface WithDataFetchingProps<T> {
|
interface WithDataFetchingProps<T> {
|
||||||
queryKey: string[]
|
queryKey: string[]
|
||||||
queryFn: () => Promise<T>
|
queryFn: () => Promise<T>
|
||||||
refetchInterval?: number
|
refetchInterval?: number
|
||||||
refetchOnWindowFocus?: boolean
|
refetchOnWindowFocus?: boolean
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
children: (data: T) => React.ReactNode
|
children: (data: T) => React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WithDataFetching<T>({
|
export default function WithDataFetching<T>({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
refetchOnWindowFocus,
|
refetchOnWindowFocus,
|
||||||
enabled,
|
enabled,
|
||||||
children,
|
children,
|
||||||
}: WithDataFetchingProps<T>) {
|
}: WithDataFetchingProps<T>) {
|
||||||
const { data, isLoading, isError, error } = useQuery<T, Error>({
|
const { data, isLoading, isError, error } = useQuery<T, Error>({
|
||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn: queryFn,
|
queryFn: queryFn,
|
||||||
refetchInterval: refetchInterval,
|
refetchInterval: refetchInterval,
|
||||||
refetchOnWindowFocus: refetchOnWindowFocus,
|
refetchOnWindowFocus: refetchOnWindowFocus,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) return <Loader />
|
if (isLoading) return <Loader />
|
||||||
if (isError) return <ErrorBlock error={error} />
|
if (isError) return <ErrorBlock error={error} />
|
||||||
if (!data) return ErrorBlock({ error: new Error("Data not found") })
|
if (!data) return ErrorBlock({ error: new Error("Data not found") })
|
||||||
|
|
||||||
return <>{children(data)}</>
|
return <>{children(data)}</>
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.btn {
|
.btn {
|
||||||
@apply px-4 py-2 rounded-md text-neutral-50 text-sm font-semibold hover:shadow-md;
|
@apply px-4 py-2 rounded-md text-neutral-50 text-sm font-semibold hover:shadow-md;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
@ -1,35 +1,35 @@
|
|||||||
import { Link, useLocation } from "react-router-dom"
|
import { Link, useLocation } from "react-router-dom"
|
||||||
import { SearchBox } from "../components/searchBox"
|
import { SearchBox } from "../components/searchBox"
|
||||||
|
|
||||||
interface HeaderLinkButtonProps {
|
interface HeaderLinkButtonProps {
|
||||||
to: string
|
to: string
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeaderLinkButton = ({ to, label }: HeaderLinkButtonProps) => {
|
const HeaderLinkButton = ({ to, label }: HeaderLinkButtonProps) => {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isActive = location.pathname === to
|
const isActive = location.pathname === to
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
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"}`}
|
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>
|
<span className="font-secondary text-lg font-semibold">{label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
return (
|
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="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">
|
<div className="w-96">
|
||||||
<SearchBox />
|
<SearchBox />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-3 ">
|
<div className="flex items-center justify-end gap-3 ">
|
||||||
<HeaderLinkButton to="/" label="Rankings" />
|
<HeaderLinkButton to="/" label="Rankings" />
|
||||||
<HeaderLinkButton to="/live" label="Live" />
|
<HeaderLinkButton to="/live" label="Live" />
|
||||||
<HeaderLinkButton to="/xpcenter" label="XP Center" />
|
<HeaderLinkButton to="/xpcenter" label="XP Center" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { Outlet } from "react-router-dom"
|
import { Outlet } from "react-router-dom"
|
||||||
import Header from "./header"
|
import Header from "./header"
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,374 +1,374 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { GetAvailableFixtures } from "../../api/fixture"
|
import { GetAvailableFixtures } from "../../api/fixture"
|
||||||
import { GetFixtureGames } from "../../api/game"
|
import { GetFixtureGames } from "../../api/game"
|
||||||
import WithDataFetching from "../../components/withdatafetching"
|
import WithDataFetching from "../../components/withdatafetching"
|
||||||
import { Fixture } from "../../types/fixture"
|
import { Fixture } from "../../types/fixture"
|
||||||
import { Game } from "../../types/game"
|
import { Game } from "../../types/game"
|
||||||
|
|
||||||
export default function Live() {
|
export default function Live() {
|
||||||
return (
|
return (
|
||||||
<WithDataFetching<Fixture[]>
|
<WithDataFetching<Fixture[]>
|
||||||
queryKey={["availableFixtures"]}
|
queryKey={["availableFixtures"]}
|
||||||
queryFn={GetAvailableFixtures}
|
queryFn={GetAvailableFixtures}
|
||||||
refetchInterval={60 * 60000}
|
refetchInterval={60 * 60000}
|
||||||
>
|
>
|
||||||
{(data) => <FixtureListLayout fixtures={data} />}
|
{(data) => <FixtureListLayout fixtures={data} />}
|
||||||
</WithDataFetching>
|
</WithDataFetching>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FixtureListLayout({ fixtures }: { fixtures: Fixture[] }) {
|
function FixtureListLayout({ fixtures }: { fixtures: Fixture[] }) {
|
||||||
const [currentIndex, setCurrentIndex] = useState(() => {
|
const [currentIndex, setCurrentIndex] = useState(() => {
|
||||||
const startedFixtureIndex = fixtures?.findIndex(
|
const startedFixtureIndex = fixtures?.findIndex(
|
||||||
(fixture) => fixture.fixtureState === "started"
|
(fixture) => fixture.fixtureState === "started"
|
||||||
)
|
)
|
||||||
return startedFixtureIndex !== -1 ? startedFixtureIndex : 0
|
return startedFixtureIndex !== -1 ? startedFixtureIndex : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentIndex < 0) {
|
if (currentIndex < 0) {
|
||||||
setCurrentIndex(0)
|
setCurrentIndex(0)
|
||||||
} else if (currentIndex >= fixtures.length) {
|
} else if (currentIndex >= fixtures.length) {
|
||||||
setCurrentIndex(fixtures.length - 1)
|
setCurrentIndex(fixtures.length - 1)
|
||||||
}
|
}
|
||||||
}, [currentIndex, fixtures?.length])
|
}, [currentIndex, fixtures?.length])
|
||||||
|
|
||||||
const selectedFixture = fixtures[currentIndex]
|
const selectedFixture = fixtures[currentIndex]
|
||||||
|
|
||||||
const handlePrevious = () => {
|
const handlePrevious = () => {
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
setCurrentIndex(currentIndex - 1)
|
setCurrentIndex(currentIndex - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (currentIndex < fixtures.length - 1) {
|
if (currentIndex < fixtures.length - 1) {
|
||||||
setCurrentIndex(currentIndex + 1)
|
setCurrentIndex(currentIndex + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
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="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">
|
<div className="flex items-center justify-center gap-6">
|
||||||
{currentIndex < fixtures.length - 1 ? (
|
{currentIndex < fixtures.length - 1 ? (
|
||||||
<button onClick={handleNext}>
|
<button onClick={handleNext}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
|
<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" />
|
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-[24px]"></div>
|
<div className="w-[24px]"></div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg">Gameweek</span>
|
<span className="text-lg">Gameweek</span>
|
||||||
<span className="text-lg font-bold">
|
<span className="text-lg font-bold">
|
||||||
{selectedFixture ? selectedFixture.gameWeek : ""}
|
{selectedFixture ? selectedFixture.gameWeek : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{currentIndex > 0 ? (
|
{currentIndex > 0 ? (
|
||||||
<button onClick={handlePrevious}>
|
<button onClick={handlePrevious}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
|
<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" />
|
<path d="M4 11h12.17l-5.59-5.59L12 4l8 8-8 8-1.41-1.41L16.17 13H4v-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-[24px]"></div>
|
<div className="w-[24px]"></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedFixture && (
|
{selectedFixture && (
|
||||||
<WithDataFetching<Game[]>
|
<WithDataFetching<Game[]>
|
||||||
queryKey={["fixtureGames", selectedFixture.slug]}
|
queryKey={["fixtureGames", selectedFixture.slug]}
|
||||||
queryFn={() => GetFixtureGames(selectedFixture.slug)}
|
queryFn={() => GetFixtureGames(selectedFixture.slug)}
|
||||||
refetchInterval={10000}
|
refetchInterval={10000}
|
||||||
enabled={!!selectedFixture}
|
enabled={!!selectedFixture}
|
||||||
>
|
>
|
||||||
{(data) => <FixtureGames fixture={selectedFixture} games={data} />}
|
{(data) => <FixtureGames fixture={selectedFixture} games={data} />}
|
||||||
</WithDataFetching>
|
</WithDataFetching>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function areDatesOnSameDay(date1: Date, date2: Date) {
|
function areDatesOnSameDay(date1: Date, date2: Date) {
|
||||||
return (
|
return (
|
||||||
date1.getFullYear() === date2.getFullYear() &&
|
date1.getFullYear() === date2.getFullYear() &&
|
||||||
date1.getMonth() === date2.getMonth() &&
|
date1.getMonth() === date2.getMonth() &&
|
||||||
date1.getDate() === date2.getDate()
|
date1.getDate() === date2.getDate()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TranslationKey = "today" | "tomorrow" | "yesterday"
|
type TranslationKey = "today" | "tomorrow" | "yesterday"
|
||||||
type LanguageCode =
|
type LanguageCode =
|
||||||
| "en"
|
| "en"
|
||||||
| "zh"
|
| "zh"
|
||||||
| "hi"
|
| "hi"
|
||||||
| "es"
|
| "es"
|
||||||
| "ar"
|
| "ar"
|
||||||
| "bn"
|
| "bn"
|
||||||
| "pt"
|
| "pt"
|
||||||
| "ru"
|
| "ru"
|
||||||
| "ja"
|
| "ja"
|
||||||
| "pa"
|
| "pa"
|
||||||
| "jv"
|
| "jv"
|
||||||
| "de"
|
| "de"
|
||||||
| "fr"
|
| "fr"
|
||||||
| "it"
|
| "it"
|
||||||
| "tr"
|
| "tr"
|
||||||
| "ko"
|
| "ko"
|
||||||
| "fa"
|
| "fa"
|
||||||
| "uk"
|
| "uk"
|
||||||
| "pl"
|
| "pl"
|
||||||
| "ro"
|
| "ro"
|
||||||
| "nl"
|
| "nl"
|
||||||
| "th"
|
| "th"
|
||||||
| "el"
|
| "el"
|
||||||
| "sv"
|
| "sv"
|
||||||
| "cs"
|
| "cs"
|
||||||
type Translations = Record<LanguageCode, Record<TranslationKey, string>>
|
type Translations = Record<LanguageCode, Record<TranslationKey, string>>
|
||||||
function getLocalizedWord(word: TranslationKey): string {
|
function getLocalizedWord(word: TranslationKey): string {
|
||||||
const translations: Translations = {
|
const translations: Translations = {
|
||||||
en: { today: "Today", tomorrow: "Tomorrow", yesterday: "Yesterday" },
|
en: { today: "Today", tomorrow: "Tomorrow", yesterday: "Yesterday" },
|
||||||
zh: { today: "今天", tomorrow: "明天", yesterday: "昨天" },
|
zh: { today: "今天", tomorrow: "明天", yesterday: "昨天" },
|
||||||
hi: { today: "आज", tomorrow: "कल", yesterday: "कल" },
|
hi: { today: "आज", tomorrow: "कल", yesterday: "कल" },
|
||||||
es: { today: "Hoy", tomorrow: "Mañana", yesterday: "Ayer" },
|
es: { today: "Hoy", tomorrow: "Mañana", yesterday: "Ayer" },
|
||||||
ar: { today: "اليوم", tomorrow: "غدًا", yesterday: "أمس" },
|
ar: { today: "اليوم", tomorrow: "غدًا", yesterday: "أمس" },
|
||||||
bn: { today: "আজ", tomorrow: "আগামীকাল", yesterday: "গতকাল" },
|
bn: { today: "আজ", tomorrow: "আগামীকাল", yesterday: "গতকাল" },
|
||||||
pt: { today: "Hoje", tomorrow: "Amanhã", yesterday: "Ontem" },
|
pt: { today: "Hoje", tomorrow: "Amanhã", yesterday: "Ontem" },
|
||||||
ru: { today: "Сегодня", tomorrow: "Завтра", yesterday: "Вчера" },
|
ru: { today: "Сегодня", tomorrow: "Завтра", yesterday: "Вчера" },
|
||||||
ja: { today: "今日", tomorrow: "明日", yesterday: "昨日" },
|
ja: { today: "今日", tomorrow: "明日", yesterday: "昨日" },
|
||||||
pa: { today: "ਅੱਜ", tomorrow: "ਕਲ", yesterday: "ਕੱਲ੍ਹ" },
|
pa: { today: "ਅੱਜ", tomorrow: "ਕਲ", yesterday: "ਕੱਲ੍ਹ" },
|
||||||
jv: { today: "Dina iki", tomorrow: "Esuk", yesterday: "Wingi" },
|
jv: { today: "Dina iki", tomorrow: "Esuk", yesterday: "Wingi" },
|
||||||
de: { today: "Heute", tomorrow: "Morgen", yesterday: "Gestern" },
|
de: { today: "Heute", tomorrow: "Morgen", yesterday: "Gestern" },
|
||||||
fr: { today: "Aujourd'hui", tomorrow: "Demain", yesterday: "Hier" },
|
fr: { today: "Aujourd'hui", tomorrow: "Demain", yesterday: "Hier" },
|
||||||
it: { today: "Oggi", tomorrow: "Domani", yesterday: "Ieri" },
|
it: { today: "Oggi", tomorrow: "Domani", yesterday: "Ieri" },
|
||||||
tr: { today: "Bugün", tomorrow: "Yarın", yesterday: "Dün" },
|
tr: { today: "Bugün", tomorrow: "Yarın", yesterday: "Dün" },
|
||||||
ko: { today: "오늘", tomorrow: "내일", yesterday: "어제" },
|
ko: { today: "오늘", tomorrow: "내일", yesterday: "어제" },
|
||||||
fa: { today: "امروز", tomorrow: "فردا", yesterday: "دیروز" },
|
fa: { today: "امروز", tomorrow: "فردا", yesterday: "دیروز" },
|
||||||
uk: { today: "Сьогодні", tomorrow: "Завтра", yesterday: "Вчора" },
|
uk: { today: "Сьогодні", tomorrow: "Завтра", yesterday: "Вчора" },
|
||||||
pl: { today: "Dziś", tomorrow: "Jutro", yesterday: "Wczoraj" },
|
pl: { today: "Dziś", tomorrow: "Jutro", yesterday: "Wczoraj" },
|
||||||
ro: { today: "Astăzi", tomorrow: "Mâine", yesterday: "Ieri" },
|
ro: { today: "Astăzi", tomorrow: "Mâine", yesterday: "Ieri" },
|
||||||
nl: { today: "Vandaag", tomorrow: "Morgen", yesterday: "Gisteren" },
|
nl: { today: "Vandaag", tomorrow: "Morgen", yesterday: "Gisteren" },
|
||||||
th: { today: "วันนี้", tomorrow: "พรุ่งนี้", yesterday: "เมื่อวาน" },
|
th: { today: "วันนี้", tomorrow: "พรุ่งนี้", yesterday: "เมื่อวาน" },
|
||||||
el: { today: "Σήμερα", tomorrow: "Αύριο", yesterday: "Χθες" },
|
el: { today: "Σήμερα", tomorrow: "Αύριο", yesterday: "Χθες" },
|
||||||
sv: { today: "Idag", tomorrow: "Imorgon", yesterday: "Igår" },
|
sv: { today: "Idag", tomorrow: "Imorgon", yesterday: "Igår" },
|
||||||
cs: { today: "Dnes", tomorrow: "Zítra", yesterday: "Včera" },
|
cs: { today: "Dnes", tomorrow: "Zítra", yesterday: "Včera" },
|
||||||
}
|
}
|
||||||
|
|
||||||
const lang = navigator.language.slice(0, 2)
|
const lang = navigator.language.slice(0, 2)
|
||||||
if ((translations as Record<string, unknown>)[lang]) {
|
if ((translations as Record<string, unknown>)[lang]) {
|
||||||
return translations[lang as LanguageCode][word] || word
|
return translations[lang as LanguageCode][word] || word
|
||||||
}
|
}
|
||||||
return word
|
return word
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDayString(day: Date) {
|
function getDayString(day: Date) {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const todayDate = today.getDate()
|
const todayDate = today.getDate()
|
||||||
const dayDate = day.getDate()
|
const dayDate = day.getDate()
|
||||||
|
|
||||||
if (areDatesOnSameDay(day, today)) {
|
if (areDatesOnSameDay(day, today)) {
|
||||||
return getLocalizedWord("today")
|
return getLocalizedWord("today")
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
dayDate === todayDate + 1 &&
|
dayDate === todayDate + 1 &&
|
||||||
day.getMonth() === today.getMonth() &&
|
day.getMonth() === today.getMonth() &&
|
||||||
day.getFullYear() === today.getFullYear()
|
day.getFullYear() === today.getFullYear()
|
||||||
) {
|
) {
|
||||||
return getLocalizedWord("tomorrow")
|
return getLocalizedWord("tomorrow")
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
dayDate === todayDate - 1 &&
|
dayDate === todayDate - 1 &&
|
||||||
day.getMonth() === today.getMonth() &&
|
day.getMonth() === today.getMonth() &&
|
||||||
day.getFullYear() === today.getFullYear()
|
day.getFullYear() === today.getFullYear()
|
||||||
) {
|
) {
|
||||||
return getLocalizedWord("yesterday")
|
return getLocalizedWord("yesterday")
|
||||||
}
|
}
|
||||||
|
|
||||||
const dayName = new Intl.DateTimeFormat(navigator.language, {
|
const dayName = new Intl.DateTimeFormat(navigator.language, {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
}).format(day)
|
}).format(day)
|
||||||
return dayName.charAt(0).toUpperCase() + dayName.slice(1)
|
return dayName.charAt(0).toUpperCase() + dayName.slice(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FixtureGames({ fixture, games }: { fixture: Fixture; games: Game[] }) {
|
function FixtureGames({ fixture, games }: { fixture: Fixture; games: Game[] }) {
|
||||||
const [showOnlyStartedAndScheduled, setShowOnlyStartedAndScheduled] =
|
const [showOnlyStartedAndScheduled, setShowOnlyStartedAndScheduled] =
|
||||||
useState(false)
|
useState(false)
|
||||||
const toggleFilter = () => {
|
const toggleFilter = () => {
|
||||||
setShowOnlyStartedAndScheduled((prevState) => !prevState)
|
setShowOnlyStartedAndScheduled((prevState) => !prevState)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [selectedDay, setSelectedDay] = useState<Date>(new Date())
|
const [selectedDay, setSelectedDay] = useState<Date>(new Date())
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let initialDate = new Date()
|
let initialDate = new Date()
|
||||||
if (
|
if (
|
||||||
initialDate > new Date(fixture.endDate) ||
|
initialDate > new Date(fixture.endDate) ||
|
||||||
initialDate < new Date(fixture.startDate)
|
initialDate < new Date(fixture.startDate)
|
||||||
) {
|
) {
|
||||||
initialDate = new Date(fixture.startDate)
|
initialDate = new Date(fixture.startDate)
|
||||||
}
|
}
|
||||||
initialDate.setHours(0, 0, 0, 0)
|
initialDate.setHours(0, 0, 0, 0)
|
||||||
setSelectedDay(initialDate)
|
setSelectedDay(initialDate)
|
||||||
}, [fixture])
|
}, [fixture])
|
||||||
|
|
||||||
const uniqueDays = useMemo(() => {
|
const uniqueDays = useMemo(() => {
|
||||||
const daysSet = new Set<string>()
|
const daysSet = new Set<string>()
|
||||||
|
|
||||||
games?.forEach((game) => {
|
games?.forEach((game) => {
|
||||||
const d = new Date(game.date)
|
const d = new Date(game.date)
|
||||||
const dateString = `${d.getFullYear()}-${String(
|
const dateString = `${d.getFullYear()}-${String(
|
||||||
d.getMonth() + 1
|
d.getMonth() + 1
|
||||||
).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||||
daysSet.add(dateString)
|
daysSet.add(dateString)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Array.from(daysSet).map((dateString) => new Date(dateString))
|
return Array.from(daysSet).map((dateString) => new Date(dateString))
|
||||||
}, [games])
|
}, [games])
|
||||||
|
|
||||||
const filteredGames = useMemo(() => {
|
const filteredGames = useMemo(() => {
|
||||||
let res = games?.filter((game: Game) =>
|
let res = games?.filter((game: Game) =>
|
||||||
areDatesOnSameDay(new Date(game.date), selectedDay)
|
areDatesOnSameDay(new Date(game.date), selectedDay)
|
||||||
)
|
)
|
||||||
if (showOnlyStartedAndScheduled) {
|
if (showOnlyStartedAndScheduled) {
|
||||||
res = res?.filter(
|
res = res?.filter(
|
||||||
(game: Game) => game.status === "playing" || game.status === "scheduled"
|
(game: Game) => game.status === "playing" || game.status === "scheduled"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return res?.sort((a: Game, b: Game) => {
|
return res?.sort((a: Game, b: Game) => {
|
||||||
const time1 = new Date(a.date).getTime()
|
const time1 = new Date(a.date).getTime()
|
||||||
const time2 = new Date(b.date).getTime()
|
const time2 = new Date(b.date).getTime()
|
||||||
if (time1 === time2) {
|
if (time1 === time2) {
|
||||||
return a.id > b.id ? 1 : -1
|
return a.id > b.id ? 1 : -1
|
||||||
}
|
}
|
||||||
return time1 - time2
|
return time1 - time2
|
||||||
})
|
})
|
||||||
}, [games, showOnlyStartedAndScheduled, selectedDay])
|
}, [games, showOnlyStartedAndScheduled, selectedDay])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto flex flex-col justify-center">
|
<div className="container mx-auto flex flex-col justify-center">
|
||||||
<div className="flex items-center justify-center gap-4">
|
<div className="flex items-center justify-center gap-4">
|
||||||
{uniqueDays
|
{uniqueDays
|
||||||
.sort((a: Date, b: Date) => a.getTime() - b.getTime())
|
.sort((a: Date, b: Date) => a.getTime() - b.getTime())
|
||||||
.map((day) => (
|
.map((day) => (
|
||||||
<div className="flex flex-col items-center justify-center gap-1">
|
<div className="flex flex-col items-center justify-center gap-1">
|
||||||
<button
|
<button
|
||||||
key={day.getDate()}
|
key={day.getDate()}
|
||||||
onClick={() => setSelectedDay(day)}
|
onClick={() => setSelectedDay(day)}
|
||||||
className={`flex flex-col rounded-lg border bg-primary-100 px-5 py-1 text-neutral-800 shadow-sm ${
|
className={`flex flex-col rounded-lg border bg-primary-100 px-5 py-1 text-neutral-800 shadow-sm ${
|
||||||
areDatesOnSameDay(day, selectedDay)
|
areDatesOnSameDay(day, selectedDay)
|
||||||
? "bg-primary-200 hover:bg-primary-200"
|
? "bg-primary-200 hover:bg-primary-200"
|
||||||
: "hover:border hover:border-primary-300 hover:bg-primary-200"
|
: "hover:border hover:border-primary-300 hover:bg-primary-200"
|
||||||
} `}
|
} `}
|
||||||
>
|
>
|
||||||
<span>{getDayString(day)}</span>
|
<span>{getDayString(day)}</span>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{day.toLocaleDateString(navigator.language, {
|
{day.toLocaleDateString(navigator.language, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-4 p-4">
|
<div className="flex justify-end gap-4 p-4">
|
||||||
<label
|
<label
|
||||||
className="inline-block pl-[0.15rem] hover:cursor-pointer"
|
className="inline-block pl-[0.15rem] hover:cursor-pointer"
|
||||||
htmlFor="flexSwitchChecked"
|
htmlFor="flexSwitchChecked"
|
||||||
>
|
>
|
||||||
Only started and scheduled
|
Only started and scheduled
|
||||||
</label>
|
</label>
|
||||||
<input
|
<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]"
|
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"
|
type="checkbox"
|
||||||
role="switch"
|
role="switch"
|
||||||
id="flexSwitchChecked"
|
id="flexSwitchChecked"
|
||||||
checked={showOnlyStartedAndScheduled}
|
checked={showOnlyStartedAndScheduled}
|
||||||
onChange={toggleFilter}
|
onChange={toggleFilter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[0.3fr_1fr_150px_1fr_0.3fr] items-center space-y-2">
|
<div className="grid grid-cols-[0.3fr_1fr_150px_1fr_0.3fr] items-center space-y-2">
|
||||||
{filteredGames.map((game) => (
|
{filteredGames.map((game) => (
|
||||||
<>
|
<>
|
||||||
<div className=" px-3 py-1 text-sm">
|
<div className=" px-3 py-1 text-sm">
|
||||||
{game.status === "playing" ? (
|
{game.status === "playing" ? (
|
||||||
game.periodType === "HALF_TIME" ? (
|
game.periodType === "HALF_TIME" ? (
|
||||||
<span>HT</span>
|
<span>HT</span>
|
||||||
) : (
|
) : (
|
||||||
<span>{game.minutes}'</span>
|
<span>{game.minutes}'</span>
|
||||||
)
|
)
|
||||||
) : game.status === "played" ? (
|
) : game.status === "played" ? (
|
||||||
<span>FT</span>
|
<span>FT</span>
|
||||||
) : game.status === "scheduled" ? (
|
) : game.status === "scheduled" ? (
|
||||||
<span className={`text-gray-700 `}>
|
<span className={`text-gray-700 `}>
|
||||||
{new Date(game.date).toLocaleString(navigator.language, {
|
{new Date(game.date).toLocaleString(navigator.language, {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`text-md flex items-center justify-end gap-3 ${game.status === "played" && game.winnerTeam?.slug === game.homeTeam.slug ? "font-semibold" : ""}`}
|
className={`text-md flex items-center justify-end gap-3 ${game.status === "played" && game.winnerTeam?.slug === game.homeTeam.slug ? "font-semibold" : ""}`}
|
||||||
>
|
>
|
||||||
{game.homeTeam.shortName}
|
{game.homeTeam.shortName}
|
||||||
<div className="flex w-8 justify-center">
|
<div className="flex w-8 justify-center">
|
||||||
<img className="h-8 " src={game.homeTeam.pictureUrl} alt="" />
|
<img className="h-8 " src={game.homeTeam.pictureUrl} alt="" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center px-6">
|
<div className="flex items-center justify-center px-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{game.homePenaltyScore > 0 || game.awayPenaltyScore > 0 ? (
|
{game.homePenaltyScore > 0 || game.awayPenaltyScore > 0 ? (
|
||||||
<span>({game.homePenaltyScore})</span>
|
<span>({game.homePenaltyScore})</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span
|
<span
|
||||||
className={`px-2 text-right text-xl ${game.status === "played" && game.winnerTeam?.slug === game.homeTeam.slug ? "font-semibold" : ""}`}
|
className={`px-2 text-right text-xl ${game.status === "played" && game.winnerTeam?.slug === game.homeTeam.slug ? "font-semibold" : ""}`}
|
||||||
>
|
>
|
||||||
{game.status !== "scheduled" ? game.homeGoals : "-"}
|
{game.status !== "scheduled" ? game.homeGoals : "-"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{game.status !== "scheduled" && <span>-</span>}
|
{game.status !== "scheduled" && <span>-</span>}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-start gap-1">
|
<div className="flex items-center justify-start gap-1">
|
||||||
<span
|
<span
|
||||||
className={`px-2 text-right text-xl ${game.status === "played" && game.winnerTeam?.slug === game.awayTeam.slug ? "font-semibold" : ""}`}
|
className={`px-2 text-right text-xl ${game.status === "played" && game.winnerTeam?.slug === game.awayTeam.slug ? "font-semibold" : ""}`}
|
||||||
>
|
>
|
||||||
{game.status !== "scheduled" ? game.awayGoals : "-"}
|
{game.status !== "scheduled" ? game.awayGoals : "-"}
|
||||||
</span>
|
</span>
|
||||||
{game.awayPenaltyScore > 0 || game.homePenaltyScore > 0 ? (
|
{game.awayPenaltyScore > 0 || game.homePenaltyScore > 0 ? (
|
||||||
<span>({game.awayPenaltyScore})</span>
|
<span>({game.awayPenaltyScore})</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`px-2 py-1 ${game.status === "played" && game.winnerTeam?.slug === game.awayTeam.slug ? "font-semibold" : ""}`}
|
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 items-center gap-3">
|
||||||
<div className="flex w-8 justify-center">
|
<div className="flex w-8 justify-center">
|
||||||
<img className="h-8 " src={game.awayTeam.pictureUrl} alt="" />
|
<img className="h-8 " src={game.awayTeam.pictureUrl} alt="" />
|
||||||
</div>
|
</div>
|
||||||
{game.awayTeam.shortName}
|
{game.awayTeam.shortName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
<div className="flex items-center justify-end gap-3">
|
||||||
<div className="text-right text-2xs">
|
<div className="text-right text-2xs">
|
||||||
{game.competition.displayName}
|
{game.competition.displayName}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<img
|
<img
|
||||||
className="h-4 w-6 border border-neutral-300 shadow-sm"
|
className="h-4 w-6 border border-neutral-300 shadow-sm"
|
||||||
src={game.competition.country?.flagFlat32Url}
|
src={game.competition.country?.flagFlat32Url}
|
||||||
alt={`${game.competition.displayName} (${game.competition.country?.displayName})`}
|
alt={`${game.competition.displayName} (${game.competition.country?.displayName})`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,79 +1,104 @@
|
|||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import Player from "../../types/player"
|
import { Link, useParams } from "react-router-dom"
|
||||||
|
import { GetPlayer } from "../../api/player"
|
||||||
export default function ClubHistory({ player }: { player: Player }) {
|
import WithDataFetching from "../../components/withdatafetching"
|
||||||
const clubMemberships = useMemo(
|
import Player from "../../types/player"
|
||||||
() =>
|
|
||||||
player.clubMembership
|
export default function ClubHistory() {
|
||||||
.filter((membership) => membership.membershipType === "club")
|
const { slug } = useParams()
|
||||||
.sort((a, b) => {
|
if (!slug) {
|
||||||
if (!a.endDate) return -1
|
return <div>No</div>
|
||||||
if (!b.endDate) return 1
|
}
|
||||||
if (new Date(a.endDate).getTime() === new Date(b.endDate).getTime()) {
|
return (
|
||||||
return (
|
<WithDataFetching
|
||||||
new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
|
queryKey={["player", slug]}
|
||||||
)
|
queryFn={() => GetPlayer(slug)}
|
||||||
}
|
>
|
||||||
return new Date(b.endDate).getTime() - new Date(a.endDate).getTime()
|
{(player: Player) => <ClubHistoryLayout player={player} />}
|
||||||
}),
|
</WithDataFetching>
|
||||||
[player.clubMembership]
|
)
|
||||||
)
|
}
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[auto_auto_3fr_1fr_1fr] items-center gap-3">
|
function ClubHistoryLayout({ player }: { player: Player }) {
|
||||||
{clubMemberships.map((membership) => (
|
const clubMemberships = useMemo(
|
||||||
<>
|
() =>
|
||||||
<div className="flex justify-center border shadow-md">
|
player.clubMembership
|
||||||
<img
|
.filter((membership) => membership.membershipType === "club")
|
||||||
className="w-6"
|
.sort((a, b) => {
|
||||||
src={membership.team?.country?.flagFlat32Url}
|
if (!a.endDate && !b.endDate) {
|
||||||
title={membership.team?.domesticLeague?.displayName}
|
return (
|
||||||
/>
|
new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
|
||||||
</div>
|
)
|
||||||
<div className="flex justify-center">
|
}
|
||||||
<img className="h-10" src={membership.team?.pictureUrl} />
|
if (!a.endDate) return -1
|
||||||
</div>
|
if (!b.endDate) return 1
|
||||||
|
if (new Date(a.endDate).getTime() === new Date(b.endDate).getTime()) {
|
||||||
<div>{membership.team?.displayName}</div>
|
return (
|
||||||
<div className="flex items-baseline gap-1">
|
new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
|
||||||
<span>
|
)
|
||||||
{new Date(membership.startDate).toLocaleDateString(undefined, {
|
}
|
||||||
month: "numeric",
|
return new Date(b.endDate).getTime() - new Date(a.endDate).getTime()
|
||||||
year: "numeric",
|
}),
|
||||||
})}
|
[player.clubMembership]
|
||||||
</span>
|
)
|
||||||
<span>{membership.endDate ? "->" : ""}</span>
|
return (
|
||||||
<span>
|
<div className="grid grid-cols-[auto_auto_3fr_1fr_1fr] items-center gap-3">
|
||||||
{membership.endDate
|
{clubMemberships.map((membership) => (
|
||||||
? new Date(membership.endDate).toLocaleDateString(undefined, {
|
<>
|
||||||
month: "numeric",
|
<div className="flex justify-center border shadow-md">
|
||||||
year: "numeric",
|
<img
|
||||||
})
|
className="w-6"
|
||||||
: ""}
|
src={membership.team?.country?.flagFlat32Url}
|
||||||
</span>
|
title={membership.team?.domesticLeague?.displayName}
|
||||||
</div>
|
/>
|
||||||
<div>
|
</div>
|
||||||
{(() => {
|
<Link to={`/team/${membership.team?.slug}`}>
|
||||||
const endDate = membership.endDate
|
<div className="flex justify-center">
|
||||||
? new Date(membership.endDate)
|
<img className="h-10" src={membership.team?.pictureUrl} />
|
||||||
: new Date()
|
</div>
|
||||||
const startDate = new Date(membership.startDate)
|
</Link>
|
||||||
const diffDays = Math.floor(
|
|
||||||
(endDate.getTime() - startDate.getTime()) /
|
<div>{membership.team?.displayName}</div>
|
||||||
(1000 * 60 * 60 * 24)
|
<div className="flex items-baseline gap-1">
|
||||||
)
|
<span>
|
||||||
const diffYears = Math.floor(diffDays / 365)
|
{new Date(membership.startDate).toLocaleDateString(undefined, {
|
||||||
const remainingDays = diffDays % 365
|
month: "numeric",
|
||||||
if (diffYears > 1) {
|
year: "numeric",
|
||||||
return `${diffYears} years ${remainingDays} days`
|
})}
|
||||||
} else if (diffYears === 1) {
|
</span>
|
||||||
return `1 year ${remainingDays} days`
|
<span>{membership.endDate ? "->" : ""}</span>
|
||||||
} else {
|
<span>
|
||||||
return `${diffDays} days`
|
{membership.endDate
|
||||||
}
|
? new Date(membership.endDate).toLocaleDateString(undefined, {
|
||||||
})()}
|
month: "numeric",
|
||||||
</div>
|
year: "numeric",
|
||||||
</>
|
})
|
||||||
))}
|
: ""}
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -1,25 +1,25 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
interface CardIconProps {
|
interface CardIconProps {
|
||||||
size?: number
|
size?: number
|
||||||
color?: string
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const CardIcon: React.FC<CardIconProps> = ({
|
const CardIcon: React.FC<CardIconProps> = ({
|
||||||
size = 16,
|
size = 16,
|
||||||
color = "currentColor",
|
color = "currentColor",
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 18 24"
|
viewBox="0 0 18 24"
|
||||||
fill={color}
|
fill={color}
|
||||||
>
|
>
|
||||||
<rect x="2" y="2" width="14" height="20" rx="2" ry="2" />
|
<rect x="2" y="2" width="14" height="20" rx="2" ry="2" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CardIcon
|
export default CardIcon
|
||||||
|
@ -1,117 +1,117 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
function extractDateWithOffset(date: Date, offsetDays: number) {
|
function extractDateWithOffset(date: Date, offsetDays: number) {
|
||||||
const year = date.getFullYear()
|
const year = date.getFullYear()
|
||||||
const month = date.getMonth()
|
const month = date.getMonth()
|
||||||
const day = date.getDate()
|
const day = date.getDate()
|
||||||
|
|
||||||
const resultDate = new Date(year, month, day)
|
const resultDate = new Date(year, month, day)
|
||||||
resultDate.setDate(resultDate.getDate() + offsetDays)
|
resultDate.setDate(resultDate.getDate() + offsetDays)
|
||||||
|
|
||||||
return resultDate
|
return resultDate
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomRadioButton = ({
|
const CustomRadioButton = ({
|
||||||
value,
|
value,
|
||||||
currentValue,
|
currentValue,
|
||||||
onClick,
|
onClick,
|
||||||
label,
|
label,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
currentValue: string
|
currentValue: string
|
||||||
onClick: (value: string) => void
|
onClick: (value: string) => void
|
||||||
label: string
|
label: string
|
||||||
}) => {
|
}) => {
|
||||||
const isActive = value === currentValue
|
const isActive = value === currentValue
|
||||||
const bg = isActive ? "bg-primary-blue-500" : "bg-neutral-700"
|
const bg = isActive ? "bg-primary-blue-500" : "bg-neutral-700"
|
||||||
const hoverBg = isActive
|
const hoverBg = isActive
|
||||||
? "hover:bg-primary-blue-600"
|
? "hover:bg-primary-blue-600"
|
||||||
: "hover:bg-neutral-600"
|
: "hover:bg-neutral-600"
|
||||||
const activeBg = isActive
|
const activeBg = isActive
|
||||||
? "active:bg-primary-blue-700"
|
? "active:bg-primary-blue-700"
|
||||||
: "active:bg-neutral-500"
|
: "active:bg-neutral-500"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => onClick(value)}
|
onClick={() => onClick(value)}
|
||||||
className={`btn py-1 text-white ${bg} ${hoverBg} ${activeBg}`}
|
className={`btn py-1 text-white ${bg} ${hoverBg} ${activeBg}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DaysSelector({
|
function DaysSelector({
|
||||||
days,
|
days,
|
||||||
setDays,
|
setDays,
|
||||||
}: {
|
}: {
|
||||||
days: number
|
days: number
|
||||||
setDays: (days: number) => void
|
setDays: (days: number) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<CustomRadioButton
|
<CustomRadioButton
|
||||||
value={"3"}
|
value={"3"}
|
||||||
currentValue={String(days)}
|
currentValue={String(days)}
|
||||||
onClick={(value) => setDays(Number(value))}
|
onClick={(value) => setDays(Number(value))}
|
||||||
label="3 days"
|
label="3 days"
|
||||||
/>
|
/>
|
||||||
<CustomRadioButton
|
<CustomRadioButton
|
||||||
value={"7"}
|
value={"7"}
|
||||||
currentValue={String(days)}
|
currentValue={String(days)}
|
||||||
onClick={(value) => setDays(Number(value))}
|
onClick={(value) => setDays(Number(value))}
|
||||||
label="1 week"
|
label="1 week"
|
||||||
/>
|
/>
|
||||||
<CustomRadioButton
|
<CustomRadioButton
|
||||||
value={"30"}
|
value={"30"}
|
||||||
currentValue={String(days)}
|
currentValue={String(days)}
|
||||||
onClick={(value) => setDays(Number(value))}
|
onClick={(value) => setDays(Number(value))}
|
||||||
label="1 month"
|
label="1 month"
|
||||||
/>
|
/>
|
||||||
<CustomRadioButton
|
<CustomRadioButton
|
||||||
value={"90"}
|
value={"90"}
|
||||||
currentValue={String(days)}
|
currentValue={String(days)}
|
||||||
onClick={(value) => setDays(Number(value))}
|
onClick={(value) => setDays(Number(value))}
|
||||||
label="3 months"
|
label="3 months"
|
||||||
/>
|
/>
|
||||||
<CustomRadioButton
|
<CustomRadioButton
|
||||||
value={"180"}
|
value={"180"}
|
||||||
currentValue={String(days)}
|
currentValue={String(days)}
|
||||||
onClick={(value) => setDays(Number(value))}
|
onClick={(value) => setDays(Number(value))}
|
||||||
label="6 months"
|
label="6 months"
|
||||||
/>
|
/>
|
||||||
<CustomRadioButton
|
<CustomRadioButton
|
||||||
value={"365"}
|
value={"365"}
|
||||||
currentValue={String(days)}
|
currentValue={String(days)}
|
||||||
onClick={(value) => setDays(Number(value))}
|
onClick={(value) => setDays(Number(value))}
|
||||||
label="1 year"
|
label="1 year"
|
||||||
/>
|
/>
|
||||||
<CustomRadioButton
|
<CustomRadioButton
|
||||||
value={"0"}
|
value={"0"}
|
||||||
currentValue={String(days)}
|
currentValue={String(days)}
|
||||||
onClick={(value) => setDays(Number(value))}
|
onClick={(value) => setDays(Number(value))}
|
||||||
label="All"
|
label="All"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DaysSelectionWrapper({
|
export default function DaysSelectionWrapper({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: (startDate: Date, endDate: Date) => React.ReactNode
|
children: (startDate: Date, endDate: Date) => React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [days, setDays] = useState(365)
|
const [days, setDays] = useState(365)
|
||||||
const farEnoughDate = new Date(2000, 0, 1) // You can set this to an earlier date if needed
|
const farEnoughDate = new Date(2000, 0, 1) // You can set this to an earlier date if needed
|
||||||
const startDate =
|
const startDate =
|
||||||
days === 0 ? farEnoughDate : extractDateWithOffset(new Date(), -days)
|
days === 0 ? farEnoughDate : extractDateWithOffset(new Date(), -days)
|
||||||
const endDate = extractDateWithOffset(new Date(), 1)
|
const endDate = extractDateWithOffset(new Date(), 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-12">
|
<div className="flex flex-col items-center gap-12">
|
||||||
<DaysSelector days={days} setDays={setDays} />
|
<DaysSelector days={days} setDays={setDays} />
|
||||||
<div className="w-full">{children(startDate, endDate)}</div>
|
<div className="w-full">{children(startDate, endDate)}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,65 +1,65 @@
|
|||||||
import Select, { MultiValue, SingleValue } from "react-select"
|
import Select, { MultiValue, SingleValue } from "react-select"
|
||||||
|
|
||||||
interface FilterOption<T> {
|
interface FilterOption<T> {
|
||||||
value: T
|
value: T
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterSelectProps<T> {
|
interface FilterSelectProps<T> {
|
||||||
value: T
|
value: T
|
||||||
options: FilterOption<T>[]
|
options: FilterOption<T>[]
|
||||||
onChange: (value: T) => void
|
onChange: (value: T) => void
|
||||||
isSearchable?: boolean
|
isSearchable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterSelect<T>({
|
export function FilterSelect<T>({
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
isSearchable = false,
|
isSearchable = false,
|
||||||
}: FilterSelectProps<T>) {
|
}: FilterSelectProps<T>) {
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={options.find((option: FilterOption<T>) => option.value === value)}
|
value={options.find((option: FilterOption<T>) => option.value === value)}
|
||||||
isSearchable={isSearchable}
|
isSearchable={isSearchable}
|
||||||
options={options}
|
options={options}
|
||||||
onChange={(option: SingleValue<FilterOption<T>>) => {
|
onChange={(option: SingleValue<FilterOption<T>>) => {
|
||||||
if (option) {
|
if (option) {
|
||||||
onChange(option.value)
|
onChange(option.value)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterSelectMultiProps<T> {
|
interface FilterSelectMultiProps<T> {
|
||||||
values: T[]
|
values: T[]
|
||||||
options: FilterOption<T>[]
|
options: FilterOption<T>[]
|
||||||
onChange: (value: T[]) => void
|
onChange: (value: T[]) => void
|
||||||
isSearchable?: boolean
|
isSearchable?: boolean
|
||||||
placeHolder?: string
|
placeHolder?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterSelectMulti<T>({
|
export function FilterSelectMulti<T>({
|
||||||
values,
|
values,
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
isSearchable = false,
|
isSearchable = false,
|
||||||
placeHolder = "Select",
|
placeHolder = "Select",
|
||||||
}: FilterSelectMultiProps<T>) {
|
}: FilterSelectMultiProps<T>) {
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={options.filter((option: FilterOption<T>) =>
|
value={options.filter((option: FilterOption<T>) =>
|
||||||
values.includes(option.value)
|
values.includes(option.value)
|
||||||
)}
|
)}
|
||||||
isMulti
|
isMulti
|
||||||
isSearchable={isSearchable}
|
isSearchable={isSearchable}
|
||||||
options={options}
|
options={options}
|
||||||
placeholder={placeHolder}
|
placeholder={placeHolder}
|
||||||
isDisabled={options.length === 0}
|
isDisabled={options.length === 0}
|
||||||
onChange={(options: MultiValue<FilterOption<T>>) => {
|
onChange={(options: MultiValue<FilterOption<T>>) => {
|
||||||
onChange(options.map((option: FilterOption<T>) => option.value) as T[])
|
onChange(options.map((option: FilterOption<T>) => option.value) as T[])
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,23 @@
|
|||||||
import { GetScoreColor } from "../../../types/game"
|
import { GetScoreColor } from "../../../types/game"
|
||||||
|
|
||||||
export default function ScoreBox({
|
export default function ScoreBox({
|
||||||
score,
|
score,
|
||||||
colorfunc,
|
colorfunc,
|
||||||
}: {
|
}: {
|
||||||
score: number
|
score: number
|
||||||
colorfunc?: (score: number, tailwind: boolean) => string
|
colorfunc?: (score: number, tailwind: boolean) => string
|
||||||
}) {
|
}) {
|
||||||
if (!colorfunc) colorfunc = GetScoreColor
|
if (!colorfunc) colorfunc = GetScoreColor
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`inline-flex h-8 w-10 items-center justify-center rounded-xl border p-4 text-center ${colorfunc(
|
className={`inline-flex h-8 w-10 items-center justify-center rounded-xl border p-4 text-center ${colorfunc(
|
||||||
score,
|
score,
|
||||||
true
|
true
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
<span className={"text-sm font-bold text-white"}>
|
<span className={"text-sm font-bold text-white"}>
|
||||||
{Number(score).toFixed(0)}
|
{Number(score).toFixed(0)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,56 +1,53 @@
|
|||||||
import { useState } from "react"
|
import { Outlet, useLocation, useParams } from "react-router"
|
||||||
import { useParams } from "react-router"
|
import { Link } from "react-router-dom"
|
||||||
import { GetPlayer } from "../../api/player"
|
import { GetPlayer } from "../../api/player"
|
||||||
import WithDataFetching from "../../components/withdatafetching"
|
import WithDataFetching from "../../components/withdatafetching"
|
||||||
import Player from "../../types/player"
|
import Player from "../../types/player"
|
||||||
import ClubHistory from "./club_history"
|
import Sidebar from "./sidebar"
|
||||||
import Scores from "./scores"
|
|
||||||
import Sidebar from "./sidebar"
|
export default function PlayerPage() {
|
||||||
|
const { slug } = useParams()
|
||||||
export default function PlayerPage() {
|
|
||||||
const { slug } = useParams()
|
if (!slug) {
|
||||||
|
return <div>No slug</div>
|
||||||
if (!slug) {
|
}
|
||||||
return <div>No slug</div>
|
return (
|
||||||
}
|
<WithDataFetching
|
||||||
return (
|
queryKey={["player", slug]}
|
||||||
<WithDataFetching
|
queryFn={() => GetPlayer(slug)}
|
||||||
queryKey={["player", slug]}
|
>
|
||||||
queryFn={() => GetPlayer(slug)}
|
{(player) => <PlayerLayout player={player} />}
|
||||||
>
|
</WithDataFetching>
|
||||||
{(player) => <PlayerLayout player={player} />}
|
)
|
||||||
</WithDataFetching>
|
|
||||||
)
|
function PlayerLayout({ player }: { player: Player }) {
|
||||||
|
const { pathname } = useLocation()
|
||||||
function PlayerLayout({ player }: { player: Player }) {
|
const tabs = [
|
||||||
const [activeTab, setActiveTab] = useState("club_history")
|
{ id: "summary", title: "Summary" },
|
||||||
const tabs = [
|
{ id: "clubhistory", title: "Club History" },
|
||||||
{ id: "club_history", title: "Club History" },
|
{ id: "scores", title: "Scores" },
|
||||||
{ id: "scores", title: "Scores" },
|
]
|
||||||
]
|
return (
|
||||||
return (
|
<div className="grid h-screen grid-cols-[260px_1fr]">
|
||||||
<div className="grid h-screen grid-cols-[260px_1fr]">
|
<Sidebar player={player}></Sidebar>
|
||||||
<Sidebar player={player}></Sidebar>
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col">
|
<div className="flex divide-x border-b border-gray-200">
|
||||||
<div className="flex border-b">
|
{tabs.map((tab) => (
|
||||||
{tabs.map((tab) => (
|
<Link key={tab.id} to={`/player/${slug}/${tab.id}`}>
|
||||||
<button
|
<div
|
||||||
key={tab.id}
|
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"}`}
|
className={`text-md px-6 py-2 text-center font-normal ${pathname.includes(tab.id) ? "bg-gray-200" : ""}`}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
>
|
||||||
>
|
{tab.title}
|
||||||
{tab.title}
|
</div>
|
||||||
</button>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{activeTab === "club_history" && (
|
<Outlet />
|
||||||
<ClubHistory player={player}></ClubHistory>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{activeTab === "scores" && <Scores player={player}></Scores>}
|
</div>
|
||||||
</div>
|
)
|
||||||
</div>
|
}
|
||||||
</div>
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
27
front/src/pages/player/routes.tsx
Normal file
27
front/src/pages/player/routes.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import PlayerPage from "."
|
||||||
|
import ClubHistory from "./club_history"
|
||||||
|
import Scores from "./scores"
|
||||||
|
import Summary from "./summary"
|
||||||
|
|
||||||
|
export const playerRoutes = {
|
||||||
|
path: "player/:slug",
|
||||||
|
element: <PlayerPage />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
element: <ClubHistory />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "summary",
|
||||||
|
element: <Summary />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "clubhistory",
|
||||||
|
element: <ClubHistory />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "scores",
|
||||||
|
element: <Scores />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
@ -1,184 +1,182 @@
|
|||||||
import {
|
import {
|
||||||
addMonths,
|
addMonths,
|
||||||
format,
|
format,
|
||||||
isSameDay,
|
isSameDay,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
startOfYear,
|
startOfYear,
|
||||||
subMonths,
|
subMonths,
|
||||||
} from "date-fns"
|
} from "date-fns"
|
||||||
import moment from "moment/moment"
|
import moment from "moment/moment"
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import {
|
import {
|
||||||
Cell,
|
Cell,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
LabelList,
|
LabelList,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Scatter,
|
Scatter,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts"
|
} from "recharts"
|
||||||
import {
|
import {
|
||||||
Game,
|
Game,
|
||||||
GamePlayer,
|
GamePlayer,
|
||||||
GamePlayerScore,
|
GamePlayerScore,
|
||||||
GetScoreColor,
|
GetScoreColor,
|
||||||
} from "../../types/game"
|
} from "../../types/game"
|
||||||
import Player from "../../types/player"
|
|
||||||
|
type DataPoint = {
|
||||||
type DataPoint = {
|
date: Date
|
||||||
date: Date
|
matchScore?: number
|
||||||
matchScore?: number
|
decisiveScore?: number
|
||||||
decisiveScore?: number
|
aaScore?: number
|
||||||
aaScore?: number
|
game?: Game
|
||||||
game?: Game
|
score?: GamePlayerScore
|
||||||
score?: GamePlayerScore
|
gameweek: number
|
||||||
gameweek: number
|
dateAsNumber: number
|
||||||
dateAsNumber: number
|
}
|
||||||
}
|
|
||||||
|
const CustomTooltip = ({ active, payload }: any) => {
|
||||||
const CustomTooltip = ({ active, payload }: any) => {
|
if (active && payload && payload.length) {
|
||||||
if (active && payload && payload.length) {
|
const dataPoint = payload[0].payload
|
||||||
const dataPoint = payload[0].payload
|
|
||||||
|
const { gameweek, date } = dataPoint
|
||||||
const { gameweek, date } = dataPoint
|
const formattedDate = moment(date).format("DD MMM YYYY")
|
||||||
const formattedDate = moment(date).format("DD MMM YYYY")
|
|
||||||
|
return (
|
||||||
return (
|
<div className="rounded-md border border-gray-300 bg-white p-2">
|
||||||
<div className="rounded-md border border-gray-300 bg-white p-2">
|
<p className="text-black">{`Date: ${formattedDate}`}</p>
|
||||||
<p className="text-black">{`Date: ${formattedDate}`}</p>
|
{gameweek && <p className="text-black">{`Gameweek: ${gameweek}`}</p>}
|
||||||
{gameweek && <p className="text-black">{`Gameweek: ${gameweek}`}</p>}
|
{payload.map((item: any) => (
|
||||||
{payload.map((item: any) => (
|
<p
|
||||||
<p
|
key={item.name}
|
||||||
key={item.name}
|
style={{ color: item.color }}
|
||||||
style={{ color: item.color }}
|
>{`${item.name}: ${Math.round(item.value)}`}</p>
|
||||||
>{`${item.name}: ${Math.round(item.value)}`}</p>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
return null
|
||||||
return null
|
}
|
||||||
}
|
|
||||||
|
export default function ScoreGraph({
|
||||||
export default function ScoreGraph({
|
scores,
|
||||||
scores,
|
startDate,
|
||||||
startDate,
|
endDate,
|
||||||
endDate,
|
}: {
|
||||||
}: {
|
scores: GamePlayer[]
|
||||||
player: Player
|
startDate: Date
|
||||||
scores: GamePlayer[]
|
endDate: Date
|
||||||
startDate: Date
|
}) {
|
||||||
endDate: Date
|
const CustomXAxisTick = ({ x, y, payload }: any) => {
|
||||||
}) {
|
const date = new Date(payload.value)
|
||||||
const CustomXAxisTick = ({ x, y, payload }: any) => {
|
const isFirstJan = isSameDay(date, startOfYear(date))
|
||||||
const date = new Date(payload.value)
|
const isFirstOfMonth = isSameDay(date, startOfMonth(date))
|
||||||
const isFirstJan = isSameDay(date, startOfYear(date))
|
const displayText = isFirstJan
|
||||||
const isFirstOfMonth = isSameDay(date, startOfMonth(date))
|
? format(date, "yyyy")
|
||||||
const displayText = isFirstJan
|
: isFirstOfMonth
|
||||||
? format(date, "yyyy")
|
? format(date, "MMM")
|
||||||
: isFirstOfMonth
|
: ""
|
||||||
? format(date, "MMM")
|
|
||||||
: ""
|
return (
|
||||||
|
<g transform={`translate(${x},${y})`}>
|
||||||
return (
|
<text x={0} y={0} dy={16} textAnchor="middle" fill="#666">
|
||||||
<g transform={`translate(${x},${y})`}>
|
{displayText}
|
||||||
<text x={0} y={0} dy={16} textAnchor="middle" fill="#666">
|
</text>
|
||||||
{displayText}
|
</g>
|
||||||
</text>
|
)
|
||||||
</g>
|
}
|
||||||
)
|
|
||||||
}
|
const adjustedStartDate = useMemo(() => {
|
||||||
|
const earliestScoreDate = scores.reduce((minDate, score) => {
|
||||||
const adjustedStartDate = useMemo(() => {
|
const scoreDate = new Date(score.game?.date ?? "")
|
||||||
const earliestScoreDate = scores.reduce((minDate, score) => {
|
return minDate > scoreDate ? scoreDate : minDate
|
||||||
const scoreDate = new Date(score.game?.date ?? "")
|
}, new Date())
|
||||||
return minDate > scoreDate ? scoreDate : minDate
|
return startDate > earliestScoreDate ? startDate : earliestScoreDate
|
||||||
}, new Date())
|
}, [scores, startDate])
|
||||||
return startDate > earliestScoreDate ? startDate : earliestScoreDate
|
|
||||||
}, [scores, startDate])
|
const filteredScores = useMemo(
|
||||||
|
() =>
|
||||||
const filteredScores = useMemo(
|
scores
|
||||||
() =>
|
.filter(
|
||||||
scores
|
(score) =>
|
||||||
.filter(
|
new Date(score.game?.date ?? "") >= startDate &&
|
||||||
(score) =>
|
new Date(score.game?.date ?? "") <= endDate
|
||||||
new Date(score.game?.date ?? "") >= startDate &&
|
)
|
||||||
new Date(score.game?.date ?? "") <= endDate
|
.map<DataPoint>((score) => ({
|
||||||
)
|
date: score.game?.fixture.startDate ?? new Date(),
|
||||||
.map<DataPoint>((score) => ({
|
matchScore: score.score?.score,
|
||||||
date: score.game?.fixture.startDate ?? new Date(),
|
decisiveScore: score.score?.decisiveScore,
|
||||||
matchScore: score.score?.score,
|
aaScore: score.score?.allAroundScore,
|
||||||
decisiveScore: score.score?.decisiveScore,
|
game: score.game,
|
||||||
aaScore: score.score?.allAroundScore,
|
score: score.score,
|
||||||
game: score.game,
|
gameweek: score.game?.fixture.gameWeek ?? 0,
|
||||||
score: score.score,
|
dateAsNumber:
|
||||||
gameweek: score.game?.fixture.gameWeek ?? 0,
|
new Date(score.game?.fixture.startDate ?? new Date()).getTime() ??
|
||||||
dateAsNumber:
|
0,
|
||||||
new Date(score.game?.fixture.startDate ?? new Date()).getTime() ??
|
}))
|
||||||
0,
|
.sort((a, b) => a.gameweek - b.gameweek),
|
||||||
}))
|
[scores, startDate, endDate]
|
||||||
.sort((a, b) => a.gameweek - b.gameweek),
|
)
|
||||||
[scores, startDate, endDate]
|
|
||||||
)
|
const ticks = useMemo(() => {
|
||||||
|
const start = startOfMonth(adjustedStartDate)
|
||||||
const ticks = useMemo(() => {
|
const end = startOfMonth(endDate)
|
||||||
const start = startOfMonth(adjustedStartDate)
|
let current = start
|
||||||
const end = startOfMonth(endDate)
|
const dates = []
|
||||||
let current = start
|
while (current <= end) {
|
||||||
const dates = []
|
dates.push(current.getTime())
|
||||||
while (current <= end) {
|
current = addMonths(current, 1)
|
||||||
dates.push(current.getTime())
|
}
|
||||||
current = addMonths(current, 1)
|
dates.unshift(subMonths(start, 1).getTime())
|
||||||
}
|
dates.push(addMonths(end, 1).getTime())
|
||||||
dates.unshift(subMonths(start, 1).getTime())
|
return dates
|
||||||
dates.push(addMonths(end, 1).getTime())
|
}, [adjustedStartDate, endDate])
|
||||||
return dates
|
|
||||||
}, [adjustedStartDate, endDate])
|
return (
|
||||||
|
<div className="z-10 flex w-full flex-col gap-6">
|
||||||
return (
|
<ResponsiveContainer width="100%" height={500}>
|
||||||
<div className="z-10 flex w-full flex-col gap-6">
|
<ComposedChart data={filteredScores}>
|
||||||
<ResponsiveContainer width="100%" height={500}>
|
<XAxis
|
||||||
<ComposedChart data={filteredScores}>
|
dataKey="dateAsNumber"
|
||||||
<XAxis
|
domain={["dataMin", "dataMax"]}
|
||||||
dataKey="dateAsNumber"
|
name="Date"
|
||||||
domain={["dataMin", "dataMax"]}
|
type="number"
|
||||||
name="Date"
|
tick={<CustomXAxisTick />}
|
||||||
type="number"
|
ticks={ticks}
|
||||||
tick={<CustomXAxisTick />}
|
/>
|
||||||
ticks={ticks}
|
<YAxis domain={[-10, 110]} ticks={[0, 20, 40, 60, 80, 100]} />
|
||||||
/>
|
<Scatter dataKey="matchScore" name="Score" isAnimationActive={false}>
|
||||||
<YAxis domain={[-10, 110]} ticks={[0, 20, 40, 60, 80, 100]} />
|
{filteredScores?.map((entry, index) => (
|
||||||
<Scatter dataKey="matchScore" name="Score" isAnimationActive={false}>
|
<Cell
|
||||||
{filteredScores?.map((entry, index) => (
|
key={`cell-${index}`}
|
||||||
<Cell
|
fill={GetScoreColor(entry.matchScore ?? 0, false)}
|
||||||
key={`cell-${index}`}
|
/>
|
||||||
fill={GetScoreColor(entry.matchScore ?? 0, false)}
|
))}
|
||||||
/>
|
<LabelList
|
||||||
))}
|
dataKey="matchScore"
|
||||||
<LabelList
|
position="top"
|
||||||
dataKey="matchScore"
|
offset={10}
|
||||||
position="top"
|
stroke={"#333"}
|
||||||
offset={10}
|
strokeWidth={0.3}
|
||||||
stroke={"#333"}
|
fontSize={12}
|
||||||
strokeWidth={0.3}
|
formatter={(value: number) => Math.round(value)}
|
||||||
fontSize={12}
|
/>
|
||||||
formatter={(value: number) => Math.round(value)}
|
</Scatter>
|
||||||
/>
|
{[0, 20, 40, 60, 80, 100].map((value) => (
|
||||||
</Scatter>
|
<ReferenceLine
|
||||||
{[0, 20, 40, 60, 80, 100].map((value) => (
|
key={value}
|
||||||
<ReferenceLine
|
y={value}
|
||||||
key={value}
|
stroke="gray"
|
||||||
y={value}
|
strokeWidth={0.3}
|
||||||
stroke="gray"
|
/>
|
||||||
strokeWidth={0.3}
|
))}
|
||||||
/>
|
|
||||||
))}
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
</ComposedChart>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
</ResponsiveContainer>
|
||||||
</ComposedChart>
|
</div>
|
||||||
</ResponsiveContainer>
|
)
|
||||||
</div>
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
@ -1,363 +1,363 @@
|
|||||||
import moment from "moment"
|
import moment from "moment"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { BiFootball } from "react-icons/bi"
|
import { BiFootball } from "react-icons/bi"
|
||||||
import { BsHandThumbsUp } from "react-icons/bs"
|
import { BsHandThumbsUp } from "react-icons/bs"
|
||||||
import { FaHandSparkles } from "react-icons/fa"
|
import { FaHandSparkles } from "react-icons/fa"
|
||||||
import { GiFist, GiFootprint, GiLeg, GiWhistle } from "react-icons/gi"
|
import { GiFist, GiFootprint, GiLeg, GiWhistle } from "react-icons/gi"
|
||||||
import { TbHandStop } from "react-icons/tb"
|
import { TbHandStop } from "react-icons/tb"
|
||||||
import {
|
import {
|
||||||
GamePlayer,
|
GamePlayer,
|
||||||
GetAAScoreColor,
|
GetAAScoreColor,
|
||||||
GetDSScoreColor,
|
GetDSScoreColor,
|
||||||
GetMinutesScoreColor,
|
GetMinutesScoreColor,
|
||||||
GetScoreColor,
|
GetScoreColor,
|
||||||
} from "../../types/game"
|
} from "../../types/game"
|
||||||
import Player from "../../types/player"
|
import Player from "../../types/player"
|
||||||
import CardIcon from "./components/card_icon"
|
import CardIcon from "./components/card_icon"
|
||||||
import { FilterSelect } from "./components/filter_select"
|
import { FilterSelect } from "./components/filter_select"
|
||||||
import ScoreBox from "./components/scorebox"
|
import ScoreBox from "./components/scorebox"
|
||||||
|
|
||||||
type GameEventProps = {
|
type GameEventProps = {
|
||||||
count: number | undefined
|
count: number | undefined
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
positive?: boolean
|
positive?: boolean
|
||||||
label?: string
|
label?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function GameEvent({
|
function GameEvent({
|
||||||
count,
|
count,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
positive = true,
|
positive = true,
|
||||||
label = "",
|
label = "",
|
||||||
}: GameEventProps) {
|
}: GameEventProps) {
|
||||||
if (!count || count === 0) {
|
if (!count || count === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div title={label} className="flex flex-col items-center">
|
<div title={label} className="flex flex-col items-center">
|
||||||
<span className="text-xs font-semibold">{count}</span>
|
<span className="text-xs font-semibold">{count}</span>
|
||||||
<div className={`${positive ? "text-green-700" : "text-red-600"}`}>
|
<div className={`${positive ? "text-green-700" : "text-red-600"}`}>
|
||||||
<Icon />
|
<Icon />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScoreText({ score }: { score: number | string }) {
|
function ScoreText({ score }: { score: number | string }) {
|
||||||
return (
|
return (
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<span className="text-xs font-bold text-gray-600">{score}</span>
|
<span className="text-xs font-bold text-gray-600">{score}</span>
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScoreHeader({ label, tooltip }: { label: string; tooltip: string }) {
|
function ScoreHeader({ label, tooltip }: { label: string; tooltip: string }) {
|
||||||
return (
|
return (
|
||||||
<th title={tooltip} className="text-center">
|
<th title={tooltip} className="text-center">
|
||||||
{label}
|
{label}
|
||||||
</th>
|
</th>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ScoreTable({
|
export default function ScoreTable({
|
||||||
player,
|
player,
|
||||||
scores,
|
scores,
|
||||||
}: {
|
}: {
|
||||||
player: Player
|
player: Player
|
||||||
scores: GamePlayer[]
|
scores: GamePlayer[]
|
||||||
}) {
|
}) {
|
||||||
const [statCategory, setStatCategory] = React.useState("def")
|
const [statCategory, setStatCategory] = React.useState("def")
|
||||||
|
|
||||||
let availableStatCategory = [
|
let availableStatCategory = [
|
||||||
{ value: "def", label: "Defensive" },
|
{ value: "def", label: "Defensive" },
|
||||||
{ value: "off", label: "Offensive" },
|
{ value: "off", label: "Offensive" },
|
||||||
{ value: "poss", label: "Possession" },
|
{ value: "poss", label: "Possession" },
|
||||||
]
|
]
|
||||||
if (player.fieldPosition === "Goalkeeper") {
|
if (player.fieldPosition === "Goalkeeper") {
|
||||||
availableStatCategory = [
|
availableStatCategory = [
|
||||||
...availableStatCategory,
|
...availableStatCategory,
|
||||||
{ value: "goal", label: "Goalkeeping" },
|
{ value: "goal", label: "Goalkeeping" },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
value={statCategory}
|
value={statCategory}
|
||||||
options={availableStatCategory}
|
options={availableStatCategory}
|
||||||
onChange={setStatCategory}
|
onChange={setStatCategory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead className="bg-neutral-50">
|
<thead className="bg-neutral-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th title="Game Week" className="th">
|
<th title="Game Week" className="th">
|
||||||
GW
|
GW
|
||||||
</th>
|
</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th title="Minutes played" className="th text-center">
|
<th title="Minutes played" className="th text-center">
|
||||||
Minutes
|
Minutes
|
||||||
</th>
|
</th>
|
||||||
<th className="text-right">Home</th>
|
<th className="text-right">Home</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th>Away</th>
|
<th>Away</th>
|
||||||
<th className="text-center">Score</th>
|
<th className="text-center">Score</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th title="Decisive score" className="text-center">
|
<th title="Decisive score" className="text-center">
|
||||||
DS
|
DS
|
||||||
</th>
|
</th>
|
||||||
<th title="All-around score" className="text-center">
|
<th title="All-around score" className="text-center">
|
||||||
AA
|
AA
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
{statCategory === "def" && (
|
{statCategory === "def" && (
|
||||||
<>
|
<>
|
||||||
<ScoreHeader tooltip={"Clean sheet"} label={"CS"} />
|
<ScoreHeader tooltip={"Clean sheet"} label={"CS"} />
|
||||||
<ScoreHeader tooltip={"Error lead to goal"} label={"ELG"} />
|
<ScoreHeader tooltip={"Error lead to goal"} label={"ELG"} />
|
||||||
<ScoreHeader tooltip={"Goals conceded"} label={"GC"} />
|
<ScoreHeader tooltip={"Goals conceded"} label={"GC"} />
|
||||||
<ScoreHeader tooltip={"Effective clearance"} label={"EC"} />
|
<ScoreHeader tooltip={"Effective clearance"} label={"EC"} />
|
||||||
<ScoreHeader tooltip={"Won tackle"} label={"WT"} />
|
<ScoreHeader tooltip={"Won tackle"} label={"WT"} />
|
||||||
<ScoreHeader tooltip={"Blocked cross"} label={"BC"} />
|
<ScoreHeader tooltip={"Blocked cross"} label={"BC"} />
|
||||||
<ScoreHeader tooltip={"Block"} label={"B"} />
|
<ScoreHeader tooltip={"Block"} label={"B"} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{statCategory === "off" && (
|
{statCategory === "off" && (
|
||||||
<>
|
<>
|
||||||
<ScoreHeader tooltip={"Shots on target"} label={"SOT"} />
|
<ScoreHeader tooltip={"Shots on target"} label={"SOT"} />
|
||||||
<ScoreHeader tooltip={"Won contest"} label={"WC"} />
|
<ScoreHeader tooltip={"Won contest"} label={"WC"} />
|
||||||
<ScoreHeader tooltip={"Big chance created"} label={"BCC"} />
|
<ScoreHeader tooltip={"Big chance created"} label={"BCC"} />
|
||||||
<ScoreHeader tooltip={"Attempted assist"} label={"AA"} />
|
<ScoreHeader tooltip={"Attempted assist"} label={"AA"} />
|
||||||
<ScoreHeader tooltip={"Penalty area entries"} label={"PAE"} />
|
<ScoreHeader tooltip={"Penalty area entries"} label={"PAE"} />
|
||||||
<ScoreHeader tooltip={"Penalty kick missed"} label={"PKM"} />
|
<ScoreHeader tooltip={"Penalty kick missed"} label={"PKM"} />
|
||||||
<ScoreHeader tooltip={"Big chance missed"} label={"BCM"} />
|
<ScoreHeader tooltip={"Big chance missed"} label={"BCM"} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{statCategory === "poss" && (
|
{statCategory === "poss" && (
|
||||||
<>
|
<>
|
||||||
<ScoreHeader tooltip={"Possession +won -lost"} label={"Poss"} />
|
<ScoreHeader tooltip={"Possession +won -lost"} label={"Poss"} />
|
||||||
<ScoreHeader tooltip={"Duel +won -lost"} label={"Duel"} />
|
<ScoreHeader tooltip={"Duel +won -lost"} label={"Duel"} />
|
||||||
<ScoreHeader tooltip={"Interceptions"} label={"Int"} />
|
<ScoreHeader tooltip={"Interceptions"} label={"Int"} />
|
||||||
<ScoreHeader tooltip={"Accurate pass"} label={"AP"} />
|
<ScoreHeader tooltip={"Accurate pass"} label={"AP"} />
|
||||||
<ScoreHeader
|
<ScoreHeader
|
||||||
tooltip={"Accurate final 3rd pass"}
|
tooltip={"Accurate final 3rd pass"}
|
||||||
label={"A3P"}
|
label={"A3P"}
|
||||||
/>
|
/>
|
||||||
<ScoreHeader tooltip={"Accurate long balls"} label={"ALB"} />
|
<ScoreHeader tooltip={"Accurate long balls"} label={"ALB"} />
|
||||||
<ScoreHeader
|
<ScoreHeader
|
||||||
tooltip={"Long pass into opposition"}
|
tooltip={"Long pass into opposition"}
|
||||||
label={"LPO"}
|
label={"LPO"}
|
||||||
/>
|
/>
|
||||||
<ScoreHeader tooltip={"Missed pass"} label={"MP"} />
|
<ScoreHeader tooltip={"Missed pass"} label={"MP"} />
|
||||||
<ScoreHeader tooltip={"Pass accuracy"} label={"PA%"} />
|
<ScoreHeader tooltip={"Pass accuracy"} label={"PA%"} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{statCategory === "goal" && (
|
{statCategory === "goal" && (
|
||||||
<>
|
<>
|
||||||
<ScoreHeader tooltip={"Saves"} label={"S"} />
|
<ScoreHeader tooltip={"Saves"} label={"S"} />
|
||||||
<ScoreHeader
|
<ScoreHeader
|
||||||
tooltip={"Saved shot from inside the box"}
|
tooltip={"Saved shot from inside the box"}
|
||||||
label={"SIB"}
|
label={"SIB"}
|
||||||
/>
|
/>
|
||||||
<ScoreHeader tooltip={"Good high claim"} label={"GHC"} />
|
<ScoreHeader tooltip={"Good high claim"} label={"GHC"} />
|
||||||
<ScoreHeader tooltip={"Punches"} label={"P"} />
|
<ScoreHeader tooltip={"Punches"} label={"P"} />
|
||||||
<ScoreHeader tooltip={"Diving save"} label={"DS"} />
|
<ScoreHeader tooltip={"Diving save"} label={"DS"} />
|
||||||
<ScoreHeader tooltip={"Diving catch"} label={"DC"} />
|
<ScoreHeader tooltip={"Diving catch"} label={"DC"} />
|
||||||
<ScoreHeader tooltip={"Cross not claimed"} label={"CNC"} />
|
<ScoreHeader tooltip={"Cross not claimed"} label={"CNC"} />
|
||||||
<ScoreHeader tooltip={"GK smother"} label={"GKS"} />
|
<ScoreHeader tooltip={"GK smother"} label={"GKS"} />
|
||||||
<ScoreHeader tooltip={"Keeper sweeper"} label={"KS"} />
|
<ScoreHeader tooltip={"Keeper sweeper"} label={"KS"} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{scores
|
{scores
|
||||||
?.sort((a, b) =>
|
?.sort((a, b) =>
|
||||||
new Date(a.game?.date ?? new Date()) <
|
new Date(a.game?.date ?? new Date()) <
|
||||||
new Date(b.game?.date ?? new Date())
|
new Date(b.game?.date ?? new Date())
|
||||||
? 1
|
? 1
|
||||||
: -1
|
: -1
|
||||||
)
|
)
|
||||||
.map((scoreData) => {
|
.map((scoreData) => {
|
||||||
if (!scoreData.game || !scoreData.score) return null
|
if (!scoreData.game || !scoreData.score) return null
|
||||||
return (
|
return (
|
||||||
<tr key={scoreData.score.gameId}>
|
<tr key={scoreData.score.gameId}>
|
||||||
<td>{scoreData.game.fixture?.gameWeek}</td>
|
<td>{scoreData.game.fixture?.gameWeek}</td>
|
||||||
<td>{moment(scoreData.game.date).format("DD MMMM YYYY")}</td>
|
<td>{moment(scoreData.game.date).format("DD MMMM YYYY")}</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<span
|
<span
|
||||||
className={`w-10 rounded-md border-2 border-gray-300 p-1 text-white ${GetMinutesScoreColor(scoreData.score?.minutesPlayed ?? 0)}`}
|
className={`w-10 rounded-md border-2 border-gray-300 p-1 text-white ${GetMinutesScoreColor(scoreData.score?.minutesPlayed ?? 0)}`}
|
||||||
>
|
>
|
||||||
{scoreData.score.minutesPlayed}
|
{scoreData.score.minutesPlayed}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{scoreData.game.homeTeam.displayName}
|
{scoreData.game.homeTeam.displayName}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right">{scoreData.game.homeGoals}</td>
|
<td className="text-right">{scoreData.game.homeGoals}</td>
|
||||||
<td className="text-center">-</td>
|
<td className="text-center">-</td>
|
||||||
<td>{scoreData.game.awayGoals}</td>
|
<td>{scoreData.game.awayGoals}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{scoreData.game.awayTeam.displayName}
|
{scoreData.game.awayTeam.displayName}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<ScoreBox
|
<ScoreBox
|
||||||
score={scoreData.score.score}
|
score={scoreData.score.score}
|
||||||
colorfunc={GetScoreColor}
|
colorfunc={GetScoreColor}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<GameEvent
|
<GameEvent
|
||||||
count={scoreData.score.goal}
|
count={scoreData.score.goal}
|
||||||
icon={BiFootball}
|
icon={BiFootball}
|
||||||
label="Goal"
|
label="Goal"
|
||||||
/>
|
/>
|
||||||
<GameEvent
|
<GameEvent
|
||||||
count={scoreData.score.assist}
|
count={scoreData.score.assist}
|
||||||
icon={GiFootprint}
|
icon={GiFootprint}
|
||||||
label="Assist"
|
label="Assist"
|
||||||
/>
|
/>
|
||||||
<GameEvent
|
<GameEvent
|
||||||
count={scoreData.score.penaltyWon}
|
count={scoreData.score.penaltyWon}
|
||||||
icon={GiWhistle}
|
icon={GiWhistle}
|
||||||
label="Penalty won"
|
label="Penalty won"
|
||||||
/>
|
/>
|
||||||
<GameEvent
|
<GameEvent
|
||||||
count={scoreData.score.clearanceOffLine}
|
count={scoreData.score.clearanceOffLine}
|
||||||
icon={TbHandStop}
|
icon={TbHandStop}
|
||||||
label="Clearance off line"
|
label="Clearance off line"
|
||||||
/>
|
/>
|
||||||
<GameEvent
|
<GameEvent
|
||||||
count={
|
count={
|
||||||
player.fieldPosition === "Goalkeeper" &&
|
player.fieldPosition === "Goalkeeper" &&
|
||||||
scoreData.score.cleanSheet
|
scoreData.score.cleanSheet
|
||||||
? 1
|
? 1
|
||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
icon={FaHandSparkles}
|
icon={FaHandSparkles}
|
||||||
label="Clean sheet"
|
label="Clean sheet"
|
||||||
/>
|
/>
|
||||||
<GameEvent
|
<GameEvent
|
||||||
count={scoreData.score.penaltySave}
|
count={scoreData.score.penaltySave}
|
||||||
icon={GiFist}
|
icon={GiFist}
|
||||||
label="Penalty saved"
|
label="Penalty saved"
|
||||||
/>
|
/>
|
||||||
<GameEvent
|
<GameEvent
|
||||||
count={scoreData.score.lastManTackle}
|
count={scoreData.score.lastManTackle}
|
||||||
icon={GiLeg}
|
icon={GiLeg}
|
||||||
label="Last man tackle"
|
label="Last man tackle"
|
||||||
/>
|
/>
|
||||||
<GameEvent
|
<GameEvent
|
||||||
count={scoreData.score.redCard ? 1 : 0}
|
count={scoreData.score.redCard ? 1 : 0}
|
||||||
icon={() => <CardIcon color={"#FF0000"} />}
|
icon={() => <CardIcon color={"#FF0000"} />}
|
||||||
label="Red card"
|
label="Red card"
|
||||||
/>
|
/>
|
||||||
<GameEvent
|
<GameEvent
|
||||||
count={scoreData.score.yellowCard ? 1 : 0}
|
count={scoreData.score.yellowCard ? 1 : 0}
|
||||||
icon={() => <CardIcon color={"#FFD700"} />}
|
icon={() => <CardIcon color={"#FFD700"} />}
|
||||||
label="Yellow card"
|
label="Yellow card"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<ScoreBox
|
<ScoreBox
|
||||||
score={scoreData.score.decisiveScore}
|
score={scoreData.score.decisiveScore}
|
||||||
colorfunc={GetDSScoreColor}
|
colorfunc={GetDSScoreColor}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<ScoreBox
|
<ScoreBox
|
||||||
score={scoreData.score?.allAroundScore ?? 0}
|
score={scoreData.score?.allAroundScore ?? 0}
|
||||||
colorfunc={GetAAScoreColor}
|
colorfunc={GetAAScoreColor}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
{statCategory === "def" &&
|
{statCategory === "def" &&
|
||||||
(scoreData.score.minutesPlayed ?? 0) > 0 && (
|
(scoreData.score.minutesPlayed ?? 0) > 0 && (
|
||||||
<>
|
<>
|
||||||
<td className="flex justify-center">
|
<td className="flex justify-center">
|
||||||
{scoreData.score.cleanSheet ? <BsHandThumbsUp /> : ""}
|
{scoreData.score.cleanSheet ? <BsHandThumbsUp /> : ""}
|
||||||
</td>
|
</td>
|
||||||
<ScoreText score={scoreData.score.errorLeadToGoal} />
|
<ScoreText score={scoreData.score.errorLeadToGoal} />
|
||||||
<ScoreText score={scoreData.score.goalsConceded} />
|
<ScoreText score={scoreData.score.goalsConceded} />
|
||||||
<ScoreText score={scoreData.score.effectiveClearance} />
|
<ScoreText score={scoreData.score.effectiveClearance} />
|
||||||
<ScoreText score={scoreData.score.wonTackle} />
|
<ScoreText score={scoreData.score.wonTackle} />
|
||||||
<ScoreText score={scoreData.score.blockedCross} />
|
<ScoreText score={scoreData.score.blockedCross} />
|
||||||
<ScoreText score={scoreData.score.block} />
|
<ScoreText score={scoreData.score.block} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{statCategory === "off" &&
|
{statCategory === "off" &&
|
||||||
scoreData.score.minutesPlayed > 0 && (
|
scoreData.score.minutesPlayed > 0 && (
|
||||||
<>
|
<>
|
||||||
<ScoreText score={scoreData.score.shotOnTarget} />
|
<ScoreText score={scoreData.score.shotOnTarget} />
|
||||||
<ScoreText score={scoreData.score.wonContest} />
|
<ScoreText score={scoreData.score.wonContest} />
|
||||||
<ScoreText score={scoreData.score.bigChanceCreated} />
|
<ScoreText score={scoreData.score.bigChanceCreated} />
|
||||||
<ScoreText score={scoreData.score.attemptedAssist} />
|
<ScoreText score={scoreData.score.attemptedAssist} />
|
||||||
<ScoreText score={scoreData.score.penaltyAreaEntries} />
|
<ScoreText score={scoreData.score.penaltyAreaEntries} />
|
||||||
<ScoreText score={scoreData.score.penaltyKickMissed} />
|
<ScoreText score={scoreData.score.penaltyKickMissed} />
|
||||||
<ScoreText score={scoreData.score.bigChanceMissed} />
|
<ScoreText score={scoreData.score.bigChanceMissed} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{statCategory === "poss" &&
|
{statCategory === "poss" &&
|
||||||
scoreData.score.minutesPlayed > 0 && (
|
scoreData.score.minutesPlayed > 0 && (
|
||||||
<>
|
<>
|
||||||
<ScoreText
|
<ScoreText
|
||||||
score={`+${scoreData.score.possessionWon} -${scoreData.score.possessionLost}`}
|
score={`+${scoreData.score.possessionWon} -${scoreData.score.possessionLost}`}
|
||||||
/>
|
/>
|
||||||
<ScoreText
|
<ScoreText
|
||||||
score={`+${scoreData.score.duelWon} -${scoreData.score.duelLost}`}
|
score={`+${scoreData.score.duelWon} -${scoreData.score.duelLost}`}
|
||||||
/>
|
/>
|
||||||
<ScoreText score={scoreData.score.interception} />
|
<ScoreText score={scoreData.score.interception} />
|
||||||
<ScoreText score={scoreData.score.accuratePass} />
|
<ScoreText score={scoreData.score.accuratePass} />
|
||||||
<ScoreText
|
<ScoreText
|
||||||
score={scoreData.score.accurateFinalThirdPass}
|
score={scoreData.score.accurateFinalThirdPass}
|
||||||
/>
|
/>
|
||||||
<ScoreText score={scoreData.score.accurateLongBall} />
|
<ScoreText score={scoreData.score.accurateLongBall} />
|
||||||
<ScoreText
|
<ScoreText
|
||||||
score={scoreData.score.longPassIntoOpposition}
|
score={scoreData.score.longPassIntoOpposition}
|
||||||
/>
|
/>
|
||||||
<ScoreText score={scoreData.score.missedPass} />
|
<ScoreText score={scoreData.score.missedPass} />
|
||||||
<ScoreText
|
<ScoreText
|
||||||
score={
|
score={
|
||||||
Number(
|
Number(
|
||||||
(scoreData.score.accuratePass /
|
(scoreData.score.accuratePass /
|
||||||
(scoreData.score.accuratePass +
|
(scoreData.score.accuratePass +
|
||||||
scoreData.score.missedPass)) *
|
scoreData.score.missedPass)) *
|
||||||
100
|
100
|
||||||
).toFixed(1) + "%"
|
).toFixed(1) + "%"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{statCategory === "goal" &&
|
{statCategory === "goal" &&
|
||||||
scoreData.score.minutesPlayed > 0 && (
|
scoreData.score.minutesPlayed > 0 && (
|
||||||
<>
|
<>
|
||||||
<ScoreText score={scoreData.score.saves} />
|
<ScoreText score={scoreData.score.saves} />
|
||||||
<ScoreText
|
<ScoreText
|
||||||
score={scoreData.score.savedShotFromInsideBox}
|
score={scoreData.score.savedShotFromInsideBox}
|
||||||
/>
|
/>
|
||||||
<ScoreText score={scoreData.score.goodHighClaim} />
|
<ScoreText score={scoreData.score.goodHighClaim} />
|
||||||
<ScoreText score={scoreData.score.punches} />
|
<ScoreText score={scoreData.score.punches} />
|
||||||
<ScoreText score={scoreData.score.divingSave} />
|
<ScoreText score={scoreData.score.divingSave} />
|
||||||
<ScoreText score={scoreData.score.divingCatch} />
|
<ScoreText score={scoreData.score.divingCatch} />
|
||||||
<ScoreText score={scoreData.score.crossNotClaimed} />
|
<ScoreText score={scoreData.score.crossNotClaimed} />
|
||||||
<ScoreText score={scoreData.score.goalkeeperSmother} />
|
<ScoreText score={scoreData.score.goalkeeperSmother} />
|
||||||
<ScoreText score={scoreData.score.keeperSweeper} />
|
<ScoreText score={scoreData.score.keeperSweeper} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,36 +1,47 @@
|
|||||||
import { GetPlayerGames } from "../../api/player"
|
import { useParams } from "react-router"
|
||||||
import WithDataFetching from "../../components/withdatafetching"
|
import { GetPlayer, GetPlayerGames } from "../../api/player"
|
||||||
import Player from "../../types/player"
|
import WithDataFetching from "../../components/withdatafetching"
|
||||||
import DaysSelectionWrapper from "./components/days_selection"
|
import Player from "../../types/player"
|
||||||
import ScoreGraph from "./score_graph"
|
import DaysSelectionWrapper from "./components/days_selection"
|
||||||
import ScoreTable from "./score_table"
|
import ScoreGraph from "./score_graph"
|
||||||
|
import ScoreTable from "./score_table"
|
||||||
export default function Scores({ player }: { player: Player }) {
|
|
||||||
return (
|
export default function Scores() {
|
||||||
<DaysSelectionWrapper>
|
const { slug } = useParams()
|
||||||
{(startDate, endDate) => {
|
if (!slug) {
|
||||||
return (
|
return <div>No</div>
|
||||||
<WithDataFetching
|
}
|
||||||
queryKey={["playerScores", player.slug]}
|
return (
|
||||||
queryFn={() => GetPlayerGames(player.slug)}
|
<WithDataFetching
|
||||||
refetchOnWindowFocus={false}
|
queryKey={["player", slug]}
|
||||||
>
|
queryFn={() => GetPlayer(slug)}
|
||||||
{(scores) => {
|
>
|
||||||
return (
|
{(player: Player) => (
|
||||||
<div className="flex flex-col items-center gap-12">
|
<DaysSelectionWrapper>
|
||||||
<ScoreGraph
|
{(startDate, endDate) => {
|
||||||
player={player}
|
return (
|
||||||
scores={scores}
|
<WithDataFetching
|
||||||
startDate={startDate}
|
queryKey={["playerScores", player.slug]}
|
||||||
endDate={endDate}
|
queryFn={() => GetPlayerGames(player.slug)}
|
||||||
/>
|
refetchOnWindowFocus={false}
|
||||||
<ScoreTable player={player} scores={scores} />
|
>
|
||||||
</div>
|
{(scores) => {
|
||||||
)
|
return (
|
||||||
}}
|
<div className="flex flex-col items-center gap-12">
|
||||||
</WithDataFetching>
|
<ScoreGraph
|
||||||
)
|
scores={scores}
|
||||||
}}
|
startDate={startDate}
|
||||||
</DaysSelectionWrapper>
|
endDate={endDate}
|
||||||
)
|
/>
|
||||||
}
|
<ScoreTable player={player} scores={scores} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</WithDataFetching>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</DaysSelectionWrapper>
|
||||||
|
)}
|
||||||
|
</WithDataFetching>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -1,57 +1,60 @@
|
|||||||
import Player from "../../types/player"
|
import { Link } from "react-router-dom"
|
||||||
|
import Player from "../../types/player"
|
||||||
function Badge({
|
|
||||||
imgUrl,
|
function Badge({
|
||||||
name,
|
imgUrl,
|
||||||
imgHeight = "full",
|
name,
|
||||||
}: {
|
imgHeight = "full",
|
||||||
imgUrl?: string
|
}: {
|
||||||
name?: string
|
imgUrl?: string
|
||||||
imgHeight?: 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">
|
return (
|
||||||
<img src={imgUrl} className={`h-${imgHeight}`} />
|
<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">
|
||||||
<span className="text-sm">{name}</span>
|
<img src={imgUrl} className={`h-${imgHeight}`} />
|
||||||
</div>
|
<span className="text-sm">{name}</span>
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
|
}
|
||||||
export default function Sidebar({ player }: { player: Player }) {
|
|
||||||
return (
|
export default function Sidebar({ player }: { player: Player }) {
|
||||||
<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">
|
return (
|
||||||
<div className="flex h-32 w-32 items-center justify-center overflow-hidden rounded-full border bg-neutral-50 p-3">
|
<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">
|
||||||
<img
|
<div className="flex h-32 w-32 items-center justify-center overflow-hidden rounded-full border bg-neutral-50 p-3">
|
||||||
src={player.avatarUrl}
|
<img
|
||||||
className="h-full w-full scale-[1.16] object-contain"
|
src={player.avatarUrl}
|
||||||
/>
|
className="h-full w-full scale-[1.16] object-contain"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
<span className="font-secondary text-lg font-semibold">
|
|
||||||
{player.displayName}
|
<span className="font-secondary text-lg font-semibold">
|
||||||
</span>
|
{player.displayName}
|
||||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
</span>
|
||||||
<span className="text-sm">
|
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||||
{new Date().getFullYear() - new Date(player.birthDate).getFullYear()}
|
<span className="text-sm">
|
||||||
</span>
|
{new Date().getFullYear() - new Date(player.birthDate).getFullYear()}
|
||||||
<span className="text-xs">
|
</span>
|
||||||
{new Date(player.birthDate).toLocaleDateString("en-GB", {
|
<span className="text-xs">
|
||||||
year: "numeric",
|
{new Date(player.birthDate).toLocaleDateString("en-GB", {
|
||||||
month: "2-digit",
|
year: "numeric",
|
||||||
day: "2-digit",
|
month: "2-digit",
|
||||||
})}
|
day: "2-digit",
|
||||||
</span>
|
})}
|
||||||
</div>
|
</span>
|
||||||
<Badge
|
</div>
|
||||||
imgUrl={player.country?.flagFlat64Url}
|
<Badge
|
||||||
name={player.country?.displayName}
|
imgUrl={player.country?.flagFlat64Url}
|
||||||
imgHeight="4"
|
name={player.country?.displayName}
|
||||||
/>
|
imgHeight="4"
|
||||||
<Badge imgUrl={player.team?.pictureUrl} name={player.team?.shortName} />
|
/>
|
||||||
<Badge
|
<Link to={`/team/${player.team?.slug}`}>
|
||||||
imgUrl={player.domesticLeague?.pictureUrl}
|
<Badge imgUrl={player.team?.pictureUrl} name={player.team?.shortName} />
|
||||||
name={player.domesticLeague?.displayName}
|
</Link>
|
||||||
/>
|
<Badge
|
||||||
</div>
|
imgUrl={player.domesticLeague?.pictureUrl}
|
||||||
)
|
name={player.domesticLeague?.displayName}
|
||||||
}
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
12
front/src/pages/player/summary.tsx
Normal file
12
front/src/pages/player/summary.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export default function Summary() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span>Summary</span>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Ceci est un résumé du joueur. Plus de détails seront ajoutés
|
||||||
|
prochainement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,67 +1,67 @@
|
|||||||
interface ProgressBarProps {
|
interface ProgressBarProps {
|
||||||
percentages: number[];
|
percentages: number[];
|
||||||
colors?: string[];
|
colors?: string[];
|
||||||
textColors?: string[];
|
textColors?: string[];
|
||||||
displayPercentage?: boolean;
|
displayPercentage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar({
|
export default function ProgressBar({
|
||||||
percentages,
|
percentages,
|
||||||
colors,
|
colors,
|
||||||
textColors,
|
textColors,
|
||||||
displayPercentage = true,
|
displayPercentage = true,
|
||||||
}: ProgressBarProps) {
|
}: ProgressBarProps) {
|
||||||
if (!colors) {
|
if (!colors) {
|
||||||
colors = ["bg-green-300", "bg-yellow-400", "bg-orange-500", "bg-red-600"];
|
colors = ["bg-green-300", "bg-yellow-400", "bg-orange-500", "bg-red-600"];
|
||||||
}
|
}
|
||||||
if (!textColors) {
|
if (!textColors) {
|
||||||
textColors = ["text-black", "text-black", "text-white", "text-white"];
|
textColors = ["text-black", "text-black", "text-white", "text-white"];
|
||||||
}
|
}
|
||||||
const getColor = (index: number) => {
|
const getColor = (index: number) => {
|
||||||
return colors ? colors[index] || "bg-neutral-100" : "bg-neutral-100";
|
return colors ? colors[index] || "bg-neutral-100" : "bg-neutral-100";
|
||||||
};
|
};
|
||||||
const getTextColor = (index: number) => textColors?.[index] || "text-black";
|
const getTextColor = (index: number) => textColors?.[index] || "text-black";
|
||||||
|
|
||||||
const sortedPercentages = [...percentages].sort((a, b) => b - a);
|
const sortedPercentages = [...percentages].sort((a, b) => b - a);
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 left-0 h-4 bg-neutral-50 border-2 border-neutral-100`}
|
className={`absolute top-0 left-0 h-4 bg-neutral-50 border-2 border-neutral-100`}
|
||||||
style={{ width: `100%` }}
|
style={{ width: `100%` }}
|
||||||
></div>
|
></div>
|
||||||
{sortedPercentages.map((percentage, index) => {
|
{sortedPercentages.map((percentage, index) => {
|
||||||
const spanleft =
|
const spanleft =
|
||||||
100 -
|
100 -
|
||||||
(((percentage - sortedPercentages[index + 1]) / 2) * 100) /
|
(((percentage - sortedPercentages[index + 1]) / 2) * 100) /
|
||||||
percentage;
|
percentage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`absolute top-0 left-0 h-4 ${getColor(index)} group`}
|
className={`absolute top-0 left-0 h-4 ${getColor(index)} group`}
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
>
|
>
|
||||||
{displayPercentage &&
|
{displayPercentage &&
|
||||||
percentage > 0 &&
|
percentage > 0 &&
|
||||||
(spanleft < 96 || isNaN(spanleft)) && (
|
(spanleft < 96 || isNaN(spanleft)) && (
|
||||||
<span
|
<span
|
||||||
className={`absolute opacity-0 group-hover:opacity-100 transition-opacity duration-300 px-1 text-xs ${getTextColor(
|
className={`absolute opacity-0 group-hover:opacity-100 transition-opacity duration-300 px-1 text-xs ${getTextColor(
|
||||||
index,
|
index,
|
||||||
)}`}
|
)}`}
|
||||||
style={{
|
style={{
|
||||||
left: `${
|
left: `${
|
||||||
index === sortedPercentages.length - 1 ? 50 : spanleft
|
index === sortedPercentages.length - 1 ? 50 : spanleft
|
||||||
}%`,
|
}%`,
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{percentage}%
|
{percentage}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -80,6 +80,26 @@ const orders = [
|
|||||||
value: "more_than_10_aa_percentage DESC",
|
value: "more_than_10_aa_percentage DESC",
|
||||||
label: "AA Percentage > 10 (High to Low)",
|
label: "AA Percentage > 10 (High to Low)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "duel_won_per_90_min DESC",
|
||||||
|
label: "Duel Won Per 90 Min (High to Low)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "effective_clearance_per_90_min DESC",
|
||||||
|
label: "Effective Clearance Per 90 Min (High to Low)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "shot_on_target_per_90_min DESC",
|
||||||
|
label: "Shot On Target Per 90 Min (High to Low)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "accurate_pass_per_90_min DESC",
|
||||||
|
label: "Accurate Pass Per 90 Min (High to Low)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "won_contest_per_90_min DESC",
|
||||||
|
label: "Won Contest Per 90 Min (High to Low)",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function SingleRankingsPage() {
|
function SingleRankingsPage() {
|
||||||
@ -399,8 +419,35 @@ function SingleRankingsPage() {
|
|||||||
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
|
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
|
||||||
AA/Min
|
AA/Min
|
||||||
</th>
|
</th>
|
||||||
|
{usedParams.order.startsWith("duel_won_per_90_min") && (
|
||||||
|
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
|
||||||
|
Duel/90
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{usedParams.order.startsWith(
|
||||||
|
"effective_clearance_per_90_min"
|
||||||
|
) && (
|
||||||
|
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
|
||||||
|
EC/90
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{usedParams.order.startsWith("shot_on_target_per_90_min") && (
|
||||||
|
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
|
||||||
|
Shot/90
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{usedParams.order.startsWith("accurate_pass_per_90_min") && (
|
||||||
|
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
|
||||||
|
AP/90
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{usedParams.order.startsWith("won_contest_per_90_min") && (
|
||||||
|
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
|
||||||
|
WC/90
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
|
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
|
||||||
Total Min
|
Total Minutes
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
|
<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">
|
<div className="flex items-center justify-center gap-3">
|
||||||
@ -444,7 +491,7 @@ function SingleRankingsPage() {
|
|||||||
{data?.map((item) => {
|
{data?.map((item) => {
|
||||||
return (
|
return (
|
||||||
<tr key={item.player.slug}>
|
<tr key={item.player.slug}>
|
||||||
<td className="relative whitespace-nowrap px-3 py-4">
|
<td className="relative whitespace-nowrap px-2 py-4">
|
||||||
<div
|
<div
|
||||||
className={"flex h-10 w-10 items-center justify-center"}
|
className={"flex h-10 w-10 items-center justify-center"}
|
||||||
>
|
>
|
||||||
@ -461,17 +508,13 @@ function SingleRankingsPage() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="relative whitespace-nowrap px-3 py-4">
|
<td className="relative whitespace-nowrap px-2 py-4">
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"flex h-8 w-full items-center justify-center"
|
"flex h-8 w-full items-center justify-center"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<a
|
<Link to={`/team/${item.player.teamSlug}`}>
|
||||||
href={`https://www.soraredata.com/playerSearch/${item.player.displayName}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{item.player.team?.pictureUrl ? (
|
{item.player.team?.pictureUrl ? (
|
||||||
<img
|
<img
|
||||||
src={item.player.team.pictureUrl}
|
src={item.player.team.pictureUrl}
|
||||||
@ -487,10 +530,10 @@ function SingleRankingsPage() {
|
|||||||
{item.player.team?.shortName?.substring(0, 3)}
|
{item.player.team?.shortName?.substring(0, 3)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-sm font-semibold">
|
<td className="text-md px-2 py-4 font-secondary font-semibold">
|
||||||
<Link to={`/player/${item.player.slug}`}>
|
<Link to={`/player/${item.player.slug}`}>
|
||||||
{item.player.displayName}
|
{item.player.displayName}
|
||||||
</Link>
|
</Link>
|
||||||
@ -502,7 +545,7 @@ function SingleRankingsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="w-80 whitespace-nowrap px-3 py-2">
|
<td className="w-80 whitespace-nowrap px-2 py-2">
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"flex flex-col items-center justify-center gap-3"
|
"flex flex-col items-center justify-center gap-3"
|
||||||
@ -550,16 +593,16 @@ function SingleRankingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-xs">
|
<td className="whitespace-nowrap px-2 py-4 text-xs">
|
||||||
±{item.totalStddevScore}
|
±{item.totalStddevScore}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
<td className="whitespace-nowrap px-2 py-4 text-sm">
|
||||||
{item.decisiveAvgScore}
|
{item.decisiveAvgScore}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
<td className="whitespace-nowrap px-2 py-4 text-sm">
|
||||||
{item.allAroundAvgScore}
|
{item.allAroundAvgScore}
|
||||||
</td>
|
</td>
|
||||||
<td className="w-40 whitespace-nowrap px-3 py-4 text-sm">
|
<td className="w-40 whitespace-nowrap px-2 py-4 text-sm">
|
||||||
<div className="flex w-full flex-col items-center gap-5">
|
<div className="flex w-full flex-col items-center gap-5">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@ -614,30 +657,64 @@ function SingleRankingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-center text-sm">
|
<td className="whitespace-nowrap px-2 py-4 text-center text-sm">
|
||||||
{item.percentageMinutesPlayed}
|
{item.percentageMinutesPlayed}
|
||||||
</td>
|
</td>
|
||||||
{["Goalkeeper", "Defender"].includes(
|
{["Goalkeeper", "Defender"].includes(
|
||||||
usedParams.position
|
usedParams.position
|
||||||
) && (
|
) && (
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
<td
|
||||||
|
className="whitespace-nowrap px-2 py-4 text-sm"
|
||||||
|
text-center
|
||||||
|
>
|
||||||
{item.avgTeamGoalsAgainst}
|
{item.avgTeamGoalsAgainst}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{["Midfielder", "Forward"].includes(
|
{["Midfielder", "Forward"].includes(
|
||||||
usedParams.position
|
usedParams.position
|
||||||
) && (
|
) && (
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
<td className="whitespace-nowrap px-2 py-4 text-center text-sm ">
|
||||||
{item.avgTeamGoalsFor}
|
{item.avgTeamGoalsFor}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
<td className="whitespace-nowrap px-2 py-4 text-center text-sm ">
|
||||||
{item.aaPerMin}
|
{item.aaPerMin}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
{usedParams.order.startsWith("duel_won_per_90_min") && (
|
||||||
|
<td className="whitespace-nowrap px-2 py-4 text-center text-sm ">
|
||||||
|
{item.duelWonPer90Min}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{usedParams.order.startsWith(
|
||||||
|
"effective_clearance_per_90_min"
|
||||||
|
) && (
|
||||||
|
<td className="whitespace-nowrap px-2 py-4 text-center text-sm ">
|
||||||
|
{item.effectiveClearancePer90Min}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{usedParams.order.startsWith(
|
||||||
|
"shot_on_target_per_90_min"
|
||||||
|
) && (
|
||||||
|
<td className="whitespace-nowrap px-2 py-4 text-center text-sm ">
|
||||||
|
{item.shotOnTargetPer90Min}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{usedParams.order.startsWith(
|
||||||
|
"accurate_pass_per_90_min"
|
||||||
|
) && (
|
||||||
|
<td className="whitespace-nowrap px-2 py-4 text-center text-sm ">
|
||||||
|
{item.accuratePassPer90Min}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{usedParams.order.startsWith("won_contest_per_90_min") && (
|
||||||
|
<td className="whitespace-nowrap px-2 py-4 text-center text-sm ">
|
||||||
|
{item.wonContestPer90Min}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="whitespace-nowrap px-2 py-4 text-center text-sm ">
|
||||||
{item.totalMinutes}
|
{item.totalMinutes}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-3 py-4">
|
<td className="whitespace-nowrap px-2 py-4">
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
percentages={[
|
percentages={[
|
||||||
item.moreThan80ScorePercentage,
|
item.moreThan80ScorePercentage,
|
||||||
@ -647,7 +724,7 @@ function SingleRankingsPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-3 py-4">
|
<td className="whitespace-nowrap px-2 py-4">
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
percentages={[
|
percentages={[
|
||||||
item.moreThan40AAPercentage,
|
item.moreThan40AAPercentage,
|
||||||
|
@ -1,42 +1,42 @@
|
|||||||
import { SingleRankingsParams } from "../../api/rankings";
|
import { SingleRankingsParams } from "../../api/rankings";
|
||||||
|
|
||||||
const positions = ["Goalkeeper", "Defender", "Midfielder", "Forward"];
|
const positions = ["Goalkeeper", "Defender", "Midfielder", "Forward"];
|
||||||
|
|
||||||
interface PositionSelectProps {
|
interface PositionSelectProps {
|
||||||
params: SingleRankingsParams;
|
params: SingleRankingsParams;
|
||||||
setParams: (params: SingleRankingsParams) => void;
|
setParams: (params: SingleRankingsParams) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PositionSelect({
|
export default function PositionSelect({
|
||||||
params,
|
params,
|
||||||
setParams,
|
setParams,
|
||||||
}: PositionSelectProps) {
|
}: PositionSelectProps) {
|
||||||
const handlePositionChange = (position: string) => {
|
const handlePositionChange = (position: string) => {
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
position,
|
position,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex border-2 border-gray-300 rounded-xl overflow-hidden">
|
<div className="flex border-2 border-gray-300 rounded-xl overflow-hidden">
|
||||||
{positions.map((position, index) => (
|
{positions.map((position, index) => (
|
||||||
<div
|
<div
|
||||||
key={position}
|
key={position}
|
||||||
className={`flex-auto ${index > 0 ? "border-l-2 border-gray-300" : ""}`}
|
className={`flex-auto ${index > 0 ? "border-l-2 border-gray-300" : ""}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={`w-full py-1 px-3 ${
|
className={`w-full py-1 px-3 ${
|
||||||
params.position === position
|
params.position === position
|
||||||
? "bg-primary-blue-500 hover:bg-primary-blue-700 text-white"
|
? "bg-primary-blue-500 hover:bg-primary-blue-700 text-white"
|
||||||
: "bg-white hover:bg-neutral-100 text-neutral-800"
|
: "bg-white hover:bg-neutral-100 text-neutral-800"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handlePositionChange(position)}
|
onClick={() => handlePositionChange(position)}
|
||||||
>
|
>
|
||||||
{position}
|
{position}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,51 +1,51 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { SingleRankingsParams } from "../../api/rankings";
|
import { SingleRankingsParams } from "../../api/rankings";
|
||||||
import { GetAllZones } from "../../api/zone";
|
import { GetAllZones } from "../../api/zone";
|
||||||
import Zone from "../../types/zone";
|
import Zone from "../../types/zone";
|
||||||
|
|
||||||
interface ZoneSelectProps {
|
interface ZoneSelectProps {
|
||||||
params: SingleRankingsParams;
|
params: SingleRankingsParams;
|
||||||
setParams: (params: SingleRankingsParams) => void;
|
setParams: (params: SingleRankingsParams) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ZoneSelect({ params, setParams }: ZoneSelectProps) {
|
export default function ZoneSelect({ params, setParams }: ZoneSelectProps) {
|
||||||
const { data: zones } = useQuery({
|
const { data: zones } = useQuery({
|
||||||
queryKey: ["zones"],
|
queryKey: ["zones"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const zones = await GetAllZones();
|
const zones = await GetAllZones();
|
||||||
return zones.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
return zones.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleZoneChange = (zone: Zone) => {
|
const handleZoneChange = (zone: Zone) => {
|
||||||
const newZones = params.zones.includes(zone.id)
|
const newZones = params.zones.includes(zone.id)
|
||||||
? params.zones.filter((z) => z !== zone.id)
|
? params.zones.filter((z) => z !== zone.id)
|
||||||
: [...params.zones, zone.id];
|
: [...params.zones, zone.id];
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
zones: newZones,
|
zones: newZones,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex border-2 border-gray-300 rounded-xl overflow-hidden">
|
<div className="flex border-2 border-gray-300 rounded-xl overflow-hidden">
|
||||||
{zones?.map((zone, index) => (
|
{zones?.map((zone, index) => (
|
||||||
<div
|
<div
|
||||||
key={zone.id}
|
key={zone.id}
|
||||||
className={`flex-auto ${index > 0 ? "border-l-2 border-gray-300" : ""}`}
|
className={`flex-auto ${index > 0 ? "border-l-2 border-gray-300" : ""}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={`w-full py-1 px-3 ${
|
className={`w-full py-1 px-3 ${
|
||||||
params.zones.includes(zone.id)
|
params.zones.includes(zone.id)
|
||||||
? "bg-primary-blue-500 hover:bg-primary-blue-700 text-white"
|
? "bg-primary-blue-500 hover:bg-primary-blue-700 text-white"
|
||||||
: "bg-white hover:bg-neutral-100 text-neutral-800"
|
: "bg-white hover:bg-neutral-100 text-neutral-800"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleZoneChange(zone)}
|
onClick={() => handleZoneChange(zone)}
|
||||||
>
|
>
|
||||||
{zone.displayName}
|
{zone.displayName}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
49
front/src/pages/team/index.tsx
Normal file
49
front/src/pages/team/index.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { Outlet, useLocation, useParams } from "react-router"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { GetTeam } from "../../api/team"
|
||||||
|
import WithDataFetching from "../../components/withdatafetching"
|
||||||
|
import Team from "../../types/team"
|
||||||
|
import Sidebar from "./sidebar"
|
||||||
|
|
||||||
|
export default function TeamPage() {
|
||||||
|
const { slug } = useParams()
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
return <div>No slug</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<WithDataFetching queryKey={["team", slug]} queryFn={() => GetTeam(slug)}>
|
||||||
|
{(team) => <TeamLayout team={team} />}
|
||||||
|
</WithDataFetching>
|
||||||
|
)
|
||||||
|
|
||||||
|
function TeamLayout({ team }: { team: Team }) {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const tabs = [
|
||||||
|
{ id: "roster", title: "Roster" },
|
||||||
|
{ id: "transfers", title: "Transfers" },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div className="grid h-screen grid-cols-[260px_1fr]">
|
||||||
|
<Sidebar team={team}></Sidebar>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex divide-x border-b border-gray-200">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Link key={tab.id} to={`/team/${slug}/${tab.id}`}>
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className={`text-md px-6 py-2 text-center font-normal ${pathname.includes(tab.id) ? "bg-gray-200" : ""}`}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
86
front/src/pages/team/roster.tsx
Normal file
86
front/src/pages/team/roster.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import { Link, useParams } from "react-router-dom"
|
||||||
|
import { GetTeamRoster } from "../../api/team"
|
||||||
|
import man from "../../assets/man.png"
|
||||||
|
import WithDataFetching from "../../components/withdatafetching"
|
||||||
|
import { GamePlayer } from "../../types/game"
|
||||||
|
import Player from "../../types/player"
|
||||||
|
import Team from "../../types/team"
|
||||||
|
|
||||||
|
export default function Roster() {
|
||||||
|
const { slug } = useParams()
|
||||||
|
if (!slug) {
|
||||||
|
return <div>No</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<WithDataFetching
|
||||||
|
queryKey={["roster", slug]}
|
||||||
|
queryFn={() => GetTeamRoster(slug)}
|
||||||
|
>
|
||||||
|
{(resp) => <RosterLayout team={resp.team} roster={resp.roster} />}
|
||||||
|
</WithDataFetching>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RosterLayout({ team, roster }: { team: Team; roster: Player[] }) {
|
||||||
|
const threeMonthsAgo = new Date()
|
||||||
|
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3)
|
||||||
|
const totalTeamMinutes = useMemo(() => {
|
||||||
|
return team.homeGames?.concat(team.awayGames)?.reduce((total, game) => {
|
||||||
|
if (new Date(game.date) >= threeMonthsAgo) {
|
||||||
|
return total + (game.minutes > 115 ? 120 : 90)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}, 0)
|
||||||
|
}, [team])
|
||||||
|
|
||||||
|
const computeTotalMinutes = (gamePlayers: GamePlayer[]) => {
|
||||||
|
return gamePlayers.reduce((total, game) => {
|
||||||
|
if (game.game && new Date(game.game.date) >= threeMonthsAgo) {
|
||||||
|
return total + (game.score?.minutesPlayed || 0)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rosterWithMinutes = roster.map((player) => ({
|
||||||
|
...player,
|
||||||
|
totalMinutesPlayed: computeTotalMinutes(player.gamePlayers),
|
||||||
|
minutesPlayedPercentage:
|
||||||
|
(computeTotalMinutes(player.gamePlayers) / totalTeamMinutes) * 100,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{["Goalkeeper", "Defender", "Midfielder", "Forward"].map((position) => (
|
||||||
|
<>
|
||||||
|
<div className="rounded-xl bg-gradient-to-br from-primary-200 to-primary-50 px-3 py-1 text-center text-xl font-semibold text-neutral-700 shadow-md">
|
||||||
|
{position}s
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-10 gap-3 p-3">
|
||||||
|
{rosterWithMinutes
|
||||||
|
.filter((player) => player.fieldPosition === position)
|
||||||
|
.sort((a, b) => b.totalMinutesPlayed - a.totalMinutesPlayed)
|
||||||
|
.map((player) => (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center gap-2"
|
||||||
|
key={player.slug}
|
||||||
|
>
|
||||||
|
<Link to={`/player/${player.slug}`}>
|
||||||
|
<img
|
||||||
|
className="h-32 rounded-md"
|
||||||
|
src={player.avatarUrl || man}
|
||||||
|
alt={""}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div>{player.displayName}</div>
|
||||||
|
<div>{player.totalMinutesPlayed}</div>
|
||||||
|
<div>{player.minutesPlayedPercentage.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
22
front/src/pages/team/routes.tsx
Normal file
22
front/src/pages/team/routes.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import TeamPage from "."
|
||||||
|
import Roster from "./roster"
|
||||||
|
import Transfers from "./transfers"
|
||||||
|
|
||||||
|
export const teamRoutes = {
|
||||||
|
path: "team/:slug",
|
||||||
|
element: <TeamPage />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
element: <Roster />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "roster",
|
||||||
|
element: <Roster />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "transfers",
|
||||||
|
element: <Transfers />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
28
front/src/pages/team/sidebar.tsx
Normal file
28
front/src/pages/team/sidebar.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import Badge from "../../components/badge"
|
||||||
|
import Team from "../../types/team"
|
||||||
|
|
||||||
|
export default function Sidebar({ team }: { team: Team }) {
|
||||||
|
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={team.pictureUrl}
|
||||||
|
className="h-full w-full scale-[1.16] object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="font-secondary text-lg font-semibold">
|
||||||
|
{team.displayName}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
imgUrl={team.country?.flagFlat64Url}
|
||||||
|
name={team.country?.displayName}
|
||||||
|
imgHeight="4"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
imgUrl={team.domesticLeague?.pictureUrl}
|
||||||
|
name={team.domesticLeague?.displayName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
249
front/src/pages/team/transfers.tsx
Normal file
249
front/src/pages/team/transfers.tsx
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { FaPersonWalkingArrowRight } from "react-icons/fa6"
|
||||||
|
import { Link, useParams } from "react-router-dom"
|
||||||
|
import { GetTeamMemberships } from "../../api/team"
|
||||||
|
import man from "../../assets/man.png"
|
||||||
|
import Badge from "../../components/badge"
|
||||||
|
import WithDataFetching from "../../components/withdatafetching"
|
||||||
|
import { Membership } from "../../types/player"
|
||||||
|
import Team from "../../types/team"
|
||||||
|
|
||||||
|
export default function Transfers() {
|
||||||
|
const { slug } = useParams()
|
||||||
|
if (!slug) {
|
||||||
|
return <div>No team slug</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WithDataFetching
|
||||||
|
queryKey={["memberships", slug]}
|
||||||
|
queryFn={() => GetTeamMemberships(slug)}
|
||||||
|
>
|
||||||
|
{(resp) => (
|
||||||
|
<TransfersLayout team={resp.team} memberships={resp.memberships} />
|
||||||
|
)}
|
||||||
|
</WithDataFetching>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransfersLayout({
|
||||||
|
memberships,
|
||||||
|
}: {
|
||||||
|
team: Team
|
||||||
|
memberships: Membership[]
|
||||||
|
}) {
|
||||||
|
type YearMonthAccumulator = {
|
||||||
|
[key: string]: {
|
||||||
|
arrivals: Membership[]
|
||||||
|
departures: Membership[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const groupedByYearMonth = memberships.reduce<YearMonthAccumulator>(
|
||||||
|
(acc, membership) => {
|
||||||
|
const startYearMonth = new Date(membership.startDate)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 7)
|
||||||
|
const endYearMonth = membership.endDate
|
||||||
|
? new Date(membership.endDate).toISOString().slice(0, 7)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!acc[startYearMonth]) {
|
||||||
|
acc[startYearMonth] = { arrivals: [], departures: [] }
|
||||||
|
}
|
||||||
|
acc[startYearMonth].arrivals.push(membership)
|
||||||
|
|
||||||
|
if (endYearMonth) {
|
||||||
|
if (!acc[endYearMonth]) {
|
||||||
|
acc[endYearMonth] = { arrivals: [], departures: [] }
|
||||||
|
}
|
||||||
|
acc[endYearMonth].departures.push(membership)
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sort each group by startDate and then by playerSlug
|
||||||
|
Object.keys(groupedByYearMonth).forEach((yearMonth) => {
|
||||||
|
groupedByYearMonth[yearMonth].arrivals.sort((a, b) => {
|
||||||
|
if (a.startDate === b.startDate) {
|
||||||
|
return a.playerSlug.localeCompare(b.playerSlug)
|
||||||
|
}
|
||||||
|
return a.startDate < b.startDate ? -1 : 1
|
||||||
|
})
|
||||||
|
groupedByYearMonth[yearMonth].departures.sort((a, b) => {
|
||||||
|
if (a.startDate === b.startDate) {
|
||||||
|
return a.playerSlug.localeCompare(b.playerSlug)
|
||||||
|
}
|
||||||
|
return a.startDate < b.startDate ? -1 : 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedYearMonths = Object.keys(groupedByYearMonth).sort().reverse()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{sortedYearMonths.map((yearMonth, index, array) => {
|
||||||
|
const { arrivals, departures } = groupedByYearMonth[yearMonth]
|
||||||
|
const year = yearMonth.slice(0, 4)
|
||||||
|
const isYearBoundary =
|
||||||
|
index === 0 || year !== array[index - 1].slice(0, 4)
|
||||||
|
const isEvenMonth = index % 2 === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={yearMonth}>
|
||||||
|
{isYearBoundary && (
|
||||||
|
<div className="flex w-full items-center justify-center rounded-xl bg-gradient-to-br from-primary-200 to-primary-50 px-3 py-1 text-xl font-semibold text-neutral-700 shadow-md">
|
||||||
|
{year}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`m-3
|
||||||
|
grid grid-cols-[1fr_240px_1fr] items-center rounded-lg p-3 ${isEvenMonth ? "bg-neutral-200" : "bg-neutral-50"}`}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{arrivals.map((m) => {
|
||||||
|
const previousMembership = m.player?.clubMembership
|
||||||
|
?.filter(
|
||||||
|
(membership) => membership.membershipType === "club"
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (!a.endDate && !b.endDate) {
|
||||||
|
return (
|
||||||
|
new Date(b.startDate).getTime() -
|
||||||
|
new Date(a.startDate).getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.find(
|
||||||
|
(membership) =>
|
||||||
|
new Date(membership.startDate) < new Date(m.startDate)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="flex items-center gap-3">
|
||||||
|
<Link to={`/player/${m.player?.slug}`}>
|
||||||
|
<img
|
||||||
|
className="h-24 min-w-16"
|
||||||
|
src={m.player?.avatarUrl || man}
|
||||||
|
alt={m.player?.displayName}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<span className="font-secondary text-lg font-semibold">
|
||||||
|
{m.player?.displayName}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
{previousMembership && previousMembership.team ? (
|
||||||
|
<>
|
||||||
|
<FaPersonWalkingArrowRight className="h-6 w-6" />
|
||||||
|
<Badge
|
||||||
|
name={previousMembership?.team?.shortName}
|
||||||
|
imgUrl={previousMembership?.team?.pictureUrl}
|
||||||
|
link={`/team/${previousMembership?.team?.slug}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"No previous club"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl px-20 text-center text-xl text-neutral-700">
|
||||||
|
<span className="rounded-xl border border-neutral-700 bg-neutral-500 px-2 py-1 text-white shadow-md">
|
||||||
|
{new Date(yearMonth + "-01")
|
||||||
|
.toLocaleString(undefined, { month: "long" })
|
||||||
|
.replace(/^\w/, (c) => c.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{departures.map((m) => {
|
||||||
|
const nextMembership = m.player?.clubMembership
|
||||||
|
?.filter(
|
||||||
|
(membership) => membership.membershipType === "club"
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (!a.endDate && !b.endDate) {
|
||||||
|
return (
|
||||||
|
new Date(b.startDate).getTime() -
|
||||||
|
new Date(a.startDate).getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.reverse()
|
||||||
|
.find(
|
||||||
|
(membership) =>
|
||||||
|
new Date(membership.startDate) >= new Date(m.endDate)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="flex items-center gap-3">
|
||||||
|
<Link to={`/player/${m.player?.slug}`}>
|
||||||
|
<img
|
||||||
|
className="h-24 min-w-16"
|
||||||
|
src={m.player?.avatarUrl || man}
|
||||||
|
alt={m.player?.displayName}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<span className="font-secondary text-lg font-semibold">
|
||||||
|
{m.player?.displayName}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
{nextMembership && nextMembership.team ? (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
name={nextMembership?.team?.shortName}
|
||||||
|
imgUrl={nextMembership?.team?.pictureUrl}
|
||||||
|
link={`/team/${nextMembership?.team?.slug}`}
|
||||||
|
/>
|
||||||
|
<FaPersonWalkingArrowRight className="h-6 w-6" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"No next club"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,205 +1,299 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useParams } from "react-router"
|
import { FaArrowAltCircleUp } from "react-icons/fa"
|
||||||
import { Link } from "react-router-dom"
|
import { IoMdFootball } from "react-icons/io"
|
||||||
import { GetUserCards } from "../../api/card"
|
import { IoManSharp } from "react-icons/io5"
|
||||||
import WithDataFetching from "../../components/withdatafetching"
|
import { useParams } from "react-router"
|
||||||
import Card from "../../types/card"
|
import { Link } from "react-router-dom"
|
||||||
|
import Select from "react-select"
|
||||||
export default function XpCenter() {
|
import { GetUserCards } from "../../api/card"
|
||||||
const { slug = "gigiz22" } = useParams()
|
import WithDataFetching from "../../components/withdatafetching"
|
||||||
|
import Card from "../../types/card"
|
||||||
return (
|
|
||||||
<WithDataFetching<Card[]>
|
export default function XpCenter() {
|
||||||
queryKey={["user", slug, "cards"]}
|
const { slug = "gigiz22" } = useParams()
|
||||||
queryFn={() => GetUserCards(slug)}
|
|
||||||
refetchOnWindowFocus={false}
|
return (
|
||||||
>
|
<WithDataFetching<Card[]>
|
||||||
{(data) => <CardList cards={data} />}
|
queryKey={["user", slug, "cards"]}
|
||||||
</WithDataFetching>
|
queryFn={() => GetUserCards(slug)}
|
||||||
)
|
refetchOnWindowFocus={false}
|
||||||
}
|
>
|
||||||
|
{(data) => <CardList cards={data} />}
|
||||||
function CardList({ cards }: { cards: Card[] }) {
|
</WithDataFetching>
|
||||||
const [sortConfig, setSortConfig] = useState<{
|
)
|
||||||
key: string
|
}
|
||||||
direction: "asc" | "desc"
|
|
||||||
} | null>(null)
|
function CardList({ cards }: { cards: Card[] }) {
|
||||||
|
const [sortConfig, setSortConfig] = useState<{
|
||||||
const [hideMaxLevelUp, setHideMaxLevelUp] = useState(false)
|
key: string
|
||||||
|
direction: "asc" | "desc"
|
||||||
const filteredCards = useMemo(() => {
|
}>(
|
||||||
return hideMaxLevelUp
|
() =>
|
||||||
? cards.filter(
|
JSON.parse(
|
||||||
(card) => card.levelUpAppliedCount < card.maxLevelUpAppliedCount
|
localStorage.getItem("sortConfig") ||
|
||||||
)
|
'{"key": "xpPercentage", "direction": "desc"}'
|
||||||
: cards
|
) as {
|
||||||
}, [cards, hideMaxLevelUp])
|
key: string
|
||||||
|
direction: "asc" | "desc"
|
||||||
const sortedCards = useMemo(() => {
|
}
|
||||||
let sortableCards = filteredCards.map((card) => ({
|
)
|
||||||
...card,
|
|
||||||
xpNeeded:
|
const [hideMaxLevelUp, setHideMaxLevelUp] = useState(() =>
|
||||||
card.xpNeededForNextGrade > card.xp
|
JSON.parse(localStorage.getItem("hideMaxLevelUp") || "true")
|
||||||
? card.xpNeededForNextGrade - card.xp
|
)
|
||||||
: "N/A",
|
const [hideMaxLevelCards, setHideMaxLevelCards] = useState(() =>
|
||||||
xpPercentage:
|
JSON.parse(localStorage.getItem("hideMaxLevelCards") || "true")
|
||||||
card.xpNeededForNextGrade > card.xp
|
)
|
||||||
? ((card.xpNeededForNextGrade - card.xp) * 100) /
|
const [minGrade, setMinGrade] = useState<number>(() =>
|
||||||
(card.xpNeededForNextGrade - card.xpNeededForCurrentGrade)
|
JSON.parse(localStorage.getItem("minGrade") || "0")
|
||||||
: "N/A",
|
)
|
||||||
}))
|
const [onlyInSeason, setOnlyInSeason] = useState(() =>
|
||||||
|
JSON.parse(localStorage.getItem("onlyInSeason") || "false")
|
||||||
if (sortConfig !== null) {
|
)
|
||||||
sortableCards.sort((a, b) => {
|
|
||||||
const key = sortConfig.key as keyof typeof a
|
useEffect(() => {
|
||||||
if (a[key] < b[key]) {
|
localStorage.setItem("sortConfig", JSON.stringify(sortConfig))
|
||||||
return sortConfig.direction === "asc" ? -1 : 1
|
}, [sortConfig])
|
||||||
}
|
|
||||||
if (a[key] > b[key]) {
|
useEffect(() => {
|
||||||
return sortConfig.direction === "asc" ? 1 : -1
|
localStorage.setItem("hideMaxLevelUp", JSON.stringify(hideMaxLevelUp))
|
||||||
}
|
}, [hideMaxLevelUp])
|
||||||
return 0
|
|
||||||
})
|
useEffect(() => {
|
||||||
}
|
localStorage.setItem("hideMaxLevelCards", JSON.stringify(hideMaxLevelCards))
|
||||||
return sortableCards
|
}, [hideMaxLevelCards])
|
||||||
}, [filteredCards, sortConfig])
|
|
||||||
|
useEffect(() => {
|
||||||
const requestSort = (key: string) => {
|
localStorage.setItem("minGrade", JSON.stringify(minGrade))
|
||||||
let direction: "asc" | "desc" = "asc"
|
}, [minGrade])
|
||||||
if (
|
|
||||||
sortConfig &&
|
useEffect(() => {
|
||||||
sortConfig.key === key &&
|
localStorage.setItem("onlyInSeason", JSON.stringify(onlyInSeason))
|
||||||
sortConfig.direction === "asc"
|
}, [onlyInSeason])
|
||||||
) {
|
|
||||||
direction = "desc"
|
const filteredCards = useMemo(() => {
|
||||||
}
|
return cards.filter((card) => {
|
||||||
setSortConfig({ key, direction })
|
if (
|
||||||
}
|
hideMaxLevelUp &&
|
||||||
|
card.levelUpAppliedCount >= card.maxLevelUpAppliedCount
|
||||||
const getSortIndicator = (key: string) => {
|
) {
|
||||||
if (!sortConfig || sortConfig.key !== key) return null
|
return false
|
||||||
return sortConfig.direction === "asc" ? "↑" : "↓"
|
}
|
||||||
}
|
if (hideMaxLevelCards && card.grade >= 20) {
|
||||||
|
return false
|
||||||
return (
|
}
|
||||||
<>
|
if (card.grade < minGrade) {
|
||||||
<div className="flex w-full items-center justify-end p-3">
|
return false
|
||||||
<label className="inline-flex items-center">
|
}
|
||||||
<input
|
if (onlyInSeason && !card.inSeasonEligible) {
|
||||||
type="checkbox"
|
return false
|
||||||
checked={hideMaxLevelUp}
|
}
|
||||||
onChange={() => setHideMaxLevelUp(!hideMaxLevelUp)}
|
return true
|
||||||
/>
|
})
|
||||||
<span className="ml-2">Hide cards with max level up applied</span>
|
}, [cards, hideMaxLevelUp, hideMaxLevelCards, minGrade, onlyInSeason])
|
||||||
</label>
|
|
||||||
</div>
|
const sortedCards = useMemo(() => {
|
||||||
<table className="min-w-full">
|
let sortableCards = filteredCards.map((card) => ({
|
||||||
<thead className="bg-gray-50">
|
...card,
|
||||||
<tr>
|
xpNeeded:
|
||||||
<th
|
card.xpNeededForNextGrade > card.xp
|
||||||
scope="col"
|
? card.xpNeededForNextGrade - card.xp
|
||||||
className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500"
|
: "N/A",
|
||||||
></th>
|
xpPercentage:
|
||||||
<th
|
card.xpNeededForNextGrade > card.xp
|
||||||
scope="col"
|
? ((card.xpNeededForNextGrade - card.xp) * 100) /
|
||||||
className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500"
|
(card.xpNeededForNextGrade - card.xpNeededForCurrentGrade)
|
||||||
>
|
: "N/A",
|
||||||
Player
|
}))
|
||||||
</th>
|
|
||||||
<th
|
if (sortConfig !== null) {
|
||||||
scope="col"
|
sortableCards.sort((a, b) => {
|
||||||
className="w-auto cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
|
const key = sortConfig.key as keyof typeof a
|
||||||
onClick={() => requestSort("grade")}
|
if (a[key] === "N/A") return 1
|
||||||
>
|
if (b[key] === "N/A") return -1
|
||||||
Grade{" "}
|
if (Number(a[key]) < Number(b[key])) {
|
||||||
<span className="ml-1 align-middle">
|
return sortConfig.direction === "asc" ? -1 : 1
|
||||||
{getSortIndicator("grade")}
|
}
|
||||||
</span>
|
if (Number(a[key]) > Number(b[key])) {
|
||||||
</th>
|
return sortConfig.direction === "asc" ? 1 : -1
|
||||||
<th
|
}
|
||||||
scope="col"
|
return 0
|
||||||
className="cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
|
})
|
||||||
onClick={() => requestSort("xp")}
|
}
|
||||||
>
|
return sortableCards
|
||||||
XP{" "}
|
}, [filteredCards, sortConfig])
|
||||||
<span className="ml-1 align-middle">
|
|
||||||
{getSortIndicator("xp")}
|
const handleSortFieldChange = (selectedOption: any) => {
|
||||||
</span>
|
setSortConfig((prevConfig) => ({
|
||||||
</th>
|
key: selectedOption.value,
|
||||||
<th
|
direction: prevConfig?.direction || "asc",
|
||||||
scope="col"
|
}))
|
||||||
className="cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
|
}
|
||||||
onClick={() => requestSort("xpNeeded")}
|
|
||||||
>
|
const handleSortDirectionChange = (selectedOption: any) => {
|
||||||
XP Needed for Next Grade{" "}
|
setSortConfig((prevConfig) => ({
|
||||||
<span className="ml-1 align-middle">
|
key: prevConfig?.key || "",
|
||||||
{getSortIndicator("xpNeeded")}
|
direction: selectedOption.value,
|
||||||
</span>
|
}))
|
||||||
</th>
|
}
|
||||||
<th
|
|
||||||
scope="col"
|
const sortFieldOptions = [
|
||||||
className="cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
|
{ value: "grade", label: "Grade" },
|
||||||
onClick={() => requestSort("xpPercentage")}
|
{ value: "xp", label: "XP" },
|
||||||
>
|
{ value: "xpNeeded", label: "XP Needed for Next Grade" },
|
||||||
% Until next grade{" "}
|
{ value: "xpPercentage", label: "% Until next grade" },
|
||||||
<span className="ml-1 align-middle">
|
]
|
||||||
{getSortIndicator("xpPercentage")}
|
|
||||||
</span>
|
const sortDirectionOptions = [
|
||||||
</th>
|
{ value: "asc", label: "Ascending" },
|
||||||
<th
|
{ value: "desc", label: "Descending" },
|
||||||
scope="col"
|
]
|
||||||
className="cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
|
|
||||||
>
|
return (
|
||||||
Level up applied
|
<div className="flex flex-col gap-3 p-3">
|
||||||
</th>
|
<div className="flex w-full items-center justify-center gap-3">
|
||||||
</tr>
|
<label className="inline-flex items-center">
|
||||||
</thead>
|
<span className="mr-2">Min Grade</span>
|
||||||
<tbody className="divide-y-2 divide-gray-200 bg-white">
|
<input
|
||||||
{sortedCards.map((card) => (
|
type="number"
|
||||||
<tr key={card.id}>
|
value={minGrade}
|
||||||
<td className="px-3 py-1">
|
onChange={(e) => setMinGrade(Number(e.target.value))}
|
||||||
<a
|
className="w-16"
|
||||||
href={`https://sorare.com/fr/football/cards/${card.slug}`}
|
/>
|
||||||
target="_blank"
|
</label>
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
<label className="inline-flex items-center">
|
||||||
<img className="h-16" src={card.pictureUrl} alt={card.name} />
|
<input
|
||||||
</a>
|
type="checkbox"
|
||||||
</td>
|
checked={onlyInSeason}
|
||||||
<td className="border-r-2 border-neutral-200 px-3 py-1">
|
onChange={() => setOnlyInSeason(!onlyInSeason)}
|
||||||
<Link to={`/player/${card.playerSlug}`}>
|
/>
|
||||||
<div className="flex items-center">
|
<span className="ml-2">Only show cards in season</span>
|
||||||
<div className="text-md font-secondary font-semibold text-neutral-900">
|
</label>
|
||||||
{card.playerDisplayName}
|
|
||||||
</div>
|
<label className="inline-flex items-center">
|
||||||
</div>
|
<input
|
||||||
</Link>
|
type="checkbox"
|
||||||
</td>
|
checked={hideMaxLevelUp}
|
||||||
<td className="w-auto border-r border-neutral-200 px-3 py-1 text-center">
|
onChange={() => setHideMaxLevelUp(!hideMaxLevelUp)}
|
||||||
<div className="text-sm text-neutral-900">{card.grade}</div>
|
/>
|
||||||
</td>
|
<span className="ml-2">Hide cards with max level up applied</span>
|
||||||
<td className="border-r-2 border-neutral-200 px-3 py-1 text-center">
|
</label>
|
||||||
<div className="text-sm text-neutral-900">{card.xp}</div>
|
|
||||||
</td>
|
<label className="inline-flex items-center">
|
||||||
<td className="border-r-2 border-neutral-200 px-3 py-1 text-center">
|
<input
|
||||||
<div className="text-sm text-neutral-900">{card.xpNeeded}</div>
|
type="checkbox"
|
||||||
</td>
|
checked={hideMaxLevelCards}
|
||||||
<td className="border-r-2 border-neutral-200 px-3 py-1 text-center">
|
onChange={() => setHideMaxLevelCards(!hideMaxLevelCards)}
|
||||||
<div className="text-sm text-neutral-900">
|
/>
|
||||||
{card.xpPercentage !== "N/A"
|
<span className="ml-2">Hide cards with max grade</span>
|
||||||
? `${Number(card.xpPercentage).toFixed(0)}%`
|
</label>
|
||||||
: "N/A"}
|
|
||||||
</div>
|
<Select
|
||||||
</td>
|
options={sortFieldOptions}
|
||||||
<td className="px-3 py-1 text-center">
|
onChange={handleSortFieldChange}
|
||||||
<div className="text-sm text-neutral-900">
|
defaultValue={sortFieldOptions.find(
|
||||||
{card.levelUpAppliedCount} / {card.maxLevelUpAppliedCount}
|
(option) => option.value === sortConfig.key
|
||||||
</div>
|
)}
|
||||||
</td>
|
placeholder="Sort by"
|
||||||
</tr>
|
className="w-64"
|
||||||
))}
|
/>
|
||||||
</tbody>
|
<Select
|
||||||
</table>
|
options={sortDirectionOptions}
|
||||||
</>
|
onChange={handleSortDirectionChange}
|
||||||
)
|
defaultValue={sortDirectionOptions.find(
|
||||||
}
|
(option) => option.value === sortConfig.direction
|
||||||
|
)}
|
||||||
|
placeholder="Sort direction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-10 gap-3">
|
||||||
|
{sortedCards.map((card) => (
|
||||||
|
<div
|
||||||
|
key={card.id}
|
||||||
|
className="flex flex-col gap-3 rounded-xl border-2 border-neutral-300 pb-3 pt-0.5 shadow-lg"
|
||||||
|
>
|
||||||
|
<Link to={`/card/${card.assetId}`}>
|
||||||
|
<img src={card.pictureUrl} alt={card.name} className=" px-0.5" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 px-3">
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<Link to={`/player/${card.playerSlug}`}>
|
||||||
|
<IoManSharp className="text-lg" />
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`https://sorare.com/fr/football/cards/${card.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<IoMdFootball className="text-lg" title="Sorare" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="rounded-full bg-gradient-to-br from-neutral-700 to-neutral-500 px-2 py-1 text-xs font-semibold text-white shadow-lg">
|
||||||
|
{card.grade}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-neutral-700">
|
||||||
|
{card.grade < 20
|
||||||
|
? `${card.xp - card.xpNeededForCurrentGrade} / ${card.xpNeededForNextGrade - card.xpNeededForCurrentGrade}`
|
||||||
|
: card.xp}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-neutral-700">
|
||||||
|
{Number((card.power - 1) * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex h-3 w-full gap-1 overflow-hidden rounded bg-neutral-300 text-xs">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${((card.xp - card.xpNeededForCurrentGrade) / (card.xpNeededForNextGrade - card.xpNeededForCurrentGrade)) * 100}%`,
|
||||||
|
}}
|
||||||
|
className="flex flex-col justify-center whitespace-nowrap bg-primary-700 text-center text-white shadow-none"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xs font-semibold text-neutral-700">
|
||||||
|
{card.grade < 20
|
||||||
|
? `${Number(card.xpPercentage).toFixed(1)}%`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-3 px-3">
|
||||||
|
<FaArrowAltCircleUp
|
||||||
|
className={
|
||||||
|
card.levelUpAppliedCount < 1
|
||||||
|
? "text-neutral-300"
|
||||||
|
: "text-green-600"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FaArrowAltCircleUp
|
||||||
|
className={
|
||||||
|
card.levelUpAppliedCount < 2
|
||||||
|
? "text-neutral-300"
|
||||||
|
: "text-green-600"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FaArrowAltCircleUp
|
||||||
|
className={
|
||||||
|
card.levelUpAppliedCount < 3
|
||||||
|
? "text-neutral-300"
|
||||||
|
: "text-green-600"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center px-3">
|
||||||
|
{card.inSeasonEligible ? (
|
||||||
|
<span className="rounded-xl bg-primary-600 px-2 py-1 text-xs font-semibold text-white">
|
||||||
|
In Season
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span> </span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { createBrowserRouter, Link, RouterProvider } from "react-router-dom"
|
import { createBrowserRouter, Link, RouterProvider } from "react-router-dom"
|
||||||
import Layout from "../pages/layout"
|
import Layout from "../pages/layout"
|
||||||
import Live from "../pages/live"
|
import Live from "../pages/live"
|
||||||
import PlayerPage from "../pages/player"
|
import { playerRoutes } from "../pages/player/routes"
|
||||||
import SingleRankingsPage from "../pages/singlerankings"
|
import SingleRankingsPage from "../pages/singlerankings"
|
||||||
|
import { teamRoutes } from "../pages/team/routes"
|
||||||
import XpCenter from "../pages/xpcenter"
|
import XpCenter from "../pages/xpcenter"
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
@ -10,14 +11,18 @@ const router = createBrowserRouter([
|
|||||||
path: "/",
|
path: "/",
|
||||||
element: <Layout />,
|
element: <Layout />,
|
||||||
children: [
|
children: [
|
||||||
{ path: "/", element: <SingleRankingsPage /> },
|
{ path: "", element: <SingleRankingsPage /> },
|
||||||
{ path: "/live", element: <Live /> },
|
{ path: "live", element: <Live /> },
|
||||||
{ path: "/xpcenter", element: <XpCenter /> },
|
{ path: "xpcenter", element: <XpCenter /> },
|
||||||
{ path: "/xpcenter/:slug", element: <XpCenter /> },
|
{ path: "xpcenter/:slug", element: <XpCenter /> },
|
||||||
{ path: "/player/:slug", element: <PlayerPage /> },
|
playerRoutes,
|
||||||
|
teamRoutes,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: "*", element: <NoMatch /> },
|
{
|
||||||
|
path: "*",
|
||||||
|
element: <NoMatch />,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
export default function RouterOutlet() {
|
export default function RouterOutlet() {
|
||||||
|
@ -1,27 +1,27 @@
|
|||||||
export default interface Card {
|
export default interface Card {
|
||||||
id: string
|
id: string
|
||||||
assetId: string
|
assetId: string
|
||||||
playerSlug: string
|
playerSlug: string
|
||||||
playerDisplayName: string
|
playerDisplayName: string
|
||||||
rarity: string
|
rarity: string
|
||||||
serialNumber: number
|
serialNumber: number
|
||||||
seasonStartYear: number
|
seasonStartYear: number
|
||||||
singleCivilYear: boolean
|
singleCivilYear: boolean
|
||||||
supply: number
|
supply: number
|
||||||
teamSlug: string
|
teamSlug: string
|
||||||
name: string
|
name: string
|
||||||
pictureUrl: string
|
pictureUrl: string
|
||||||
slug: string
|
slug: string
|
||||||
power: number
|
power: number
|
||||||
powerMalusAfterTransfer: number
|
powerMalusAfterTransfer: number
|
||||||
rivalsPower: number
|
rivalsPower: number
|
||||||
grade: number
|
grade: number
|
||||||
gradeAfterTransfer: number
|
gradeAfterTransfer: number
|
||||||
xp: number
|
xp: number
|
||||||
xpAfterTransfer: number
|
xpAfterTransfer: number
|
||||||
xpNeededForNextGrade: number
|
xpNeededForNextGrade: number
|
||||||
xpNeededForCurrentGrade: number
|
xpNeededForCurrentGrade: number
|
||||||
inSeasonEligible: boolean
|
inSeasonEligible: boolean
|
||||||
levelUpAppliedCount: number
|
levelUpAppliedCount: number
|
||||||
maxLevelUpAppliedCount: number
|
maxLevelUpAppliedCount: number
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import Country from "./country"
|
import Country from "./country"
|
||||||
import Zone from "./zone"
|
import Zone from "./zone"
|
||||||
|
|
||||||
export default interface Competition {
|
export default interface Competition {
|
||||||
slug: string
|
slug: string
|
||||||
displayName: string
|
displayName: string
|
||||||
countrySlug: string
|
countrySlug: string
|
||||||
competitionFormat: string
|
competitionFormat: string
|
||||||
competitionType: string
|
competitionType: string
|
||||||
pictureUrl: string
|
pictureUrl: string
|
||||||
logoUrl: string
|
logoUrl: string
|
||||||
zoneId?: number
|
zoneId?: number
|
||||||
zone?: Zone
|
zone?: Zone
|
||||||
country: Country
|
country: Country
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
export default interface Country {
|
export default interface Country {
|
||||||
slug: string;
|
slug: string;
|
||||||
code: string;
|
code: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
threeLetterCode: string;
|
threeLetterCode: string;
|
||||||
flagFlat64Url: string;
|
flagFlat64Url: string;
|
||||||
flagFlat32Url: string;
|
flagFlat32Url: string;
|
||||||
flagRound64Url: string;
|
flagRound64Url: string;
|
||||||
flagRound32Url: string;
|
flagRound32Url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
export interface Fixture {
|
export interface Fixture {
|
||||||
slug: string
|
slug: string
|
||||||
displayName: string
|
displayName: string
|
||||||
fixtureState: string
|
fixtureState: string
|
||||||
startDate: Date
|
startDate: Date
|
||||||
endDate: Date
|
endDate: Date
|
||||||
gameWeek: number
|
gameWeek: number
|
||||||
}
|
}
|
||||||
|
@ -1,177 +1,178 @@
|
|||||||
import Competition from "./competition"
|
import Competition from "./competition"
|
||||||
import { Fixture } from "./fixture"
|
import { Fixture } from "./fixture"
|
||||||
import Player from "./player"
|
import Player from "./player"
|
||||||
import Team from "./team"
|
import Team from "./team"
|
||||||
|
|
||||||
export interface Game {
|
export interface Game {
|
||||||
id: string
|
id: string
|
||||||
date: Date
|
date: Date
|
||||||
coverageStatus: string
|
coverageStatus: string
|
||||||
lowCoverage: boolean
|
lowCoverage: boolean
|
||||||
minutes: number
|
minutes: number
|
||||||
periodType: string
|
periodType: string
|
||||||
scored: boolean
|
scored: boolean
|
||||||
status: string
|
status: string
|
||||||
competitionSlug: string
|
competitionSlug: string
|
||||||
fixtureSlug: string
|
fixtureSlug: string
|
||||||
awayTeamSlug: string
|
awayTeamSlug: string
|
||||||
awayGoals: number
|
awayGoals: number
|
||||||
awayExtraTimeScore: number
|
awayExtraTimeScore: number
|
||||||
awayPenaltyScore: number
|
awayPenaltyScore: number
|
||||||
homeTeamSlug: string
|
homeTeamSlug: string
|
||||||
homeGoals: number
|
homeGoals: number
|
||||||
homeExtraTimeScore: number
|
homeExtraTimeScore: number
|
||||||
homePenaltyScore: number
|
homePenaltyScore: number
|
||||||
winnerTeamSlug: string
|
winnerTeamSlug: string
|
||||||
competition: Competition
|
competition: Competition
|
||||||
homeTeam: Team
|
homeTeam: Team
|
||||||
awayTeam: Team
|
awayTeam: Team
|
||||||
winnerTeam: Team
|
winnerTeam: Team
|
||||||
gamePlayers: GamePlayer[]
|
gamePlayers: GamePlayer[]
|
||||||
fixture: Fixture
|
fixture: Fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GamePlayer {
|
export interface GamePlayer {
|
||||||
gameId: string
|
gameId: string
|
||||||
playerSlug: string
|
playerSlug: string
|
||||||
teamSlug: string
|
teamSlug: string
|
||||||
status: string
|
status: string
|
||||||
|
|
||||||
game?: Game
|
game?: Game
|
||||||
player?: Player
|
player?: Player
|
||||||
score?: GamePlayerScore
|
score?: GamePlayerScore
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GamePlayerScore {
|
export interface GamePlayerScore {
|
||||||
gameId: string
|
gameId: string
|
||||||
playerSlug: string
|
playerSlug: string
|
||||||
score: number
|
gameDate: Date
|
||||||
decisiveScore: number
|
score: number
|
||||||
allAroundScore: number
|
decisiveScore: number
|
||||||
minutesPlayed: number
|
allAroundScore: number
|
||||||
gameStarted: boolean
|
minutesPlayed: number
|
||||||
formationPlace: number
|
gameStarted: boolean
|
||||||
live: boolean
|
formationPlace: number
|
||||||
onGameSheet: boolean
|
live: boolean
|
||||||
reviewed: boolean
|
onGameSheet: boolean
|
||||||
goal: number
|
reviewed: boolean
|
||||||
assist: number
|
goal: number
|
||||||
penaltyWon: number
|
assist: number
|
||||||
clearanceOffLine: number
|
penaltyWon: number
|
||||||
lastManTackle: number
|
clearanceOffLine: number
|
||||||
penaltySave: number
|
lastManTackle: number
|
||||||
ownGoal: number
|
penaltySave: number
|
||||||
redCard: boolean
|
ownGoal: number
|
||||||
errorLeadToGoal: number
|
redCard: boolean
|
||||||
penaltyConceded: number
|
errorLeadToGoal: number
|
||||||
yellowCard: number
|
penaltyConceded: number
|
||||||
fouls: number
|
yellowCard: number
|
||||||
fouled: number
|
fouls: number
|
||||||
cleanSheet: boolean
|
fouled: number
|
||||||
doubleDouble: boolean
|
cleanSheet: boolean
|
||||||
tripleDouble: boolean
|
doubleDouble: boolean
|
||||||
tripleTriple: boolean
|
tripleDouble: boolean
|
||||||
errorLeadToShot: number
|
tripleTriple: boolean
|
||||||
saves: number
|
errorLeadToShot: number
|
||||||
savedShotFromInsideBox: number
|
saves: number
|
||||||
goodHighClaim: number
|
savedShotFromInsideBox: number
|
||||||
punches: number
|
goodHighClaim: number
|
||||||
divingSave: number
|
punches: number
|
||||||
divingCatch: number
|
divingSave: number
|
||||||
crossNotClaimed: number
|
divingCatch: number
|
||||||
goalkeeperSmother: number
|
crossNotClaimed: number
|
||||||
sixSecondViolation: number
|
goalkeeperSmother: number
|
||||||
keeperSweeper: number
|
sixSecondViolation: number
|
||||||
goalsConceded: number
|
keeperSweeper: number
|
||||||
effectiveClearance: number
|
goalsConceded: number
|
||||||
wonTackle: number
|
effectiveClearance: number
|
||||||
blockedCross: number
|
wonTackle: number
|
||||||
block: number
|
blockedCross: number
|
||||||
possessionLost: number
|
block: number
|
||||||
possessionWon: number
|
possessionLost: number
|
||||||
duelLost: number
|
possessionWon: number
|
||||||
duelWon: number
|
duelLost: number
|
||||||
interception: number
|
duelWon: number
|
||||||
accuratePass: number
|
interception: number
|
||||||
accurateFinalThirdPass: number
|
accuratePass: number
|
||||||
accurateLongBall: number
|
accurateFinalThirdPass: number
|
||||||
longPassIntoOpposition: number
|
accurateLongBall: number
|
||||||
missedPass: number
|
longPassIntoOpposition: number
|
||||||
shotOnTarget: number
|
missedPass: number
|
||||||
wonContest: number
|
shotOnTarget: number
|
||||||
bigChanceCreated: number
|
wonContest: number
|
||||||
attemptedAssist: number
|
bigChanceCreated: number
|
||||||
penaltyAreaEntries: number
|
attemptedAssist: number
|
||||||
penaltyKickMissed: number
|
penaltyAreaEntries: number
|
||||||
bigChanceMissed: number
|
penaltyKickMissed: number
|
||||||
}
|
bigChanceMissed: number
|
||||||
|
}
|
||||||
export function GetScoreColor(score: number, tailwind: boolean): string {
|
|
||||||
if (score < 15) {
|
export function GetScoreColor(score: number, tailwind: boolean): string {
|
||||||
return tailwind ? "bg-support-red-400" : "#FF0000" // red
|
if (score < 15) {
|
||||||
} else if (score < 40) {
|
return tailwind ? "bg-support-red-400" : "#FF0000" // red
|
||||||
return tailwind ? "bg-orange-400" : "#FFA500" // orange
|
} else if (score < 40) {
|
||||||
} else if (score < 60) {
|
return tailwind ? "bg-orange-400" : "#FFA500" // orange
|
||||||
return tailwind ? "bg-yellow-400" : "#FFFF00" // yellow
|
} else if (score < 60) {
|
||||||
} else if (score < 75) {
|
return tailwind ? "bg-yellow-400" : "#FFFF00" // yellow
|
||||||
return tailwind ? "bg-green-600" : "#32CD32" // lime green, a bit darker than light green
|
} else if (score < 75) {
|
||||||
} else {
|
return tailwind ? "bg-green-600" : "#32CD32" // lime green, a bit darker than light green
|
||||||
return tailwind ? "bg-green-700" : "#006400" // dark green
|
} else {
|
||||||
}
|
return tailwind ? "bg-green-700" : "#006400" // dark green
|
||||||
}
|
}
|
||||||
|
}
|
||||||
export function GetLScoreColor(score: number): string {
|
|
||||||
if (score < 40) {
|
export function GetLScoreColor(score: number): string {
|
||||||
return "bg-support-red-500"
|
if (score < 40) {
|
||||||
} else if (score < 46) {
|
return "bg-support-red-500"
|
||||||
return "bg-orange-500"
|
} else if (score < 46) {
|
||||||
} else if (score < 54) {
|
return "bg-orange-500"
|
||||||
return "bg-yellow-500"
|
} else if (score < 54) {
|
||||||
} else if (score < 66) {
|
return "bg-yellow-500"
|
||||||
return "bg-green-500"
|
} else if (score < 66) {
|
||||||
} else {
|
return "bg-green-500"
|
||||||
return "bg-green-600"
|
} else {
|
||||||
}
|
return "bg-green-600"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
export function GetDSScoreColor(score: number): string {
|
|
||||||
if (score < 34) {
|
export function GetDSScoreColor(score: number): string {
|
||||||
return "bg-support-red-500"
|
if (score < 34) {
|
||||||
} else if (score < 37) {
|
return "bg-support-red-500"
|
||||||
return "bg-orange-500"
|
} else if (score < 37) {
|
||||||
} else if (score < 42) {
|
return "bg-orange-500"
|
||||||
return "bg-yellow-500"
|
} else if (score < 42) {
|
||||||
} else if (score < 52) {
|
return "bg-yellow-500"
|
||||||
return "bg-green-500"
|
} else if (score < 52) {
|
||||||
} else {
|
return "bg-green-500"
|
||||||
return "bg-green-600"
|
} else {
|
||||||
}
|
return "bg-green-600"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
export function GetAAScoreColor(score: number): string {
|
|
||||||
if (score < 7) {
|
export function GetAAScoreColor(score: number): string {
|
||||||
return "bg-support-red-500"
|
if (score < 7) {
|
||||||
} else if (score < 10) {
|
return "bg-support-red-500"
|
||||||
return "bg-orange-500"
|
} else if (score < 10) {
|
||||||
} else if (score < 16) {
|
return "bg-orange-500"
|
||||||
return "bg-yellow-500"
|
} else if (score < 16) {
|
||||||
} else if (score < 25) {
|
return "bg-yellow-500"
|
||||||
return "bg-green-500"
|
} else if (score < 25) {
|
||||||
} else {
|
return "bg-green-500"
|
||||||
return "bg-green-600"
|
} else {
|
||||||
}
|
return "bg-green-600"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
export function GetMinutesScoreColor(score: number): string {
|
|
||||||
if (score < 38) {
|
export function GetMinutesScoreColor(score: number): string {
|
||||||
return "bg-support-red-500"
|
if (score < 38) {
|
||||||
} else if (score < 58) {
|
return "bg-support-red-500"
|
||||||
return "bg-orange-500"
|
} else if (score < 58) {
|
||||||
} else if (score < 78) {
|
return "bg-orange-500"
|
||||||
return "bg-yellow-500"
|
} else if (score < 78) {
|
||||||
} else if (score < 90) {
|
return "bg-yellow-500"
|
||||||
return "bg-green-500"
|
} else if (score < 90) {
|
||||||
} else {
|
return "bg-green-500"
|
||||||
return "bg-green-600"
|
} else {
|
||||||
}
|
return "bg-green-600"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
@ -1,41 +1,45 @@
|
|||||||
import Competition from "./competition"
|
import Competition from "./competition"
|
||||||
import Country from "./country"
|
import Country from "./country"
|
||||||
import Team from "./team"
|
import { GamePlayer } from "./game"
|
||||||
|
import Team from "./team"
|
||||||
interface CardSupply {
|
|
||||||
playerSlug: string
|
interface CardSupply {
|
||||||
seasonStartYear: number
|
playerSlug: string
|
||||||
limited: number
|
seasonStartYear: number
|
||||||
rare: number
|
limited: number
|
||||||
superRare: number
|
rare: number
|
||||||
unique: number
|
superRare: number
|
||||||
lastUpdated: Date
|
unique: number
|
||||||
}
|
lastUpdated: Date
|
||||||
interface Membership {
|
}
|
||||||
id: string
|
export interface Membership {
|
||||||
playerSlug: string
|
id: string
|
||||||
teamSlug: string
|
playerSlug: string
|
||||||
startDate: Date
|
teamSlug: string
|
||||||
endDate: Date
|
startDate: Date
|
||||||
membershipType: string
|
endDate: Date
|
||||||
|
membershipType: string
|
||||||
team?: Team
|
|
||||||
}
|
player?: Player
|
||||||
|
team?: Team
|
||||||
export default interface Player {
|
}
|
||||||
slug: string
|
|
||||||
displayName: string
|
export default interface Player {
|
||||||
birthDate: Date
|
slug: string
|
||||||
countrySlug: string
|
displayName: string
|
||||||
teamSlug?: string
|
birthDate: Date
|
||||||
domesticLeagueSlug?: string
|
countrySlug: string
|
||||||
avatarUrl: string
|
teamSlug?: string
|
||||||
fieldPosition: string
|
domesticLeagueSlug?: string
|
||||||
status: string
|
avatarUrl: string
|
||||||
shirtNumber: number
|
fieldPosition: string
|
||||||
country?: Country
|
status: string
|
||||||
team?: Team
|
shirtNumber: number
|
||||||
domesticLeague?: Competition
|
country?: Country
|
||||||
cardSupply: CardSupply[]
|
team?: Team
|
||||||
clubMembership: Membership[]
|
domesticLeague?: Competition
|
||||||
}
|
cardSupply: CardSupply[]
|
||||||
|
clubMembership: Membership[]
|
||||||
|
|
||||||
|
gamePlayers: GamePlayer[]
|
||||||
|
}
|
||||||
|
@ -1,15 +1,18 @@
|
|||||||
import Competition from "./competition"
|
import Competition from "./competition"
|
||||||
import Country from "./country"
|
import Country from "./country"
|
||||||
|
import { Game } from "./game"
|
||||||
export default interface Team {
|
|
||||||
slug: string
|
export default interface Team {
|
||||||
displayName: string
|
slug: string
|
||||||
countrySlug: string
|
displayName: string
|
||||||
domesticLeagueSlug?: string
|
countrySlug: string
|
||||||
shortName: string
|
domesticLeagueSlug?: string
|
||||||
pictureUrl: string
|
shortName: string
|
||||||
teamType: string
|
pictureUrl: string
|
||||||
|
teamType: string
|
||||||
country?: Country
|
|
||||||
domesticLeague?: Competition
|
country?: Country
|
||||||
}
|
domesticLeague?: Competition
|
||||||
|
homeGames: Game[]
|
||||||
|
awayGames: Game[]
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
export default interface Zone {
|
export default interface Zone {
|
||||||
id: number;
|
id: number;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
@ -1,12 +1,15 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GamePlayerScore struct {
|
type GamePlayerScore struct {
|
||||||
GameID string `bun:"game_id,pk" json:"gameID"`
|
GameID string `bun:"game_id,pk" json:"gameID"`
|
||||||
PlayerSlug string `bun:"player_slug,pk" json:"playerSlug"`
|
PlayerSlug string `bun:"player_slug,pk" json:"playerSlug"`
|
||||||
|
GameDate time.Time `bun:"game_date,pk" json:"gameDate"`
|
||||||
Score decimal.Decimal `bun:"score" json:"score"`
|
Score decimal.Decimal `bun:"score" json:"score"`
|
||||||
DecisiveScore decimal.Decimal `bun:"decisive_score" json:"decisiveScore"`
|
DecisiveScore decimal.Decimal `bun:"decisive_score" json:"decisiveScore"`
|
||||||
AllAroundScore decimal.Decimal `bun:"all_around_score" json:"allAroundScore"`
|
AllAroundScore decimal.Decimal `bun:"all_around_score" json:"allAroundScore"`
|
||||||
|
@ -22,4 +22,6 @@ type Player struct {
|
|||||||
DomesticLeague *Competition `bun:"rel:has-one,join:domestic_league_slug=slug" json:"domesticLeague"`
|
DomesticLeague *Competition `bun:"rel:has-one,join:domestic_league_slug=slug" json:"domesticLeague"`
|
||||||
CardSupply []CardSupply `bun:"rel:has-many,join:slug=player_slug" json:"cardSupply"`
|
CardSupply []CardSupply `bun:"rel:has-many,join:slug=player_slug" json:"cardSupply"`
|
||||||
ClubMembership []Membership `bun:"rel:has-many,join:slug=player_slug" json:"clubMembership"`
|
ClubMembership []Membership `bun:"rel:has-many,join:slug=player_slug" json:"clubMembership"`
|
||||||
|
|
||||||
|
GamePlayers []GamePlayer `bun:"rel:has-many,join:slug=player_slug" json:"gamePlayers"`
|
||||||
}
|
}
|
||||||
|
@ -11,4 +11,7 @@ type Team struct {
|
|||||||
|
|
||||||
Country *Country `bun:"rel:has-one,join:country_slug=slug" json:"country"`
|
Country *Country `bun:"rel:has-one,join:country_slug=slug" json:"country"`
|
||||||
DomesticLeague *Competition `bun:"rel:has-one,join:domestic_league_slug=slug" json:"domesticLeague"`
|
DomesticLeague *Competition `bun:"rel:has-one,join:domestic_league_slug=slug" json:"domesticLeague"`
|
||||||
|
|
||||||
|
HomeGames []Game `bun:"rel:has-many,join:slug=home_team_slug" json:"homeGames"`
|
||||||
|
AwayGames []Game `bun:"rel:has-many,join:slug=away_team_slug" json:"awayGames"`
|
||||||
}
|
}
|
||||||
|
26
sorare_utils/competition.go
Normal file
26
sorare_utils/competition.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package sorare_utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.lehouerou.net/laurent/sorare"
|
||||||
|
"git.lehouerou.net/laurent/sorare/football"
|
||||||
|
"git.lehouerou.net/laurent/sorarebuddy/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CompetitionUpdater struct {
|
||||||
|
s *sorare.Sorare
|
||||||
|
db *db.Client
|
||||||
|
|
||||||
|
countryUpdater *CountryUpdater
|
||||||
|
cache []football.Competition
|
||||||
|
slugsToRead []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCompetitionUpdater(s *sorare.Sorare, db *db.Client, countryUpdater *CountryUpdater) *CompetitionUpdater {
|
||||||
|
return &CompetitionUpdater{
|
||||||
|
s: s,
|
||||||
|
db: db,
|
||||||
|
countryUpdater: countryUpdater,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
93
sorare_utils/country.go
Normal file
93
sorare_utils/country.go
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
package sorare_utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lehouerou.net/laurent/sorare"
|
||||||
|
"git.lehouerou.net/laurent/sorare/graphql"
|
||||||
|
"git.lehouerou.net/laurent/sorarebuddy/db"
|
||||||
|
"git.lehouerou.net/laurent/sorarebuddy/model"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCountryFromSorare(s sorare.Country) model.Country {
|
||||||
|
return model.Country{
|
||||||
|
Slug: s.Slug,
|
||||||
|
Code: s.Code,
|
||||||
|
DisplayName: s.Name,
|
||||||
|
ThreeLetterCode: s.ThreeLetterCode,
|
||||||
|
FlagFlat64Url: s.FlagFlat64Url,
|
||||||
|
FlagFlat32Url: s.FlagFlat32Url,
|
||||||
|
FlagRound64Url: s.FlagRound64Url,
|
||||||
|
FlagRound32Url: s.FlagRound32Url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CountryUpdater struct {
|
||||||
|
db *db.Client
|
||||||
|
s *sorare.Sorare
|
||||||
|
cache []sorare.Country
|
||||||
|
slugsToRead []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCountryUpdater(s *sorare.Sorare, db *db.Client) *CountryUpdater {
|
||||||
|
return &CountryUpdater{db: db, s: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *CountryUpdater) AddSlugsToRead(slugs []string) {
|
||||||
|
slugs = lo.Filter(slugs, func(slug string, index int) bool {
|
||||||
|
return slug != ""
|
||||||
|
})
|
||||||
|
u.slugsToRead = append(u.slugsToRead, slugs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *CountryUpdater) Read(ctx context.Context, onlyMissings bool) error {
|
||||||
|
if len(u.slugsToRead) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("reading %d countries...", len(u.slugsToRead))
|
||||||
|
slugs := u.slugsToRead
|
||||||
|
u.slugsToRead = nil
|
||||||
|
|
||||||
|
if onlyMissings {
|
||||||
|
log.Debug().Msgf("filtering countries in db...")
|
||||||
|
slugsNotInDb, err := u.db.Countries.GetCountrySlugsNotInDb(ctx, slugs)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting countries not in db")
|
||||||
|
}
|
||||||
|
slugs = slugsNotInDb
|
||||||
|
log.Debug().Msgf("%d countries not in db", len(slugs))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msgf("getting countries from sorare...")
|
||||||
|
u.cache = nil
|
||||||
|
for i, chunk := range lo.Chunk(slugs, 100) {
|
||||||
|
log.Debug().Msgf("\tbatch %d/%d", i+1, (len(slugs)/100)+1)
|
||||||
|
c, err := u.s.Countries.Get(ctx, graphql.SlugsParams{Slugs: chunk})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.cache = append(u.cache, c...)
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("%d countries fetched from sorare", len(u.cache))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *CountryUpdater) Write(ctx context.Context) error {
|
||||||
|
log.Debug().Msg("inserting countries into db...")
|
||||||
|
err := u.db.Countries.CreateOrUpdateMany(
|
||||||
|
ctx,
|
||||||
|
lo.Map(u.cache, func(country sorare.Country, index int) model.Country {
|
||||||
|
return NewCountryFromSorare(country)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "inserting countries")
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("%d countries inserted", len(u.cache))
|
||||||
|
u.cache = nil
|
||||||
|
return nil
|
||||||
|
}
|
@ -15,6 +15,7 @@ func NewGamePlayerScoreFromSorare(
|
|||||||
res := model.GamePlayerScore{
|
res := model.GamePlayerScore{
|
||||||
GameID: gameId,
|
GameID: gameId,
|
||||||
PlayerSlug: s.Player.Slug,
|
PlayerSlug: s.Player.Slug,
|
||||||
|
GameDate: s.Game.Date,
|
||||||
MinutesPlayed: int(s.PlayerGameStats.MinsPlayed),
|
MinutesPlayed: int(s.PlayerGameStats.MinsPlayed),
|
||||||
GameStarted: s.PlayerGameStats.GameStarted == 1,
|
GameStarted: s.PlayerGameStats.GameStarted == 1,
|
||||||
FormationPlace: int(s.PlayerGameStats.FormationPlace),
|
FormationPlace: int(s.PlayerGameStats.FormationPlace),
|
||||||
|
@ -20,7 +20,8 @@ type Updater struct {
|
|||||||
s *sorare.Sorare
|
s *sorare.Sorare
|
||||||
db *db.Client
|
db *db.Client
|
||||||
|
|
||||||
countrySlugsToRead []string
|
countryUpdater *CountryUpdater
|
||||||
|
|
||||||
competitionSlugsToRead []string
|
competitionSlugsToRead []string
|
||||||
teamSlugsToRead []string
|
teamSlugsToRead []string
|
||||||
playerSlugsToRead []string
|
playerSlugsToRead []string
|
||||||
@ -29,7 +30,6 @@ type Updater struct {
|
|||||||
gameFormationsToRead []string
|
gameFormationsToRead []string
|
||||||
gameScoresToReadFromMap map[string][]string
|
gameScoresToReadFromMap map[string][]string
|
||||||
|
|
||||||
countryCache []sorare.Country
|
|
||||||
competitionCache []football.Competition
|
competitionCache []football.Competition
|
||||||
clubCache []football.Club
|
clubCache []football.Club
|
||||||
nationalTeamCache []football.NationalTeam
|
nationalTeamCache []football.NationalTeam
|
||||||
@ -77,14 +77,16 @@ func WithUpdateOnlyMissingPlayers(value bool) UpdaterOption {
|
|||||||
|
|
||||||
func NewUpdater(s *sorare.Sorare, db *db.Client, opts ...UpdaterOption) *Updater {
|
func NewUpdater(s *sorare.Sorare, db *db.Client, opts ...UpdaterOption) *Updater {
|
||||||
return &Updater{
|
return &Updater{
|
||||||
s: s,
|
s: s,
|
||||||
db: db,
|
db: db,
|
||||||
|
|
||||||
|
countryUpdater: NewCountryUpdater(s, db),
|
||||||
|
|
||||||
gameScoresToReadFromMap: make(map[string][]string),
|
gameScoresToReadFromMap: make(map[string][]string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *Updater) Reset() {
|
func (u *Updater) Reset() {
|
||||||
u.countrySlugsToRead = nil
|
|
||||||
u.competitionSlugsToRead = nil
|
u.competitionSlugsToRead = nil
|
||||||
u.teamSlugsToRead = nil
|
u.teamSlugsToRead = nil
|
||||||
u.playerSlugsToRead = nil
|
u.playerSlugsToRead = nil
|
||||||
@ -95,7 +97,6 @@ func (u *Updater) Reset() {
|
|||||||
delete(u.gameScoresToReadFromMap, k)
|
delete(u.gameScoresToReadFromMap, k)
|
||||||
}
|
}
|
||||||
|
|
||||||
u.countryCache = nil
|
|
||||||
u.competitionCache = nil
|
u.competitionCache = nil
|
||||||
u.clubCache = nil
|
u.clubCache = nil
|
||||||
u.nationalTeamCache = nil
|
u.nationalTeamCache = nil
|
||||||
@ -144,11 +145,11 @@ func (u *Updater) Update(ctx context.Context, opts ...UpdaterOption) error {
|
|||||||
return errors.Wrap(err, "reading competitions")
|
return errors.Wrap(err, "reading competitions")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := u.readCountries(ctx, options.UpdateOnlyMissingCountries); err != nil {
|
if err := u.countryUpdater.Read(ctx, options.UpdateOnlyMissingCountries); err != nil {
|
||||||
return errors.Wrap(err, "reading countries")
|
return errors.Wrap(err, "reading countries")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := u.writeCountries(ctx); err != nil {
|
if err := u.countryUpdater.Write(ctx); err != nil {
|
||||||
return errors.Wrap(err, "writing countries")
|
return errors.Wrap(err, "writing countries")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,68 +181,7 @@ func (u *Updater) Update(ctx context.Context, opts ...UpdaterOption) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *Updater) AddCountriesToRead(slugs ...string) {
|
func (u *Updater) AddCountriesToRead(slugs ...string) {
|
||||||
slugs = lo.Filter(slugs, func(slug string, index int) bool {
|
u.countryUpdater.AddSlugsToRead(slugs)
|
||||||
return slug != ""
|
|
||||||
})
|
|
||||||
u.countrySlugsToRead = lo.Uniq(append(u.countrySlugsToRead, slugs...))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *Updater) readCountries(ctx context.Context, onlyMissings bool) error {
|
|
||||||
if len(u.countrySlugsToRead) == 0 {
|
|
||||||
log.Debug().Msg("no countries to read")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
log.Debug().Msgf("reading %d countries...", len(u.countrySlugsToRead))
|
|
||||||
slugs := u.countrySlugsToRead
|
|
||||||
u.countrySlugsToRead = nil
|
|
||||||
|
|
||||||
if onlyMissings {
|
|
||||||
log.Debug().Msgf("filtering countries in db...")
|
|
||||||
slugsNotInDb, err := u.db.Countries.GetCountrySlugsNotInDb(ctx, slugs)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting countries not in db")
|
|
||||||
}
|
|
||||||
slugs = slugsNotInDb
|
|
||||||
log.Debug().Msgf("%d countries not in db", len(slugs))
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug().Msgf("getting countries from sorare...")
|
|
||||||
u.countryCache = nil
|
|
||||||
for i, chunk := range lo.Chunk(slugs, 100) {
|
|
||||||
log.Debug().Msgf("\tbatch %d/%d", i+1, (len(slugs)/100)+1)
|
|
||||||
c, err := u.s.Countries.Get(ctx, graphql.SlugsParams{Slugs: chunk})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.countryCache = append(u.countryCache, c...)
|
|
||||||
}
|
|
||||||
log.Debug().Msgf("%d countries fetched from sorare", len(u.countryCache))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *Updater) writeCountries(ctx context.Context) error {
|
|
||||||
log.Debug().Msg("inserting countries into db...")
|
|
||||||
err := u.db.Countries.CreateOrUpdateMany(
|
|
||||||
ctx,
|
|
||||||
lo.Map(u.countryCache, func(country sorare.Country, index int) model.Country {
|
|
||||||
return model.Country{
|
|
||||||
Slug: country.Slug,
|
|
||||||
Code: country.Code,
|
|
||||||
DisplayName: country.Name,
|
|
||||||
ThreeLetterCode: country.ThreeLetterCode,
|
|
||||||
FlagFlat64Url: country.FlagFlat64Url,
|
|
||||||
FlagFlat32Url: country.FlagFlat32Url,
|
|
||||||
FlagRound64Url: country.FlagRound64Url,
|
|
||||||
FlagRound32Url: country.FlagRound32Url,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "inserting countries")
|
|
||||||
}
|
|
||||||
log.Debug().Msgf("%d countries inserted", len(u.countryCache))
|
|
||||||
u.countryCache = nil
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *Updater) AddCompetitionsToRead(slugs ...string) {
|
func (u *Updater) AddCompetitionsToRead(slugs ...string) {
|
||||||
@ -384,7 +324,7 @@ func (u *Updater) readTeams(ctx context.Context, onlyMissings bool) error {
|
|||||||
|
|
||||||
func (u *Updater) writeTeams(ctx context.Context) error {
|
func (u *Updater) writeTeams(ctx context.Context) error {
|
||||||
log.Debug().Msg("inserting teams into db...")
|
log.Debug().Msg("inserting teams into db...")
|
||||||
err := u.db.Teams.CreateOrUpdateMany(ctx, lo.Union(
|
err := u.db.Teams.CreateOrUpdateMany(ctx, append(
|
||||||
lo.Map(u.clubCache, func(club football.Club, index int) model.Team {
|
lo.Map(u.clubCache, func(club football.Club, index int) model.Team {
|
||||||
return model.Team{
|
return model.Team{
|
||||||
Slug: club.Slug,
|
Slug: club.Slug,
|
||||||
@ -411,7 +351,7 @@ func (u *Updater) writeTeams(ctx context.Context) error {
|
|||||||
PictureUrl: nationalTeam.PictureUrl,
|
PictureUrl: nationalTeam.PictureUrl,
|
||||||
TeamType: "national",
|
TeamType: "national",
|
||||||
}
|
}
|
||||||
}),
|
})...,
|
||||||
))
|
))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "inserting teams")
|
return errors.Wrap(err, "inserting teams")
|
||||||
|
Loading…
Reference in New Issue
Block a user