From 1e231713705049404b4eecb9aa6c99de530e7f20 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 12 May 2026 23:23:04 +0200 Subject: [PATCH] feat(TournamentTab): add HTML escaping utility and improve player name rendering - 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. --- frontend/src/views/TournamentTab.vue | 13 +- mobile-app/TODO.md | 11 +- .../de/tt_tagebuch/app/pdf/DiaryPdfShare.kt | 8 +- .../app/stats/TrainingStatsDerived.kt | 319 ++++++++ .../kotlin/de/tt_tagebuch/app/ui/AppRoot.kt | 49 -- .../de/tt_tagebuch/app/ui/LanguageLocals.kt | 6 + .../tt_tagebuch/app/ui/TrainingStatsScreen.kt | 716 ++++++++++++++++++ 7 files changed, 1063 insertions(+), 59 deletions(-) create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/stats/TrainingStatsDerived.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/LanguageLocals.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TrainingStatsScreen.kt diff --git a/frontend/src/views/TournamentTab.vue b/frontend/src/views/TournamentTab.vue index 0da6d141..2d8ffc3e 100644 --- a/frontend/src/views/TournamentTab.vue +++ b/frontend/src/views/TournamentTab.vue @@ -1999,6 +1999,14 @@ export default { name2: this.getPlayerName(match.player2) }; }, + escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }, 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 `${table}${name1}${name2}`; + const { name1, name2 } = this.getMatchPlayerNames(match); + return `${this.escapeHtml(table)}${this.escapeHtml(name1)}${this.escapeHtml(name2)}`; }); const html = `${rows.join('')}
${this.$t('tournaments.table')}${this.$t('tournaments.playerOne')}${this.$t('tournaments.playerTwo')}
`; diff --git a/mobile-app/TODO.md b/mobile-app/TODO.md index bef35b9a..bf5c27f1 100644 --- a/mobile-app/TODO.md +++ b/mobile-app/TODO.md @@ -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) diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/pdf/DiaryPdfShare.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/pdf/DiaryPdfShare.kt index d0e8b023..b508f0ca 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/pdf/DiaryPdfShare.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/pdf/DiaryPdfShare.kt @@ -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) +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/stats/TrainingStatsDerived.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/stats/TrainingStatsDerived.kt new file mode 100644 index 00000000..c6e26db1 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/stats/TrainingStatsDerived.kt @@ -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, selectedWeekday: String): List { + 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, trainingDayId: String): Set? { + 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, + selectedTrainingGroup: String, + participantIds: Set?, + ): List { + 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, + selectedTrainingDay: String, + ): List { + if (selectedTrainingDay == "all") return daysByWeekday + return daysByWeekday.filter { it.id.toString() == selectedTrainingDay } + } + + fun filteredOverview( + filteredTrainingDays: List, + filteredMembers: List, + ): 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): List { + data class Acc(var label: String, var trainingCount: Int, var participantCount: Int) + val map = linkedMapOf() + 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): List { + val acc = mutableMapOf() + 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): 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): List { + val groups = linkedMapOf() + 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): List { + 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): List> { + val map = linkedMapOf() + 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, + sortField: String, + sortDirectionAsc: Boolean, + ): List = + 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, + asc: Boolean, + selector: (TrainingStatsMember) -> Number, + ): List = + if (asc) members.sortedBy { selector(it).toDouble() } else members.sortedByDescending { selector(it).toDouble() } + + fun buildMembersCsv(members: List): 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") + } +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt index 44535683..43a6e843 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt @@ -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() diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/LanguageLocals.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/LanguageLocals.kt new file mode 100644 index 00000000..ca33f9c9 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/LanguageLocals.kt @@ -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 } diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TrainingStatsScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TrainingStatsScreen.kt new file mode 100644 index 00000000..b59c2d03 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TrainingStatsScreen.kt @@ -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(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)) + } +}