This commit is contained in:
Laurent Le Houerou 2024-06-06 09:52:54 +04:00
parent 7a5e9244a7
commit 8f6317be6e
79 changed files with 3548 additions and 7473 deletions

View File

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

3
.gitignore vendored
View File

@ -20,3 +20,6 @@ go.work.sum
bin/ bin/
.env .env
cmd/server/dist/
.vscode/launch.json

View File

@ -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
View 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

View File

@ -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'
} }

View File

@ -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)
}

View File

@ -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),
} }

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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;

View 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);

View File

@ -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")

View File

@ -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
}

View File

@ -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": ["."]
} }

Binary file not shown.

4802
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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
} }

View File

@ -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(",") },
}); });
} }

View File

@ -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)
} }

View File

@ -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 !== ""
}) })
} }

View File

@ -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
} }

View File

@ -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
View 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
}

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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>
)
}

View File

@ -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>
) )
} }

View File

@ -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>
); );
} }

View File

@ -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" }),
}}
/> />
) )
} }

View File

@ -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)}</>
} }

View File

@ -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;
} }

View File

@ -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>
) )
} }

View File

@ -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>
</> </>
) )
} }

View File

@ -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>
) )
} }

View File

@ -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>
)
}

View File

@ -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

View File

@ -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>
) )
} }

View File

@ -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[])
}} }}
/> />
) )
} }

View File

@ -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>
) )
} }

View File

@ -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> }
)
}
}

View 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 />,
},
],
}

View File

@ -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> }
)
}

View File

@ -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>
) )
} }

View File

@ -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>
)
}

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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>
); );
} }

View File

@ -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">
&plusmn;{item.totalStddevScore} &plusmn;{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,

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View 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>
)
}
}

View 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>
)
}

View 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 />,
},
],
}

View 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>
)
}

View 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>
)
}

View File

@ -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>&nbsp;</span>
)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -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() {

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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;
} }

View File

@ -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
} }

View File

@ -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"
} }
}

View File

@ -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[]
}

View File

@ -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[]
}

View File

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

View File

@ -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"`

View File

@ -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"`
} }

View File

@ -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"`
} }

View 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
View 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
}

View File

@ -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),

View File

@ -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")