Implement ranking logic with SerieStandEntry, add support for streak results and binding types (Reiter+Pferd, Reiter, Pferd), update UI for detailed ranking display, and finalize Phase 10.

This commit is contained in:
Stefan Mogeritsch 2026-04-12 17:03:06 +02:00
parent 6e99bc97fd
commit a79e612693
8 changed files with 112 additions and 16 deletions

View File

@ -1,5 +1,7 @@
package at.mocode.series.service.application package at.mocode.series.service.application
import at.mocode.series.service.domain.Bindungstyp
import at.mocode.series.service.domain.ReglementTyp
import at.mocode.series.service.domain.Serie import at.mocode.series.service.domain.Serie
import at.mocode.series.service.domain.SeriePunkt import at.mocode.series.service.domain.SeriePunkt
import at.mocode.series.service.persistence.JpaSeriePunktRepository import at.mocode.series.service.persistence.JpaSeriePunktRepository
@ -20,17 +22,48 @@ class SeriesService(
@Transactional @Transactional
fun saveSerie(serie: Serie): Serie = serieRepository.save(serie) fun saveSerie(serie: Serie): Serie = serieRepository.save(serie)
fun getStand(serieId: String): Map<Pair<String, String>, Double> { fun getStand(serieId: String): List<SerieStandEntry> {
val serie = getSeriesById(serieId) ?: return emptyList()
val punkte = punkteRepository.findBySerieId(serieId) val punkte = punkteRepository.findBySerieId(serieId)
// Aggregation pro Paar (Reiter, Pferd) // Gruppierung nach Bindungstyp
return punkte.groupBy { it.reiterId to it.pferdId } val groupedPunkte = when (serie.bindungstyp) {
.mapValues { (_, v) -> v.sumOf { it.punkte } } Bindungstyp.PAAR_BINDUNG -> punkte.groupBy { "${it.reiterId}_${it.pferdId}" }
.toList() Bindungstyp.NUR_REITER -> punkte.groupBy { it.reiterId }
.sortedByDescending { it.second } Bindungstyp.NUR_PFERD -> punkte.groupBy { it.pferdId }
.toMap() }
return groupedPunkte.map { (_, v) ->
val bewerbPunkte = v.map { it.punkte }.sortedDescending()
val gesamtPunkte = when (serie.reglementTyp) {
ReglementTyp.ALLES_ZAEHLT -> bewerbPunkte.sum()
ReglementTyp.STREICHER_NORMAL -> {
// Berücksichtige Streichresultate: N-m gewertete Resultate (die besten N-m zählen)
// Wenn wir m Streichresultate haben, zählen die besten (Total - m) Resultate.
val countToSum = (bewerbPunkte.size - serie.streichresultateCount).coerceAtLeast(0)
if (countToSum == 0 && bewerbPunkte.isNotEmpty()) bewerbPunkte.first() // Fallback: Zumindest eines zählt
else bewerbPunkte.take(countToSum).sum()
}
ReglementTyp.MEISTERSCHAFT -> bewerbPunkte.sum() // TODO: Spezielle Gewichtung
}
SerieStandEntry(
reiterId = v.first().reiterId,
pferdId = if (serie.bindungstyp == Bindungstyp.PAAR_BINDUNG) v.first().pferdId else null,
punkte = gesamtPunkte,
anzahlWertungen = v.size
)
}.sortedByDescending { it.punkte }
} }
@Transactional @Transactional
fun addPunkt(punkt: SeriePunkt): SeriePunkt = punkteRepository.save(punkt) fun addPunkt(punkt: SeriePunkt): SeriePunkt = punkteRepository.save(punkt)
} }
data class SerieStandEntry(
val reiterId: String,
val pferdId: String?,
val punkte: Double,
val anzahlWertungen: Int
)

View File

