feat: add QTTR values feature to member area
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m49s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m7s

- Implemented QTTR values screen in the member area with data fetching and display.
- Added new API endpoint for QTTR values retrieval.
- Created a new view model for managing QTTR data state.
- Updated navigation to include QTTR section.
- Enhanced error handling and loading states for QTTR data.
- Adjusted server-side logic to import QTTR values from external source.
- Updated Android app version and adjusted build configurations.
- Added necessary UI components and styling for QTTR display.
This commit is contained in:
Torsten Schulz (local)
2026-05-30 23:43:06 +02:00
parent 387ce6e08e
commit 6507afea5f
29 changed files with 1182 additions and 94 deletions

1
.gitignore vendored
View File

@@ -93,6 +93,7 @@ dist
/android-app/.kotlin/
/android-app/**/build/
/android-app/local.properties
/android-app/gradle-local.properties
# Build output (but keep production data!)
.output

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("com.google.devtools.ksp")
@@ -25,15 +27,40 @@ val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
.orElse("true")
.get()
.toBoolean()
val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull
val localSigningProperties = Properties().apply {
val localSigningFile = rootProject.file("gradle-local.properties")
if (localSigningFile.exists()) {
localSigningFile.inputStream().use { load(it) }
}
}
fun signingProperty(name: String): String? =
providers.gradleProperty(name).orNull
?: providers.environmentVariable(name).orNull
?: localSigningProperties.getProperty(name)
val releaseStoreFile = signingProperty("RELEASE_STORE_FILE")
val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD")
val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS")
val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD")
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
!releaseStorePassword.isNullOrBlank() &&
!releaseKeyAlias.isNullOrBlank() &&
!releaseKeyPassword.isNullOrBlank()
val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
doFirst {
if (!hasReleaseSigning) {
throw GradleException(
"Production release signing is not configured. " +
"Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " +
"(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)."
)
}
}
}
android {
namespace = "de.harheimertc"
compileSdk = 35
@@ -135,6 +162,7 @@ val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("package
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
group = "distribution"
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
dependsOn(ensureReleaseSigning)
dependsOn(":app:bundleProductionRelease")
dependsOn(packageNativeDebugSymbolsForProductionRelease)
@@ -161,6 +189,12 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
}
}
tasks.matching {
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
}.configureEach {
dependsOn(ensureReleaseSigning)
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)

View File

@@ -10,6 +10,15 @@
@retrofit2.http.* <methods>;
}
# Retrofit + R8 full mode: keep interfaces with HTTP methods and suspend continuation generics.
-if interface * { @retrofit2.http.* <methods>; }
-keep,allowobfuscation interface <1>
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
# Avoid Retrofit generic signature loss on release builds for our API interface.
-keep interface de.harheimertc.data.ApiService { *; }
-keepclassmembers interface de.harheimertc.data.ApiService { *; }
# Keep app DTO/request/response models used via Moshi reflection.
-keep class de.harheimertc.data.*Dto { *; }
-keep class de.harheimertc.data.*Request { *; }
@@ -19,3 +28,10 @@
-keepclassmembers class * {
@com.squareup.moshi.Json <fields>;
}
# Keep WorkManager + Room generated classes used reflectively at startup.
-keep class * extends androidx.work.ListenableWorker {
<init>(android.content.Context, androidx.work.WorkerParameters);
}
-keep class androidx.work.impl.WorkDatabase_Impl { *; }
-keep class * extends androidx.room.RoomDatabase { *; }

View File

