feat(TournamentTab): add HTML escaping utility and improve player name rendering
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s

- Introduced `escapeHtml` method to sanitize HTML content, enhancing security against XSS attacks.
- Refactored player name rendering in tournament results to utilize the new HTML escaping method, ensuring safe display of player names and table data.
This commit is contained in:
Torsten Schulz (local)
2026-05-12 23:23:04 +02:00
parent 48f71b9df1
commit 1e23171370
7 changed files with 1063 additions and 59 deletions

View File

@@ -1999,6 +1999,14 @@ export default {
name2: this.getPlayerName(match.player2)
};
},
escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
async loadTournaments() {
try {
@@ -2507,9 +2515,8 @@ export default {
// 8. Dialog mit Ergebnis anzeigen
const rows = assignments.map(({ match, table }) => {
const name1 = this.getPlayerName(match.player1);
const name2 = this.getPlayerName(match.player2);
return `<tr><td style="font-weight:bold; padding:0.35rem 0.75rem;">${table}</td><td style="padding:0.35rem 0.75rem;">${name1}</td><td style="padding:0.35rem 0.75rem;">${name2}</td></tr>`;
const { name1, name2 } = this.getMatchPlayerNames(match);
return `<tr><td style="font-weight:bold; padding:0.35rem 0.75rem;">${this.escapeHtml(table)}</td><td style="padding:0.35rem 0.75rem;">${this.escapeHtml(name1)}</td><td style="padding:0.35rem 0.75rem;">${this.escapeHtml(name2)}</td></tr>`;
});
const html = `<table style="margin:0.75rem auto; border-collapse:collapse; color:#000;"><thead><tr><th style="padding:0.35rem 0.75rem; border-bottom:2px solid #ccc; text-align:left;">${this.$t('tournaments.table')}</th><th style="padding:0.35rem 0.75rem; border-bottom:2px solid #ccc; text-align:left;">${this.$t('tournaments.playerOne')}</th><th style="padding:0.35rem 0.75rem; border-bottom:2px solid #ccc; text-align:left;">${this.$t('tournaments.playerTwo')}</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;

View File

@@ -132,12 +132,13 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug in Web nach `apiClien
---
## Phase 5 Trainings-Statistik (Parität TrainingStatsView)
## Phase 5 Trainings-Statistik (Parität TrainingStatsView) — erledigt
- [ ] Alle Kennzahlen/Tabellen/Filter aus Web
- [ ] Zeiträume, Exporte, falls vorhanden
---
- [x] **DTO / API:** `TrainingStats.kt` `weekdayStats`, `monthlyTrend`, `memberDistribution`, `overview.bestTrainingDay`, Mitglied mit `birthDate`, `participationRate12Months`, `trainingGroups`, `trainingDetails`, `lastTrainingTs` usw.
- [x] **Ableitungen wie Web:** `TrainingStatsDerived.kt` Filter (Wochentag, Trainingstag, Gruppe), `filteredOverview`, Monats-/Wochentags-Trends, Mitgliederstruktur, Gruppen-Performance, Altersklassen, Sortierung, CSV
- [x] **UI:** `TrainingStatsScreen.kt` Kennzahlen-Kacheln, Panels, kollabierbare Listen Trainingstage/Mitglieder, Detail-Dialog, CSV-Share (`shareFileWithMime` in `DiaryPdfShare.kt`)
- [x] **i18n-Local:** `LanguageLocals.kt` (`LocalLanguageCode`) aus `AppRoot.kt` ausgelagert
- [x] **Hinweis:** Web hat keinen CSV-Export für diese Statistik; mobil zusätzlich **CSV exportieren** (gefilterte/sortierte Mitgliederliste)
## Phase 6 Terminplan (ScheduleView)

View File

@@ -6,17 +6,21 @@ import androidx.core.content.FileProvider
import de.tt_tagebuch.app.BuildConfig
import java.io.File
fun sharePdfFile(context: Context, file: File, chooserTitle: String) {
fun shareFileWithMime(context: Context, file: File, mimeType: String, chooserTitle: String) {
val uri = FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
file,
)
val send = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
type = mimeType
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(send, chooserTitle).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser)
}
fun sharePdfFile(context: Context, file: File, chooserTitle: String) {
shareFileWithMime(context, file, "application/pdf", chooserTitle)
}

View File

@@ -0,0 +1,319 @@
package de.tt_tagebuch.app.stats
import de.tt_tagebuch.shared.api.models.TrainingStatsDay
import de.tt_tagebuch.shared.api.models.TrainingStatsMember
import de.tt_tagebuch.shared.api.models.TrainingStatsMemberDistribution
import de.tt_tagebuch.shared.api.models.TrainingStatsMonthlyTrend
import de.tt_tagebuch.shared.api.models.TrainingStatsWeekdayBucket
import java.time.DayOfWeek
import java.time.LocalDate
import java.util.Locale
data class FilteredOverview(
val totalParticipants: Int,
val averageParticipants: Double,
val attendanceRate: Double,
val bestTrainingDay: TrainingStatsDay?,
)
data class GroupPerformanceRow(
val name: String,
val memberCount: Int,
val averageParticipations12Months: Double,
val participationRate: Double,
)
data class AgeGroupStatRow(
val label: String,
val memberCount: Int,
val averageParticipations12Months: Double,
)
private data class GroupAgg(
val name: String,
var memberCount: Int = 0,
var totalParticipations12Months: Int = 0,
var participationRateSum: Double = 0.0,
)
private val weekdayDe = listOf("Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag")
object TrainingStatsDerived {
fun parseLocalDate(dateStr: String?): LocalDate? {
if (dateStr.isNullOrBlank()) return null
val prefix = dateStr.trim().take(10)
return runCatching { LocalDate.parse(prefix) }.getOrNull()
}
fun jsWeekdayIndex(dateStr: String): Int {
val ld = parseLocalDate(dateStr) ?: return 0
return when (ld.dayOfWeek) {
DayOfWeek.SUNDAY -> 0
DayOfWeek.MONDAY -> 1
DayOfWeek.TUESDAY -> 2
DayOfWeek.WEDNESDAY -> 3
DayOfWeek.THURSDAY -> 4
DayOfWeek.FRIDAY -> 5
DayOfWeek.SATURDAY -> 6
}
}
fun formatDateGerman(dateStr: String?): String {
val ld = parseLocalDate(dateStr) ?: return "-"
return String.format(Locale.GERMAN, "%02d.%02d.%d", ld.dayOfMonth, ld.monthValue, ld.year)
}
fun weekdayGerman(dateStr: String): String = weekdayDe.getOrElse(jsWeekdayIndex(dateStr)) { "" }
fun ageFromBirthDate(birthDate: String?): Int? {
val bd = parseLocalDate(birthDate) ?: return null
val now = LocalDate.now()
var age = now.year - bd.year
if (now.monthValue < bd.monthValue || (now.monthValue == bd.monthValue && now.dayOfMonth < bd.dayOfMonth)) {
age--
}
return age
}
fun trainingDaysByWeekday(trainingDays: List<TrainingStatsDay>, selectedWeekday: String): List<TrainingStatsDay> {
if (selectedWeekday == "all") return trainingDays
val w = selectedWeekday.toIntOrNull() ?: return trainingDays
return trainingDays.filter { jsWeekdayIndex(it.date) == w }
}
/** Wie Vue: Teilnehmermenge für den gewählten Trainingstag aus der vollen `trainingDays`-Liste. */
fun trainingDayParticipantIds(allTrainingDays: List<TrainingStatsDay>, trainingDayId: String): Set<Int>? {
if (trainingDayId == "all") return null
val day = allTrainingDays.find { it.id.toString() == trainingDayId } ?: return emptySet()
return day.participants.map { it.id }.toSet()
}
fun filteredMembers(
members: List<TrainingStatsMember>,
selectedTrainingGroup: String,
participantIds: Set<Int>?,
): List<TrainingStatsMember> {
var m = members
if (selectedTrainingGroup != "all") {
m = m.filter { mem -> mem.trainingGroups.any { it.id.toString() == selectedTrainingGroup } }
}
if (participantIds != null) {
m = m.filter { participantIds.contains(it.id) }
}
return m
}
fun filteredTrainingDays(
daysByWeekday: List<TrainingStatsDay>,
selectedTrainingDay: String,
): List<TrainingStatsDay> {
if (selectedTrainingDay == "all") return daysByWeekday
return daysByWeekday.filter { it.id.toString() == selectedTrainingDay }
}
fun filteredOverview(
filteredTrainingDays: List<TrainingStatsDay>,
filteredMembers: List<TrainingStatsMember>,
): FilteredOverview {
val totalParticipants = filteredTrainingDays.sumOf { it.participantCount }
val averageParticipants = if (filteredTrainingDays.isNotEmpty()) {
totalParticipants.toDouble() / filteredTrainingDays.size
} else {
0.0
}
val denom = filteredMembers.size
val attendanceRate = if (denom > 0 && filteredTrainingDays.isNotEmpty()) {
(totalParticipants.toDouble() / (denom * filteredTrainingDays.size)) * 100.0
} else {
0.0
}
val bestTrainingDay = filteredTrainingDays.maxByOrNull { it.participantCount }
return FilteredOverview(
totalParticipants = totalParticipants,
averageParticipants = averageParticipants,
attendanceRate = attendanceRate,
bestTrainingDay = bestTrainingDay,
)
}
fun filteredMonthlyTrend(filteredTrainingDays: List<TrainingStatsDay>): List<TrainingStatsMonthlyTrend> {
data class Acc(var label: String, var trainingCount: Int, var participantCount: Int)
val map = linkedMapOf<String, Acc>()
for (day in filteredTrainingDays) {
val ld = parseLocalDate(day.date) ?: continue
val key = "${ld.year}-${ld.monthValue.toString().padStart(2, '0')}"
val label = "${ld.monthValue.toString().padStart(2, '0')}.${ld.year}"
val acc = map.getOrPut(key) { Acc(label, 0, 0) }
acc.trainingCount += 1
acc.participantCount += day.participantCount
}
return map.entries
.sortedBy { it.key }
.map { (key, acc) ->
val avg = if (acc.trainingCount > 0) acc.participantCount.toDouble() / acc.trainingCount else 0.0
TrainingStatsMonthlyTrend(
key = key,
label = acc.label,
trainingCount = acc.trainingCount,
participantCount = acc.participantCount,
averageParticipants = avg,
)
}
}
fun filteredWeekdayStats(filteredTrainingDays: List<TrainingStatsDay>): List<TrainingStatsWeekdayBucket> {
val acc = mutableMapOf<Int, IntArray>()
for (day in filteredTrainingDays) {
val idx = jsWeekdayIndex(day.date)
val a = acc.getOrPut(idx) { intArrayOf(0, 0) }
a[0] += 1
a[1] += day.participantCount
}
return acc.entries
.sortedBy { it.key }
.map { (idx, ar) ->
val tc = ar[0]
val pc = ar[1]
TrainingStatsWeekdayBucket(
weekday = weekdayDe[idx],
weekdayIndex = idx,
trainingCount = tc,
participantCount = pc,
averageParticipants = if (tc > 0) pc.toDouble() / tc else 0.0,
)
}
}
fun filteredMemberDistribution(members: List<TrainingStatsMember>): TrainingStatsMemberDistribution =
TrainingStatsMemberDistribution(
highlyActive = members.count { it.participationRate12Months >= 0.75 },
regular = members.count { it.participationRate12Months >= 0.4 && it.participationRate12Months < 0.75 },
occasional = members.count { it.participationRate12Months > 0 && it.participationRate12Months < 0.4 },
inactive = members.count { it.participation12Months == 0 },
)
fun groupPerformance(filteredMembers: List<TrainingStatsMember>): List<GroupPerformanceRow> {
val groups = linkedMapOf<String, GroupAgg>()
for (member in filteredMembers) {
val memberGroups = member.trainingGroups
if (memberGroups.isEmpty()) {
val e = groups.getOrPut("ohne-gruppe") { GroupAgg("Ohne Trainingsgruppe") }
e.memberCount += 1
e.totalParticipations12Months += member.participation12Months
e.participationRateSum += member.participationRate12Months
} else {
for (g in memberGroups) {
val key = g.id.toString()
val e = groups.getOrPut(key) { GroupAgg(g.name) }
e.memberCount += 1
e.totalParticipations12Months += member.participation12Months
e.participationRateSum += member.participationRate12Months
}
}
}
return groups.values
.map { entry ->
val mc = entry.memberCount
GroupPerformanceRow(
name = entry.name,
memberCount = mc,
averageParticipations12Months = if (mc > 0) entry.totalParticipations12Months.toDouble() / mc else 0.0,
participationRate = if (mc > 0) (entry.participationRateSum / mc) * 100.0 else 0.0,
)
}
.sortedByDescending { it.averageParticipations12Months }
}
fun ageGroupStats(filteredMembers: List<TrainingStatsMember>): List<AgeGroupStatRow> {
data class Bucket(val label: String, val match: (Int?) -> Boolean, var memberCount: Int = 0, var totalP12: Int = 0)
val buckets = listOf(
Bucket("Kinder U13", { a -> a != null && a <= 12 }),
Bucket("Jugend U19", { a -> a != null && a in 13..18 }),
Bucket("Erwachsene", { a -> a != null && a in 19..59 }),
Bucket("Senioren 60+", { a -> a != null && a >= 60 }),
Bucket("Ohne Geburtsdatum", { a -> a == null }),
)
for (member in filteredMembers) {
val age = ageFromBirthDate(member.birthDate)
val bucket = buckets.find { it.match(age) } ?: continue
bucket.memberCount += 1
bucket.totalP12 += member.participation12Months
}
return buckets
.filter { it.memberCount > 0 }
.map {
val mc = it.memberCount
AgeGroupStatRow(
label = it.label,
memberCount = mc,
averageParticipations12Months = if (mc > 0) it.totalP12.toDouble() / mc else 0.0,
)
}
}
fun trainingGroupOptions(members: List<TrainingStatsMember>): List<Pair<String, String>> {
val map = linkedMapOf<String, String>()
for (m in members) {
for (g in m.trainingGroups) {
map.putIfAbsent(g.id.toString(), g.name)
}
}
return map.entries.map { it.key to it.value }.sortedWith(compareBy { it.second.lowercase(Locale.GERMAN) })
}
fun sortedMembers(
members: List<TrainingStatsMember>,
sortField: String,
sortDirectionAsc: Boolean,
): List<TrainingStatsMember> =
when (sortField) {
"name" -> members.sortedWith { a, b ->
val na = "${a.firstName} ${a.lastName}".lowercase(Locale.GERMAN)
val nb = "${b.firstName} ${b.lastName}".lowercase(Locale.GERMAN)
val c = na.compareTo(nb)
if (sortDirectionAsc) c else -c
}
"participation12Months" -> numericSort(members, sortDirectionAsc) { it.participation12Months }
"participation3Months" -> numericSort(members, sortDirectionAsc) { it.participation3Months }
"participationTotal" -> numericSort(members, sortDirectionAsc) { it.participationTotal }
"lastTrainingTs" -> numericSort(members, sortDirectionAsc) { it.lastTrainingTs }
else -> members
}
private fun numericSort(
members: List<TrainingStatsMember>,
asc: Boolean,
selector: (TrainingStatsMember) -> Number,
): List<TrainingStatsMember> =
if (asc) members.sortedBy { selector(it).toDouble() } else members.sortedByDescending { selector(it).toDouble() }
fun buildMembersCsv(members: List<TrainingStatsMember>): String {
fun esc(s: String): String {
val needs = s.contains(';') || s.contains('"') || s.contains('\n') || s.contains('\r')
if (!needs) return s
return "\"" + s.replace("\"", "\"\"") + "\""
}
val header = listOf(
"Vorname", "Nachname", "TTR", "QTTR", "Geburtsdatum", "Teilnahmen 12M", "Teilnahmen 3M", "Teilnahmen gesamt",
"Quote 12M", "Letztes Training", "Trainingsgruppen",
).joinToString(";")
val lines = members.map { m ->
val groups = m.trainingGroups.joinToString(", ") { it.name }
listOf(
esc(m.firstName),
esc(m.lastName),
m.ttr?.toString() ?: "",
m.qttr?.toString() ?: "",
formatDateGerman(m.birthDate).takeUnless { it == "-" } ?: "",
m.participation12Months.toString(),
m.participation3Months.toString(),
m.participationTotal.toString(),
String.format(Locale.GERMAN, "%.4f", m.participationRate12Months),
formatDateGerman(m.lastTraining).takeUnless { it == "-" } ?: "",
esc(groups),
).joinToString(";")
}
return (sequenceOf(header) + lines.asSequence()).joinToString("\n")
}
}

View File

@@ -59,7 +59,6 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
@@ -124,8 +123,6 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.File
private val LocalLanguageCode = compositionLocalOf { MobileStrings.DEFAULT_LANGUAGE }
/** Ab dieser Fensterbreite (dp): seitliche Navigation wie auf Tablet/Web. */
private const val MAIN_NAV_RAIL_MIN_WIDTH_DP = 600
@@ -3914,52 +3911,6 @@ private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> U
}
}
@Composable
private fun TrainingStatsScreen(dependencies: AppDependencies) {
val clubState by dependencies.clubManager.state.collectAsState()
val statsState by dependencies.trainingStatsManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
LaunchedEffect(clubId) {
dependencies.trainingStatsManager.loadStats(clubId)
}
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.navigationBarsPadding()
.padding(horizontal = ScreenHorizontalPadding, vertical = 16.dp),
) {
Header(tr("trainingStats.title", "Trainings-Statistik"))
OutlinedButton(
onClick = { dependencies.applicationScope.launch { dependencies.trainingStatsManager.loadStats(clubId) } },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = TouchMinHeight),
) {
Text(tr("mobile.refresh", "Aktualisieren"))
}
if (statsState.isLoading) LoadingInline()
ErrorText(statsState.error)
statsState.stats?.let { stats ->
DetailLine(tr("trainingStats.trainings12Months", "Trainings 12 Monate"), stats.trainingsCount12Months.toString())
DetailLine(tr("trainingStats.trainings3Months", "Trainings 3 Monate"), stats.trainingsCount3Months.toString())
DetailLine(tr("members.activeMembers", "Aktive Mitglieder"), stats.overview.activeMembersCount.toString())
DetailLine(tr("trainingStats.averageParticipants", "Durchschnitt Teilnehmer"), "%.1f".format(stats.overview.averageParticipants12Months))
SectionTitle(tr("mobile.participationTop", "Top Teilnahmen"))
LazyColumn(modifier = Modifier.weight(1f)) {
items(stats.members.take(20)) { member ->
Text(
"${member.lastName}, ${member.firstName}: ${member.participationTotal}",
modifier = Modifier.padding(vertical = 4.dp),
)
}
}
}
}
}
@Composable
private fun SettingsScreen(dependencies: AppDependencies) {
val authState by dependencies.authManager.state.collectAsState()

View File

@@ -0,0 +1,6 @@
package de.tt_tagebuch.app.ui
import androidx.compose.runtime.compositionLocalOf
import de.tt_tagebuch.shared.i18n.MobileStrings
internal val LocalLanguageCode = compositionLocalOf { MobileStrings.DEFAULT_LANGUAGE }

View File

@@ -0,0 +1,716 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.app.pdf.shareFileWithMime
import de.tt_tagebuch.app.stats.TrainingStatsDerived
import de.tt_tagebuch.shared.api.models.TrainingStatsMember
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private val StatsPad = 20.dp
private val StatsTouchMin = 48.dp
private val weekdayFilterLabels = listOf(
"Sonntag" to "0",
"Montag" to "1",
"Dienstag" to "2",
"Mittwoch" to "3",
"Donnerstag" to "4",
"Freitag" to "5",
"Samstag" to "6",
)
@Composable
internal fun TrainingStatsScreen(dependencies: AppDependencies) {
val clubState by dependencies.clubManager.state.collectAsState()
val statsState by dependencies.trainingStatsManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
var selectedWeekday by rememberSaveable { mutableStateOf("all") }
var selectedTrainingDay by rememberSaveable { mutableStateOf("all") }
var selectedTrainingGroup by rememberSaveable { mutableStateOf("all") }
var showTrainingDays by rememberSaveable { mutableStateOf(true) }
var showMembers by rememberSaveable { mutableStateOf(false) }
var sortField by rememberSaveable { mutableStateOf("name") }
var sortAsc by rememberSaveable { mutableStateOf(true) }
var weekdayMenu by remember { mutableStateOf(false) }
var trainingDayMenu by remember { mutableStateOf(false) }
var trainingGroupMenu by remember { mutableStateOf(false) }
var detailMember by remember { mutableStateOf<TrainingStatsMember?>(null) }
LaunchedEffect(clubId) {
selectedWeekday = "all"
selectedTrainingDay = "all"
selectedTrainingGroup = "all"
dependencies.trainingStatsManager.loadStats(clubId)
}
val languageCode = LocalLanguageCode.current
fun statsTr(key: String, fallback: String) = MobileStrings.get(languageCode, key, fallback)
fun onSortColumn(field: String) {
if (sortField == field) {
sortAsc = !sortAsc
} else {
sortField = field
sortAsc = false
}
}
fun sortIcon(field: String): String = when {
sortField != field -> ""
sortAsc -> ""
else -> ""
}
val stats = statsState.stats
val trainingDaysByWeekday = remember(stats, selectedWeekday) {
if (stats == null) emptyList() else TrainingStatsDerived.trainingDaysByWeekday(stats.trainingDays, selectedWeekday)
}
LaunchedEffect(selectedWeekday, trainingDaysByWeekday) {
if (selectedTrainingDay != "all" && trainingDaysByWeekday.none { it.id.toString() == selectedTrainingDay }) {
selectedTrainingDay = "all"
}
}
val trainingDayOptions = remember(trainingDaysByWeekday) {
trainingDaysByWeekday.map { day ->
day.id.toString() to "${TrainingStatsDerived.formatDateGerman(day.date)} (${TrainingStatsDerived.weekdayGerman(day.date)})"
}
}
val trainingGroupOptions = remember(stats) {
if (stats == null) emptyList() else TrainingStatsDerived.trainingGroupOptions(stats.members)
}
val participantIds = remember(stats, selectedTrainingDay) {
if (stats == null) null else TrainingStatsDerived.trainingDayParticipantIds(stats.trainingDays, selectedTrainingDay)
}
val filteredMembers = remember(stats, selectedTrainingGroup, participantIds) {
if (stats == null) emptyList() else TrainingStatsDerived.filteredMembers(stats.members, selectedTrainingGroup, participantIds)
}
val filteredTrainingDays = remember(trainingDaysByWeekday, selectedTrainingDay) {
TrainingStatsDerived.filteredTrainingDays(trainingDaysByWeekday, selectedTrainingDay)
}
val filteredOverview = remember(filteredTrainingDays, filteredMembers) {
TrainingStatsDerived.filteredOverview(filteredTrainingDays, filteredMembers)
}
val filteredMonthlyTrend = remember(filteredTrainingDays) {
TrainingStatsDerived.filteredMonthlyTrend(filteredTrainingDays)
}
val filteredWeekdayStats = remember(filteredTrainingDays) {
TrainingStatsDerived.filteredWeekdayStats(filteredTrainingDays)
}
val filteredMemberDistribution = remember(filteredMembers) {
TrainingStatsDerived.filteredMemberDistribution(filteredMembers)
}
val groupPerformance = remember(filteredMembers) {
TrainingStatsDerived.groupPerformance(filteredMembers)
}
val ageGroupStats = remember(filteredMembers) {
TrainingStatsDerived.ageGroupStats(filteredMembers)
}
val sortedMembers = remember(filteredMembers, sortField, sortAsc) {
TrainingStatsDerived.sortedMembers(filteredMembers, sortField, sortAsc)
}
val maxMonthAvg = remember(filteredMonthlyTrend) {
(filteredMonthlyTrend.maxOfOrNull { it.averageParticipants } ?: 1.0).coerceAtLeast(1.0)
}
val scroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.imePadding()
.navigationBarsPadding()
.padding(horizontal = StatsPad, vertical = 16.dp),
) {
TrainingStatsMainHeader(statsTr("trainingStats.title", "Trainings-Statistik"))
OutlinedButton(
onClick = { dependencies.applicationScope.launch { dependencies.trainingStatsManager.loadStats(clubId) } },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = StatsTouchMin),
) {
Text(statsTr("mobile.refresh", "Aktualisieren"))
}
if (statsState.isLoading) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
}
if (!statsState.error.isNullOrBlank()) {
Text(statsState.error!!, color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp))
}
stats?.let { s ->
StatsSectionTitle(statsTr("mobile.filters", "Filter"))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(modifier = Modifier.weight(1f)) {
OutlinedButton(
onClick = { weekdayMenu = true },
modifier = Modifier.fillMaxWidth().heightIn(min = StatsTouchMin),
) {
val label = if (selectedWeekday == "all") {
statsTr("trainingStats.allWeekdays", "Alle Wochentage")
} else {
weekdayFilterLabels.find { it.second == selectedWeekday }?.first ?: selectedWeekday
}
Text(label, maxLines = 1)
}
DropdownMenu(expanded = weekdayMenu, onDismissRequest = { weekdayMenu = false }) {
DropdownMenuItem(onClick = {
selectedWeekday = "all"
weekdayMenu = false
}) { Text(statsTr("trainingStats.allWeekdays", "Alle Wochentage")) }
weekdayFilterLabels.forEach { (label, value) ->
DropdownMenuItem(onClick = {
selectedWeekday = value
weekdayMenu = false
}) { Text(label) }
}
}
}
Box(modifier = Modifier.weight(1f)) {
OutlinedButton(
onClick = { trainingDayMenu = true },
modifier = Modifier.fillMaxWidth().heightIn(min = StatsTouchMin),
) {
val label = trainingDayOptions.find { it.first == selectedTrainingDay }?.second
?: statsTr("trainingStats.allTrainingDays", "Alle Trainingstage")
Text(label, maxLines = 2)
}
DropdownMenu(expanded = trainingDayMenu, onDismissRequest = { trainingDayMenu = false }) {
DropdownMenuItem(onClick = {
selectedTrainingDay = "all"
trainingDayMenu = false
}) { Text(statsTr("trainingStats.allTrainingDays", "Alle Trainingstage")) }
trainingDayOptions.forEach { (id, label) ->
DropdownMenuItem(onClick = {
selectedTrainingDay = id
trainingDayMenu = false
}) { Text(label) }
}
}
}
}
Box(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
OutlinedButton(
onClick = { trainingGroupMenu = true },
modifier = Modifier.fillMaxWidth().heightIn(min = StatsTouchMin),
) {
val label = if (selectedTrainingGroup == "all") {
statsTr("trainingStats.allGroups", "Alle Trainingsgruppen")
} else {
trainingGroupOptions.find { it.first == selectedTrainingGroup }?.second ?: selectedTrainingGroup
}
Text(label, maxLines = 1)
}
DropdownMenu(expanded = trainingGroupMenu, onDismissRequest = { trainingGroupMenu = false }) {
DropdownMenuItem(onClick = {
selectedTrainingGroup = "all"
trainingGroupMenu = false
}) { Text(statsTr("trainingStats.allGroups", "Alle Trainingsgruppen")) }
trainingGroupOptions.forEach { (id, name) ->
DropdownMenuItem(onClick = {
selectedTrainingGroup = id
trainingGroupMenu = false
}) { Text(name) }
}
}
}
StatsSectionTitle(statsTr("trainingStats.summary", "Übersicht"))
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("members.activeMembers", "Aktive Mitglieder"),
value = filteredMembers.size.toString(),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.trainingDays12m", "Trainingstage (Filter)"),
value = filteredTrainingDays.size.toString(),
modifier = Modifier.weight(1f),
)
}
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("trainingStats.averageParticipants", "Ø Teilnehmer je Training"),
value = "%.1f".format(filteredOverview.averageParticipants),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.totalParticipations", "Teilnahmen gesamt"),
value = filteredOverview.totalParticipants.toString(),
modifier = Modifier.weight(1f),
)
}
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("trainingStats.attendanceRate12m", "Anwesenheitsquote %"),
value = "%.1f %%".format(filteredOverview.attendanceRate),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.notInTraining", "Nicht im Training"),
value = filteredMembers.count { it.notInTraining }.toString(),
modifier = Modifier.weight(1f),
)
}
StatsSectionTitle(statsTr("trainingStats.monthlyTrend", "Monatlicher Verlauf"))
Text(
"${filteredMonthlyTrend.size} Monate",
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(bottom = 4.dp),
)
filteredMonthlyTrend.forEach { month ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(month.label, fontWeight = FontWeight.SemiBold)
Text(
"${month.trainingCount} ${statsTr("trainingStats.trainingDaysShort", "Trainingstage")}",
style = MaterialTheme.typography.caption,
)
}
Column(modifier = Modifier.widthIn(min = 96.dp), horizontalAlignment = Alignment.End) {
Text("%.1f".format(month.averageParticipants), fontWeight = FontWeight.Medium)
}
}
LinearProgressIndicator(
progress = (month.averageParticipants / maxMonthAvg).toFloat().coerceIn(0f, 1f),
modifier = Modifier
.fillMaxWidth()
.height(6.dp),
)
}
StatsSectionTitle(statsTr("trainingStats.weekdayStats", "Trainingstage nach Wochentag"))
Text(
"${filteredWeekdayStats.size} ${statsTr("trainingStats.weekdays", "Wochentage")}",
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(bottom = 4.dp),
)
filteredWeekdayStats.chunked(2).forEach { row ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { w ->
StatMiniCard(
title = w.weekday,
value = "${w.trainingCount} Term.\nØ %.1f".format(w.averageParticipants),
modifier = Modifier.weight(1f),
)
}
if (row.size == 1) Spacer(modifier = Modifier.weight(1f))
}
}
StatsSectionTitle(statsTr("trainingStats.memberStructure", "Mitgliederstruktur"))
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("trainingStats.distHighlyActive", "Sehr aktiv"),
value = filteredMemberDistribution.highlyActive.toString(),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.distRegular", "Regelmäßig"),
value = filteredMemberDistribution.regular.toString(),
modifier = Modifier.weight(1f),
)
}
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("trainingStats.distOccasional", "Gelegentlich"),
value = filteredMemberDistribution.occasional.toString(),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.distInactive", "Ohne Teilnahme"),
value = filteredMemberDistribution.inactive.toString(),
modifier = Modifier.weight(1f),
)
}
StatsSectionTitle(statsTr("trainingStats.bestDay", "Stärkster Trainingstag"))
val best = filteredOverview.bestTrainingDay
Card(modifier = Modifier.fillMaxWidth(), elevation = 2.dp) {
Column(modifier = Modifier.padding(12.dp)) {
if (best != null) {
Text(TrainingStatsDerived.formatDateGerman(best.date), fontWeight = FontWeight.Bold)
Text(TrainingStatsDerived.weekdayGerman(best.date), style = MaterialTheme.typography.body2)
Text(
"${best.participantCount} ${statsTr("trainingStats.participants", "Teilnehmer")}",
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 4.dp),
)
Text(
statsTr("trainingStats.bestDayHint", "Beim bestbesuchten Training im Filterzeitraum."),
style = MaterialTheme.typography.caption,
)
} else {
Text(statsTr("trainingStats.noData", "Keine Daten"))
}
}
}
StatsSectionTitle(statsTr("trainingStats.groupPerformance", "Entwicklung pro Gruppe"))
Text(
"${groupPerformance.size} ${statsTr("trainingStats.groups", "Gruppen")}",
style = MaterialTheme.typography.caption,
)
groupPerformance.forEach { g ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
elevation = 1.dp,
) {
Column(modifier = Modifier.padding(10.dp)) {
Text(g.name, fontWeight = FontWeight.SemiBold)
Text("${g.memberCount} ${statsTr("members.members", "Mitglieder")}", style = MaterialTheme.typography.caption)
Text(
"%.1f Ø / 12M · %.1f %% ${statsTr("trainingStats.presence", "Anwesenheit")}"
.format(g.averageParticipations12Months, g.participationRate),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
StatsSectionTitle(statsTr("trainingStats.ageGroups", "Anwesenheit nach Altersklasse"))
ageGroupStats.chunked(2).forEach { row ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { a ->
StatMiniCard(
title = a.label,
value = "${a.memberCount}\nØ %.1f / 12M".format(a.averageParticipations12Months),
modifier = Modifier.weight(1f),
)
}
if (row.size == 1) Spacer(modifier = Modifier.weight(1f))
}
}
StatsSectionTitle(statsTr("trainingStats.rawCounts", "Rohzahlen (Verein)"))
StatsDetailLine(statsTr("trainingStats.trainings12Months", "Trainings 12 Monate"), s.trainingsCount12Months.toString())
StatsDetailLine(statsTr("trainingStats.trainings3Months", "Trainings 3 Monate"), s.trainingsCount3Months.toString())
StatsDetailLine(statsTr("members.activeMembers", "Aktive Mitglieder (Backend)"), s.overview.activeMembersCount.toString())
CollapsibleHeader(
title = statsTr("trainingStats.trainingDays", "Trainingstage"),
expanded = showTrainingDays,
onToggle = { showTrainingDays = !showTrainingDays },
)
if (showTrainingDays) {
filteredTrainingDays.forEach { day ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
elevation = 1.dp,
) {
Column(modifier = Modifier.padding(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(TrainingStatsDerived.formatDateGerman(day.date), fontWeight = FontWeight.SemiBold)
Text("${day.participantCount} ${statsTr("trainingStats.participants", "Teilnehmer")}")
}
Text(TrainingStatsDerived.weekdayGerman(day.date), style = MaterialTheme.typography.caption)
if (day.participants.isNotEmpty()) {
Text(
day.participants.joinToString(", ") { p -> "${p.firstName} ${p.lastName}".trim() },
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 6.dp),
)
} else {
Text(
statsTr("trainingStats.noParticipants", "Keine Teilnehmer"),
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
}
CollapsibleHeader(
title = statsTr("trainingStats.memberParticipations", "Mitglieder-Teilnahmen"),
expanded = showMembers,
onToggle = { showMembers = !showMembers },
)
if (showMembers) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = {
scope.launch(Dispatchers.IO) {
val csv = TrainingStatsDerived.buildMembersCsv(sortedMembers)
val f = File(context.cacheDir, "training-stats-${System.currentTimeMillis()}.csv")
f.writeText(csv, Charsets.UTF_8)
withContext(Dispatchers.Main) {
shareFileWithMime(
context,
f,
"text/csv",
statsTr("trainingStats.shareCsv", "Mitgliederstatistik teilen"),
)
}
}
},
modifier = Modifier
.weight(1f)
.heightIn(min = StatsTouchMin),
) {
Text(statsTr("trainingStats.exportCsv", "CSV exportieren"))
}
}
val hScroll = rememberScrollState()
Row(Modifier.horizontalScroll(hScroll)) {
Column(modifier = Modifier.widthIn(min = 920.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
SortHeader(statsTr("trainingStats.name", "Name"), "name", ::sortIcon, ::onSortColumn, 140.dp)
Text("TTR", modifier = Modifier.widthIn(min = 48.dp), fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.caption)
Text("QTTR", modifier = Modifier.widthIn(min = 52.dp), fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.caption)
Text(
statsTr("trainingStats.birthdate", "Geb."),
modifier = Modifier.widthIn(min = 88.dp),
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.caption,
)
SortHeader("12M", "participation12Months", ::sortIcon, ::onSortColumn, 44.dp)
SortHeader("3M", "participation3Months", ::sortIcon, ::onSortColumn, 44.dp)
SortHeader(statsTr("trainingStats.totalShort", "Σ"), "participationTotal", ::sortIcon, ::onSortColumn, 44.dp)
SortHeader(statsTr("trainingStats.lastShort", "Letzt."), "lastTrainingTs", ::sortIcon, ::onSortColumn, 88.dp)
Text(statsTr("trainingStats.actions", "Aktion"), modifier = Modifier.widthIn(min = 88.dp), fontWeight = FontWeight.SemiBold)
}
Divider()
sortedMembers.forEach { m ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${m.firstName} ${m.lastName}".trim().ifBlank { "" },
modifier = Modifier.widthIn(min = 140.dp, max = 180.dp),
maxLines = 2,
style = MaterialTheme.typography.body2,
)
Text((m.ttr ?: "").toString(), modifier = Modifier.widthIn(48.dp))
Text((m.qttr ?: "").toString(), modifier = Modifier.widthIn(52.dp))
Text(
TrainingStatsDerived.formatDateGerman(m.birthDate).takeUnless { it == "-" } ?: "",
modifier = Modifier.widthIn(88.dp),
style = MaterialTheme.typography.caption,
)
Text(m.participation12Months.toString(), modifier = Modifier.widthIn(44.dp))
Text(m.participation3Months.toString(), modifier = Modifier.widthIn(44.dp))
Text(m.participationTotal.toString(), modifier = Modifier.widthIn(44.dp))
Text(
TrainingStatsDerived.formatDateGerman(m.lastTraining).takeUnless { it == "-" } ?: "",
modifier = Modifier.widthIn(88.dp),
style = MaterialTheme.typography.caption,
)
TextButton(onClick = { detailMember = m }) {
Text(statsTr("trainingStats.showDetails", "Details"))
}
}
Divider()
}
}
}
}
}
}
detailMember?.let { member ->
AlertDialog(
onDismissRequest = { detailMember = null },
title = {
Text(
"${statsTr("trainingDetails.title", "Trainingsdetails")}: ${member.firstName} ${member.lastName}",
)
},
text = {
val dScroll = rememberScrollState()
Column(
modifier = Modifier
.heightIn(max = 420.dp)
.verticalScroll(dScroll),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"${statsTr("trainingDetails.birthdate", "Geburtsdatum")}: ${TrainingStatsDerived.formatDateGerman(member.birthDate)}",
)
val by = TrainingStatsDerived.parseLocalDate(member.birthDate)?.year
Text("${statsTr("trainingDetails.birthYear", "Geburtsjahr")}: ${by ?: ""}")
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text("${statsTr("trainingDetails.last12Months", "12 Monate")}: ${member.participation12Months}")
Text("${statsTr("trainingDetails.last3Months", "3 Monate")}: ${member.participation3Months}")
Text("${statsTr("trainingDetails.total", "Gesamt")}: ${member.participationTotal}")
}
Text(statsTr("trainingDetails.trainingParticipations", "Trainingsteilnahmen"), fontWeight = FontWeight.SemiBold)
if (member.trainingDetails.isEmpty()) {
Text(statsTr("trainingDetails.noTrainings", "Keine Einträge"), style = MaterialTheme.typography.caption)
} else {
member.trainingDetails.forEach { t ->
Text(
"${TrainingStatsDerived.formatDateGerman(t.date)} · ${t.activityName ?: "—"}",
style = MaterialTheme.typography.body2,
)
}
}
}
},
confirmButton = {
TextButton(onClick = { detailMember = null }) {
Text(statsTr("common.close", "Schließen"))
}
},
)
}
}
@Composable
private fun TrainingStatsMainHeader(text: String) {
Text(text, style = MaterialTheme.typography.h5, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(12.dp))
}
@Composable
private fun SortHeader(
label: String,
field: String,
sortIcon: (String) -> String,
onSort: (String) -> Unit,
minWidth: Dp,
) {
TextButton(onClick = { onSort(field) }, modifier = Modifier.widthIn(min = minWidth)) {
Text("$label ${sortIcon(field)}", maxLines = 2, style = MaterialTheme.typography.caption)
}
}
@Composable
private fun CollapsibleHeader(title: String, expanded: Boolean, onToggle: () -> Unit) {
TextButton(
onClick = onToggle,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold)
Text(if (expanded) "" else "")
}
}
}
@Composable
private fun StatMiniCard(title: String, value: String, modifier: Modifier = Modifier) {
Card(modifier = modifier.padding(4.dp), elevation = 1.dp) {
Column(modifier = Modifier.padding(10.dp)) {
Text(title, style = MaterialTheme.typography.caption, maxLines = 3)
Text(value, style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 4.dp))
}
}
}
@Composable
private fun StatsSectionTitle(text: String) {
Spacer(modifier = Modifier.height(16.dp))
Divider()
Text(text, style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 8.dp))
}
@Composable
private fun StatsDetailLine(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
Text(label, modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
Text(value, modifier = Modifier.weight(1f))
}
}