wip
This commit is contained in:
parent
7a5e9244a7
commit
8f6317be6e
3
.gitignore
vendored
3
.gitignore
vendored
@ -20,3 +20,6 @@ go.work.sum
|
||||
|
||||
bin/
|
||||
.env
|
||||
|
||||
cmd/server/dist/
|
||||
.vscode/launch.json
|
||||
|
16
Makefile
Normal file
16
Makefile
Normal file
@ -0,0 +1,16 @@
|
||||
build:
|
||||
docker build --rm -t registry.lehouerou.net/sorareplus:latest .
|
||||
|
||||
push: build
|
||||
docker push registry.lehouerou.net/sorareplus:latest
|
||||
|
||||
deploy: push
|
||||
ssh srv03 'docker pull registry.lehouerou.net/sorareplus; cd services/sorare; docker compose up -d sorareplus'
|
||||
|
||||
dbup:
|
||||
goose -dir=./db/migrations postgres "host=192.168.1.250 port=5436 user=sorare password=sorare dbname=sorare sslmode=disable" up
|
||||
|
||||
dbdown:
|
||||
goose -dir=./db/migrations postgres "host=192.168.1.250 port=5436 user=sorare password=sorare dbname=sorare sslmode=disable" down
|
||||
|
||||
.PHONY: build push deploy dbup
|
@ -169,6 +169,12 @@ func run(cmd *cobra.Command, _ []string) error {
|
||||
player := api.Group("/player")
|
||||
player.GET("/:slug", s.GetPlayer)
|
||||
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("/competition/club", s.GetAllClubCompetitions)
|
||||
api.POST("/rankings/single", s.SingleRankings)
|
||||
@ -360,3 +366,66 @@ func (s *ApiServer) SearchMulti(c echo.Context) error {
|
||||
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *ApiServer) GetTeam(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
slug := c.Param("slug")
|
||||
res, err := s.db.Teams.GetOne(ctx, slug, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *ApiServer) GetTeamRoster(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
slug := c.Param("slug")
|
||||
|
||||
team, err := s.db.Teams.GetOne(ctx, slug, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
roster, err := s.db.Teams.GetRoster(ctx, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
roster = lo.Map(roster, func(player model.Player, _ int) model.Player {
|
||||
player.GamePlayers = lo.Filter(player.GamePlayers, func(gp model.GamePlayer, _ int) bool {
|
||||
return gp.TeamSlug == slug
|
||||
})
|
||||
return player
|
||||
})
|
||||
|
||||
var res struct {
|
||||
Team model.Team `json:"team"`
|
||||
Roster []model.Player `json:"roster"`
|
||||
}
|
||||
res.Team = team
|
||||
res.Roster = roster
|
||||
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *ApiServer) GetTeamMemberships(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
slug := c.Param("slug")
|
||||
memberships, err := s.db.Teams.GetMemberships(ctx, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
team, err := s.db.Teams.GetOne(ctx, slug, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Team model.Team `json:"team"`
|
||||
Memberships []model.Membership `json:"memberships"`
|
||||
}
|
||||
res.Team = team
|
||||
res.Memberships = memberships
|
||||
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ func NewClient(db *bun.DB) *Client {
|
||||
Games: NewGameRepository(db),
|
||||
Players: NewPlayerRepository(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),
|
||||
}
|
||||
|
||||
|
@ -1,116 +0,0 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS player_averages (
|
||||
player_slug VARCHAR NOT NULL,
|
||||
fixture_slug VARCHAR NOT NULL,
|
||||
PRIMARY KEY (player_slug, fixture_slug),
|
||||
l5 INTEGER,
|
||||
l5r INTEGER,
|
||||
l15 INTEGER,
|
||||
l15r INTEGER,
|
||||
l40 INTEGER,
|
||||
l40r INTEGER,
|
||||
gameweek INTEGER,
|
||||
ds5 INTEGER,
|
||||
ds15 INTEGER,
|
||||
ds40 INTEGER,
|
||||
aa5 INTEGER,
|
||||
aa15 INTEGER,
|
||||
aa40 INTEGER,
|
||||
minutes15 INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX player_averages_player_slug_gameweek_index ON player_averages (player_slug ASC, gameweek DESC);
|
||||
|
||||
CREATE OR REPLACE PROCEDURE calc_stats (IN starting_gameweek INTEGER) LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
fixture RECORD;
|
||||
player RECORD;
|
||||
startdate TIMESTAMP WITH TIME ZONE;
|
||||
l5 INTEGER;
|
||||
l15 INTEGER;
|
||||
l40 INTEGER;
|
||||
l5r INTEGER;
|
||||
l15r INTEGER;
|
||||
l40r INTEGER;
|
||||
ds5 INTEGER;
|
||||
ds15 INTEGER;
|
||||
ds40 INTEGER;
|
||||
aa5 INTEGER;
|
||||
aa15 INTEGER;
|
||||
aa40 INTEGER;
|
||||
minutes15 INTEGER;
|
||||
|
||||
BEGIN
|
||||
|
||||
FOR fixture IN
|
||||
(SELECT * FROM fixtures WHERE game_week >= starting_gameweek ORDER BY game_week)
|
||||
LOOP
|
||||
RAISE NOTICE 'Processing fixture: %, Game week: %', fixture.slug, fixture.game_week;
|
||||
|
||||
SELECT start_date FROM fixtures WHERE game_week = fixture.game_week - 1 INTO startdate;
|
||||
IF startdate IS NULL THEN
|
||||
startdate = fixture.start_date - INTERVAL '3 days';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Start date for calculations: %', startdate;
|
||||
|
||||
FOR player IN
|
||||
(SELECT * FROM players)
|
||||
LOOP
|
||||
RAISE NOTICE 'Calculating averages for player: %', player.slug;
|
||||
WITH subquery AS (SELECT row_number() OVER (ORDER BY g.date DESC) AS rn,
|
||||
score,
|
||||
decisive_score,
|
||||
all_around_score,
|
||||
minutes
|
||||
FROM game_player_scores AS gps
|
||||
JOIN games AS g ON g.id = gps.game_id
|
||||
WHERE player_slug = player.slug
|
||||
AND g.date < startdate)
|
||||
select coalesce(round(avg(score) filter ( where rn <= 5 )), 0),
|
||||
coalesce(round(avg(score) filter ( where rn <= 15 )), 0),
|
||||
coalesce(round(avg(score) filter ( where rn <= 40 )), 0),
|
||||
coalesce(round(avg(score) filter ( where rn <= 5 and minutes > 0 )), 0),
|
||||
coalesce(round(avg(score) filter ( where rn <= 15 and minutes > 0 )), 0),
|
||||
coalesce(round(avg(score) filter ( where rn <= 40 and minutes > 0 )), 0),
|
||||
coalesce(round(avg(decisive_score) filter ( where rn <= 5 and minutes > 0 )), 0),
|
||||
coalesce(round(avg(decisive_score) filter ( where rn <= 15 and minutes > 0 )), 0),
|
||||
coalesce(round(avg(decisive_score) filter ( where rn <= 40 and minutes > 0 )), 0),
|
||||
coalesce(round(avg(all_around_score) filter ( where rn <= 5 and minutes > 0 )), 0),
|
||||
coalesce(round(avg(all_around_score) filter ( where rn <= 15 and minutes > 0 )), 0),
|
||||
coalesce(round(avg(all_around_score) filter ( where rn <= 40 and minutes > 0 )), 0),
|
||||
coalesce(round(avg(minutes) filter ( where rn <= 15 )), 0)
|
||||
|
||||
from subquery
|
||||
into l5r, l15r, l40r, l5, l15, l40, ds5, ds15, ds40, aa5, aa15, aa40, minutes15;
|
||||
|
||||
RAISE NOTICE 'Inserting/updating averages for player: %, Fixture: %, Game week: %, l15: %', player.slug, fixture.slug, fixture.game_week, l15;
|
||||
|
||||
INSERT INTO player_averages (player_slug, fixture_slug, gameweek, l5, l5r, l15, l15r, l40, l40r, ds5, ds15, ds40, aa5, aa15, aa40, minutes15)
|
||||
VALUES (player.slug, fixture.slug, fixture.game_week, l5, l5r, l15, l15r, l40, l40r, ds5, ds15, ds40, aa5, aa15, aa40, minutes15)
|
||||
ON CONFLICT (player_slug, fixture_slug) DO UPDATE SET l5 = EXCLUDED.l5,
|
||||
l5r = EXCLUDED.l5r,
|
||||
l15 = EXCLUDED.l15,
|
||||
l15r = EXCLUDED.l15r,
|
||||
l40 = EXCLUDED.l40,
|
||||
l40r = EXCLUDED.l40r,
|
||||
ds5 = EXCLUDED.ds5,
|
||||
ds15 = EXCLUDED.ds15,
|
||||
ds40 = EXCLUDED.ds40,
|
||||
aa5 = EXCLUDED.aa5,
|
||||
aa15 = EXCLUDED.aa15,
|
||||
aa40 = EXCLUDED.aa40,
|
||||
minutes15 = EXCLUDED.minutes15;
|
||||
|
||||
END LOOP;
|
||||
COMMIT;
|
||||
END LOOP;
|
||||
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- +goose Down
|
||||
DROP PROCEDURE IF EXISTS calc_stats;
|
||||
DROP INDEX IF EXISTS player_averages_player_slug_gameweek_index;
|
||||
DROP TABLE IF EXISTS player_averages;
|
||||
|
10
db/migrations/00010_game_date_on_game_player_scores.sql
Normal file
10
db/migrations/00010_game_date_on_game_player_scores.sql
Normal file
@ -0,0 +1,10 @@
|
||||
|
||||
-- +goose Up
|
||||
ALTER TABLE game_player_scores ADD COLUMN game_date TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_game_player_scores_player_slug_game_date ON game_player_scores (player_slug, game_date);
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE game_player_scores DROP COLUMN game_date;
|
||||
|
||||
DROP INDEX IF EXISTS idx_game_player_scores_player_slug_game_date;
|
12
db/migrations/00011_game_player_scores_hypertable.sql
Normal file
12
db/migrations/00011_game_player_scores_hypertable.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE game_player_scores DROP CONSTRAINT game_player_scores_pkey;
|
||||
ALTER TABLE game_player_scores ADD PRIMARY KEY (game_id, player_slug, game_date);
|
||||
SELECT create_hypertable('game_player_scores', 'game_date', migrate_data => true);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_players_player_slug_team_slug ON game_players(player_slug, team_slug);
|
||||
-- +goose Down
|
||||
|
||||
DROP INDEX IF EXISTS idx_game_players_player_slug_team_slug;
|
||||
ALTER TABLE game_player_scores DROP CONSTRAINT game_player_scores_pkey;
|
||||
ALTER TABLE game_player_scores ADD PRIMARY KEY (game_id, player_slug, game_date);
|
||||
SELECT drop_hypertable('game_player_scores', cascade => true);
|
20
db/player.go
20
db/player.go
@ -32,8 +32,6 @@ func (r *PlayerRepository) SearchByDisplayName(
|
||||
err := r.db.NewSelect().
|
||||
Model(&players).
|
||||
Relation("Team").
|
||||
Relation("DomesticLeague").
|
||||
Relation("DomesticLeague.Zone").
|
||||
Where("f_unaccent(player.display_name) ILIKE ?", "%"+displayName+"%").
|
||||
Limit(limit).
|
||||
Scan(ctx)
|
||||
@ -153,10 +151,15 @@ type SingleRanking struct {
|
||||
MoreThan20AAPercentage decimal.Decimal `bun:"more_than_20_aa_percentage" json:"moreThan20AAPercentage"`
|
||||
MoreThan10AAPercentage decimal.Decimal `bun:"more_than_10_aa_percentage" json:"moreThan10AAPercentage"`
|
||||
|
||||
TotalMinutes decimal.Decimal `bun:"total_minutes" json:"totalMinutes"`
|
||||
TotalAA decimal.Decimal `bun:"total_aa" json:"totalAA"`
|
||||
AAPerMin decimal.Decimal `bun:"aa_per_min" json:"aaPerMin"`
|
||||
Scores []*decimal.Decimal `bun:"scores,array" json:"scores"`
|
||||
TotalMinutes decimal.Decimal `bun:"total_minutes" json:"totalMinutes"`
|
||||
TotalAA decimal.Decimal `bun:"total_aa" json:"totalAA"`
|
||||
AAPerMin decimal.Decimal `bun:"aa_per_min" json:"aaPerMin"`
|
||||
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"`
|
||||
}
|
||||
@ -294,6 +297,11 @@ func (r *PlayerRepository) SingleRankings(ctx context.Context, opts SingleRankin
|
||||
ColumnExpr("SUM(fs.minutes_played) as total_minutes").
|
||||
ColumnExpr("SUM(fs.all_around_score) as total_aa").
|
||||
ColumnExpr("COALESCE(ROUND(SUM(fs.all_around_score) / NULLIF(SUM(fs.minutes_played), 0), 3), 0) AS aa_per_min").
|
||||
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").
|
||||
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")
|
||||
|
44
db/team.go
44
db/team.go
@ -20,6 +20,23 @@ func NewTeamRepository(db *bun.DB) *TeamRepository {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TeamRepository) GetOne(ctx context.Context, slug string, includeGames bool) (model.Team, error) {
|
||||
var team model.Team
|
||||
req := r.db.NewSelect().
|
||||
Model(&team).
|
||||
Where("team.slug = ?", slug).
|
||||
Relation("Country").
|
||||
Relation("DomesticLeague").
|
||||
Relation("DomesticLeague.Zone")
|
||||
if includeGames {
|
||||
req = req.Relation("HomeGames").
|
||||
Relation("AwayGames")
|
||||
}
|
||||
|
||||
err := req.Scan(ctx)
|
||||
return team, err
|
||||
}
|
||||
|
||||
func (r *TeamRepository) GetTeamSlugsNotInDb(ctx context.Context, teamSlugs []string) ([]string, error) {
|
||||
var teams []model.Team
|
||||
err := r.db.NewSelect().
|
||||
@ -45,3 +62,30 @@ func (r *TeamRepository) SearchByDisplayName(ctx context.Context, displayName st
|
||||
Scan(ctx)
|
||||
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
|
||||
}
|
||||
|
BIN
front/bun.lockb
BIN
front/bun.lockb
Binary file not shown.
4802
front/package-lock.json
generated
4802
front/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -59,6 +59,11 @@ export interface SingleRanking {
|
||||
moreThan20AAPercentage: number
|
||||
moreThan10AAPercentage: number
|
||||
aaPerMin: number
|
||||
duelWonPer90Min: number
|
||||
effectiveClearancePer90Min: number
|
||||
shotOnTargetPer90Min: number
|
||||
accuratePassPer90Min: number
|
||||
wonContestPer90Min: number
|
||||
totalMinutes: number
|
||||
scores: number[]
|
||||
player: Player
|
||||
|
36
front/src/api/team.ts
Normal file
36
front/src/api/team.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import Player, { Membership } from "../types/player"
|
||||
import Team from "../types/team"
|
||||
import { Get } from "./api"
|
||||
|
||||
export async function GetTeam(slug: string): Promise<Team> {
|
||||
const raw = await Get<Team>({
|
||||
endpoint: `/api/team/${slug}`,
|
||||
})
|
||||
return raw
|
||||
}
|
||||
|
||||
export interface TeamRosterResponse {
|
||||
team: Team
|
||||
roster: Player[]
|
||||
}
|
||||
|
||||
export async function GetTeamRoster(slug: string): Promise<TeamRosterResponse> {
|
||||
const raw = await Get<TeamRosterResponse>({
|
||||
endpoint: `/api/team/${slug}/roster`,
|
||||
})
|
||||
return raw
|
||||
}
|
||||
|
||||
export interface TeamMembershipsResponse {
|
||||
team: Team
|
||||
memberships: Membership[]
|
||||
}
|
||||
|
||||
export async function GetTeamMemberships(
|
||||
slug: string
|
||||
): Promise<TeamMembershipsResponse> {
|
||||
const raw = await Get<TeamMembershipsResponse>({
|
||||
endpoint: `/api/team/${slug}/memberships`,
|
||||
})
|
||||
return raw
|
||||
}
|
BIN
front/src/assets/man.png
Normal file
BIN
front/src/assets/man.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
27
front/src/components/badge.tsx
Normal file
27
front/src/components/badge.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export default function Badge({
|
||||
imgUrl,
|
||||
name,
|
||||
imgHeight = "full",
|
||||
link = "",
|
||||
}: {
|
||||
imgUrl?: string
|
||||
name?: string
|
||||
imgHeight?: string
|
||||
link?: string
|
||||
}) {
|
||||
return link ? (
|
||||
<Link to={link}>
|
||||
<div className="flex h-8 items-center gap-3 rounded-full border border-neutral-300 bg-gradient-to-r from-primary-blue-200 to-neutral-50 px-3 py-1 shadow-md">
|
||||
<img src={imgUrl} className={`h-${imgHeight}`} />
|
||||
<span className="text-sm">{name}</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex h-8 items-center gap-3 rounded-full border border-neutral-300 bg-gradient-to-r from-primary-blue-200 to-neutral-50 px-3 py-1 shadow-md">
|
||||
<img src={imgUrl} className={`h-${imgHeight}`} />
|
||||
<span className="text-sm">{name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -118,6 +118,10 @@ export function SearchBox() {
|
||||
onChange={handleSelect}
|
||||
formatGroupLabel={formatGroupLabel}
|
||||
formatOptionLabel={formatOptionLabel}
|
||||
styles={{
|
||||
menu: (base) => ({ ...base, maxHeight: "600px" }),
|
||||
menuList: (base) => ({ ...base, maxHeight: "600px" }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -1,12 +1,35 @@
|
||||
import { useMemo } from "react"
|
||||
import { Link, useParams } from "react-router-dom"
|
||||
import { GetPlayer } from "../../api/player"
|
||||
import WithDataFetching from "../../components/withdatafetching"
|
||||
import Player from "../../types/player"
|
||||
|
||||
export default function ClubHistory({ player }: { player: Player }) {
|
||||
export default function ClubHistory() {
|
||||
const { slug } = useParams()
|
||||
if (!slug) {
|
||||
return <div>No</div>
|
||||
}
|
||||
return (
|
||||
<WithDataFetching
|
||||
queryKey={["player", slug]}
|
||||
queryFn={() => GetPlayer(slug)}
|
||||
>
|
||||
{(player: Player) => <ClubHistoryLayout player={player} />}
|
||||
</WithDataFetching>
|
||||
)
|
||||
}
|
||||
|
||||
function ClubHistoryLayout({ player }: { player: Player }) {
|
||||
const clubMemberships = useMemo(
|
||||
() =>
|
||||
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()) {
|
||||
@ -29,9 +52,11 @@ export default function ClubHistory({ player }: { player: Player }) {
|
||||
title={membership.team?.domesticLeague?.displayName}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<img className="h-10" src={membership.team?.pictureUrl} />
|
||||
</div>
|
||||
<Link to={`/team/${membership.team?.slug}`}>
|
||||
<div className="flex justify-center">
|
||||
<img className="h-10" src={membership.team?.pictureUrl} />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div>{membership.team?.displayName}</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { useState } from "react"
|
||||
import { useParams } from "react-router"
|
||||
import { Outlet, useLocation, useParams } from "react-router"
|
||||
import { Link } from "react-router-dom"
|
||||
import { GetPlayer } from "../../api/player"
|
||||
import WithDataFetching from "../../components/withdatafetching"
|
||||
import Player from "../../types/player"
|
||||
import ClubHistory from "./club_history"
|
||||
import Scores from "./scores"
|
||||
import Sidebar from "./sidebar"
|
||||
|
||||
export default function PlayerPage() {
|
||||
@ -23,31 +21,30 @@ export default function PlayerPage() {
|
||||
)
|
||||
|
||||
function PlayerLayout({ player }: { player: Player }) {
|
||||
const [activeTab, setActiveTab] = useState("club_history")
|
||||
const { pathname } = useLocation()
|
||||
const tabs = [
|
||||
{ id: "club_history", title: "Club History" },
|
||||
{ id: "summary", title: "Summary" },
|
||||
{ id: "clubhistory", title: "Club History" },
|
||||
{ id: "scores", title: "Scores" },
|
||||
]
|
||||
return (
|
||||
<div className="grid h-screen grid-cols-[260px_1fr]">
|
||||
<Sidebar player={player}></Sidebar>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex border-b">
|
||||
<div className="flex divide-x border-b border-gray-200">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`px-4 py-2 text-center text-sm font-medium ${activeTab === tab.id ? "border-b-2 border-blue-300 text-blue-400" : "text-gray-500 hover:text-gray-700"}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.title}
|
||||
</button>
|
||||
<Link key={tab.id} to={`/player/${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">
|
||||
{activeTab === "club_history" && (
|
||||
<ClubHistory player={player}></ClubHistory>
|
||||
)}
|
||||
{activeTab === "scores" && <Scores player={player}></Scores>}
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
27
front/src/pages/player/routes.tsx
Normal file
27
front/src/pages/player/routes.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import PlayerPage from "."
|
||||
import ClubHistory from "./club_history"
|
||||
import Scores from "./scores"
|
||||
import Summary from "./summary"
|
||||
|
||||
export const playerRoutes = {
|
||||
path: "player/:slug",
|
||||
element: <PlayerPage />,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
element: <ClubHistory />,
|
||||
},
|
||||
{
|
||||
path: "summary",
|
||||
element: <Summary />,
|
||||
},
|
||||
{
|
||||
path: "clubhistory",
|
||||
element: <ClubHistory />,
|
||||
},
|
||||
{
|
||||
path: "scores",
|
||||
element: <Scores />,
|
||||
},
|
||||
],
|
||||
}
|
@ -25,7 +25,6 @@ import {
|
||||
GamePlayerScore,
|
||||
GetScoreColor,
|
||||
} from "../../types/game"
|
||||
import Player from "../../types/player"
|
||||
|
||||
type DataPoint = {
|
||||
date: Date
|
||||
@ -67,7 +66,6 @@ export default function ScoreGraph({
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
player: Player
|
||||
scores: GamePlayer[]
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
|
@ -1,36 +1,47 @@
|
||||
import { GetPlayerGames } from "../../api/player"
|
||||
import { useParams } from "react-router"
|
||||
import { GetPlayer, GetPlayerGames } from "../../api/player"
|
||||
import WithDataFetching from "../../components/withdatafetching"
|
||||
import Player from "../../types/player"
|
||||
import DaysSelectionWrapper from "./components/days_selection"
|
||||
import ScoreGraph from "./score_graph"
|
||||
import ScoreTable from "./score_table"
|
||||
|
||||
export default function Scores({ player }: { player: Player }) {
|
||||
export default function Scores() {
|
||||
const { slug } = useParams()
|
||||
if (!slug) {
|
||||
return <div>No</div>
|
||||
}
|
||||
return (
|
||||
<DaysSelectionWrapper>
|
||||
{(startDate, endDate) => {
|
||||
return (
|
||||
<WithDataFetching
|
||||
queryKey={["playerScores", player.slug]}
|
||||
queryFn={() => GetPlayerGames(player.slug)}
|
||||
refetchOnWindowFocus={false}
|
||||
>
|
||||
{(scores) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-12">
|
||||
<ScoreGraph
|
||||
player={player}
|
||||
scores={scores}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
<ScoreTable player={player} scores={scores} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</WithDataFetching>
|
||||
)
|
||||
}}
|
||||
</DaysSelectionWrapper>
|
||||
<WithDataFetching
|
||||
queryKey={["player", slug]}
|
||||
queryFn={() => GetPlayer(slug)}
|
||||
>
|
||||
{(player: Player) => (
|
||||
<DaysSelectionWrapper>
|
||||
{(startDate, endDate) => {
|
||||
return (
|
||||
<WithDataFetching
|
||||
queryKey={["playerScores", player.slug]}
|
||||
queryFn={() => GetPlayerGames(player.slug)}
|
||||
refetchOnWindowFocus={false}
|
||||
>
|
||||
{(scores) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-12">
|
||||
<ScoreGraph
|
||||
scores={scores}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
<ScoreTable player={player} scores={scores} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</WithDataFetching>
|
||||
)
|
||||
}}
|
||||
</DaysSelectionWrapper>
|
||||
)}
|
||||
</WithDataFetching>
|
||||
)
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Link } from "react-router-dom"
|
||||
import Player from "../../types/player"
|
||||
|
||||
function Badge({
|
||||
@ -47,7 +48,9 @@ export default function Sidebar({ player }: { player: Player }) {
|
||||
name={player.country?.displayName}
|
||||
imgHeight="4"
|
||||
/>
|
||||
<Badge imgUrl={player.team?.pictureUrl} name={player.team?.shortName} />
|
||||
<Link to={`/team/${player.team?.slug}`}>
|
||||
<Badge imgUrl={player.team?.pictureUrl} name={player.team?.shortName} />
|
||||
</Link>
|
||||
<Badge
|
||||
imgUrl={player.domesticLeague?.pictureUrl}
|
||||
name={player.domesticLeague?.displayName}
|
||||
|
12
front/src/pages/player/summary.tsx
Normal file
12
front/src/pages/player/summary.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
export default function Summary() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span>Summary</span>
|
||||
|
||||
<p>
|
||||
Ceci est un résumé du joueur. Plus de détails seront ajoutés
|
||||
prochainement.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -80,6 +80,26 @@ const orders = [
|
||||
value: "more_than_10_aa_percentage DESC",
|
||||
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() {
|
||||
@ -399,8 +419,35 @@ function SingleRankingsPage() {
|
||||
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
|
||||
AA/Min
|
||||
</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">
|
||||
Total Min
|
||||
Total Minutes
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-500">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
@ -444,7 +491,7 @@ function SingleRankingsPage() {
|
||||
{data?.map((item) => {
|
||||
return (
|
||||
<tr key={item.player.slug}>
|
||||
<td className="relative whitespace-nowrap px-3 py-4">
|
||||
<td className="relative whitespace-nowrap px-2 py-4">
|
||||
<div
|
||||
className={"flex h-10 w-10 items-center justify-center"}
|
||||
>
|
||||
@ -461,17 +508,13 @@ function SingleRankingsPage() {
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td className="relative whitespace-nowrap px-3 py-4">
|
||||
<td className="relative whitespace-nowrap px-2 py-4">
|
||||
<div
|
||||
className={
|
||||
"flex h-8 w-full items-center justify-center"
|
||||
}
|
||||
>
|
||||
<a
|
||||
href={`https://www.soraredata.com/playerSearch/${item.player.displayName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Link to={`/team/${item.player.teamSlug}`}>
|
||||
{item.player.team?.pictureUrl ? (
|
||||
<img
|
||||
src={item.player.team.pictureUrl}
|
||||
@ -487,10 +530,10 @@ function SingleRankingsPage() {
|
||||
{item.player.team?.shortName?.substring(0, 3)}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</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}`}>
|
||||
{item.player.displayName}
|
||||
</Link>
|
||||
@ -502,7 +545,7 @@ function SingleRankingsPage() {
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-80 whitespace-nowrap px-3 py-2">
|
||||
<td className="w-80 whitespace-nowrap px-2 py-2">
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-center gap-3"
|
||||
@ -550,16 +593,16 @@ function SingleRankingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-xs">
|
||||
<td className="whitespace-nowrap px-2 py-4 text-xs">
|
||||
±{item.totalStddevScore}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
<td className="whitespace-nowrap px-2 py-4 text-sm">
|
||||
{item.decisiveAvgScore}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
<td className="whitespace-nowrap px-2 py-4 text-sm">
|
||||
{item.allAroundAvgScore}
|
||||
</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="w-full">
|
||||
<ProgressBar
|
||||
@ -614,30 +657,64 @@ function SingleRankingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</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}
|
||||
</td>
|
||||
{["Goalkeeper", "Defender"].includes(
|
||||
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}
|
||||
</td>
|
||||
)}
|
||||
{["Midfielder", "Forward"].includes(
|
||||
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}
|
||||
</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}
|
||||
</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}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4">
|
||||
<td className="whitespace-nowrap px-2 py-4">
|
||||
<ProgressBar
|
||||
percentages={[
|
||||
item.moreThan80ScorePercentage,
|
||||
@ -647,7 +724,7 @@ function SingleRankingsPage() {
|
||||
]}
|
||||
/>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4">
|
||||
<td className="whitespace-nowrap px-2 py-4">
|
||||
<ProgressBar
|
||||
percentages={[
|
||||
item.moreThan40AAPercentage,
|
||||
|
49
front/src/pages/team/index.tsx
Normal file
49
front/src/pages/team/index.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { Outlet, useLocation, useParams } from "react-router"
|
||||
import { Link } from "react-router-dom"
|
||||
import { GetTeam } from "../../api/team"
|
||||
import WithDataFetching from "../../components/withdatafetching"
|
||||
import Team from "../../types/team"
|
||||
import Sidebar from "./sidebar"
|
||||
|
||||
export default function TeamPage() {
|
||||
const { slug } = useParams()
|
||||
|
||||
if (!slug) {
|
||||
return <div>No slug</div>
|
||||
}
|
||||
return (
|
||||
<WithDataFetching queryKey={["team", slug]} queryFn={() => GetTeam(slug)}>
|
||||
{(team) => <TeamLayout team={team} />}
|
||||
</WithDataFetching>
|
||||
)
|
||||
|
||||
function TeamLayout({ team }: { team: Team }) {
|
||||
const { pathname } = useLocation()
|
||||
const tabs = [
|
||||
{ id: "roster", title: "Roster" },
|
||||
{ id: "transfers", title: "Transfers" },
|
||||
]
|
||||
return (
|
||||
<div className="grid h-screen grid-cols-[260px_1fr]">
|
||||
<Sidebar team={team}></Sidebar>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex divide-x border-b border-gray-200">
|
||||
{tabs.map((tab) => (
|
||||
<Link key={tab.id} to={`/team/${slug}/${tab.id}`}>
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`text-md px-6 py-2 text-center font-normal ${pathname.includes(tab.id) ? "bg-gray-200" : ""}`}
|
||||
>
|
||||
{tab.title}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
86
front/src/pages/team/roster.tsx
Normal file
86
front/src/pages/team/roster.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { useMemo } from "react"
|
||||
import { Link, useParams } from "react-router-dom"
|
||||
import { GetTeamRoster } from "../../api/team"
|
||||
import man from "../../assets/man.png"
|
||||
import WithDataFetching from "../../components/withdatafetching"
|
||||
import { GamePlayer } from "../../types/game"
|
||||
import Player from "../../types/player"
|
||||
import Team from "../../types/team"
|
||||
|
||||
export default function Roster() {
|
||||
const { slug } = useParams()
|
||||
if (!slug) {
|
||||
return <div>No</div>
|
||||
}
|
||||
return (
|
||||
<WithDataFetching
|
||||
queryKey={["roster", slug]}
|
||||
queryFn={() => GetTeamRoster(slug)}
|
||||
>
|
||||
{(resp) => <RosterLayout team={resp.team} roster={resp.roster} />}
|
||||
</WithDataFetching>
|
||||
)
|
||||
}
|
||||
|
||||
function RosterLayout({ team, roster }: { team: Team; roster: Player[] }) {
|
||||
const threeMonthsAgo = new Date()
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3)
|
||||
const totalTeamMinutes = useMemo(() => {
|
||||
return team.homeGames?.concat(team.awayGames)?.reduce((total, game) => {
|
||||
if (new Date(game.date) >= threeMonthsAgo) {
|
||||
return total + (game.minutes > 115 ? 120 : 90)
|
||||
}
|
||||
return total
|
||||
}, 0)
|
||||
}, [team])
|
||||
|
||||
const computeTotalMinutes = (gamePlayers: GamePlayer[]) => {
|
||||
return gamePlayers.reduce((total, game) => {
|
||||
if (game.game && new Date(game.game.date) >= threeMonthsAgo) {
|
||||
return total + (game.score?.minutesPlayed || 0)
|
||||
}
|
||||
return total
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const rosterWithMinutes = roster.map((player) => ({
|
||||
...player,
|
||||
totalMinutesPlayed: computeTotalMinutes(player.gamePlayers),
|
||||
minutesPlayedPercentage:
|
||||
(computeTotalMinutes(player.gamePlayers) / totalTeamMinutes) * 100,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{["Goalkeeper", "Defender", "Midfielder", "Forward"].map((position) => (
|
||||
<>
|
||||
<div className="rounded-xl bg-gradient-to-br from-primary-200 to-primary-50 px-3 py-1 text-center text-xl font-semibold text-neutral-700 shadow-md">
|
||||
{position}s
|
||||
</div>
|
||||
<div className="grid grid-cols-10 gap-3 p-3">
|
||||
{rosterWithMinutes
|
||||
.filter((player) => player.fieldPosition === position)
|
||||
.sort((a, b) => b.totalMinutesPlayed - a.totalMinutesPlayed)
|
||||
.map((player) => (
|
||||
<div
|
||||
className="flex flex-col items-center gap-2"
|
||||
key={player.slug}
|
||||
>
|
||||
<Link to={`/player/${player.slug}`}>
|
||||
<img
|
||||
className="h-32 rounded-md"
|
||||
src={player.avatarUrl || man}
|
||||
alt={""}
|
||||
/>
|
||||
</Link>
|
||||
<div>{player.displayName}</div>
|
||||
<div>{player.totalMinutesPlayed}</div>
|
||||
<div>{player.minutesPlayedPercentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
22
front/src/pages/team/routes.tsx
Normal file
22
front/src/pages/team/routes.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import TeamPage from "."
|
||||
import Roster from "./roster"
|
||||
import Transfers from "./transfers"
|
||||
|
||||
export const teamRoutes = {
|
||||
path: "team/:slug",
|
||||
element: <TeamPage />,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
element: <Roster />,
|
||||
},
|
||||
{
|
||||
path: "roster",
|
||||
element: <Roster />,
|
||||
},
|
||||
{
|
||||
path: "transfers",
|
||||
element: <Transfers />,
|
||||
},
|
||||
],
|
||||
}
|
28
front/src/pages/team/sidebar.tsx
Normal file
28
front/src/pages/team/sidebar.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import Badge from "../../components/badge"
|
||||
import Team from "../../types/team"
|
||||
|
||||
export default function Sidebar({ team }: { team: Team }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 border-r border-r-neutral-300 bg-gradient-to-b from-neutral-100 to-neutral-50 p-3 text-neutral-700 shadow-md">
|
||||
<div className="flex h-32 w-32 items-center justify-center overflow-hidden rounded-full border bg-neutral-50 p-3">
|
||||
<img
|
||||
src={team.pictureUrl}
|
||||
className="h-full w-full scale-[1.16] object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="font-secondary text-lg font-semibold">
|
||||
{team.displayName}
|
||||
</span>
|
||||
<Badge
|
||||
imgUrl={team.country?.flagFlat64Url}
|
||||
name={team.country?.displayName}
|
||||
imgHeight="4"
|
||||
/>
|
||||
<Badge
|
||||
imgUrl={team.domesticLeague?.pictureUrl}
|
||||
name={team.domesticLeague?.displayName}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
249
front/src/pages/team/transfers.tsx
Normal file
249
front/src/pages/team/transfers.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
import React from "react"
|
||||
import { FaPersonWalkingArrowRight } from "react-icons/fa6"
|
||||
import { Link, useParams } from "react-router-dom"
|
||||
import { GetTeamMemberships } from "../../api/team"
|
||||
import man from "../../assets/man.png"
|
||||
import Badge from "../../components/badge"
|
||||
import WithDataFetching from "../../components/withdatafetching"
|
||||
import { Membership } from "../../types/player"
|
||||
import Team from "../../types/team"
|
||||
|
||||
export default function Transfers() {
|
||||
const { slug } = useParams()
|
||||
if (!slug) {
|
||||
return <div>No team slug</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<WithDataFetching
|
||||
queryKey={["memberships", slug]}
|
||||
queryFn={() => GetTeamMemberships(slug)}
|
||||
>
|
||||
{(resp) => (
|
||||
<TransfersLayout team={resp.team} memberships={resp.memberships} />
|
||||
)}
|
||||
</WithDataFetching>
|
||||
)
|
||||
}
|
||||
|
||||
function TransfersLayout({
|
||||
memberships,
|
||||
}: {
|
||||
team: Team
|
||||
memberships: Membership[]
|
||||
}) {
|
||||
type YearMonthAccumulator = {
|
||||
[key: string]: {
|
||||
arrivals: Membership[]
|
||||
departures: Membership[]
|
||||
}
|
||||
}
|
||||
const groupedByYearMonth = memberships.reduce<YearMonthAccumulator>(
|
||||
(acc, membership) => {
|
||||
const startYearMonth = new Date(membership.startDate)
|
||||
.toISOString()
|
||||
.slice(0, 7)
|
||||
const endYearMonth = membership.endDate
|
||||
? new Date(membership.endDate).toISOString().slice(0, 7)
|
||||
: null
|
||||
|
||||
if (!acc[startYearMonth]) {
|
||||
acc[startYearMonth] = { arrivals: [], departures: [] }
|
||||
}
|
||||
acc[startYearMonth].arrivals.push(membership)
|
||||
|
||||
if (endYearMonth) {
|
||||
if (!acc[endYearMonth]) {
|
||||
acc[endYearMonth] = { arrivals: [], departures: [] }
|
||||
}
|
||||
acc[endYearMonth].departures.push(membership)
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
// Sort each group by startDate and then by playerSlug
|
||||
Object.keys(groupedByYearMonth).forEach((yearMonth) => {
|
||||
groupedByYearMonth[yearMonth].arrivals.sort((a, b) => {
|
||||
if (a.startDate === b.startDate) {
|
||||
return a.playerSlug.localeCompare(b.playerSlug)
|
||||
}
|
||||
return a.startDate < b.startDate ? -1 : 1
|
||||
})
|
||||
groupedByYearMonth[yearMonth].departures.sort((a, b) => {
|
||||
if (a.startDate === b.startDate) {
|
||||
return a.playerSlug.localeCompare(b.playerSlug)
|
||||
}
|
||||
return a.startDate < b.startDate ? -1 : 1
|
||||
})
|
||||
})
|
||||
|
||||
const sortedYearMonths = Object.keys(groupedByYearMonth).sort().reverse()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{sortedYearMonths.map((yearMonth, index, array) => {
|
||||
const { arrivals, departures } = groupedByYearMonth[yearMonth]
|
||||
const year = yearMonth.slice(0, 4)
|
||||
const isYearBoundary =
|
||||
index === 0 || year !== array[index - 1].slice(0, 4)
|
||||
const isEvenMonth = index % 2 === 0
|
||||
|
||||
return (
|
||||
<React.Fragment key={yearMonth}>
|
||||
{isYearBoundary && (
|
||||
<div className="flex w-full items-center justify-center rounded-xl bg-gradient-to-br from-primary-200 to-primary-50 px-3 py-1 text-xl font-semibold text-neutral-700 shadow-md">
|
||||
{year}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`m-3
|
||||
grid grid-cols-[1fr_240px_1fr] items-center rounded-lg p-3 ${isEvenMonth ? "bg-neutral-200" : "bg-neutral-50"}`}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{arrivals.map((m) => {
|
||||
const previousMembership = m.player?.clubMembership
|
||||
?.filter(
|
||||
(membership) => membership.membershipType === "club"
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (!a.endDate && !b.endDate) {
|
||||
return (
|
||||
new Date(b.startDate).getTime() -
|
||||
new Date(a.startDate).getTime()
|
||||
)
|
||||
}
|
||||
if (!a.endDate) return -1
|
||||
if (!b.endDate) return 1
|
||||
if (
|
||||
new Date(a.endDate).getTime() ===
|
||||
new Date(b.endDate).getTime()
|
||||
) {
|
||||
return (
|
||||
new Date(b.startDate).getTime() -
|
||||
new Date(a.startDate).getTime()
|
||||
)
|
||||
}
|
||||
return (
|
||||
new Date(b.endDate).getTime() -
|
||||
new Date(a.endDate).getTime()
|
||||
)
|
||||
})
|
||||
.find(
|
||||
(membership) =>
|
||||
new Date(membership.startDate) < new Date(m.startDate)
|
||||
)
|
||||
return (
|
||||
<div key={m.id} className="flex items-center gap-3">
|
||||
<Link to={`/player/${m.player?.slug}`}>
|
||||
<img
|
||||
className="h-24 min-w-16"
|
||||
src={m.player?.avatarUrl || man}
|
||||
alt={m.player?.displayName}
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="font-secondary text-lg font-semibold">
|
||||
{m.player?.displayName}
|
||||
</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{previousMembership && previousMembership.team ? (
|
||||
<>
|
||||
<FaPersonWalkingArrowRight className="h-6 w-6" />
|
||||
<Badge
|
||||
name={previousMembership?.team?.shortName}
|
||||
imgUrl={previousMembership?.team?.pictureUrl}
|
||||
link={`/team/${previousMembership?.team?.slug}`}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
"No previous club"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="rounded-xl px-20 text-center text-xl text-neutral-700">
|
||||
<span className="rounded-xl border border-neutral-700 bg-neutral-500 px-2 py-1 text-white shadow-md">
|
||||
{new Date(yearMonth + "-01")
|
||||
.toLocaleString(undefined, { month: "long" })
|
||||
.replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{departures.map((m) => {
|
||||
const nextMembership = m.player?.clubMembership
|
||||
?.filter(
|
||||
(membership) => membership.membershipType === "club"
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (!a.endDate && !b.endDate) {
|
||||
return (
|
||||
new Date(b.startDate).getTime() -
|
||||
new Date(a.startDate).getTime()
|
||||
)
|
||||
}
|
||||
if (!a.endDate) return -1
|
||||
if (!b.endDate) return 1
|
||||
if (
|
||||
new Date(a.endDate).getTime() ===
|
||||
new Date(b.endDate).getTime()
|
||||
) {
|
||||
return (
|
||||
new Date(b.startDate).getTime() -
|
||||
new Date(a.startDate).getTime()
|
||||
)
|
||||
}
|
||||
return (
|
||||
new Date(b.endDate).getTime() -
|
||||
new Date(a.endDate).getTime()
|
||||
)
|
||||
})
|
||||
.reverse()
|
||||
.find(
|
||||
(membership) =>
|
||||
new Date(membership.startDate) >= new Date(m.endDate)
|
||||
)
|
||||
return (
|
||||
<div key={m.id} className="flex items-center gap-3">
|
||||
<Link to={`/player/${m.player?.slug}`}>
|
||||
<img
|
||||
className="h-24 min-w-16"
|
||||
src={m.player?.avatarUrl || man}
|
||||
alt={m.player?.displayName}
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="font-secondary text-lg font-semibold">
|
||||
{m.player?.displayName}
|
||||
</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{nextMembership && nextMembership.team ? (
|
||||
<>
|
||||
<Badge
|
||||
name={nextMembership?.team?.shortName}
|
||||
imgUrl={nextMembership?.team?.pictureUrl}
|
||||
link={`/team/${nextMembership?.team?.slug}`}
|
||||
/>
|
||||
<FaPersonWalkingArrowRight className="h-6 w-6" />
|
||||
</>
|
||||
) : (
|
||||
"No next club"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,6 +1,10 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { FaArrowAltCircleUp } from "react-icons/fa"
|
||||
import { IoMdFootball } from "react-icons/io"
|
||||
import { IoManSharp } from "react-icons/io5"
|
||||
import { useParams } from "react-router"
|
||||
import { Link } from "react-router-dom"
|
||||
import Select from "react-select"
|
||||
import { GetUserCards } from "../../api/card"
|
||||
import WithDataFetching from "../../components/withdatafetching"
|
||||
import Card from "../../types/card"
|
||||
@ -23,17 +27,70 @@ function CardList({ cards }: { cards: Card[] }) {
|
||||
const [sortConfig, setSortConfig] = useState<{
|
||||
key: string
|
||||
direction: "asc" | "desc"
|
||||
} | null>(null)
|
||||
}>(
|
||||
() =>
|
||||
JSON.parse(
|
||||
localStorage.getItem("sortConfig") ||
|
||||
'{"key": "xpPercentage", "direction": "desc"}'
|
||||
) as {
|
||||
key: string
|
||||
direction: "asc" | "desc"
|
||||
}
|
||||
)
|
||||
|
||||
const [hideMaxLevelUp, setHideMaxLevelUp] = useState(false)
|
||||
const [hideMaxLevelUp, setHideMaxLevelUp] = useState(() =>
|
||||
JSON.parse(localStorage.getItem("hideMaxLevelUp") || "true")
|
||||
)
|
||||
const [hideMaxLevelCards, setHideMaxLevelCards] = useState(() =>
|
||||
JSON.parse(localStorage.getItem("hideMaxLevelCards") || "true")
|
||||
)
|
||||
const [minGrade, setMinGrade] = useState<number>(() =>
|
||||
JSON.parse(localStorage.getItem("minGrade") || "0")
|
||||
)
|
||||
const [onlyInSeason, setOnlyInSeason] = useState(() =>
|
||||
JSON.parse(localStorage.getItem("onlyInSeason") || "false")
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("sortConfig", JSON.stringify(sortConfig))
|
||||
}, [sortConfig])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("hideMaxLevelUp", JSON.stringify(hideMaxLevelUp))
|
||||
}, [hideMaxLevelUp])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("hideMaxLevelCards", JSON.stringify(hideMaxLevelCards))
|
||||
}, [hideMaxLevelCards])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("minGrade", JSON.stringify(minGrade))
|
||||
}, [minGrade])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("onlyInSeason", JSON.stringify(onlyInSeason))
|
||||
}, [onlyInSeason])
|
||||
|
||||
const filteredCards = useMemo(() => {
|
||||
return hideMaxLevelUp
|
||||
? cards.filter(
|
||||
(card) => card.levelUpAppliedCount < card.maxLevelUpAppliedCount
|
||||
)
|
||||
: cards
|
||||
}, [cards, hideMaxLevelUp])
|
||||
return cards.filter((card) => {
|
||||
if (
|
||||
hideMaxLevelUp &&
|
||||
card.levelUpAppliedCount >= card.maxLevelUpAppliedCount
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (hideMaxLevelCards && card.grade >= 20) {
|
||||
return false
|
||||
}
|
||||
if (card.grade < minGrade) {
|
||||
return false
|
||||
}
|
||||
if (onlyInSeason && !card.inSeasonEligible) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [cards, hideMaxLevelUp, hideMaxLevelCards, minGrade, onlyInSeason])
|
||||
|
||||
const sortedCards = useMemo(() => {
|
||||
let sortableCards = filteredCards.map((card) => ({
|
||||
@ -52,10 +109,12 @@ function CardList({ cards }: { cards: Card[] }) {
|
||||
if (sortConfig !== null) {
|
||||
sortableCards.sort((a, b) => {
|
||||
const key = sortConfig.key as keyof typeof a
|
||||
if (a[key] < b[key]) {
|
||||
if (a[key] === "N/A") return 1
|
||||
if (b[key] === "N/A") return -1
|
||||
if (Number(a[key]) < Number(b[key])) {
|
||||
return sortConfig.direction === "asc" ? -1 : 1
|
||||
}
|
||||
if (a[key] > b[key]) {
|
||||
if (Number(a[key]) > Number(b[key])) {
|
||||
return sortConfig.direction === "asc" ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
@ -64,26 +123,54 @@ function CardList({ cards }: { cards: Card[] }) {
|
||||
return sortableCards
|
||||
}, [filteredCards, sortConfig])
|
||||
|
||||
const requestSort = (key: string) => {
|
||||
let direction: "asc" | "desc" = "asc"
|
||||
if (
|
||||
sortConfig &&
|
||||
sortConfig.key === key &&
|
||||
sortConfig.direction === "asc"
|
||||
) {
|
||||
direction = "desc"
|
||||
}
|
||||
setSortConfig({ key, direction })
|
||||
const handleSortFieldChange = (selectedOption: any) => {
|
||||
setSortConfig((prevConfig) => ({
|
||||
key: selectedOption.value,
|
||||
direction: prevConfig?.direction || "asc",
|
||||
}))
|
||||
}
|
||||
|
||||
const getSortIndicator = (key: string) => {
|
||||
if (!sortConfig || sortConfig.key !== key) return null
|
||||
return sortConfig.direction === "asc" ? "↑" : "↓"
|
||||
const handleSortDirectionChange = (selectedOption: any) => {
|
||||
setSortConfig((prevConfig) => ({
|
||||
key: prevConfig?.key || "",
|
||||
direction: selectedOption.value,
|
||||
}))
|
||||
}
|
||||
|
||||
const sortFieldOptions = [
|
||||
{ value: "grade", label: "Grade" },
|
||||
{ value: "xp", label: "XP" },
|
||||
{ value: "xpNeeded", label: "XP Needed for Next Grade" },
|
||||
{ value: "xpPercentage", label: "% Until next grade" },
|
||||
]
|
||||
|
||||
const sortDirectionOptions = [
|
||||
{ value: "asc", label: "Ascending" },
|
||||
{ value: "desc", label: "Descending" },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full items-center justify-end p-3">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex w-full items-center justify-center gap-3">
|
||||
<label className="inline-flex items-center">
|
||||
<span className="mr-2">Min Grade</span>
|
||||
<input
|
||||
type="number"
|
||||
value={minGrade}
|
||||
onChange={(e) => setMinGrade(Number(e.target.value))}
|
||||
className="w-16"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyInSeason}
|
||||
onChange={() => setOnlyInSeason(!onlyInSeason)}
|
||||
/>
|
||||
<span className="ml-2">Only show cards in season</span>
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -92,114 +179,121 @@ function CardList({ cards }: { cards: Card[] }) {
|
||||
/>
|
||||
<span className="ml-2">Hide cards with max level up applied</span>
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideMaxLevelCards}
|
||||
onChange={() => setHideMaxLevelCards(!hideMaxLevelCards)}
|
||||
/>
|
||||
<span className="ml-2">Hide cards with max grade</span>
|
||||
</label>
|
||||
|
||||
<Select
|
||||
options={sortFieldOptions}
|
||||
onChange={handleSortFieldChange}
|
||||
defaultValue={sortFieldOptions.find(
|
||||
(option) => option.value === sortConfig.key
|
||||
)}
|
||||
placeholder="Sort by"
|
||||
className="w-64"
|
||||
/>
|
||||
<Select
|
||||
options={sortDirectionOptions}
|
||||
onChange={handleSortDirectionChange}
|
||||
defaultValue={sortDirectionOptions.find(
|
||||
(option) => option.value === sortConfig.direction
|
||||
)}
|
||||
placeholder="Sort direction"
|
||||
/>
|
||||
</div>
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500"
|
||||
></th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500"
|
||||
>
|
||||
Player
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-auto cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
|
||||
onClick={() => requestSort("grade")}
|
||||
>
|
||||
Grade{" "}
|
||||
<span className="ml-1 align-middle">
|
||||
{getSortIndicator("grade")}
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
|
||||
onClick={() => requestSort("xp")}
|
||||
>
|
||||
XP{" "}
|
||||
<span className="ml-1 align-middle">
|
||||
{getSortIndicator("xp")}
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
|
||||
onClick={() => requestSort("xpNeeded")}
|
||||
>
|
||||
XP Needed for Next Grade{" "}
|
||||
<span className="ml-1 align-middle">
|
||||
{getSortIndicator("xpNeeded")}
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
|
||||
onClick={() => requestSort("xpPercentage")}
|
||||
>
|
||||
% Until next grade{" "}
|
||||
<span className="ml-1 align-middle">
|
||||
{getSortIndicator("xpPercentage")}
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="cursor-pointer px-6 py-3 text-center text-xs font-medium uppercase text-gray-500"
|
||||
>
|
||||
Level up applied
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y-2 divide-gray-200 bg-white">
|
||||
{sortedCards.map((card) => (
|
||||
<tr key={card.id}>
|
||||
<td className="px-3 py-1">
|
||||
<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"
|
||||
>
|
||||
<img className="h-16" src={card.pictureUrl} alt={card.name} />
|
||||
<IoMdFootball className="text-lg" title="Sorare" />
|
||||
</a>
|
||||
</td>
|
||||
<td className="border-r-2 border-neutral-200 px-3 py-1">
|
||||
<Link to={`/player/${card.playerSlug}`}>
|
||||
<div className="flex items-center">
|
||||
<div className="text-md font-secondary font-semibold text-neutral-900">
|
||||
{card.playerDisplayName}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="w-auto border-r border-neutral-200 px-3 py-1 text-center">
|
||||
<div className="text-sm text-neutral-900">{card.grade}</div>
|
||||
</td>
|
||||
<td className="border-r-2 border-neutral-200 px-3 py-1 text-center">
|
||||
<div className="text-sm text-neutral-900">{card.xp}</div>
|
||||
</td>
|
||||
<td className="border-r-2 border-neutral-200 px-3 py-1 text-center">
|
||||
<div className="text-sm text-neutral-900">{card.xpNeeded}</div>
|
||||
</td>
|
||||
<td className="border-r-2 border-neutral-200 px-3 py-1 text-center">
|
||||
<div className="text-sm text-neutral-900">
|
||||
{card.xpPercentage !== "N/A"
|
||||
? `${Number(card.xpPercentage).toFixed(0)}%`
|
||||
: "N/A"}
|
||||
</div>
|
||||
<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>
|
||||
</td>
|
||||
<td className="px-3 py-1 text-center">
|
||||
<div className="text-sm text-neutral-900">
|
||||
{card.levelUpAppliedCount} / {card.maxLevelUpAppliedCount}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
<span className="text-2xs font-semibold text-neutral-700">
|
||||
{card.grade < 20
|
||||
? `${Number(card.xpPercentage).toFixed(1)}%`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 px-3">
|
||||
<FaArrowAltCircleUp
|
||||
className={
|
||||
card.levelUpAppliedCount < 1
|
||||
? "text-neutral-300"
|
||||
: "text-green-600"
|
||||
}
|
||||
/>
|
||||
<FaArrowAltCircleUp
|
||||
className={
|
||||
card.levelUpAppliedCount < 2
|
||||
? "text-neutral-300"
|
||||
: "text-green-600"
|
||||
}
|
||||
/>
|
||||
<FaArrowAltCircleUp
|
||||
className={
|
||||
card.levelUpAppliedCount < 3
|
||||
? "text-neutral-300"
|
||||
: "text-green-600"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center px-3">
|
||||
{card.inSeasonEligible ? (
|
||||
<span className="rounded-xl bg-primary-600 px-2 py-1 text-xs font-semibold text-white">
|
||||
In Season
|
||||
</span>
|
||||
) : (
|
||||
<span> </span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { createBrowserRouter, Link, RouterProvider } from "react-router-dom"
|
||||
import Layout from "../pages/layout"
|
||||
import Live from "../pages/live"
|
||||
import PlayerPage from "../pages/player"
|
||||
import { playerRoutes } from "../pages/player/routes"
|
||||
import SingleRankingsPage from "../pages/singlerankings"
|
||||
import { teamRoutes } from "../pages/team/routes"
|
||||
import XpCenter from "../pages/xpcenter"
|
||||
|
||||
const router = createBrowserRouter([
|
||||
@ -10,14 +11,18 @@ const router = createBrowserRouter([
|
||||
path: "/",
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ path: "/", element: <SingleRankingsPage /> },
|
||||
{ path: "/live", element: <Live /> },
|
||||
{ path: "/xpcenter", element: <XpCenter /> },
|
||||
{ path: "/xpcenter/:slug", element: <XpCenter /> },
|
||||
{ path: "/player/:slug", element: <PlayerPage /> },
|
||||
{ path: "", element: <SingleRankingsPage /> },
|
||||
{ path: "live", element: <Live /> },
|
||||
{ path: "xpcenter", element: <XpCenter /> },
|
||||
{ path: "xpcenter/:slug", element: <XpCenter /> },
|
||||
playerRoutes,
|
||||
teamRoutes,
|
||||
],
|
||||
},
|
||||
{ path: "*", element: <NoMatch /> },
|
||||
{
|
||||
path: "*",
|
||||
element: <NoMatch />,
|
||||
},
|
||||
])
|
||||
|
||||
export default function RouterOutlet() {
|
||||
|
@ -45,6 +45,7 @@ export interface GamePlayer {
|
||||
export interface GamePlayerScore {
|
||||
gameId: string
|
||||
playerSlug: string
|
||||
gameDate: Date
|
||||
score: number
|
||||
decisiveScore: number
|
||||
allAroundScore: number
|
||||
|
@ -1,5 +1,6 @@
|
||||
import Competition from "./competition"
|
||||
import Country from "./country"
|
||||
import { GamePlayer } from "./game"
|
||||
import Team from "./team"
|
||||
|
||||
interface CardSupply {
|
||||
@ -11,7 +12,7 @@ interface CardSupply {
|
||||
unique: number
|
||||
lastUpdated: Date
|
||||
}
|
||||
interface Membership {
|
||||
export interface Membership {
|
||||
id: string
|
||||
playerSlug: string
|
||||
teamSlug: string
|
||||
@ -19,6 +20,7 @@ interface Membership {
|
||||
endDate: Date
|
||||
membershipType: string
|
||||
|
||||
player?: Player
|
||||
team?: Team
|
||||
}
|
||||
|
||||
@ -38,4 +40,6 @@ export default interface Player {
|
||||
domesticLeague?: Competition
|
||||
cardSupply: CardSupply[]
|
||||
clubMembership: Membership[]
|
||||
|
||||
gamePlayers: GamePlayer[]
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import Competition from "./competition"
|
||||
import Country from "./country"
|
||||
import { Game } from "./game"
|
||||
|
||||
export default interface Team {
|
||||
slug: string
|
||||
@ -12,4 +13,6 @@ export default interface Team {
|
||||
|
||||
country?: Country
|
||||
domesticLeague?: Competition
|
||||
homeGames: Game[]
|
||||
awayGames: Game[]
|
||||
}
|
||||
|
@ -1,12 +1,15 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type GamePlayerScore struct {
|
||||
GameID string `bun:"game_id,pk" json:"gameID"`
|
||||
PlayerSlug string `bun:"player_slug,pk" json:"playerSlug"`
|
||||
GameDate time.Time `bun:"game_date,pk" json:"gameDate"`
|
||||
Score decimal.Decimal `bun:"score" json:"score"`
|
||||
DecisiveScore decimal.Decimal `bun:"decisive_score" json:"decisiveScore"`
|
||||
AllAroundScore decimal.Decimal `bun:"all_around_score" json:"allAroundScore"`
|
||||
|
@ -22,4 +22,6 @@ type Player struct {
|
||||
DomesticLeague *Competition `bun:"rel:has-one,join:domestic_league_slug=slug" json:"domesticLeague"`
|
||||
CardSupply []CardSupply `bun:"rel:has-many,join:slug=player_slug" json:"cardSupply"`
|
||||
ClubMembership []Membership `bun:"rel:has-many,join:slug=player_slug" json:"clubMembership"`
|
||||
|
||||
GamePlayers []GamePlayer `bun:"rel:has-many,join:slug=player_slug" json:"gamePlayers"`
|
||||
}
|
||||
|
@ -11,4 +11,7 @@ type Team struct {
|
||||
|
||||
Country *Country `bun:"rel:has-one,join:country_slug=slug" json:"country"`
|
||||
DomesticLeague *Competition `bun:"rel:has-one,join:domestic_league_slug=slug" json:"domesticLeague"`
|
||||
|
||||
HomeGames []Game `bun:"rel:has-many,join:slug=home_team_slug" json:"homeGames"`
|
||||
AwayGames []Game `bun:"rel:has-many,join:slug=away_team_slug" json:"awayGames"`
|
||||
}
|
||||
|
26
sorare_utils/competition.go
Normal file
26
sorare_utils/competition.go
Normal file
@ -0,0 +1,26 @@
|
||||
package sorare_utils
|
||||
|
||||
import (
|
||||
"git.lehouerou.net/laurent/sorare"
|
||||
"git.lehouerou.net/laurent/sorare/football"
|
||||
"git.lehouerou.net/laurent/sorarebuddy/db"
|
||||
)
|
||||
|
||||
type CompetitionUpdater struct {
|
||||
s *sorare.Sorare
|
||||
db *db.Client
|
||||
|
||||
countryUpdater *CountryUpdater
|
||||
cache []football.Competition
|
||||
slugsToRead []string
|
||||
}
|
||||
|
||||
func NewCompetitionUpdater(s *sorare.Sorare, db *db.Client, countryUpdater *CountryUpdater) *CompetitionUpdater {
|
||||
return &CompetitionUpdater{
|
||||
s: s,
|
||||
db: db,
|
||||
countryUpdater: countryUpdater,
|
||||
}
|
||||
}
|
||||
|
||||
|
93
sorare_utils/country.go
Normal file
93
sorare_utils/country.go
Normal file
@ -0,0 +1,93 @@
|
||||
package sorare_utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lehouerou.net/laurent/sorare"
|
||||
"git.lehouerou.net/laurent/sorare/graphql"
|
||||
"git.lehouerou.net/laurent/sorarebuddy/db"
|
||||
"git.lehouerou.net/laurent/sorarebuddy/model"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func NewCountryFromSorare(s sorare.Country) model.Country {
|
||||
return model.Country{
|
||||
Slug: s.Slug,
|
||||
Code: s.Code,
|
||||
DisplayName: s.Name,
|
||||
ThreeLetterCode: s.ThreeLetterCode,
|
||||
FlagFlat64Url: s.FlagFlat64Url,
|
||||
FlagFlat32Url: s.FlagFlat32Url,
|
||||
FlagRound64Url: s.FlagRound64Url,
|
||||
FlagRound32Url: s.FlagRound32Url,
|
||||
}
|
||||
}
|
||||
|
||||
type CountryUpdater struct {
|
||||
db *db.Client
|
||||
s *sorare.Sorare
|
||||
cache []sorare.Country
|
||||
slugsToRead []string
|
||||
}
|
||||
|
||||
func NewCountryUpdater(s *sorare.Sorare, db *db.Client) *CountryUpdater {
|
||||
return &CountryUpdater{db: db, s: s}
|
||||
}
|
||||
|
||||
func (u *CountryUpdater) AddSlugsToRead(slugs []string) {
|
||||
slugs = lo.Filter(slugs, func(slug string, index int) bool {
|
||||
return slug != ""
|
||||
})
|
||||
u.slugsToRead = append(u.slugsToRead, slugs...)
|
||||
}
|
||||
|
||||
func (u *CountryUpdater) Read(ctx context.Context, onlyMissings bool) error {
|
||||
if len(u.slugsToRead) == 0 {
|
||||
return nil
|
||||
}
|
||||
log.Debug().Msgf("reading %d countries...", len(u.slugsToRead))
|
||||
slugs := u.slugsToRead
|
||||
u.slugsToRead = nil
|
||||
|
||||
if onlyMissings {
|
||||
log.Debug().Msgf("filtering countries in db...")
|
||||
slugsNotInDb, err := u.db.Countries.GetCountrySlugsNotInDb(ctx, slugs)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting countries not in db")
|
||||
}
|
||||
slugs = slugsNotInDb
|
||||
log.Debug().Msgf("%d countries not in db", len(slugs))
|
||||
}
|
||||
|
||||
log.Debug().Msgf("getting countries from sorare...")
|
||||
u.cache = nil
|
||||
for i, chunk := range lo.Chunk(slugs, 100) {
|
||||
log.Debug().Msgf("\tbatch %d/%d", i+1, (len(slugs)/100)+1)
|
||||
c, err := u.s.Countries.Get(ctx, graphql.SlugsParams{Slugs: chunk})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.cache = append(u.cache, c...)
|
||||
}
|
||||
log.Debug().Msgf("%d countries fetched from sorare", len(u.cache))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *CountryUpdater) Write(ctx context.Context) error {
|
||||
log.Debug().Msg("inserting countries into db...")
|
||||
err := u.db.Countries.CreateOrUpdateMany(
|
||||
ctx,
|
||||
lo.Map(u.cache, func(country sorare.Country, index int) model.Country {
|
||||
return NewCountryFromSorare(country)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "inserting countries")
|
||||
}
|
||||
log.Debug().Msgf("%d countries inserted", len(u.cache))
|
||||
u.cache = nil
|
||||
return nil
|
||||
}
|
@ -15,6 +15,7 @@ func NewGamePlayerScoreFromSorare(
|
||||
res := model.GamePlayerScore{
|
||||
GameID: gameId,
|
||||
PlayerSlug: s.Player.Slug,
|
||||
GameDate: s.Game.Date,
|
||||
MinutesPlayed: int(s.PlayerGameStats.MinsPlayed),
|
||||
GameStarted: s.PlayerGameStats.GameStarted == 1,
|
||||
FormationPlace: int(s.PlayerGameStats.FormationPlace),
|
||||
|
@ -20,7 +20,8 @@ type Updater struct {
|
||||
s *sorare.Sorare
|
||||
db *db.Client
|
||||
|
||||
countrySlugsToRead []string
|
||||
countryUpdater *CountryUpdater
|
||||
|
||||
competitionSlugsToRead []string
|
||||
teamSlugsToRead []string
|
||||
playerSlugsToRead []string
|
||||
@ -29,7 +30,6 @@ type Updater struct {
|
||||
gameFormationsToRead []string
|
||||
gameScoresToReadFromMap map[string][]string
|
||||
|
||||
countryCache []sorare.Country
|
||||
competitionCache []football.Competition
|
||||
clubCache []football.Club
|
||||
nationalTeamCache []football.NationalTeam
|
||||
@ -77,14 +77,16 @@ func WithUpdateOnlyMissingPlayers(value bool) UpdaterOption {
|
||||
|
||||
func NewUpdater(s *sorare.Sorare, db *db.Client, opts ...UpdaterOption) *Updater {
|
||||
return &Updater{
|
||||
s: s,
|
||||
db: db,
|
||||
s: s,
|
||||
db: db,
|
||||
|
||||
countryUpdater: NewCountryUpdater(s, db),
|
||||
|
||||
gameScoresToReadFromMap: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Updater) Reset() {
|
||||
u.countrySlugsToRead = nil
|
||||
u.competitionSlugsToRead = nil
|
||||
u.teamSlugsToRead = nil
|
||||
u.playerSlugsToRead = nil
|
||||
@ -95,7 +97,6 @@ func (u *Updater) Reset() {
|
||||
delete(u.gameScoresToReadFromMap, k)
|
||||
}
|
||||
|
||||
u.countryCache = nil
|
||||
u.competitionCache = nil
|
||||
u.clubCache = nil
|
||||
u.nationalTeamCache = nil
|
||||
@ -144,11 +145,11 @@ func (u *Updater) Update(ctx context.Context, opts ...UpdaterOption) error {
|
||||
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")
|
||||
}
|
||||
|
||||
if err := u.writeCountries(ctx); err != nil {
|
||||
if err := u.countryUpdater.Write(ctx); err != nil {
|
||||
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) {
|
||||
slugs = lo.Filter(slugs, func(slug string, index int) bool {
|
||||
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
|
||||
u.countryUpdater.AddSlugsToRead(slugs)
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
return model.Team{
|
||||
Slug: club.Slug,
|
||||
@ -411,7 +351,7 @@ func (u *Updater) writeTeams(ctx context.Context) error {
|
||||
PictureUrl: nationalTeam.PictureUrl,
|
||||
TeamType: "national",
|
||||
}
|
||||
}),
|
||||
})...,
|
||||
))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "inserting teams")
|
||||
|
Loading…
Reference in New Issue
Block a user