@@ -81,7 +81,7 @@ data class LeagueTableRowDto(
)
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
data class NewsDto(
val id: Int? = null,
val id: String? = null,
val title: String = "",
val content: String = "",
val created: String? = null,
@@ -96,7 +96,7 @@ data class NewsResponse(
val news: List<NewsDto> = emptyList(),
)
data class NewsSaveRequest(
val id: Int? = null,
val id: String? = null,
val title: String,
val content: String,
val isPublic: Boolean = false,
@@ -260,6 +260,28 @@ data class BirthdaysResponse(
val success: Boolean = false,
val birthdays: List<BirthdayDto> = emptyList(),
)
data class QttrSourceDto(
val url: String = "",
)
data class QttrRowDto(
val rank: Int? = null,
val playerNumber: Int? = null,
val gender: String? = null,
val playerName: String = "",
val clubName: String = "",
val currentQttr: Int? = null,
val previousQttr: Int? = null,
val birthdate: String? = null,
)
data class QttrValuesResponse(
val format: String = "",
val importedAt: String = "",
val source: QttrSourceDto = QttrSourceDto(),
val title: String? = null,
val headerCount: Int = 0,
val rowCount: Int = 0,
val rows: List<QttrRowDto> = emptyList(),
)
data class MemberDto(
val id: String? = null,
val name: String = "",
@@ -548,7 +570,7 @@ interface ApiService {
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
@DELETE("/api/news")
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse>
suspend fun deleteNews(@Query("id") id: String): Response<AuthMessageResponse>
@GET("/api/mannschaften")
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
@@ -620,6 +642,9 @@ interface ApiService {
@GET("/api/birthdays")
suspend fun birthdays(): Response<BirthdaysResponse>
@GET("/api/mitgliederbereich/qttr")
suspend fun qttrValues(): Response<QttrValuesResponse>
@GET("/api/members")
suspend fun members(): Response<MembersResponse>

View File

@@ -16,6 +16,7 @@ class SecureOfflineCache @Inject constructor(
) {
private companion object {
const val KEY_BIRTHDAYS = "birthdays"
const val KEY_QTTR_VALUES = "qttr_values"
const val KEY_MEMBERS = "members"
const val KEY_MEMBER_NEWS = "member_news"
const val KEY_CMS_CONFIG = "cms_config"
@@ -43,6 +44,9 @@ class SecureOfflineCache @Inject constructor(
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
fun putQttrValues(response: QttrValuesResponse) = put(KEY_QTTR_VALUES, response, QttrValuesResponse::class.java)
fun getQttrValues(maxAgeMillis: Long? = null): QttrValuesResponse? = get(KEY_QTTR_VALUES, QttrValuesResponse::class.java, maxAgeMillis)
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
@@ -93,6 +97,7 @@ class SecureOfflineCache @Inject constructor(
KEY_NEWSLETTER_GROUPS,
KEY_PASSWORD_RESET_DIAGNOSTICS,
KEY_MEMBER_NEWS,
KEY_QTTR_VALUES,
)
}

View File

@@ -267,7 +267,7 @@ class CmsRepository @Inject constructor(
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
suspend fun deleteNews(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
cache.clearCmsNewsCache()

View File

@@ -1,5 +1,6 @@
package de.harheimertc.repositories
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto
@@ -7,6 +8,7 @@ import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TerminDto
import io.sentry.Sentry
import javax.inject.Inject
import javax.inject.Singleton
@@ -17,19 +19,121 @@ data class HomeData(
val selectedSpielplanSeason: String?,
val news: List<NewsDto>,
val homepageSections: List<HomepageSectionDto>,
val diagnostics: List<String> = emptyList(),
)
@Singleton
class HomeRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
val termine = api.termine().body()?.termine.orEmpty()
val spielplanResponse = api.spielplan().body()
val diagnostics = mutableListOf<String>()
val termine = runCatching {
val response = api.termine()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/termine",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.termine.orEmpty()
}.onFailure { error ->
captureLoadIssue("fetchHomeData.termine", error)
if (diagnostics.none { it.contains("GET /api/termine") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/termine",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val spielplanResponse = runCatching {
val response = api.spielplan()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/spielplan",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
}
response.body()
}.onFailure { error ->
captureLoadIssue("fetchHomeData.spielplan", error)
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/spielplan",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrNull()
val spiele = spielplanResponse?.data.orEmpty()
val news = api.publicNews().body()?.news.orEmpty()
val news = runCatching {
val response = api.publicNews()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/news-public",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.news.orEmpty()
}.onFailure { error ->
captureLoadIssue("fetchHomeData.news", error)
if (diagnostics.none { it.contains("GET /api/news-public") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/news-public",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val homepageSections = runCatching {
val configResponse = api.config()
if (!configResponse.isSuccessful) return@runCatching emptyList()
configResponse.body()?.homepage?.sections.orEmpty()
val response = api.config()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/config",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.homepage?.sections.orEmpty()
}.onFailure { error ->
captureLoadIssue("fetchHomeData.config", error)
if (diagnostics.none { it.contains("GET /api/config") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/config",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
HomeData(
termine = termine,
@@ -38,12 +142,56 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
selectedSpielplanSeason = spielplanResponse?.season,
news = news,
homepageSections = homepageSections,
diagnostics = diagnostics,
)
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", "fetchHomeData")
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body() ?: error("Leere Antwort")
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", "fetchSpielplanForSeason")
scope.setExtra("season", season)
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
private fun captureLoadIssue(operation: String, error: Throwable) {
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", operation)
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
private fun buildDiagnostic(
endpoint: String,
requestPayload: String,
httpCode: Int?,
responseBody: String?,
throwable: Throwable?,
): String {
val responsePreview = responseBody?.trim()?.take(500).orEmpty().ifBlank { "none" }
val throwableInfo = throwable?.let { "${it::class.simpleName}: ${it.message}" }.orEmpty().ifBlank { "none" }
return buildString {
append("Endpoint: ").append(endpoint).append('\n')
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint.substringAfter(' ')).append('\n')
append("Request: ").append(requestPayload).append('\n')
append("HTTP: ").append(httpCode?.toString() ?: "none").append('\n')
append("Response: ").append(responsePreview).append('\n')
append("Throwable: ").append(throwableInfo)
}
}
}

View File

@@ -1,5 +1,6 @@
package de.harheimertc.repositories
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService
import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
@@ -9,6 +10,7 @@ import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SessionRefresher
import io.sentry.Sentry
import javax.inject.Inject
import javax.inject.Singleton
@@ -19,13 +21,41 @@ class LoginRepository @Inject constructor(
private val sessionRefresher: SessionRefresher,
) {
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
val endpoint = "api/auth/login"
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
val response = api.login(LoginRequest(email.trim(), password))
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
if (!response.isSuccessful) {
val body = response.errorBody()?.string().orEmpty()
val serverMessage = extractServerMessage(body)
val fallback = when (response.code()) {
401 -> "Ungueltige Anmeldedaten"
403 -> "Konto nicht freigeschaltet"
429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen."
else -> "Anmeldung fehlgeschlagen"
}
val diagnostic = buildString {
append("\n\nDiagnose:\n")
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n')
append("Request: ").append(requestPreview).append('\n')
append("HTTP: ").append(response.code()).append('\n')
append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n')
append("Server message: ").append(serverMessage ?: "none")
}
error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic")
}
val body = response.body() ?: error("Leere Antwort")
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId)
body
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "LoginRepository")
scope.setTag("operation", "login")
scope.setExtra("emailDomain", email.substringAfter('@', "unknown"))
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
suspend fun logout(): Result<Unit> = runCatching {
@@ -51,6 +81,15 @@ class LoginRepository @Inject constructor(
}
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
status
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "LoginRepository")
scope.setTag("operation", "status")
scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString())
scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString())
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
@@ -64,4 +103,19 @@ class LoginRepository @Inject constructor(
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
response.body() ?: error("Leere Antwort")
}
private fun extractServerMessage(raw: String): String? {
if (raw.isBlank()) return null
val msgRegex = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"")
return msgRegex.find(raw)?.groupValues?.getOrNull(1)
}
private fun maskEmail(rawEmail: String): String {
val email = rawEmail.trim()
if (!email.contains('@')) return "hidden"
val local = email.substringBefore('@')
val domain = email.substringAfter('@')
val localMasked = if (local.length <= 2) "**" else local.take(2) + "***"
return "$localMasked@$domain"
}
}

View File

@@ -5,6 +5,7 @@ import de.harheimertc.data.BirthdaysResponse
import de.harheimertc.data.MembersResponse
import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.data.QttrValuesResponse
import de.harheimertc.data.SecureOfflineCache
import javax.inject.Inject
@@ -24,6 +25,18 @@ class MemberAreaRepository @Inject constructor(
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
)
suspend fun qttrValues(): Result<QttrValuesResponse> =
fetchEncryptedFallback(
load = {
val response = api.qttrValues()
if (!response.isSuccessful) error("QTTR-Werte konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putQttrValues,
cached = { cache.getQttrValues(24L * 60L * 60L * 1000L) },
fallbackMessage = "QTTR-Werte konnten nicht geladen werden.",
)
suspend fun members(): Result<MembersResponse> =
fetchEncryptedFallback(
load = {
@@ -88,7 +101,7 @@ class MemberAreaRepository @Inject constructor(
response.body() ?: emptyMap()
}
suspend fun deleteNews(id: Int): Result<Unit> = runCatching {
suspend fun deleteNews(id: String): Result<Unit> = runCatching {
val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
}

View File

@@ -73,14 +73,25 @@ private fun CompactNavigation(
navigationState: NavigationUiState = NavigationUiState(),
) {
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f))
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate)
CompactLink("Verein", Destinations.VereinAbout.route, selectedRoute, onNavigate)
CompactLink("Mannschaften", Destinations.Mannschaften.route, selectedRoute, onNavigate)
CompactLink("Training", Destinations.Training.route, selectedRoute, onNavigate)
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate)
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate)
if (navigationState.showGallery) {
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate)
}
if (navigationState.loggedIn) {
CompactLink("Intern", Destinations.MemberArea.route, selectedRoute, onNavigate)
}
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate)
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate)
}
}
}
@@ -107,7 +118,6 @@ private fun WebTabletNavigation(
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) })
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
if (navigationState.showGallery) {
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
@@ -289,6 +299,7 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
Destinations.MemberArea.route,
Destinations.Members.route,
Destinations.Qttr.route,
Destinations.MemberNews.route,
Destinations.Profile.route,
Destinations.MemberApi.route,
@@ -339,6 +350,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
add(MenuTarget("QTTR", Destinations.Qttr.route))
add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))

View File

@@ -36,6 +36,7 @@ sealed class Destinations(val route: String) {
object Register : Destinations("register")
object MemberArea : Destinations("intern")
object Members : Destinations("intern/mitglieder")
object Qttr : Destinations("intern/qttr")
object MemberNews : Destinations("intern/news")
object Profile : Destinations("intern/profil")
object MemberApi : Destinations("intern/api")

View File

@@ -261,6 +261,12 @@ fun NavGraph(
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Qttr.route) {
de.harheimertc.ui.screens.memberarea.QttrScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MemberNews.route) {
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
navController = navController,

View File

@@ -49,7 +49,7 @@ import java.util.Locale
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope()
var selection by remember { mutableStateOf(setOf<Int>()) }
var selection by remember { mutableStateOf(setOf<String>()) }
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
val loginState by loginVm.state.collectAsState()
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
@@ -62,7 +62,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
// Local dialog state for create/edit + delete confirmation (hoisted)
var dialogOpen by remember { mutableStateOf(false) }
var deletingIds by remember { mutableStateOf<List<Int>?>(null) }
var deletingIds by remember { mutableStateOf<List<String>?>(null) }
var editing by remember { mutableStateOf<NewsDto?>(null) }
var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") }
@@ -256,9 +256,9 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
private fun NewsListItem(
news: NewsDto,
selected: Boolean = false,
onSelect: (Int?, Boolean) -> Unit = { _, _ -> },
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
onEdit: (NewsDto) -> Unit,
onDelete: (Int) -> Unit,
onDelete: (String) -> Unit,
) {
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {

View File

@@ -207,7 +207,7 @@ class CmsViewModel @Inject constructor(
}
}
fun bulkSetPublic(ids: List<Int>, makePublic: Boolean) {
fun bulkSetPublic(ids: List<String>, makePublic: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
@@ -229,7 +229,7 @@ class CmsViewModel @Inject constructor(
}
}
fun bulkSetHidden(ids: List<Int>, makeHidden: Boolean) {
fun bulkSetHidden(ids: List<String>, makeHidden: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
@@ -251,7 +251,7 @@ class CmsViewModel @Inject constructor(
}
}
fun bulkDelete(ids: List<Int>) {
fun bulkDelete(ids: List<String>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
@@ -262,7 +262,7 @@ class CmsViewModel @Inject constructor(
}
}
fun deleteNews(id: Int) {
fun deleteNews(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.deleteNews(id)

View File

@@ -44,6 +44,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -126,6 +127,47 @@ fun HomeScreen(
onReset = viewModel::resetSections,
)
}
if (state.error) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = state.errorMessage ?: "Daten konnten nicht geladen werden.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(onClick = viewModel::load) {
Text("Erneut versuchen")
}
}
}
}
if (state.debugDiagnostics.isNotEmpty()) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Technische Diagnose (vorübergehend)",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = state.debugDiagnostics.joinToString("\n\n---\n\n"),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.error,
)
}
}
}
state.homepageSections.forEachIndexed { index, section ->
if (!section.enabled) return@forEachIndexed
val sectionKey = homeSectionKey(section)

View File

@@ -44,6 +44,8 @@ data class HomeUiState(
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
val widgetsLoading: Boolean = false,
val error: Boolean = false,
val errorMessage: String? = null,
val debugDiagnostics: List<String> = emptyList(),
)
@HiltViewModel
@@ -62,7 +64,12 @@ class HomeViewModel @Inject constructor(
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = false)
_state.value = _state.value.copy(
loading = true,
error = false,
errorMessage = null,
debugDiagnostics = emptyList(),
)
repository.fetchHomeData()
.onSuccess { data ->
serverSections = normalizedHomepageSections(data.homepageSections)
@@ -99,10 +106,16 @@ class HomeViewModel @Inject constructor(
spielplanTeamsBySeason = widgetData.teamsBySeason,
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
spielplanWidgetErrors = widgetData.errorsBySectionKey,
debugDiagnostics = data.diagnostics,
)
}
.onFailure {
_state.value = HomeUiState(loading = false, error = true)
.onFailure { err ->
_state.value = HomeUiState(
loading = false,
error = true,
errorMessage = err.message ?: "Daten konnten nicht geladen werden.",
debugDiagnostics = listOf(err.message ?: "Unbekannter Fehler"),
)
}
}
}

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
@@ -25,8 +26,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import android.util.Log
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -35,6 +38,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.QttrRowDto
import de.harheimertc.ui.components.RichText
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
@@ -208,7 +212,7 @@ fun MemberNewsScreen(
fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) {
val groups = listOf(
"Authentifizierung" to listOf("POST /api/auth/login", "POST /api/auth/refresh", "GET /api/auth/status", "POST /api/auth/logout"),
"Mitgliederbereich" to listOf("GET /api/members", "GET /api/news", "GET /api/profile", "PUT /api/profile"),
"Mitgliederbereich" to listOf("GET /api/members", "GET /api/mitgliederbereich/qttr", "GET /api/news", "GET /api/profile", "PUT /api/profile"),
"CMS" to listOf("GET /api/cms/users/list", "GET /api/cms/contact-requests", "GET /api/newsletter/list", "GET /api/config"),
)
MemberAreaPage(navController, showBackNavigation, "API-Dokumentation", "Kurzüberblick der wichtigsten App-Endpunkte") {
@@ -237,6 +241,42 @@ fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) {
}
}
@Composable
fun QttrScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: QttrViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val uriHandler = LocalUriHandler.current
val externalUrl = "https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all&current-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021"
MemberAreaPage(navController, showBackNavigation, "QTTR-Werte", "Aus technischen Gründen sind nur die QTTR-Werte verfügbar.") {
item {
Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Für TTR bitte die myTischtennis-Rangliste verwenden.", color = Primary900)
TextButton(onClick = { uriHandler.openUri(externalUrl) }) { Text("myTischtennis öffnen") }
}
}
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(state.title ?: "Andro-Rangliste", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text("${state.rows.size} Einträge · Aktualisiert ${state.importedAt ?: "unbekannt"}", color = Accent500)
}
}
}
when {
state.loading -> item { CircularProgressIndicator(color = Primary600) }
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
state.rows.isEmpty() -> item { Text("Keine QTTR-Werte gefunden.", color = Accent700) }
else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) }
}
}
}
@Composable
private fun MemberAreaPage(
navController: NavController,
@@ -322,6 +362,83 @@ private fun Badge(label: String) {
}
}
@Composable
private fun QttrRowCard(row: QttrRowDto, highlighted: Boolean) {
Surface(
color = if (highlighted) Primary100 else Color.White,
shape = RoundedCornerShape(14.dp),
shadowElevation = 3.dp,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(color = if (highlighted) Primary100 else Accent100, shape = RoundedCornerShape(10.dp), modifier = Modifier.size(46.dp)) {
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(row.rank?.toString() ?: "-", color = Accent900, fontWeight = FontWeight.Bold)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
row.playerName.ifBlank { "Unbekannt" },
style = MaterialTheme.typography.titleMedium,
color = qttrNameColor(row.gender, isMinor(row.birthdate)),
fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Medium,
)
Text(row.clubName.ifBlank { "Harheimer TC" }, color = qttrNameColor(row.gender, isMinor(row.birthdate)).copy(alpha = 0.88f))
}
Text(row.currentQttr?.toString() ?: "-", color = Primary600, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
}
}
private fun isOwnRow(playerName: String?, currentUserName: String): Boolean {
fun normalize(value: String?): String {
return java.text.Normalizer.normalize(value.orEmpty().trim().lowercase(), java.text.Normalizer.Form.NFKD)
.replace(Regex("[\\u0300-\\u036f]"), "")
.replace(Regex("['`]"), "")
.replace(Regex("\\s+"), " ")
}
val current = normalize(currentUserName)
if (current.isBlank()) return false
return normalize(playerName) == current
}
private fun qttrNameColor(gender: String?, isMinor: Boolean): Color {
val value = gender.orEmpty().trim().lowercase()
return when {
value.startsWith('m') || value.contains("männ") || value.contains("maenn") -> if (isMinor) Color(0xFF60A5FA) else Color(0xFF2563EB)
value.startsWith('w') || value.contains("weib") || value.contains("frau") -> if (isMinor) Color(0xFFF9A8D4) else Color(0xFF9D174D)
else -> Accent900
}
}
private fun isMinor(birthdate: String?): Boolean {
val date = parseBirthdate(birthdate) ?: return false
val today = java.time.LocalDate.now()
var age = today.year - date.year
if (today.monthValue < date.monthValue || (today.monthValue == date.monthValue && today.dayOfMonth < date.dayOfMonth)) {
age -= 1
}
return age < 18
}
private fun parseBirthdate(value: String?): java.time.LocalDate? {
val raw = value.orEmpty().trim()
if (raw.isBlank()) return null
return try {
if (Regex("^\\d{4}$").matches(raw)) {
java.time.LocalDate.of(raw.toInt(), 1, 1)
} else {
java.time.LocalDate.parse(raw)
}
} catch (_: Exception) {
null
}
}
@Composable
private fun ErrorCard(message: String, onRetry: () -> Unit) {
Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(12.dp)) {

View File

@@ -3,9 +3,12 @@ package de.harheimertc.ui.screens.memberarea
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.QttrRowDto
import de.harheimertc.repositories.MemberAreaRepository
import de.harheimertc.repositories.LoginRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -101,3 +104,45 @@ class MemberNewsViewModel @Inject constructor(
}
}
}
data class QttrUiState(
val rows: List<QttrRowDto> = emptyList(),
val title: String? = null,
val importedAt: String? = null,
val currentUserName: String = "",
val loading: Boolean = true,
val error: String? = null,
)
@HiltViewModel
class QttrViewModel @Inject constructor(
private val repository: MemberAreaRepository,
private val loginRepository: LoginRepository,
) : ViewModel() {
private val _state = MutableStateFlow(QttrUiState())
val state: StateFlow<QttrUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
val authStatus = loginRepository.status().getOrDefault(AuthStatusResponse())
repository.qttrValues()
.onSuccess { response ->
_state.value = _state.value.copy(
rows = response.rows,
title = response.title,
importedAt = response.importedAt,
currentUserName = authStatus.user?.name.orEmpty(),
loading = false,
)
}
.onFailure {
_state.value = _state.value.copy(loading = false, error = it.message ?: "QTTR-Werte konnten nicht geladen werden.")
}
}
}
}

