From 2f9242c30f91266f85213e62293b9283803ca992 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 5 May 2020 16:18:58 -0600 Subject: [PATCH] swiss WIP --- app/controllers/Swiss.scala | 15 ++- app/controllers/Tournament.scala | 2 +- app/views/swiss/bits.scala | 7 +- app/views/tournament/bits.scala | 1 - conf/logback.xml | 6 + conf/prod-logger.xml | 38 ++++-- conf/routes | 1 + modules/i18n/src/main/I18nKeys.scala | 1 - modules/swiss/src/main/Env.scala | 1 + modules/swiss/src/main/SwissApi.scala | 72 ++++++++-- modules/swiss/src/main/SwissJson.scala | 76 +++++++---- modules/swiss/src/main/SwissPairing.scala | 2 + modules/swiss/src/main/SwissPlayer.scala | 23 ++++ modules/swiss/src/main/SwissStandingApi.scala | 11 +- translation/source/site.xml | 1 - ui/common/css/component/_crosstable.scss | 2 +- ui/swiss/css/_layout.scss | 11 +- ui/swiss/css/_player-info.scss | 123 ++++++++++++++++++ ui/swiss/css/_show.scss | 2 +- ui/swiss/css/build/_swiss.show.scss | 1 + ui/swiss/src/ctrl.ts | 5 +- ui/swiss/src/interfaces.ts | 11 ++ ui/swiss/src/view/main.ts | 2 + ui/swiss/src/view/playerInfo.ts | 100 ++++++++++++++ ui/swiss/src/view/standing.ts | 9 +- ui/swiss/src/view/util.ts | 8 ++ ui/swiss/src/xhr.ts | 18 +-- 27 files changed, 468 insertions(+), 81 deletions(-) create mode 100644 ui/swiss/css/_player-info.scss create mode 100644 ui/swiss/src/view/playerInfo.ts diff --git a/app/controllers/Swiss.scala b/app/controllers/Swiss.scala index 0ea4d9c414..077821c71c 100644 --- a/app/controllers/Swiss.scala +++ b/app/controllers/Swiss.scala @@ -29,6 +29,7 @@ final class Swiss( me = ctx.me, reqPage = page, socketVersion = version.some, + playerInfo = none, isInTeam = isInTeam ) canChat <- canHaveChat(swiss) @@ -45,11 +46,13 @@ final class Swiss( for { socketVersion <- getBool("socketVersion").??(env.swiss version swiss.id dmap some) isInTeam <- isCtxInTheTeam(swiss.teamId) + playerInfo <- get("playerInfo").?? { env.swiss.api.playerInfo(swiss, _) } json <- env.swiss.json( swiss = swiss, me = ctx.me, reqPage = page, socketVersion = socketVersion, + playerInfo = playerInfo, isInTeam = isInTeam ) } yield Ok(json) @@ -133,7 +136,17 @@ final class Swiss( } } - private def WithSwiss(id: String)(f: SwissModel => Fu[Result])(implicit ctx: Context): Fu[Result] = + def player(id: String, userId: String) = Action.async { + WithSwiss(id) { swiss => + env.swiss.api.playerInfo(swiss, userId) flatMap { + _.fold(notFoundJson()) { player => + JsonOk(fuccess(lila.swiss.SwissJson.playerJsonExt(swiss, player))) + } + } + } + } + + private def WithSwiss(id: String)(f: SwissModel => Fu[Result]): Fu[Result] = env.swiss.api.byId(SwissId(id)) flatMap { _ ?? f } private def WithEditableSwiss(id: String, me: lila.user.User)( diff --git a/app/controllers/Tournament.scala b/app/controllers/Tournament.scala index 791492ee2b..aa5e35b3e6 100644 --- a/app/controllers/Tournament.scala +++ b/app/controllers/Tournament.scala @@ -152,7 +152,7 @@ final class Tournament( } } - def player(tourId: String, userId: String) = Open { _ => + def player(tourId: String, userId: String) = Action.async { env.tournament.tournamentRepo byId tourId flatMap { _ ?? { tour => JsonOk { diff --git a/app/views/swiss/bits.scala b/app/views/swiss/bits.scala index 33a3ca54da..43ae1595b3 100644 --- a/app/views/swiss/bits.scala +++ b/app/views/swiss/bits.scala @@ -77,6 +77,11 @@ object bits { trans.withdraw, trans.youArePlaying, trans.joinTheGame, - trans.signIn + trans.signIn, + trans.averageElo, + trans.gamesPlayed, + trans.winRate, + trans.performance, + trans.averageOpponent ).map(_.key) } diff --git a/app/views/tournament/bits.scala b/app/views/tournament/bits.scala index c79a5da2ed..eb4b5469ec 100644 --- a/app/views/tournament/bits.scala +++ b/app/views/tournament/bits.scala @@ -108,7 +108,6 @@ object bits { trans.blackWins, trans.draws, trans.nextXTournament, - trans.viewMoreTournaments, trans.averageOpponent, trans.ratedTournament, trans.casualTournament diff --git a/conf/logback.xml b/conf/logback.xml index 32ddf2390c..692a5ee331 100644 --- a/conf/logback.xml +++ b/conf/logback.xml @@ -48,6 +48,12 @@ %date [%level] %message%n%xException + + + ${application.home}/logs/swiss.log + %date [%level] %message%n%xException + + ${application.home}/logs/relay.log diff --git a/conf/prod-logger.xml b/conf/prod-logger.xml index 27ae190276..b78f6c1d3d 100644 --- a/conf/prod-logger.xml +++ b/conf/prod-logger.xml @@ -64,7 +64,7 @@ - + /var/log/lichess/puzzle.log %date %-5level %logger{30} %message%n%xException @@ -74,7 +74,17 @@ - + + + /var/log/lichess/swiss.log + %date %-5level %logger{30} %message%n%xException + + /var/log/lichess/swiss-log-%d{yyyy-MM-dd}.gz + 7 + + + + /var/log/lichess/relay.log %date %-5level %logger{30} %message%n%xException @@ -84,7 +94,7 @@ - + /var/log/lichess/lobby.log %date %-5level %logger{30} %message%n%xException @@ -94,7 +104,7 @@ - + /var/log/lichess/pool.log %date %-5level %logger{30} %message%n%xException @@ -104,7 +114,7 @@ - + /var/log/lichess/tournament.log %date %-5level %logger{30} %message%n%xException @@ -114,7 +124,7 @@ - + /var/log/lichess/ratelimit.log %date %-5level %logger{30} %message%n%xException @@ -134,7 +144,7 @@ - + /var/log/lichess/cheat.log %date %-5level %logger{30} %message%n%xException @@ -144,7 +154,7 @@ - + /var/log/lichess/fishnet.log %date %-5level %logger{30} %message%n%xException @@ -164,7 +174,7 @@ - + /var/log/lichess/csrf.log %date %-5level %logger{30} %message%n%xException @@ -174,7 +184,7 @@ - + /var/log/lichess/http.log %date %-5level %logger{30} %message%n%xException @@ -184,7 +194,7 @@ - + /var/log/lichess/auth.log %date %-5level %logger{30} %message%n%xException @@ -194,7 +204,7 @@ - + /var/log/lichess/report.log %date %-5level %logger{30} %message%n%xException @@ -204,7 +214,7 @@ - + /var/log/lichess/sandbag.log %date %-5level %logger{30} %message%n%xException @@ -214,7 +224,7 @@ - + /var/log/lichess/security.log %date %-5level %logger{30} %message%n%xException diff --git a/conf/routes b/conf/routes index c30d7e3be6..bce3b93a6e 100644 --- a/conf/routes +++ b/conf/routes @@ -253,6 +253,7 @@ GET /swiss/$id<\w{8}>/edit controllers.Swiss.edit(id: String) POST /swiss/$id<\w{8}>/edit controllers.Swiss.update(id: String) POST /swiss/$id<\w{8}>/terminate controllers.Swiss.terminate(id: String) GET /swiss/$id<\w{8}>/standing/:page controllers.Swiss.standing(id: String, page: Int) +GET /swiss/$id<\w{8}>/player/:user controllers.Swiss.player(id: String, user: String) # Simul GET /simul controllers.Simul.home diff --git a/modules/i18n/src/main/I18nKeys.scala b/modules/i18n/src/main/I18nKeys.scala index 21375c335e..2aa61317fe 100644 --- a/modules/i18n/src/main/I18nKeys.scala +++ b/modules/i18n/src/main/I18nKeys.scala @@ -317,7 +317,6 @@ val `whiteWins` = new I18nKey("whiteWins") val `blackWins` = new I18nKey("blackWins") val `draws` = new I18nKey("draws") val `nextXTournament` = new I18nKey("nextXTournament") -val `viewMoreTournaments` = new I18nKey("viewMoreTournaments") val `averageOpponent` = new I18nKey("averageOpponent") val `membersOnly` = new I18nKey("membersOnly") val `boardEditor` = new I18nKey("boardEditor") diff --git a/modules/swiss/src/main/Env.scala b/modules/swiss/src/main/Env.scala index 9de3fbcbb7..259962d700 100644 --- a/modules/swiss/src/main/Env.scala +++ b/modules/swiss/src/main/Env.scala @@ -13,6 +13,7 @@ final class Env( appConfig: Configuration, db: lila.db.Db, gameRepo: lila.game.GameRepo, + userRepo: lila.user.UserRepo, onStart: lila.round.OnStart, remoteSocketApi: lila.socket.RemoteSocket, chatApi: lila.chat.ChatApi, diff --git a/modules/swiss/src/main/SwissApi.scala b/modules/swiss/src/main/SwissApi.scala index 846d577d1f..f43a81150d 100644 --- a/modules/swiss/src/main/SwissApi.scala +++ b/modules/swiss/src/main/SwissApi.scala @@ -7,19 +7,21 @@ import reactivemongo.api.bson._ import scala.concurrent.duration._ import lila.chat.Chat -import lila.common.{ Bus, GreatPlayer, WorkQueues } +import lila.common.{ Bus, GreatPlayer, LightUser, WorkQueues } import lila.db.dsl._ import lila.game.Game import lila.hub.LightTeam.TeamID import lila.round.actorApi.round.QuietFlag -import lila.user.User +import lila.user.{ User, UserRepo } final class SwissApi( colls: SwissColls, + userRepo: UserRepo, socket: SwissSocket, director: SwissDirector, scoring: SwissScoring, - chatApi: lila.chat.ChatApi + chatApi: lila.chat.ChatApi, + lightUserApi: lila.user.LightUserApi )( implicit ec: scala.concurrent.ExecutionContext, mat: akka.stream.Materializer, @@ -96,6 +98,55 @@ final class SwissApi( def featuredInTeam(teamId: TeamID): Fu[List[Swiss]] = colls.swiss.ext.find($doc("teamId" -> teamId)).sort($sort desc "startsAt").list[Swiss](5) + def playerInfo(swiss: Swiss, userId: User.ID): Fu[Option[SwissPlayer.ViewExt]] = + userRepo named userId flatMap { + _ ?? { user => + colls.player.byId[SwissPlayer](SwissPlayer.makeId(swiss.id, user.id).value) flatMap { + _ ?? { player => + SwissPairing.fields { f => + colls.pairing.ext + .find($doc(f.swissId -> swiss.id, f.players -> player.number)) + .sort($sort desc f.date) + .list[SwissPairing]() + } flatMap { + pairingViews(_, player) + } flatMap { pairings => + SwissPlayer.fields { f => + colls.player.countSel($doc(f.swissId -> swiss.id, f.score $gt player.score)).dmap(1.+) + } map { rank => + SwissPlayer + .ViewExt(player, rank, user.light, pairings.view.map { p => + p.pairing.round -> p + }.toMap) + .some + } + } + } + } + } + } + + def pairingViews(pairings: Seq[SwissPairing], player: SwissPlayer): Fu[Seq[SwissPairing.View]] = + pairings.headOption ?? { first => + SwissPlayer.fields { f => + colls.player.ext + .find($doc(f.swissId -> first.swissId, f.number $in pairings.map(_ opponentOf player.number))) + .list[SwissPlayer]() + } flatMap { opponents => + lightUserApi asyncMany opponents.map(_.userId) map { users => + opponents.zip(users) map { + case (o, u) => SwissPlayer.WithUser(o, u | LightUser.fallback(o.userId)) + } + } map { opponents => + pairings flatMap { pairing => + opponents.find(_.player.number == pairing.opponentOf(player.number)) map { + SwissPairing.View(pairing, _) + } + } + } + } + } + private[swiss] def finishGame(game: Game): Funit = game.swissId ?? { swissId => Sequencing(Swiss.Id(swissId))(startedById) { swiss => colls.pairing.byId[SwissPairing](game.id).dmap(_.filter(_.isOngoing)) flatMap { @@ -186,12 +237,15 @@ final class SwissApi( swiss.id, s"Not enough players for round ${swiss.round.value + 1}; terminating tournament." ) - ) { s => - scoring.recompute(s) >>- - systemChat( - swiss.id, - s"Round ${swiss.round.value + 1} started." - ) + ) { + case s if s.nextRoundAt.isEmpty => + scoring.recompute(s) >>- + systemChat(swiss.id, s"Round ${swiss.round.value + 1} started.") + case s => + colls.swiss.update + .one($id(swiss.id), $set("nextRoundAt" -> DateTime.now.plusSeconds(61))) + .void >>- + systemChat(swiss.id, s"Round ${swiss.round.value + 1} failed.", true) } } else { if (swiss.startsAt isBefore DateTime.now.minusMinutes(60)) destroy(swiss) diff --git a/modules/swiss/src/main/SwissJson.scala b/modules/swiss/src/main/SwissJson.scala index 5a4d79be90..5ad3c7b268 100644 --- a/modules/swiss/src/main/SwissJson.scala +++ b/modules/swiss/src/main/SwissJson.scala @@ -29,7 +29,8 @@ final class SwissJson( me: Option[User], reqPage: Option[Int], // None = focus on me socketVersion: Option[SocketVersion], - isInTeam: Boolean + isInTeam: Boolean, + playerInfo: Option[SwissPlayer.ViewExt] )(implicit lang: Lang): Fu[JsObject] = for { myInfo <- me.?? { fetchMyInfo(swiss, _) } @@ -69,19 +70,23 @@ final class SwissJson( .add("greatPlayer" -> GreatPlayer.wikiUrl(swiss.name).map { url => Json.obj("name" -> swiss.name, "url" -> url) }) + .add("playerInfo" -> playerInfo.map { playerJsonExt(swiss, _) }) def fetchMyInfo(swiss: Swiss, me: User): Fu[Option[MyInfo]] = colls.player.byId[SwissPlayer](SwissPlayer.makeId(swiss.id, me.id).value) flatMap { _ ?? { player => SwissPairing.fields { f => - colls.pairing - .find( - $doc(f.swissId -> swiss.id, f.players -> player.number, f.status -> SwissPairing.ongoing), - $doc(f.id -> true).some - ) - .sort($sort desc f.date) - .one[Bdoc] - .dmap { _.flatMap(_.getAsOpt[Game.ID](f.id)) } + (swiss.nbOngoing > 0) + .?? { + colls.pairing + .find( + $doc(f.swissId -> swiss.id, f.players -> player.number, f.status -> SwissPairing.ongoing), + $doc(f.id -> true).some + ) + .sort($sort desc f.date) + .one[Bdoc] + .dmap { _.flatMap(_.getAsOpt[Game.ID](f.id)) } + } .flatMap { gameId => getOrGuessRank(swiss, player) dmap { rank => MyInfo(rank + 1, gameId, me).some @@ -117,32 +122,46 @@ final class SwissJson( object SwissJson { - private[swiss] def playerJson( - swiss: Swiss, - rankedPlayer: SwissPlayer.Ranked, - user: lila.common.LightUser, - pairings: Map[SwissRound.Number, SwissPairing] - ): JsObject = { - val p = rankedPlayer.player + private[swiss] def playerJson(swiss: Swiss, view: SwissPlayer.View): JsObject = + playerJsonBase(swiss, view) ++ Json.obj( + "pairings" -> swiss.allRounds.map(view.pairings.get).map(_ map pairingJson(view.player)) + ) + + def playerJsonExt(swiss: Swiss, view: SwissPlayer.ViewExt): JsObject = + playerJsonBase(swiss, view) ++ Json.obj( + "pairings" -> swiss.allRounds.map(view.pairings.get).map { + _ map { p => + pairingJson(view.player)(p.pairing) ++ Json.obj( + "user" -> p.player.user, + "rating" -> p.player.player.rating + ) + } + } + ) + + private def playerJsonBase(swiss: Swiss, view: SwissPlayer.Viewish): JsObject = { + val p = view.player Json .obj( - "rank" -> rankedPlayer.rank, - "user" -> user, + "rank" -> view.rank, + "user" -> view.user, "rating" -> p.rating, "points" -> p.points, - "tieBreak" -> p.tieBreak, - "pairings" -> swiss.allRounds.map(pairings.get).map { - _ map { pairing => - Json - .obj("g" -> pairing.gameId) - .add("o" -> pairing.isOngoing) - .add("w" -> pairing.resultFor(p.number)) - } - } + "tieBreak" -> p.tieBreak ) + .add("performance" -> p.performance) .add("provisional" -> p.provisional) } + private def pairingJson(player: SwissPlayer)(pairing: SwissPairing) = + Json + .obj( + "g" -> pairing.gameId, + "c" -> (pairing.white == player.number) + ) + .add("o" -> pairing.isOngoing) + .add("w" -> pairing.resultFor(player.number)) + implicit private val roundNumberWriter: Writes[SwissRound.Number] = Writes[SwissRound.Number] { n => JsNumber(n.value) } @@ -155,6 +174,9 @@ object SwissJson { implicit private val tieBreakWriter: Writes[Swiss.TieBreak] = Writes[Swiss.TieBreak] { t => JsNumber(t.value) } + implicit private val performanceWriter: Writes[Swiss.Performance] = Writes[Swiss.Performance] { t => + JsNumber(t.value.toInt) + } implicit private val clockWrites: OWrites[chess.Clock.Config] = OWrites { clock => Json.obj( diff --git a/modules/swiss/src/main/SwissPairing.scala b/modules/swiss/src/main/SwissPairing.scala index 897ce1b1ab..8f4d288a43 100644 --- a/modules/swiss/src/main/SwissPairing.scala +++ b/modules/swiss/src/main/SwissPairing.scala @@ -38,6 +38,8 @@ object SwissPairing { type PairingMap = Map[SwissPlayer.Number, Map[SwissRound.Number, SwissPairing]] + case class View(pairing: SwissPairing, player: SwissPlayer.WithUser) + object Fields { val id = "_id" val swissId = "s" diff --git a/modules/swiss/src/main/SwissPlayer.scala b/modules/swiss/src/main/SwissPlayer.scala index aaa4b03126..292de3cd49 100644 --- a/modules/swiss/src/main/SwissPlayer.scala +++ b/modules/swiss/src/main/SwissPlayer.scala @@ -1,5 +1,6 @@ package lila.swiss +import lila.common.LightUser import lila.rating.Perf import lila.user.{ Perfs, User } @@ -56,6 +57,28 @@ object SwissPlayer { override def toString = s"$rank. ${player.userId}[${player.rating}]" } + case class WithUser(player: SwissPlayer, user: LightUser) + + sealed trait Viewish { + val player: SwissPlayer + val rank: Int + val user: lila.common.LightUser + } + + case class View( + player: SwissPlayer, + rank: Int, + user: lila.common.LightUser, + pairings: Map[SwissRound.Number, SwissPairing] + ) extends Viewish + + case class ViewExt( + player: SwissPlayer, + rank: Int, + user: lila.common.LightUser, + pairings: Map[SwissRound.Number, SwissPairing.View] + ) extends Viewish + def toMap(players: List[SwissPlayer]): Map[SwissPlayer.Number, SwissPlayer] = players.view.map(p => p.number -> p).toMap diff --git a/modules/swiss/src/main/SwissStandingApi.scala b/modules/swiss/src/main/SwissStandingApi.scala index 0c95afb929..8d5dbcfdc9 100644 --- a/modules/swiss/src/main/SwissStandingApi.scala +++ b/modules/swiss/src/main/SwissStandingApi.scala @@ -83,12 +83,15 @@ final class SwissStandingApi( } yield Json.obj( "page" -> page, "players" -> rankedPlayers.zip(users).map { - case (p, u) => + case (SwissPlayer.Ranked(rank, player), user) => SwissJson.playerJson( swiss, - p, - u | LightUser.fallback(p.player.userId), - ~pairings.get(p.player.number) + SwissPlayer.View( + player, + rank, + user | LightUser.fallback(player.userId), + ~pairings.get(player.number) + ) ) } ) diff --git a/translation/source/site.xml b/translation/source/site.xml index aad485a59b..e57ed9657f 100644 --- a/translation/source/site.xml +++ b/translation/source/site.xml @@ -426,7 +426,6 @@ computer analysis, game chat and shareable URL. Black wins Draws Next %s tournament: - View more tournaments Average opponent Members only Board editor diff --git a/ui/common/css/component/_crosstable.scss b/ui/common/css/component/_crosstable.scss index c4b48a71db..9edb1f1019 100644 --- a/ui/common/css/component/_crosstable.scss +++ b/ui/common/css/component/_crosstable.scss @@ -59,7 +59,7 @@ &.current a { background: mix($c-accent, $c-bg-box, 70%); color: #fff; - opacity: 1; + opacity: 1!important; } &.new { border: $c-border; diff --git a/ui/swiss/css/_layout.scss b/ui/swiss/css/_layout.scss index 4e1bb4b8a8..ade381868e 100644 --- a/ui/swiss/css/_layout.scss +++ b/ui/swiss/css/_layout.scss @@ -11,29 +11,32 @@ $chat-optimal-size: calc(100vh - #{$site-header-outer-height} - #{$block-gap} - display: grid; &__side { grid-area: side } + &__table { grid-area: table } &__main { grid-area: main } .chat__members { grid-area: uchat; } grid-template-areas: 'main' 'side' - 'uchat'; + 'uchat' + 'table'; grid-gap: $block-gap; @include breakpoint($mq-col2) { - grid-template-columns: $col2-uniboard-default-width; + grid-template-columns: $col2-uniboard-default-width $col2-uniboard-table; grid-template-rows: $chat-optimal-size min-content; grid-template-areas: 'main side' - 'main uchat'; + 'main uchat' + 'table table'; .mchat__messages { max-height: inherit; } } @include breakpoint($mq-col3) { - grid-template-columns: $col3-uniboard-side $col3-uniboard-default-width; + grid-template-columns: $col3-uniboard-side $col3-uniboard-default-width $col3-uniboard-table; grid-template-rows: $chat-optimal-size auto; grid-template-areas: 'side main table' diff --git a/ui/swiss/css/_player-info.scss b/ui/swiss/css/_player-info.scss new file mode 100644 index 0000000000..9ce48cf415 --- /dev/null +++ b/ui/swiss/css/_player-info.scss @@ -0,0 +1,123 @@ +.swiss__player-info { + @extend %box-neat-force; + background: $c-bg-box; + position: relative; + align-self: flex-start; + + .spinner { + margin: 5em auto; + } + + .close { + position: absolute; + top: 4px; + right: 5px; + opacity: .6; + @include transition(); + color: $c-red; + &:hover { + opacity: 1; + } + } + + .stats { + @extend %flex-column; + justify-content: center; + h2 { + @extend %metal; + font-size: 1.4em; + padding: .6rem 1rem; + border-bottom: $border; + } + table { + margin: 1em auto; + } + td { + font-weight: bold; + padding-left: 10px; + text-align: right; + line-height: 1.8em; + &:last-child { + @extend %roboto; + } + } + } + + .sublist { + width: 100%; + tr { + cursor: pointer; + @include transition(background-color); + &:nth-child(odd) { + background: $c-bg-zebra; + } + &:hover { + background: mix($c-link, $c-bg-box, 10%); + } + } + th, td { + padding: .3em; + } + th { + @extend %roboto; + padding-left: 7px; + } + .title { + color: $c-brag; + font-weight: bold; + } + } + + .pairings { + width: 100%; + tr { + cursor: pointer; + @include transition(background-color); + &:nth-child(odd) { + background: $c-bg-zebra; + } + &:hover { + background: mix($c-link, $c-bg-box, 10%); + } + } + th, td { + padding: .3em; + } + th { + border-left: 3px solid transparent; + @include transition(); + } + tr:hover th { + border-color: $c-font-dimmer; + } + tr.win:hover th { + border-color: $c-good; + } + tr.loss:hover th { + border-color: $c-bad; + } + td:nth-child(2) { + @extend %nowrap-ellipsis; + max-width: 200px; + } + td:last-child { + font-weight: bold; + opacity: .8; + } + tr.win td:last-child { + color: $c-good; + opacity: 1; + } + tr.loss td:last-child { + color: $c-bad; + opacity: 1; + } + .bye { + font-style: italic; + color: $c-font-dim; + } + } + .color-icon { + opacity: .6; + } +} diff --git a/ui/swiss/css/_show.scss b/ui/swiss/css/_show.scss index ada5b38a4b..eb92efbcc0 100644 --- a/ui/swiss/css/_show.scss +++ b/ui/swiss/css/_show.scss @@ -13,7 +13,7 @@ $mq-col3: $mq-col3-uniboard; // @import 'stats'; // @import 'duel'; // @import 'actor-info'; -// @import 'player-info'; +@import 'player-info'; // @import 'team-info'; .swiss { diff --git a/ui/swiss/css/build/_swiss.show.scss b/ui/swiss/css/build/_swiss.show.scss index 7147521e36..bf7cddb2c1 100644 --- a/ui/swiss/css/build/_swiss.show.scss +++ b/ui/swiss/css/build/_swiss.show.scss @@ -3,5 +3,6 @@ @import '../../../common/css/component/bar-glider'; @import '../../../common/css/component/slist'; @import '../../../common/css/component/quote'; +@import '../../../common/css/component/color-icon'; @import '../../../chat/css/chat'; @import '../show'; diff --git a/ui/swiss/src/ctrl.ts b/ui/swiss/src/ctrl.ts index c3521856f7..10c05c28d6 100644 --- a/ui/swiss/src/ctrl.ts +++ b/ui/swiss/src/ctrl.ts @@ -15,6 +15,7 @@ export default class SwissCtrl { lastPageDisplayed: number | undefined; focusOnMe: boolean; joinSpinner: boolean = false; + playerInfoId?: string; disableClicks: boolean = true; searching: boolean = false; redraw: () => void; @@ -41,8 +42,6 @@ export default class SwissCtrl { this.data = {...this.data, ...data}; this.data.me = data.me; // to account for removal on withdraw this.data.nextRound = data.nextRound; // to account for removal - // if (data.playerInfo && data.playerInfo.player.id === this.playerInfo.id) - // this.playerInfo.data = data.playerInfo; this.loadPage(data.standing); if (this.focusOnMe) this.scrollToMe(); // if (data.featured) this.startWatching(data.featured.id); @@ -121,6 +120,8 @@ export default class SwissCtrl { userLastPage = () => this.userSetPage(players(this).nbPages); showPlayerInfo = (player: Player) => { + this.playerInfoId = this.playerInfoId === player.user.id ? undefined : player.user.id; + if (this.playerInfoId) xhr.playerInfo(this, this.playerInfoId); }; askReload = () => xhr.reloadNow(this); diff --git a/ui/swiss/src/interfaces.ts b/ui/swiss/src/interfaces.ts index 2b4617c3f4..50f2eaeaef 100644 --- a/ui/swiss/src/interfaces.ts +++ b/ui/swiss/src/interfaces.ts @@ -31,6 +31,7 @@ export interface SwissData { nbOngoing: number; status: Status; standing: Standing; + playerInfo?: PlayerExt; isStarted?: boolean; isFinished?: boolean; socketVersion?: number; @@ -60,9 +61,14 @@ export interface MyInfo { export interface Pairing { g: string; // game + c: boolean; // color w?: boolean; // won o?: boolean; // ongoing } +export interface PairingExt extends Pairing { + user: LightUser; + rating: number; +} export interface Standing { page: number; @@ -77,6 +83,7 @@ export interface Player { withdraw?: boolean; points: number; tieBreak: number; + performance: number; rank: number; pairings: [Pairing | null]; } @@ -96,3 +103,7 @@ export type Page = Player[]; export interface Pages { [n: number]: Page } + +export interface PlayerExt extends Player { + pairings: [PairingExt | null]; +} diff --git a/ui/swiss/src/view/main.ts b/ui/swiss/src/view/main.ts index 8b1f0fc39f..0469ff41cb 100644 --- a/ui/swiss/src/view/main.ts +++ b/ui/swiss/src/view/main.ts @@ -6,6 +6,7 @@ import * as pagination from '../pagination'; import { MaybeVNodes } from '../interfaces'; import header from './header'; import standing from './standing'; +import playerInfo from './playerInfo'; export default function(ctrl: SwissCtrl) { const d = ctrl.data; @@ -22,6 +23,7 @@ export default function(ctrl: SwissCtrl) { $(el).replaceWith($('.swiss__underchat.none').removeClass('none')); }) }), + playerInfo(ctrl), h('div.swiss__main', h('div.box.swiss__main-' + d.status, content) ), diff --git a/ui/swiss/src/view/playerInfo.ts b/ui/swiss/src/view/playerInfo.ts new file mode 100644 index 0000000000..a50bdbbd13 --- /dev/null +++ b/ui/swiss/src/view/playerInfo.ts @@ -0,0 +1,100 @@ +import { h } from 'snabbdom' +import { VNode } from 'snabbdom/vnode'; +import { spinner, bind, userName, dataIcon, player as renderPlayer, numberRow } from './util'; +import { Player, PlayerExt, Pairing } from '../interfaces'; +import SwissCtrl from '../ctrl'; + +export default function(ctrl: SwissCtrl): VNode { + if (!ctrl.playerInfoId) return; + const data = ctrl.data.playerInfo; + const noarg = ctrl.trans.noarg; + const tag = 'div.swiss__player-info.swiss__table'; + if (data?.user.id !== ctrl.playerInfoId) return h(tag, [ + h('div.stats', [ + h('h2', ctrl.playerInfoId), + spinner() + ]) + ]); + const games = data.pairings.filter(p => p).length; + const wins = data.pairings.filter(p => p?.w).length; + const avgOp: number | undefined = games ? + Math.round(data.pairings.reduce((r, p) => r + (p ? p.rating : 0), 0) / games) : + undefined; + return h(tag, { + hook: { + insert: setup, + postpatch(_, vnode) { setup(vnode) } + } + }, [ + h('a.close', { + attrs: dataIcon('L'), + hook: bind('click', () => ctrl.showPlayerInfo(data), ctrl.redraw) + }), + h('div.stats', [ + h('h2', [ + h('span.rank', data.rank + '. '), + renderPlayer(data, true, false) + ]), + h('table', [ + numberRow('Points', data.points, 'raw'), + numberRow('Tie break', data.tieBreak, 'raw'), + ...(games ? [ + data.performance ? numberRow( + noarg('performance'), + data.performance + (games < 3 ? '?' : ''), + 'raw') : null, + numberRow(noarg('winRate'), [wins, games], 'percent'), + numberRow(noarg('averageOpponent'), avgOp, 'raw') + ] : []) + ]) + ]), + h('div', [ + h('table.pairings.sublist', { + hook: bind('click', e => { + const href = ((e.target as HTMLElement).parentNode as HTMLElement).getAttribute('data-href'); + if (href) window.open(href, '_blank'); + }) + }, data.pairings.map((p, i) => { + const round = i + 1; + if (!p) return h('tr', [ + h('th', '' + round), + h('td.bye', { + attrs: { colspan: 3}, + }, 'Bye'), + h('td', '½') + ]); + const res = result(p); + return h('tr.glpt.' + (res === '1' ? ' win' : (res === '0' ? ' loss' : '')), { + key: p.g, + attrs: { 'data-href': '/' + p.g + (p.c ? '' : '/black') }, + hook: { + destroy: vnode => $.powerTip.destroy(vnode.elm as HTMLElement) + } + }, [ + h('th', '' + round), + h('td', userName(p.user)), + h('td', '' + p.rating), + h('td.is.color-icon.' + (p.c ? 'white' : 'black')), + h('td', res) + ]); + })) + ]) + ]); +}; + +function result(p: Pairing): string { + switch (p.w) { + case true: + return '1'; + case false: + return '0'; + default: + return p.o ? '*' : '½'; + } +} + +function setup(vnode: VNode) { + const el = vnode.elm as HTMLElement, p = window.lichess.powertip; + p.manualUserIn(el); + p.manualGameIn(el); +} diff --git a/ui/swiss/src/view/standing.ts b/ui/swiss/src/view/standing.ts index 983f20dde2..f57c68c2f1 100644 --- a/ui/swiss/src/view/standing.ts +++ b/ui/swiss/src/view/standing.ts @@ -12,7 +12,8 @@ function playerTr(ctrl: SwissCtrl, player: Player) { return h('tr', { key: userId, class: { - me: ctrl.data.me?.id == userId + me: ctrl.data.me?.id == userId, + active: ctrl.playerInfoId === userId }, hook: bind('click', _ => ctrl.showPlayerInfo(player), ctrl.redraw) }, [ @@ -89,7 +90,11 @@ export default function standing(ctrl: SwissCtrl, pag, klass?: string): VNode { pag.currentPageResults.map(res => playerTr(ctrl, res)) : lastBody; if (pag.currentPageResults) lastBody = tableBody; return h('table.slist.swiss__standing' + (klass ? '.' + klass : ''), { - class: { loading: !pag.currentPageResults }, + class: { + loading: !pag.currentPageResults, + long: ctrl.data.round > 35, + xlong: ctrl.data.round > 80, + }, }, [ h('tbody', { hook: { diff --git a/ui/swiss/src/view/util.ts b/ui/swiss/src/view/util.ts index b520129bf4..86d0bee0fc 100644 --- a/ui/swiss/src/view/util.ts +++ b/ui/swiss/src/view/util.ts @@ -65,6 +65,14 @@ export function player(p: Player, asLink: boolean, withRating: boolean) { ]); } +export function numberRow(name: string, value: any, typ?: string) { + return h('tr', [h('th', name), h('td', + typ === 'raw' ? value : (typ === 'percent' ? ( + value[1] > 0 ? ratio2percent(value[0] / value[1]) : 0 + ) : window.lichess.numberFormat(value)) + )]); +} + export function spinner(): VNode { return h('div.spinner', [ h('svg', { attrs: { viewBox: '0 0 40 40' } }, [ diff --git a/ui/swiss/src/xhr.ts b/ui/swiss/src/xhr.ts index 4b7ebdf8ba..48a1fcf22c 100644 --- a/ui/swiss/src/xhr.ts +++ b/ui/swiss/src/xhr.ts @@ -22,20 +22,16 @@ const loadPageOf = (ctrl: SwissCtrl, userId: string): Promise => json(`/swiss/${ctrl.data.id}/page-of/${userId}`); const reload = (ctrl: SwissCtrl) => - json(`/swiss/${ctrl.data.id}?page=${ctrl.focusOnMe ? 0 : ctrl.page}`).then(data => { + json(`/swiss/${ctrl.data.id}?page=${ctrl.focusOnMe ? 0 : ctrl.page}&playerInfo=${ctrl.playerInfoId}`).then(data => { ctrl.reload(data); ctrl.redraw(); }).catch(onFail); -// function playerInfo(ctrl: SwissCtrl, userId: string) { -// return $.ajax({ -// url: ['/swiss', ctrl.data.id, 'player', userId].join('/'), -// headers -// }).then(data => { -// ctrl.setPlayerInfoData(data); -// ctrl.redraw(); -// }, onFail); -// } +const playerInfo = (ctrl: SwissCtrl, userId: string) => + json(`/swiss/${ctrl.data.id}/player/${userId}`).then(data => { + ctrl.data.playerInfo = data; + ctrl.redraw(); + }).catch(onFail); export default { join: throttle(1000, join), @@ -43,5 +39,5 @@ export default { loadPageOf, reloadSoon: throttle(4000, reload), reloadNow: reload, - // playerInfo + playerInfo };