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 = `| ${this.$t('tournaments.table')} | ${this.$t('tournaments.playerOne')} | ${this.$t('tournaments.playerTwo')} |
${rows.join('')}
`;
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))
+ }
+}