View File

@@ -95,6 +95,12 @@ private fun MemberAreaCardGrid(navController: NavController) {
marker = "N",
onClick = { navController.navigate(Destinations.MemberNews.route) },
)
MemberAreaCard(
title = "QTTR",
description = "Aktuelle QTTR-Werte der Vereinsmitglieder",
marker = "Q",
onClick = { navController.navigate(Destinations.Qttr.route) },
)
}
}

View File

@@ -0,0 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<provider
android:name="io.sentry.android.core.SentryInitProvider"
tools:node="remove" />
<provider
android:name="io.sentry.android.core.SentryPerformanceProvider"
tools:node="remove" />
</application>
</manifest>

View File

@@ -0,0 +1,6 @@
# Copy this file to android-app/gradle-local.properties (ignored by git)
# and fill in your release signing credentials.
RELEASE_STORE_FILE=/home/torsten/android\ keystore/harheimertc.jks
RELEASE_STORE_PASSWORD=
RELEASE_KEY_ALIAS=
RELEASE_KEY_PASSWORD=

View File

@@ -5,17 +5,18 @@ org.gradle.workers.max=2
LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
# Production backend for Play Store build variant
PRODUCTION_API_BASE_URL=https://harheimertc.de/
PRODUCTION_API_BASE_URL=https://harheimertc.tsschulz.de/
# Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=7
ANDROID_VERSION_NAME=0.9.2
ANDROID_VERSION_CODE=17
ANDROID_VERSION_NAME=0.9.12
# Enable R8 for release by default so mapping.txt is generated for Play Console.
RELEASE_MINIFY_ENABLED=true
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false
# Release signing (set in local, untracked gradle.properties or via CI secrets)
# RELEASE_STORE_FILE=/absolute/path/to/keystore.jks
RELEASE_STORE_FILE=/home/torsten/android\ keystore/harheimertc.jks
# Keep secrets out of git. Use ~/.gradle/gradle.properties or environment variables.
# RELEASE_STORE_PASSWORD=***
# RELEASE_KEY_ALIAS=***
# RELEASE_KEY_PASSWORD=***

