From 179a502bbc518a1fbb4be714ab6813c073c44214 Mon Sep 17 00:00:00 2001 From: Claude Brisson Date: Mon, 15 Apr 2024 16:33:17 +0200 Subject: [PATCH] Teams handing in progress --- .../org/jeudego/pairgoth/api/ApiTools.kt | 2 +- .../org/jeudego/pairgoth/api/PlayerHandler.kt | 4 +- .../org/jeudego/pairgoth/api/TeamHandler.kt | 4 +- .../jeudego/pairgoth/api/TournamentHandler.kt | 10 ++-- .../org/jeudego/pairgoth/model/Pairable.kt | 9 +-- .../org/jeudego/pairgoth/model/Tournament.kt | 59 ++++++++++++------- api-webapp/src/test/kotlin/BasicTests.kt | 10 +++- .../org/jeudego/pairgoth/view/PairgothTool.kt | 7 +++ view-webapp/src/main/sass/tour.scss | 41 ++++++++++++- .../src/main/webapp/js/tour-pairing.inc.js | 29 +++++---- .../src/main/webapp/js/tour-teams.inc.js | 44 ++++++++++++++ .../src/main/webapp/result-sheets.html | 10 +++- .../src/main/webapp/tour-information.inc.html | 2 +- .../src/main/webapp/tour-menu.inc.html | 7 +++ .../src/main/webapp/tour-pairing.inc.html | 8 +-- .../main/webapp/tour-registration.inc.html | 21 ++++--- .../src/main/webapp/tour-results.inc.html | 4 +- .../src/main/webapp/tour-standings.inc.html | 4 +- .../src/main/webapp/tour-teams.inc.html | 34 +++++++++++ view-webapp/src/main/webapp/tour.html | 8 ++- 20 files changed, 249 insertions(+), 68 deletions(-) create mode 100644 view-webapp/src/main/webapp/js/tour-teams.inc.js create mode 100644 view-webapp/src/main/webapp/tour-teams.inc.html diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiTools.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiTools.kt index 3ca47db..6928eed 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiTools.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiTools.kt @@ -88,7 +88,7 @@ fun Tournament<*>.getSortedPairables(round: Int): List { Criterion.DC -> StandingsHandler.nullMap } } - val pairables = pairables.values.filter { it.final }.map { it.toMutableJson() } + val pairables = pairables.values.filter { it.final }.map { it.toDetailedJson() } pairables.forEach { player -> for (crit in criteria) { player[crit.first] = (crit.second[player.getID()] ?: 0.0).toInt() diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt index e6e01c3..cd7edbe 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt @@ -14,8 +14,8 @@ object PlayerHandler: PairgothApiHandler { override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? { val tournament = getTournament(request) return when (val pid = getSubSelector(request)?.toIntOrNull()) { - null -> tournament.pairables.values.map { it.toJson() }.toJsonArray() - else -> tournament.pairables[pid]?.toJson() ?: badRequest("no player with id #${pid}") + null -> tournament.players.values.map { it.toJson() }.toJsonArray() + else -> tournament.players[pid]?.toJson() ?: badRequest("no player with id #${pid}") } } diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TeamHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TeamHandler.kt index c89fd26..15a43e1 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TeamHandler.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TeamHandler.kt @@ -14,8 +14,8 @@ object TeamHandler: PairgothApiHandler { val tournament = getTournament(request) if (tournament !is TeamTournament) badRequest("tournament is not a team tournament") return when (val pid = getSubSelector(request)?.toIntOrNull()) { - null -> tournament.teams.values.map { it.toJson() }.toJsonArray() - else -> tournament.teams[pid]?.toJson() ?: badRequest("no team with id #${pid}") + null -> tournament.teams.values.map { it.toDetailedJson() }.toJsonArray() + else -> tournament.teams[pid]?.toDetailedJson() ?: badRequest("no team with id #${pid}") } } diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt index 59dd0c9..bf39fd3 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt @@ -26,12 +26,14 @@ object TournamentHandler: PairgothApiHandler { else -> when { ApiServlet.isJson(accept) -> { - getStore(request).getTournament(id)?.let { + getStore(request).getTournament(id)?.let { tour -> if (accept == "application/pairgoth") { - it.toFullJson() + tour.toFullJson() } else { - it.toJson().also { json -> - (json as Json.MutableObject)["stats"] = it.stats() + tour.toJson().also { json -> + // additional attributes for the webapp + json["stats"] = tour.stats() + json["teamSize"] = tour.type.playersNumber } } } ?: badRequest("no tournament with id #${id}") diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt index 1c27c7b..270608d 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt @@ -8,13 +8,14 @@ import java.util.* // Pairable -sealed class Pairable(val id: ID, val name: String, open val rating: Int, open val rank: Int, val final: Boolean, val mmsCorrection: Int = 0) { +sealed class Pairable(val id: ID, val name: String, val rating: Int, val rank: Int, val final: Boolean, val mmsCorrection: Int = 0) { companion object { const val MIN_RANK: Int = -30 // 30k const val MAX_RANK: Int = 8 // 9D } - abstract fun toJson(): Json.Object + fun toJson(): Json.Object = toMutableJson() abstract fun toMutableJson(): Json.MutableObject + open fun toDetailedJson() = toMutableJson() abstract val club: String? abstract val country: String? open fun fullName(separator: String = " "): String { @@ -28,9 +29,6 @@ sealed class Pairable(val id: ID, val name: String, open val rating: Int, open v } object ByePlayer: Pairable(0, "bye", 0, Int.MIN_VALUE, true) { - override fun toJson(): Json.Object { - throw Error("bye player should never be serialized") - } override fun toMutableJson(): Json.MutableObject { throw Error("bye player should never be serialized") } @@ -95,7 +93,6 @@ class Player( } } - override fun toJson(): Json.Object = toMutableJson() override fun fullName(separator: String): String { return name + separator + firstname } diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt index f7ca112..50fb97c 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt @@ -8,7 +8,6 @@ import java.time.LocalDate import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest import org.jeudego.pairgoth.pairing.solver.MacMahonSolver import org.jeudego.pairgoth.pairing.solver.SwissSolver -import org.jeudego.pairgoth.store.Store import org.jeudego.pairgoth.store.nextPlayerId import org.jeudego.pairgoth.store.nextTournamentId import kotlin.math.max @@ -172,39 +171,53 @@ class TeamTournament( gobanSize: Int = 19, komi: Double = 7.5 ): Tournament(id, type, name, shortName, startDate, endDate, director, country, location, online, timeSystem, rounds, pairing, rules, gobanSize, komi) { - companion object {} + companion object { + private val epsilon = 0.0001 + } override val players = mutableMapOf() val teams: MutableMap = _pairables - inner class Team(id: ID, name: String, final: Boolean): Pairable(id, name, 0, 0, final) { + private fun List.average(provider: (Player)->Int) = (sumOf {id -> provider(players[id]!!)} - epsilon / players.size).roundToInt() + + inner class Team(id: ID, name: String, rating: Int, rank: Int, final: Boolean, mmsCorrection: Int = 0): Pairable(id, name, rating, rank, final, mmsCorrection) { val playerIds = mutableSetOf() - val teamPlayers: Set get() = playerIds.mapNotNull { players[id] }.toSet() - override val rating: Int get() = if (teamPlayers.isEmpty()) super.rating else (teamPlayers.sumOf { player -> player.rating.toDouble() } / players.size).roundToInt() - override val rank: Int get() = if (teamPlayers.isEmpty()) super.rank else (teamPlayers.sumOf { player -> player.rank.toDouble() } / players.size).roundToInt() - override val club: String? get() = teamPlayers.map { club }.distinct().let { if (it.size == 1) it[0] else null } - override val country: String? get() = teamPlayers.map { country }.distinct().let { if (it.size == 1) it[0] else null } + val teamPlayers: Set get() = playerIds.mapNotNull { players[it] }.toSet() + override val club: String? get() = teamPlayers.map { it.club }.distinct().let { if (it.size == 1) it[0] else null } + override val country: String? get() = teamPlayers.map { it.country }.distinct().let { if (it.size == 1) it[0] else null } override fun toMutableJson() = Json.MutableObject( "id" to id, "name" to name, "players" to playerIds.toList().toJsonArray() ) - override fun toJson(): Json.Object = toMutableJson() + + override fun toDetailedJson() = toMutableJson().also { json -> + json["rank"] = rank + country?.also { json["country"] = it } + club?.also { json["club"] = it } + } val teamOfIndividuals: Boolean get() = type.individual } - fun teamFromJson(json: Json.Object, default: TeamTournament.Team? = null) = Team( - id = json.getInt("id") ?: default?.id ?: nextPlayerId, - name = json.getString("name") ?: default?.name ?: badRequest("missing name"), - final = json.getBoolean("final") ?: default?.final ?: badRequest("missing final") - ).apply { - json.getArray("players")?.let { arr -> - arr.mapTo(playerIds) { - if (it != null && it is Number) it.toInt().also { id -> players.containsKey(id) } + fun teamFromJson(json: Json.Object, default: TeamTournament.Team? = null): Team { + val teamPlayersIds = json.getArray("players")?.let { arr -> + arr.map { + if (it != null && it is Number) it.toInt().also { id -> + if (!players.containsKey(id)) badRequest("invalid player id: ${id}") + } else badRequest("invalid players array") } } ?: badRequest("missing players") + return Team( + id = json.getInt("id") ?: default?.id ?: nextPlayerId, + name = json.getString("name") ?: default?.name ?: badRequest("missing name"), + rating = json.getInt("rating") ?: default?.rating ?: teamPlayersIds.average(Player::rating), + rank = json.getInt("rank") ?: default?.rank ?: teamPlayersIds.average(Player::rank), + final = teamPlayersIds.all { players[it]!!.final }, + mmsCorrection = json.getInt("mmsCorrection") ?: default?.mmsCorrection ?: 0 + ).also { + it.playerIds.addAll(teamPlayersIds) + } } - } // Serialization @@ -250,10 +263,16 @@ fun Tournament.Companion.fromJson(json: Json.Object, default: Tournament<*>? = n rounds = json.getInt("rounds") ?: default?.rounds ?: badRequest("missing rounds"), pairing = json.getObject("pairing")?.let { Pairing.fromJson(it, default?.pairing) } ?: default?.pairing ?: badRequest("missing pairing") ) - (json["players"] as Json.Array?)?.forEach { obj -> + json.getArray("players")?.forEach { obj -> val pairable = obj as Json.Object tournament.players[pairable.getID("id")!!] = Player.fromJson(pairable) } + if (tournament is TeamTournament) { + json.getArray("teams")?.forEach { obj -> + val team = obj as Json.Object + tournament.teams[team.getID("id")!!] = tournament.teamFromJson(team) + } + } (json["games"] as Json.Array?)?.forEachIndexed { i, arr -> val round = i + 1 val tournamentGames = tournament.games(round) @@ -286,7 +305,7 @@ fun Tournament<*>.toJson() = Json.MutableObject( ) fun Tournament<*>.toFullJson(): Json.Object { - val json = Json.MutableObject(toJson()) + val json = toJson() json["players"] = Json.Array(players.values.map { it.toJson() }) if (this is TeamTournament) { json["teams"] = Json.Array(teams.values.map { it.toJson() }) diff --git a/api-webapp/src/test/kotlin/BasicTests.kt b/api-webapp/src/test/kotlin/BasicTests.kt index 0a097f1..4c877bb 100644 --- a/api-webapp/src/test/kotlin/BasicTests.kt +++ b/api-webapp/src/test/kotlin/BasicTests.kt @@ -2,11 +2,13 @@ package org.jeudego.pairgoth.test import com.republicate.kson.Json import com.republicate.kson.toJsonObject +import com.republicate.kson.toMutableJsonObject import org.jeudego.pairgoth.model.ID import org.junit.jupiter.api.MethodOrderer.MethodName import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestMethodOrder +import java.io.Serializable import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -143,7 +145,13 @@ class BasicTests: TestBase() { // filter out "id", and also "komi", "rules" and "gobanSize" which were provided by default // also filter out "pairing", which is filled by all default values val cmp = Json.Object(*resp.entries.filter { it.key !in listOf("id", "komi", "rules", "gobanSize", "pairing") }.map { Pair(it.key, it.value) }.toTypedArray()) - val expected = aTournament.entries.filter { it.key != "pairing" }.map { Pair(it.key, it.value) }.toMap().toJsonObject() + val expected = aTournament.entries.filter { it.key != "pairing" }.map { Pair(it.key, it.value) }.toMap().toMutableJsonObject().also { map -> + map["stats"] = Json.Array( + Json.Object("participants" to 0, "paired" to 0, "games" to 0, "ready" to 0), + Json.Object("participants" to 0, "paired" to 0, "games" to 0, "ready" to 0) + ) + map["teamSize"] = 1 + } assertEquals(expected.toString(), cmp.toString(), "tournament differs") } diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/view/PairgothTool.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/view/PairgothTool.kt index 0382211..60e9bdd 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/view/PairgothTool.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/view/PairgothTool.kt @@ -97,4 +97,11 @@ class PairgothTool { } fun getRatingsDates() = RatingsManager.getRatingsDates() + + fun getTeamables(players: Collection, teams: Collection): List { + val teamed = teams.flatMap { team -> + team.getArray("players")!!.map { it -> it as Long } + }.toSet() + return players.filter { p -> !teamed.contains(p.getLong("id")) } + } } \ No newline at end of file diff --git a/view-webapp/src/main/sass/tour.scss b/view-webapp/src/main/sass/tour.scss index 470e50d..785e7fb 100644 --- a/view-webapp/src/main/sass/tour.scss +++ b/view-webapp/src/main/sass/tour.scss @@ -237,6 +237,45 @@ } } + /* teams section */ + + #teams-content { + display: flex; + flex-flow: column; + justify-content: start; + align-items: center; + } + + #teams-lists { + margin-top: 1em; + flex-grow: 2; + display: flex; + flex-flow: row wrap; + justify-content: center; + gap: 1em; + align-items: start; + } + #teams-buttons { + display: flex; + flex-flow: column nowrap; + justify-content: start; + align-items: stretch; + gap: 1em; + max-width: max(10em, 20vw); + } + #teams-right { + display: inline-flex; + flex-flow: row wrap; + gap: 1em; + flex-shrink: 1; + max-width: max(300px, 60vw); + justify-content: center; + } + + #teams { + max-width: max(50vw, 20em); + } + /* pairing section */ #pairing-content { @@ -428,7 +467,7 @@ } } - @media(max-width: 1400px) { + @media(max-width: 1600px) { .ui.steps > .step:not(.active) { padding-left: 1.2em; padding-right: 0.8em; diff --git a/view-webapp/src/main/webapp/js/tour-pairing.inc.js b/view-webapp/src/main/webapp/js/tour-pairing.inc.js index a95bc5b..9dd4d6a 100644 --- a/view-webapp/src/main/webapp/js/tour-pairing.inc.js +++ b/view-webapp/src/main/webapp/js/tour-pairing.inc.js @@ -81,36 +81,43 @@ function updatePairable() { } onLoad(()=>{ + // note - this handler is also in use for lists on Mac Mahon super groups and teams pages $('.listitem').on('click', e => { + let listitem = e.target.closest('.listitem'); + let box = e.target.closest('.multi-select'); if (e.shiftKey && typeof(focused) !== 'undefined') { let from = focused.index('.listitem'); - let to = e.target.closest('.listitem').index('.listitem'); + let to = listitem.index('.listitem'); if (from > to) { let tmp = from; from = to; to = tmp; } - let parent = e.target.closest('.multi-select'); - let children = parent.childNodes.filter('.listitem'); + let children = box.childNodes.filter('.listitem'); for (let j = from; j <= to; ++j) { new Tablesort($('#players')[0]); children.item(j).addClass('selected'); children.item(j).attr('draggable', true); } } else { - let target = e.target.closest('.listitem'); if (e.detail === 1) { - focused = target.toggleClass('selected').attr('draggable', target.hasClass('selected')); - } else if (target.closest('#paired')) { - focused = target.attr('draggable', target.hasClass('selected')); - editGame(focused); - } else { - editPairable(focused); + // single click + focused = listitem.toggleClass('selected').attr('draggable', listitem.hasClass('selected')); + } else if (listitem.closest('#pairing-lists')) { + // on pairing page + if (listitem.closest('#paired')) { + // double click + focused = listitem.attr('draggable', listitem.hasClass('selected')); + editGame(focused); + } else if (listitem.closest('#pairables')) { + editPairable(focused); + } } } + box.dispatchEvent(new CustomEvent('listitems')); }); $('#pair').on('click', e => { let parts = $('#pairables .selected.listitem').map(item => parseInt(item.data("id"))); - if (parts.length == 0) { + if (parts.length) { $('#pairables .listitem').addClass('selected'); parts = $('#pairables .selected.listitem').map(item => parseInt(item.data("id"))); } diff --git a/view-webapp/src/main/webapp/js/tour-teams.inc.js b/view-webapp/src/main/webapp/js/tour-teams.inc.js new file mode 100644 index 0000000..c2f0dc4 --- /dev/null +++ b/view-webapp/src/main/webapp/js/tour-teams.inc.js @@ -0,0 +1,44 @@ +function teamUp(players) { + api.postJson(`tour/${tour_id}/team`, { + "name": $('#team-name')[0].value, + "players": players + }).then(rst => { + if (rst !== 'error') { + document.location.reload(); + } + }); +} + +function split(teams) { + let promises = teams.map(team => api.deleteJson(`tour/${tour_id}/team/${team}`)); + Promise.all(promises) + .then(rsts => { + for (let rst of rsts) { + if (!rst.success) console.error(rst.error) + } + document.location.reload(); + }); +} + +onLoad(() => { + $('#teamup').on('click', e => { + let rows = $('#teamables .selected.listitem') + let players = rows.map(item => parseInt(item.data("id"))); + if (players.length !== 0) teamUp(players); + }); + $('#split').on('click', e => { + let rows = $('#teams .selected.listitem') + let teams = rows.map(item => parseInt(item.data("id"))); + if (teams.length !== 0) split(teams); + }); + $('#teamables').on('listitems', () => { + let rows = $('#teamables .selected.listitem'); + if (rows.length === teamSize) { + $('#team-name')[0].value = rows.map(row => row.data('name')).join('-'); + $('#teamup').removeClass('disabled'); + } else { + $('#team-name')[0].value = ''; + $('#teamup').addClass('disabled'); + } + }); +}); \ No newline at end of file diff --git a/view-webapp/src/main/webapp/result-sheets.html b/view-webapp/src/main/webapp/result-sheets.html index c867f04..ace0f08 100644 --- a/view-webapp/src/main/webapp/result-sheets.html +++ b/view-webapp/src/main/webapp/result-sheets.html @@ -11,7 +11,11 @@ #set($round = $math.min($math.max($round, 1), $tour.rounds)) #end
-#set($parts = $api.get("tour/${params.id}/part")) +#if($tour.type == 'INDIVIDUAL' || $tour.type.startsWith('TEAM')) + #set($parts = $api.get("tour/${params.id}/part")) +#else + #set($parts = $api.get("tour/${params.id}/team")) +#end #set($pmap = $utils.toMap($parts)) #set($roundPairing = $api.get("tour/${params.id}/pair/$round")) #if($roundPairing.error) @@ -46,13 +50,13 @@
White
-
$white.name $white.firstname #rank($white.rank)
($white.country.toUpperCase(), $white.club)
+
$white.name $!white.firstname #rank($white.rank)
#if($white.country)($white.country.toUpperCase()#if($white.club), $white.club#end)#end
##
$white.egf
½-½
Black
-
$black.name $black.firstname #rank($black.rank)
($black.country.toUpperCase(), $black.club)
+
$black.name $!black.firstname #rank($black.rank)
#if($black.country)($black.country.toUpperCase()#if($black.club), $black.club#end)#end
##
$black.egf
diff --git a/view-webapp/src/main/webapp/tour-information.inc.html b/view-webapp/src/main/webapp/tour-information.inc.html index 0b925be..6cac88e 100644 --- a/view-webapp/src/main/webapp/tour-information.inc.html +++ b/view-webapp/src/main/webapp/tour-information.inc.html @@ -62,8 +62,8 @@ + +
+ + +
+#foreach($team in $teams) +
$team.name#rank($team.rank)#if($team.country) $team.country#end
+#end +
+ + + + diff --git a/view-webapp/src/main/webapp/tour.html b/view-webapp/src/main/webapp/tour.html index 1c93e46..407bc35 100644 --- a/view-webapp/src/main/webapp/tour.html +++ b/view-webapp/src/main/webapp/tour.html @@ -38,6 +38,9 @@ #translate('tour-information.inc.html') #if($tour) #translate('tour-registration.inc.html') + #if($tour.type != 'INDIVIDUAL') + #translate('tour-teams.inc.html') + #end #translate('tour-pairing.inc.html') #translate('tour-results.inc.html') #translate('tour-standings.inc.html') @@ -54,7 +57,7 @@ let correction = #if($tour.pairing.type == 'MAC_MAHON')${tour.pairing.handicap.correction}#{else}0#end; let standingsUpToDate = true; let pairablesUpToDate = true; - // $params + const teamSize = $tour.teamSize; #end #set($datepickerLocale = $translate.datepickerLocale($request.lang, $request.loc)) const datepickerLocale = '$datepickerLocale'; @@ -162,6 +165,9 @@ #include('/js/tour-information.inc.js') #if($tour) #include('/js/tour-registration.inc.js') + #if($tour.type != 'INDIVIDUAL') + #include('/js/tour-teams.inc.js') + #end #include('/js/tour-pairing.inc.js') #include('/js/tour-results.inc.js') #include('/js/tour-standings.inc.js')