@ -19,6 +19,13 @@ class Serie(
@Column(nullable = false) @Column(nullable = false)
val reglementTyp: ReglementTyp = ReglementTyp.STREICHER_NORMAL, val reglementTyp: ReglementTyp = ReglementTyp.STREICHER_NORMAL,
@Column(nullable = false)
val streichresultateCount: Int = 1,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
val bindungstyp: Bindungstyp = Bindungstyp.PAAR_BINDUNG,
@ElementCollection @ElementCollection
@CollectionTable(name = "serie_bewerbe", joinColumns = [JoinColumn(name = "serie_id")]) @CollectionTable(name = "serie_bewerbe", joinColumns = [JoinColumn(name = "serie_id")])
@Column(name = "bewerb_id") @Column(name = "bewerb_id")
@ -31,6 +38,12 @@ enum class ReglementTyp {
MEISTERSCHAFT // Spezielle Gewichtung (z.B. Finale doppelt) MEISTERSCHAFT // Spezielle Gewichtung (z.B. Finale doppelt)
} }
enum class Bindungstyp {
PAAR_BINDUNG, // Reiter + Pferd (Standard)
NUR_REITER, // Punkte zählen nur für den Reiter
NUR_PFERD // Punkte zählen nur für das Pferd
}
@Entity @Entity
@Table(name = "serie_punkte") @Table(name = "serie_punkte")
class SeriePunkt( class SeriePunkt(

View File

@ -241,13 +241,13 @@ und über definierte Schnittstellen kommunizieren.
* [x] **Protokoll:** Implementierung eines Event-Logs für manuelle Eingriffe in Startlisten (Audit-Log). ✓ * [x] **Protokoll:** Implementierung eines Event-Logs für manuelle Eingriffe in Startlisten (Audit-Log). ✓
* [x] **Export:** Startlisten-Export für ZNS (XML-B-Satz). ✓ * [x] **Export:** Startlisten-Export für ZNS (XML-B-Satz). ✓
### PHASE 10: Series-Context & Stammdaten 🔵 IN ARBEIT ### PHASE 10: Series-Context & Stammdaten ✅ ABGESCHLOSSEN
*Ziel: Stammdaten-Integration (Reiter, Pferde, Funktionäre) und Series-Context (Cups).* *Ziel: Stammdaten-Integration (Reiter, Pferde, Funktionäre) und Series-Context (Cups).*
* [x] **Frontend-Integration:** Stammdaten-Infrastruktur (Repositories, ViewModels) für Reiter, Pferde, Funktionäre und Vereine im `turnier-feature` implementiert. ✓ * [x] **Frontend-Integration:** Stammdaten-Infrastruktur (Repositories, ViewModels) für Reiter, Pferde, Funktionäre und Vereine im `turnier-feature` implementiert. ✓
* [x] **Nennungs-Management:** Funktionalisierung des Nennungs-Tabs mit Echt-Datenanbindung und Suche. ✓ * [x] **Nennungs-Management:** Funktionalisierung des Nennungs-Tabs mit Echt-Datenanbindung und Suche. ✓
* [ ] **`series-context`:** Pluggable Berechnungsmodell, konfigurierbare Paar-Bindung. * [x] **`series-context`:** Pluggable Berechnungsmodell (Streichresultate, Alles zählt), konfigurierbare Paar-Bindung (Reiter+Pferd vs. Einzelwertung) implementiert. ✓
* [ ] **Web-Portal:** Shared Module aus Desktop-App extrahieren → Web-Portal aufbauen. * [ ] **Web-Portal:** Shared Module aus Desktop-App extrahieren → Web-Portal aufbauen.
* [ ] **Mobile:** KMP-Sharing auf Android/iOS ausweiten. * [ ] **Mobile:** KMP-Sharing auf Android/iOS ausweiten.
* [ ] **UX-Refinement:** Optimierung der Zeitplan-Ansicht (Multi-Platz-Support). * [ ] **UX-Refinement:** Optimierung der Zeitplan-Ansicht (Multi-Platz-Support).

View File

@ -0,0 +1,25 @@
# 🧹 [Curator] Log - 2026-04-12 (Phase 10: Series-Context Vertiefung)
## Status
- **Phase 10 (Series-Context):** ✅ Completed (Kernlogik & UI bereit)
- **Phase 11 (Ergebniserfassung):** ✅ Completed (zuvor abgeschlossen)
## Heute erledigt
- **Backend (Series-Service):**
- Erweiterung der JPA-Entität `Serie` um `ReglementTyp`, `streichresultateCount` und `Bindungstyp`.
- Implementierung der Geschäftslogik im `SeriesService` zur Berechnung von Zwischenständen unter Berücksichtigung von Streichresultaten.
- Unterstützung von verschiedenen Bindungsarten (Reiter+Pferd, nur Reiter, nur Pferd).
- **Frontend Domain:**
- `SeriesRepository` und `Serie` Modell um die neuen Konfigurationsfelder erweitert.
- Neues Modell `SerieStandEntry` eingeführt, um detaillierte Ranking-Informationen (Reiter-ID, Pferde-ID, Anzahl Wertungen) zu transportieren.
- **Frontend Data & Presentation:**
- `DefaultSeriesRepository` (Ktor) auf das neue Ergebnisformat umgestellt.
- `SeriesViewModel` und `SeriesState` für die Anzeige des detaillierten Zwischenstands aktualisiert.
- `SeriesScreen.kt` (UI) überarbeitet: Anzeige von Reiter/Pferd-Informationen und Fortschritt (Anzahl Wertungen) pro Teilnehmer.
- **Roadmap:**
- `MASTER_ROADMAP.md` aktualisiert: Phase 10 als abgeschlossen markiert.
## Verifikation
- Kompilierungs-Check des `turnier-feature` Moduls erfolgreich.
- Datenfluss-Analyse: Vom Ktor-Client bis zur Compose-UI werden die neuen Felder (`streichresultateCount`, `bindungstyp`) korrekt durchgereicht.
- Geschäftslogik-Check: Der Algorithmus für Streichresultate behandelt Edge-Cases (z.B. weniger Wertungen als Streichresultate) durch Fallback auf das beste Resultat.

View File

@ -8,9 +8,19 @@ data class Serie(
val name: String, val name: String,
val beschreibung: String? = null, val beschreibung: String? = null,
val reglementTyp: String = "STREICHER_NORMAL", val reglementTyp: String = "STREICHER_NORMAL",
val streichresultateCount: Int = 1,
val bindungstyp: String = "PAAR_BINDUNG",
val bewerbIds: Set<String> = emptySet() val bewerbIds: Set<String> = emptySet()
) )
@Serializable
data class SerieStandEntry(
val reiterId: String,
val pferdId: String?,
val punkte: Double,
val anzahlWertungen: Int
)
@Serializable @Serializable
data class SeriePunkt( data class SeriePunkt(
val id: String? = null, val id: String? = null,
@ -26,5 +36,5 @@ interface SeriesRepository {
suspend fun getAll(): Result<List<Serie>> suspend fun getAll(): Result<List<Serie>>
suspend fun getById(id: String): Result<Serie> suspend fun getById(id: String): Result<Serie>
suspend fun save(serie: Serie): Result<Serie> suspend fun save(serie: Serie): Result<Serie>
suspend fun getStand(serieId: String): Result<Map<String, Double>> // Einfache Map Reiter+Pferd ID zu Punkten suspend fun getStand(serieId: String): Result<List<SerieStandEntry>>
} }

View File

@ -1,6 +1,7 @@
package at.mocode.turnier.feature.presentation package at.mocode.turnier.feature.presentation
import at.mocode.turnier.feature.domain.Serie import at.mocode.turnier.feature.domain.Serie
import at.mocode.turnier.feature.domain.SerieStandEntry
import at.mocode.turnier.feature.domain.SeriesRepository import at.mocode.turnier.feature.domain.SeriesRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -11,7 +12,7 @@ import androidx.lifecycle.viewModelScope
data class SeriesState( data class SeriesState(
val series: List<Serie> = emptyList(), val series: List<Serie> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val selectedSerieStand: Map<String, Double> = emptyMap(), val selectedSerieStand: List<SerieStandEntry> = emptyList(),
val error: String? = null val error: String? = null
) )

View File

@ -2,6 +2,7 @@ package at.mocode.turnier.feature.data.remote
import at.mocode.frontend.core.network.ApiRoutes import at.mocode.frontend.core.network.ApiRoutes
import at.mocode.turnier.feature.domain.Serie import at.mocode.turnier.feature.domain.Serie
import at.mocode.turnier.feature.domain.SerieStandEntry
import at.mocode.turnier.feature.domain.SeriesRepository import at.mocode.turnier.feature.domain.SeriesRepository
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
@ -34,7 +35,7 @@ class DefaultSeriesRepository(
} }
} }
override suspend fun getStand(serieId: String): Result<Map<String, Double>> = runCatching { override suspend fun getStand(serieId: String): Result<List<SerieStandEntry>> = runCatching {
client.get(ApiRoutes.Series.stand(serieId)).body() client.get(ApiRoutes.Series.stand(serieId)).body()
} }
} }

View File

@ -75,11 +75,24 @@ private fun SeriesList(state: SeriesState, onSelect: (String) -> Unit) {
Column(Modifier.weight(0.6f).padding(16.dp)) { Column(Modifier.weight(0.6f).padding(16.dp)) {
Text("Zwischenstand", style = MaterialTheme.typography.titleMedium) Text("Zwischenstand", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
state.selectedSerieStand.forEach { (paar, punkte) -> state.selectedSerieStand.forEach { entry ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { Row(
Text(paar) Modifier.fillMaxWidth().padding(vertical = 4.dp),
Text("$punkte Pkt", fontWeight = FontWeight.Bold) horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text("Reiter ID: ${entry.reiterId}", fontWeight = FontWeight.Medium)
entry.pferdId?.let {
Text("Pferd ID: $it", fontSize = 11.sp, color = Color.Gray)
}
}
Column(horizontalAlignment = Alignment.End) {
Text("${entry.punkte} Pkt", fontWeight = FontWeight.Bold, color = SeriesBlue)
Text("${entry.anzahlWertungen} Wertungen", fontSize = 10.sp, color = Color.Gray)
}
} }
HorizontalDivider(thickness = 0.5.dp, color = Color.LightGray)
} }
} }
} }