View File

@@ -274,6 +274,13 @@
>
Mitgliederliste
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/qttr"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
QTTR
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/news"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
@@ -714,6 +721,13 @@
>
Mitgliederliste
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/qttr"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
QTTR
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/news"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"

View File

@@ -1,6 +1,6 @@
{
"name": "harheimertc-website",
"version": "1.7.0",
"version": "1.8.0",
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true,
"type": "module",

View File

@@ -0,0 +1,176 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
<div>
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
QTTR-Werte
</h1>
<div class="w-24 h-1 bg-primary-600 mb-6" />
<p class="text-lg text-gray-700 max-w-3xl">
Aus technischen Gründen sind nur die QTTR-Werte verfügbar. Für TTR bitte auf
<a
:href="externalUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:text-primary-800 underline"
>myTischtennis</a>
wechseln.
</p>
</div>
<div class="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
<div class="flex flex-wrap items-center gap-4 justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-gray-900">
Harheimer TC Rangliste
</h2>
<p class="text-sm text-gray-600">
{{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge
</p>
</div>
<div class="text-sm text-gray-500">
Aktualisiert: {{ formatDate(data?.importedAt) }}
</div>
</div>
<div v-if="pending" class="py-12 text-center text-gray-500">
Lade QTTR-Werte...
</div>
<div v-else-if="error" class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800">
{{ error.statusMessage || error.message || 'QTTR-Werte konnten nicht geladen werden.' }}
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Rang
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Spieler
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Verein
</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
QTTR
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
<tr
v-for="row in data?.rows || []"
:key="`${row.rank}-${row.playerNumber || row.playerName}`"
:class="isOwnRow(row.playerName) ? 'bg-primary-100' : ''"
>
<td class="px-4 py-3 text-sm text-gray-600">
{{ row.rank ?? '' }}
</td>
<td class="px-4 py-3">
<div :class="['font-medium', getPlayerNameClass(row)]">
{{ row.playerName || 'Unbekannt' }}
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-700">
{{ row.clubName || 'Harheimer TC' }}
</td>
<td class="px-4 py-3 text-right text-lg font-semibold text-gray-900 tabular-nums">
{{ row.currentQttr ?? '' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const authStore = useAuthStore()
const externalUrl = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all&current-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021'
definePageMeta({
middleware: 'auth',
layout: 'default'
})
await authStore.checkAuth()
const { data, pending, error } = await useFetch('/api/mitgliederbereich/qttr')
const currentUserName = computed(() => authStore.user?.name?.trim() || '')
function normalizeName(value) {
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/['`]/g, '')
}
function isMaleGender(value) {
const gender = normalizeName(value)
return gender.startsWith('m') || gender.includes('mann') || gender.includes('maenn')
}
function isFemaleGender(value) {
const gender = normalizeName(value)
return gender.startsWith('w') || gender.includes('weib') || gender.includes('frau')
}
function isOwnRow(playerName) {
const current = normalizeName(currentUserName.value)
if (!current) return false
return normalizeName(playerName) === current
}
function getPlayerNameClass(row) {
const minor = isMinor(row.birthdate)
if (minor && isMaleGender(row.gender)) return 'text-blue-400'
if (minor && isFemaleGender(row.gender)) return 'text-pink-400'
if (isMaleGender(row.gender)) return 'text-blue-700'
if (isFemaleGender(row.gender)) return 'text-pink-800'
return 'text-gray-900'
}
function isMinor(birthdate) {
const date = parseBirthdate(birthdate)
if (!date) return false
const today = new Date()
let age = today.getFullYear() - date.getFullYear()
const monthDiff = today.getMonth() - date.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < date.getDate())) {
age -= 1
}
return age < 18
}
function parseBirthdate(value) {
const raw = String(value || '').trim()
if (!raw) return null
if (/^\d{4}$/.test(raw)) {
const parsed = new Date(Number(raw), 0, 1)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
const parsed = new Date(raw)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
function formatDate(value) {
if (!value) return 'unbekannt'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'unbekannt'
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date)
}
useHead({
title: 'QTTR-Werte - Harheimer TC'
})
</script>

View File

@@ -0,0 +1,90 @@
import { readFile } from 'fs/promises'
import { getServerDataPath } from '../../utils/paths.js'
import { getUserFromToken, verifyToken } from '../../utils/auth.js'
import { readMembers } from '../../utils/members.js'
import { readUsers } from '../../utils/auth.js'
const QTTR_FILE = getServerDataPath('qttr-values.json')
function normalizeName(value) {
return String(value || '')
.trim()
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.replace(/['`]/g, '')
}
function buildBirthdateLookup(entries) {
const lookup = new Map()
for (const entry of entries || []) {
const candidates = [
entry?.name,
`${entry?.firstName || ''} ${entry?.lastName || ''}`.trim(),
]
const birthdate = entry?.geburtsdatum || entry?.birthday || entry?.birthDate || ''
if (!birthdate) continue
for (const candidate of candidates) {
const normalized = normalizeName(candidate)
if (!normalized || lookup.has(normalized)) continue
lookup.set(normalized, birthdate)
}
}
return lookup
}
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
if (!token || !verifyToken(token)) {
throw createError({
statusCode: 401,
message: 'Nicht authentifiziert.'
})
}
const currentUser = await getUserFromToken(token)
if (!currentUser) {
throw createError({
statusCode: 401,
message: 'Ungültiges Token.'
})
}
try {
const content = await readFile(QTTR_FILE, 'utf8')
const payload = JSON.parse(content)
const [manualMembers, registeredUsers] = await Promise.all([
readMembers(),
readUsers()
])
const birthdateLookup = buildBirthdateLookup([...manualMembers, ...registeredUsers])
return {
...payload,
rows: Array.isArray(payload.rows)
? payload.rows.map((row) => ({
...row,
birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || ''
}))
: []
}
} catch (error) {
if (error?.code === 'ENOENT') {
throw createError({
statusCode: 404,
message: 'QTTR-Datei nicht gefunden.'
})
}
console.error('Fehler beim Laden der QTTR-Werte:', error)
throw createError({
statusCode: 500,
message: 'Fehler beim Laden der QTTR-Werte.'
})
}
})

View File

@@ -1,16 +1,20 @@
import { importSpielplan } from '../utils/spielplan-import.js'
import { importLeagueTables } from '../utils/spielklassen-tables-import.js'
import { importQttrValues } from '../utils/qttr-import.js'
import { publishImportedSpielplan } from '../utils/spielplan-publish.js'
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
import { cleanupPasswordResetLogs } from '../utils/password-reset-log.js'
const TIME_ZONE = 'Europe/Berlin'
const RUN_HOUR = 7
const RUN_MINUTE = 0
const MAX_TIMEOUT = 2_147_483_647
let timer = null
let running = false
const JOBS = [
{ label: 'spielplan-import', hour: 7, minute: 0 },
{ label: 'qttr-import', hour: 7, minute: 30 }
]
const timers = new Map()
const runningJobs = new Set()
function getTimeParts(date) {
const parts = new Intl.DateTimeFormat('en-CA', {
@@ -47,12 +51,12 @@ function zonedDateToUtc(year, month, day, hour, minute) {
return new Date(utcGuess.getTime() - offset)
}
function nextRunAt(now = new Date()) {
function nextRunAt(hour, minute, now = new Date()) {
const parts = getTimeParts(now)
let year = Number(parts.year)
let month = Number(parts.month)
let day = Number(parts.day)
let candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE)
let candidate = zonedDateToUtc(year, month, day, hour, minute)
if (candidate <= now) {
const nextDay = zonedDateToUtc(year, month, day + 1, 12, 0)
@@ -60,17 +64,42 @@ function nextRunAt(now = new Date()) {
year = Number(nextParts.year)
month = Number(nextParts.month)
day = Number(nextParts.day)
candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE)
candidate = zonedDateToUtc(year, month, day, hour, minute)
}
return candidate
}
async function runDailyJobs(reason, skipSpielplanImport = false) {
if (running) return
async function runJob(job, reason) {
if (runningJobs.has(job.label)) return
running = true
runningJobs.add(job.label)
try {
await job.run(reason)
} catch (error) {
loggerError(`[${job.label}] Import fehlgeschlagen:`, { error })
} finally {
runningJobs.delete(job.label)
}
}
function scheduleNext(job) {
const runAt = nextRunAt(job.hour, job.minute)
const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT)
const timer = setTimeout(async () => {
await runJob(job, 'taeglicher Lauf')
scheduleNext(job)
}, delay)
timer.unref?.()
timers.set(job.label, timer)
loggerInfo(`[${job.label}] Naechster Lauf`, { runAt: runAt.toISOString(), tz: TIME_ZONE, time: `${String(job.hour).padStart(2, '0')}:${String(job.minute).padStart(2, '0')}` })
}
function createSpielplanJob(skipSpielplanImport) {
return {
run: async (reason) => {
try {
const cleanup = await cleanupPasswordResetLogs()
loggerInfo(`[password-reset-log] ${reason}: Bereinigung abgeschlossen`, cleanup)
@@ -101,24 +130,20 @@ async function runDailyJobs(reason, skipSpielplanImport = false) {
} catch (error) {
loggerError('[spielplan-import] Tabellen-Import fehlgeschlagen:', { error })
}
} catch (error) {
loggerError('[spielplan-import] Import fehlgeschlagen:', { error })
} finally {
running = false
}
}
}
function scheduleNext(skipSpielplanImport = false) {
const runAt = nextRunAt()
const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT)
timer = setTimeout(async () => {
await runDailyJobs('taeglicher Lauf', skipSpielplanImport)
scheduleNext(skipSpielplanImport)
}, delay)
timer.unref?.()
loggerInfo('[spielplan-import] Naechster Lauf', { runAt: runAt.toISOString(), tz: TIME_ZONE, time: `${String(RUN_HOUR).padStart(2, '0')}:${String(RUN_MINUTE).padStart(2, '0')}` })
function createQttrJob() {
return {
run: async (reason) => {
const qttr = await importQttrValues()
loggerInfo(`[qttr-import] ${reason}: ${qttr.rowCount} QTTR-Werte importiert`, {
outputFile: qttr.outputFile,
tableCount: qttr.tableCount
})
}
}
}
export default defineNitroPlugin((nitroApp) => {
@@ -127,13 +152,19 @@ export default defineNitroPlugin((nitroApp) => {
loggerInfo('[spielplan-import] Import deaktiviert; Passwort-Reset-Log-Bereinigung bleibt aktiv')
}
scheduleNext(skipSpielplanImport)
const spielplanJob = createSpielplanJob(skipSpielplanImport)
const qttrJob = createQttrJob()
scheduleNext({ ...JOBS[0], ...spielplanJob })
scheduleNext({ ...JOBS[1], ...qttrJob })
if (process.env.SPIELPLAN_IMPORT_RUN_ON_START === 'true') {
runDailyJobs('Startlauf', skipSpielplanImport)
runJob({ label: 'spielplan-import', run: spielplanJob.run }, 'Startlauf')
}
nitroApp.hooks.hookOnce('close', () => {
if (timer) clearTimeout(timer)
for (const timer of timers.values()) {
clearTimeout(timer)
}
})
})

220
server/utils/qttr-import.js Normal file
View File

@@ -0,0 +1,220 @@
import { promises as fs } from 'fs'
import { getServerDataPath } from './paths.js'
const QTTR_URL = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all&current-ranking=no&ttr-range=100%3B3000&birth-range=1926%3B2021'
const OUTPUT_FILE = getServerDataPath('qttr-values.json')
function decodeHtmlEntities(value) {
const namedEntities = {
amp: '&',
apos: "'",
gt: '>',
lt: '<',
nbsp: ' ',
quot: '"'
}
return String(value || '')
.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity) => {
if (entity.startsWith('#x')) {
const codePoint = Number.parseInt(entity.slice(2), 16)
return Number.isNaN(codePoint) ? match : String.fromCodePoint(codePoint)
}
if (entity.startsWith('#')) {
const codePoint = Number.parseInt(entity.slice(1), 10)
return Number.isNaN(codePoint) ? match : String.fromCodePoint(codePoint)
}
return Object.prototype.hasOwnProperty.call(namedEntities, entity) ? namedEntities[entity] : match
})
}
function stripTags(value) {
return decodeHtmlEntities(String(value || '')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<br\s*\/?/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim())
}
function normalizeHeaderKey(value) {
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
}
function toNumberOrNull(value) {
const raw = String(value || '').replace(',', '.').match(/-?\d+(?:\.\d+)?/)
if (!raw) return null
const numberValue = Number(raw[0])
return Number.isNaN(numberValue) ? null : numberValue
}
function normalizeName(value) {
return String(value || '')
.trim()
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.replace(/['`]/g, '')
}
function normalizeGender(value) {
const normalized = String(value || '').trim().toLowerCase()
if (normalized === 'm' || normalized === 'männlich') return 'männlich'
if (normalized === 'w' || normalized === 'weiblich') return 'weiblich'
return normalized || null
}
function extractTableBlocks(html) {
return [...String(html || '').matchAll(/<table\b[^>]*>[\s\S]*?<\/table>/gi)].map((match) => match[0])
}
function extractCellTexts(rowHtml) {
return [...String(rowHtml || '').matchAll(/<(?:t[hd])\b[^>]*>([\s\S]*?)<\/t[hd]>/gi)].map((match) => stripTags(match[1]))
}
function findBestTable(html) {
const tables = extractTableBlocks(html)
if (tables.length === 0) return null
return (
tables.find((table) => /Harheimer TC/i.test(table) && /Q-?TTR/i.test(table)) ||
tables.find((table) => /Harheimer TC/i.test(table)) ||
tables.find((table) => /Q-?TTR/i.test(table)) ||
tables[0]
)
}
function extractTableTitle(html, tableHtml) {
const tableIndex = String(html || '').indexOf(tableHtml)
if (tableIndex === -1) return null
const prefix = String(html || '').slice(0, tableIndex)
const headingMatches = [...prefix.matchAll(/<(h[1-6])\b[^>]*>([\s\S]*?)<\/\1>/gi)]
if (headingMatches.length === 0) return null
return stripTags(headingMatches[headingMatches.length - 1][2]) || null
}
function extractRowsFromTable(tableHtml) {
const rows = [...String(tableHtml || '').matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)]
if (rows.length === 0) return { headers: [], rows: [] }
const headerRow = rows.find((row) => /<th\b/i.test(row[0]))
const headerCells = headerRow ? extractCellTexts(headerRow[1]) : extractCellTexts(rows[0][1])
const headers = headerCells.map((cell) => ({
label: cell,
key: normalizeHeaderKey(cell)
}))
const dataRows = rows
.filter((row) => row !== headerRow)
.map((row) => extractCellTexts(row[1]))
.filter((cells) => cells.length > 0 && cells.some((cell) => cell !== ''))
return { headers, rows: dataRows }
}
function deriveQttrFields(headers, cells) {
const valuesByHeader = {}
headers.forEach((header, index) => {
valuesByHeader[header.key] = cells[index] ?? null
})
const lookup = (pattern) => {
const entry = headers.find((header) => pattern.test(header.key))
return entry ? valuesByHeader[entry.key] : null
}
const rank = lookup(/platz|rank|rang/) ?? cells[0] ?? null
const playerNumber = toNumberOrNull(cells[1] ?? lookup(/spieler.*nr|spielernr|id/))
let gender = lookup(/geschlecht/) ?? null
let playerName = lookup(/^(name|spielername)$/) ?? lookup(/\bspieler\b/) ?? null
const combinedPlayerCell = cells[2] ?? lookup(/\bspieler\b/) ?? null
const clubName = lookup(/verein|club/) ?? cells[3] ?? null
const currentQttr = toNumberOrNull(cells[4] ?? lookup(/aktuell.*q.*ttr|current.*q.*ttr|q.*ttr/))
const previousQttr = toNumberOrNull(lookup(/vorher|previous/))
if (combinedPlayerCell) {
const genderAndName = String(combinedPlayerCell).match(/^(m|w|männlich|weiblich)\s+(.*)$/i)
if (genderAndName) {
gender = gender ?? normalizeGender(genderAndName[1])
playerName = genderAndName[2].trim()
} else {
playerName = playerName ?? String(combinedPlayerCell).trim()
}
}
gender = normalizeGender(gender)
return {
rank: toNumberOrNull(rank),
playerNumber,
gender,
playerName,
clubName,
currentQttr,
previousQttr,
valuesByHeader,
rawCells: cells
}
}
export async function importQttrValues(options = {}) {
const url = options.url || QTTR_URL
const response = await fetch(url, {
headers: {
accept: 'text/html,application/xhtml+xml',
'accept-language': 'de-DE,de;q=0.9'
}
})
if (!response.ok) {
throw new Error(`QTTR-Download fehlgeschlagen: HTTP ${response.status}`)
}
const html = await response.text()
const tableHtml = findBestTable(html)
if (!tableHtml) {
throw new Error('Keine QTTR-Tabelle im HTML gefunden')
}
const { headers, rows } = extractRowsFromTable(tableHtml)
if (headers.length === 0 || rows.length === 0) {
throw new Error('QTTR-Tabelle ist leer oder unvollständig')
}
const parsedRows = rows.map((cells) => deriveQttrFields(headers, cells))
const payload = {
format: 'harheimertc.qttr.v1',
importedAt: new Date().toISOString(),
source: {
url
},
title: extractTableTitle(html, tableHtml),
headerCount: headers.length,
rowCount: parsedRows.length,
headers,
rows: parsedRows
}
await fs.mkdir(getServerDataPath(), { recursive: true })
await fs.writeFile(OUTPUT_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
return {
outputFile: OUTPUT_FILE,
tableCount: 1,
rowCount: parsedRows.length,
...payload
}
}