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/.kotlin/
/android-app/**/build/ /android-app/**/build/
/android-app/local.properties /android-app/local.properties
/android-app/gradle-local.properties
# Build output (but keep production data!) # Build output (but keep production data!)
.output .output

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
@@ -25,15 +27,40 @@ val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
.orElse("true") .orElse("true")
.get() .get()
.toBoolean() .toBoolean()
val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull val localSigningProperties = Properties().apply {
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull val localSigningFile = rootProject.file("gradle-local.properties")
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull 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() && val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
!releaseStorePassword.isNullOrBlank() && !releaseStorePassword.isNullOrBlank() &&
!releaseKeyAlias.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank() &&
!releaseKeyPassword.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 { android {
namespace = "de.harheimertc" namespace = "de.harheimertc"
compileSdk = 35 compileSdk = 35
@@ -135,6 +162,7 @@ val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("package
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") { val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
group = "distribution" group = "distribution"
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload." description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
dependsOn(ensureReleaseSigning)
dependsOn(":app:bundleProductionRelease") dependsOn(":app:bundleProductionRelease")
dependsOn(packageNativeDebugSymbolsForProductionRelease) dependsOn(packageNativeDebugSymbolsForProductionRelease)
@@ -161,6 +189,12 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
} }
} }
tasks.matching {
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
}.configureEach {
dependsOn(ensureReleaseSigning)
}
kotlin { kotlin {
compilerOptions { compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)

View File

@@ -10,6 +10,15 @@
@retrofit2.http.* <methods>; @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 app DTO/request/response models used via Moshi reflection.
-keep class de.harheimertc.data.*Dto { *; } -keep class de.harheimertc.data.*Dto { *; }
-keep class de.harheimertc.data.*Request { *; } -keep class de.harheimertc.data.*Request { *; }
@@ -19,3 +28,10 @@
-keepclassmembers class * { -keepclassmembers class * {
@com.squareup.moshi.Json <fields>; @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 NewsPublicResponse(val news: List<NewsDto> = emptyList())
data class NewsDto( data class NewsDto(
val id: Int? = null, val id: String? = null,
val title: String = "", val title: String = "",
val content: String = "", val content: String = "",
val created: String? = null, val created: String? = null,
@@ -96,7 +96,7 @@ data class NewsResponse(
val news: List<NewsDto> = emptyList(), val news: List<NewsDto> = emptyList(),
) )
data class NewsSaveRequest( data class NewsSaveRequest(
val id: Int? = null, val id: String? = null,
val title: String, val title: String,
val content: String, val content: String,
val isPublic: Boolean = false, val isPublic: Boolean = false,
@@ -260,6 +260,28 @@ data class BirthdaysResponse(
val success: Boolean = false, val success: Boolean = false,
val birthdays: List<BirthdayDto> = emptyList(), 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( data class MemberDto(
val id: String? = null, val id: String? = null,
val name: String = "", val name: String = "",
@@ -548,7 +570,7 @@ interface ApiService {
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse> suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
@DELETE("/api/news") @DELETE("/api/news")
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse> suspend fun deleteNews(@Query("id") id: String): Response<AuthMessageResponse>
@GET("/api/mannschaften") @GET("/api/mannschaften")
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody> suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
@@ -620,6 +642,9 @@ interface ApiService {
@GET("/api/birthdays") @GET("/api/birthdays")
suspend fun birthdays(): Response<BirthdaysResponse> suspend fun birthdays(): Response<BirthdaysResponse>
@GET("/api/mitgliederbereich/qttr")
suspend fun qttrValues(): Response<QttrValuesResponse>
@GET("/api/members") @GET("/api/members")
suspend fun members(): Response<MembersResponse> suspend fun members(): Response<MembersResponse>

View File

@@ -16,6 +16,7 @@ class SecureOfflineCache @Inject constructor(
) { ) {
private companion object { private companion object {
const val KEY_BIRTHDAYS = "birthdays" const val KEY_BIRTHDAYS = "birthdays"
const val KEY_QTTR_VALUES = "qttr_values"
const val KEY_MEMBERS = "members" const val KEY_MEMBERS = "members"
const val KEY_MEMBER_NEWS = "member_news" const val KEY_MEMBER_NEWS = "member_news"
const val KEY_CMS_CONFIG = "cms_config" 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 putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis) 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 putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis) 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_NEWSLETTER_GROUPS,
KEY_PASSWORD_RESET_DIAGNOSTICS, KEY_PASSWORD_RESET_DIAGNOSTICS,
KEY_MEMBER_NEWS, 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") 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) val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
cache.clearCmsNewsCache() cache.clearCmsNewsCache()

View File

@@ -1,5 +1,6 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService import de.harheimertc.data.ApiService
import de.harheimertc.data.HomepageSectionDto import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
@@ -7,6 +8,7 @@ import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto import de.harheimertc.data.SpielDto
import de.harheimertc.data.SpielplanResponse import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TerminDto import de.harheimertc.data.TerminDto
import io.sentry.Sentry
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -17,19 +19,121 @@ data class HomeData(
val selectedSpielplanSeason: String?, val selectedSpielplanSeason: String?,
val news: List<NewsDto>, val news: List<NewsDto>,
val homepageSections: List<HomepageSectionDto>, val homepageSections: List<HomepageSectionDto>,
val diagnostics: List<String> = emptyList(),
) )
@Singleton @Singleton
class HomeRepository @Inject constructor(private val api: ApiService) { class HomeRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchHomeData(): Result<HomeData> = runCatching { suspend fun fetchHomeData(): Result<HomeData> = runCatching {
val termine = api.termine().body()?.termine.orEmpty() val diagnostics = mutableListOf<String>()
val spielplanResponse = api.spielplan().body()
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 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 homepageSections = runCatching {
val configResponse = api.config() val response = api.config()
if (!configResponse.isSuccessful) return@runCatching emptyList() if (!response.isSuccessful) {
configResponse.body()?.homepage?.sections.orEmpty() 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()) }.getOrDefault(emptyList())
HomeData( HomeData(
termine = termine, termine = termine,
@@ -38,12 +142,56 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
selectedSpielplanSeason = spielplanResponse?.season, selectedSpielplanSeason = spielplanResponse?.season,
news = news, news = news,
homepageSections = homepageSections, 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 { suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
val response = api.spielplan(season) val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}") if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body() ?: error("Leere Antwort") 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 package de.harheimertc.repositories
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService import de.harheimertc.data.ApiService
import de.harheimertc.data.LoginRequest import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse import de.harheimertc.data.LoginResponse
@@ -9,6 +10,7 @@ import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.RegistrationRequest import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SessionRefresher import de.harheimertc.data.SessionRefresher
import io.sentry.Sentry
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -19,13 +21,41 @@ class LoginRepository @Inject constructor(
private val sessionRefresher: SessionRefresher, private val sessionRefresher: SessionRefresher,
) { ) {
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching { 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)) 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 body = response.body() ?: error("Leere Antwort")
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank) val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.") ?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId) authRepository.setSession(token, body.refreshToken, body.sessionId)
body 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 { suspend fun logout(): Result<Unit> = runCatching {
@@ -51,6 +81,15 @@ class LoginRepository @Inject constructor(
} }
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession() if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
status 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 { suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
@@ -64,4 +103,19 @@ class LoginRepository @Inject constructor(
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.") if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
response.body() ?: error("Leere Antwort") 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.MembersResponse
import de.harheimertc.data.NewsResponse import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsSaveRequest import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.data.QttrValuesResponse
import de.harheimertc.data.SecureOfflineCache import de.harheimertc.data.SecureOfflineCache
import javax.inject.Inject import javax.inject.Inject
@@ -24,6 +25,18 @@ class MemberAreaRepository @Inject constructor(
fallbackMessage = "Geburtstage konnten nicht geladen werden.", 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> = suspend fun members(): Result<MembersResponse> =
fetchEncryptedFallback( fetchEncryptedFallback(
load = { load = {
@@ -88,7 +101,7 @@ class MemberAreaRepository @Inject constructor(
response.body() ?: emptyMap() response.body() ?: emptyMap()
} }
suspend fun deleteNews(id: Int): Result<Unit> = runCatching { suspend fun deleteNews(id: String): Result<Unit> = runCatching {
val response = api.deleteNews(id) val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
} }

View File

@@ -73,14 +73,25 @@ private fun CompactNavigation(
navigationState: NavigationUiState = NavigationUiState(), navigationState: NavigationUiState = NavigationUiState(),
) { ) {
BrandRow(onLogin = { onNavigate(Destinations.Login.route) }) BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f)) modifier = Modifier.horizontalScroll(rememberScrollState()),
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f)) horizontalArrangement = Arrangement.spacedBy(4.dp),
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f)) ) {
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f)) CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate)
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f)) 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) { 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("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) }) MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.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) }) MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
if (navigationState.showGallery) { if (navigationState.showGallery) {
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) }) 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.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
Destinations.MemberArea.route, Destinations.MemberArea.route,
Destinations.Members.route, Destinations.Members.route,
Destinations.Qttr.route,
Destinations.MemberNews.route, Destinations.MemberNews.route,
Destinations.Profile.route, Destinations.Profile.route,
Destinations.MemberApi.route, Destinations.MemberApi.route,
@@ -339,6 +350,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuSection.INTERN -> buildList { MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route)) add(MenuTarget("Übersicht", Destinations.MemberArea.route))
add(MenuTarget("Mitgliederliste", Destinations.Members.route)) add(MenuTarget("Mitgliederliste", Destinations.Members.route))
add(MenuTarget("QTTR", Destinations.Qttr.route))
add(MenuTarget("News", Destinations.MemberNews.route)) add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Mein Profil", Destinations.Profile.route)) add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.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 Register : Destinations("register")
object MemberArea : Destinations("intern") object MemberArea : Destinations("intern")
object Members : Destinations("intern/mitglieder") object Members : Destinations("intern/mitglieder")
object Qttr : Destinations("intern/qttr")
object MemberNews : Destinations("intern/news") object MemberNews : Destinations("intern/news")
object Profile : Destinations("intern/profil") object Profile : Destinations("intern/profil")
object MemberApi : Destinations("intern/api") object MemberApi : Destinations("intern/api")

View File

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

View File

@@ -49,7 +49,7 @@ import java.util.Locale
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope() 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 loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
val loginState by loginVm.state.collectAsState() val loginState by loginVm.state.collectAsState()
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" } 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) // Local dialog state for create/edit + delete confirmation (hoisted)
var dialogOpen by remember { mutableStateOf(false) } 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 editing by remember { mutableStateOf<NewsDto?>(null) }
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") } var content by remember { mutableStateOf("") }
@@ -256,9 +256,9 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
private fun NewsListItem( private fun NewsListItem(
news: NewsDto, news: NewsDto,
selected: Boolean = false, selected: Boolean = false,
onSelect: (Int?, Boolean) -> Unit = { _, _ -> }, onSelect: (String?, Boolean) -> Unit = { _, _ -> },
onEdit: (NewsDto) -> Unit, onEdit: (NewsDto) -> Unit,
onDelete: (Int) -> Unit, onDelete: (String) -> Unit,
) { ) {
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) { androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { 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 { viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null) _state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id -> 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 { viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null) _state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id -> ids.forEach { id ->
@@ -251,7 +251,7 @@ class CmsViewModel @Inject constructor(
} }
} }
fun bulkDelete(ids: List<Int>) { fun bulkDelete(ids: List<String>) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null) _state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id -> ids.forEach { id ->
@@ -262,7 +262,7 @@ class CmsViewModel @Inject constructor(
} }
} }
fun deleteNews(id: Int) { fun deleteNews(id: String) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null) _state.value = _state.value.copy(saving = true, error = null, message = null)
repository.deleteNews(id) 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.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -126,6 +127,47 @@ fun HomeScreen(
onReset = viewModel::resetSections, 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 -> state.homepageSections.forEachIndexed { index, section ->
if (!section.enabled) return@forEachIndexed if (!section.enabled) return@forEachIndexed
val sectionKey = homeSectionKey(section) val sectionKey = homeSectionKey(section)

View File

@@ -44,6 +44,8 @@ data class HomeUiState(
val spielplanWidgetErrors: Map<String, String> = emptyMap(), val spielplanWidgetErrors: Map<String, String> = emptyMap(),
val widgetsLoading: Boolean = false, val widgetsLoading: Boolean = false,
val error: Boolean = false, val error: Boolean = false,
val errorMessage: String? = null,
val debugDiagnostics: List<String> = emptyList(),
) )
@HiltViewModel @HiltViewModel
@@ -62,7 +64,12 @@ class HomeViewModel @Inject constructor(
fun load() { fun load() {
viewModelScope.launch { 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() repository.fetchHomeData()
.onSuccess { data -> .onSuccess { data ->
serverSections = normalizedHomepageSections(data.homepageSections) serverSections = normalizedHomepageSections(data.homepageSections)
@@ -99,10 +106,16 @@ class HomeViewModel @Inject constructor(
spielplanTeamsBySeason = widgetData.teamsBySeason, spielplanTeamsBySeason = widgetData.teamsBySeason,
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey, spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
spielplanWidgetErrors = widgetData.errorsBySectionKey, spielplanWidgetErrors = widgetData.errorsBySectionKey,
debugDiagnostics = data.diagnostics,
) )
} }
.onFailure { .onFailure { err ->
_state.value = HomeUiState(loading = false, error = true) _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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -25,8 +26,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import android.util.Log import android.util.Log
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -35,6 +38,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import de.harheimertc.data.MemberDto import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.data.QttrRowDto
import de.harheimertc.ui.components.RichText import de.harheimertc.ui.components.RichText
import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
@@ -208,7 +212,7 @@ fun MemberNewsScreen(
fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) { fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) {
val groups = listOf( val groups = listOf(
"Authentifizierung" to listOf("POST /api/auth/login", "POST /api/auth/refresh", "GET /api/auth/status", "POST /api/auth/logout"), "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"), "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") { 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 @Composable
private fun MemberAreaPage( private fun MemberAreaPage(
navController: NavController, 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 @Composable
private fun ErrorCard(message: String, onRetry: () -> Unit) { private fun ErrorCard(message: String, onRetry: () -> Unit) {
Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(12.dp)) { 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.MemberDto import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.data.QttrRowDto
import de.harheimertc.repositories.MemberAreaRepository import de.harheimertc.repositories.MemberAreaRepository
import de.harheimertc.repositories.LoginRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch 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", marker = "N",
onClick = { navController.navigate(Destinations.MemberNews.route) }, 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/ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
# Production backend for Play Store build variant # 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 app versioning for Play Store uploads
ANDROID_VERSION_CODE=7 ANDROID_VERSION_CODE=17
ANDROID_VERSION_NAME=0.9.2 ANDROID_VERSION_NAME=0.9.12
# Enable R8 for release by default so mapping.txt is generated for Play Console. # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=true RELEASE_MINIFY_ENABLED=false
# Release signing (set in local, untracked gradle.properties or via CI secrets) # 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_STORE_PASSWORD=***
# RELEASE_KEY_ALIAS=*** # RELEASE_KEY_ALIAS=***
# RELEASE_KEY_PASSWORD=*** # RELEASE_KEY_PASSWORD=***

View File

@@ -274,6 +274,13 @@
> >
Mitgliederliste Mitgliederliste
</NuxtLink> </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 <NuxtLink
to="/mitgliederbereich/news" 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" 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 Mitgliederliste
</NuxtLink> </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 <NuxtLink
to="/mitgliederbereich/news" 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" 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", "name": "harheimertc-website",
"version": "1.7.0", "version": "1.8.0",
"description": "Moderne Webseite für den Harheimer Tischtennis Club", "description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true, "private": true,
"type": "module", "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 { importSpielplan } from '../utils/spielplan-import.js'
import { importLeagueTables } from '../utils/spielklassen-tables-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 { publishImportedSpielplan } from '../utils/spielplan-publish.js'
import { info as loggerInfo, error as loggerError } from '../utils/logger.js' import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
import { cleanupPasswordResetLogs } from '../utils/password-reset-log.js' import { cleanupPasswordResetLogs } from '../utils/password-reset-log.js'
const TIME_ZONE = 'Europe/Berlin' const TIME_ZONE = 'Europe/Berlin'
const RUN_HOUR = 7
const RUN_MINUTE = 0
const MAX_TIMEOUT = 2_147_483_647 const MAX_TIMEOUT = 2_147_483_647
let timer = null const JOBS = [
let running = false { 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) { function getTimeParts(date) {
const parts = new Intl.DateTimeFormat('en-CA', { const parts = new Intl.DateTimeFormat('en-CA', {
@@ -47,12 +51,12 @@ function zonedDateToUtc(year, month, day, hour, minute) {
return new Date(utcGuess.getTime() - offset) return new Date(utcGuess.getTime() - offset)
} }
function nextRunAt(now = new Date()) { function nextRunAt(hour, minute, now = new Date()) {
const parts = getTimeParts(now) const parts = getTimeParts(now)
let year = Number(parts.year) let year = Number(parts.year)
let month = Number(parts.month) let month = Number(parts.month)
let day = Number(parts.day) 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) { if (candidate <= now) {
const nextDay = zonedDateToUtc(year, month, day + 1, 12, 0) const nextDay = zonedDateToUtc(year, month, day + 1, 12, 0)
@@ -60,65 +64,86 @@ function nextRunAt(now = new Date()) {
year = Number(nextParts.year) year = Number(nextParts.year)
month = Number(nextParts.month) month = Number(nextParts.month)
day = Number(nextParts.day) day = Number(nextParts.day)
candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE) candidate = zonedDateToUtc(year, month, day, hour, minute)
} }
return candidate return candidate
} }
async function runDailyJobs(reason, skipSpielplanImport = false) { async function runJob(job, reason) {
if (running) return if (runningJobs.has(job.label)) return
running = true runningJobs.add(job.label)
try { try {
try { await job.run(reason)
const cleanup = await cleanupPasswordResetLogs()
loggerInfo(`[password-reset-log] ${reason}: Bereinigung abgeschlossen`, cleanup)
} catch (error) {
loggerError('[password-reset-log] Bereinigung fehlgeschlagen:', { error })
}
if (skipSpielplanImport) {
return
}
const spielplan = await importSpielplan()
loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` })
const published = await publishImportedSpielplan({ inputPath: spielplan.jsonFile })
loggerInfo(`[spielplan-import] ${reason}: Spielplan publiziert`, {
season: published.seasonSlug,
internalPath: published.internalSeasonPath
})
try {
const tables = await importLeagueTables()
loggerInfo(`[spielplan-import] ${reason}: ${tables.importedCount}/${tables.teamCount} Tabellen importiert`, {
season: tables.seasonSlug,
outputFile: tables.outputFile,
errors: tables.errorCount
})
} catch (error) {
loggerError('[spielplan-import] Tabellen-Import fehlgeschlagen:', { error })
}
} catch (error) { } catch (error) {
loggerError('[spielplan-import] Import fehlgeschlagen:', { error }) loggerError(`[${job.label}] Import fehlgeschlagen:`, { error })
} finally { } finally {
running = false runningJobs.delete(job.label)
} }
} }
function scheduleNext(skipSpielplanImport = false) { function scheduleNext(job) {
const runAt = nextRunAt() const runAt = nextRunAt(job.hour, job.minute)
const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT) const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT)
timer = setTimeout(async () => { const timer = setTimeout(async () => {
await runDailyJobs('taeglicher Lauf', skipSpielplanImport) await runJob(job, 'taeglicher Lauf')
scheduleNext(skipSpielplanImport) scheduleNext(job)
}, delay) }, delay)
timer.unref?.() 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')}` }) 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)
} catch (error) {
loggerError('[password-reset-log] Bereinigung fehlgeschlagen:', { error })
}
if (skipSpielplanImport) {
return
}
const spielplan = await importSpielplan()
loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` })
const published = await publishImportedSpielplan({ inputPath: spielplan.jsonFile })
loggerInfo(`[spielplan-import] ${reason}: Spielplan publiziert`, {
season: published.seasonSlug,
internalPath: published.internalSeasonPath
})
try {
const tables = await importLeagueTables()
loggerInfo(`[spielplan-import] ${reason}: ${tables.importedCount}/${tables.teamCount} Tabellen importiert`, {
season: tables.seasonSlug,
outputFile: tables.outputFile,
errors: tables.errorCount
})
} catch (error) {
loggerError('[spielplan-import] Tabellen-Import fehlgeschlagen:', { error })
}
}
}
}
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) => { export default defineNitroPlugin((nitroApp) => {
@@ -127,13 +152,19 @@ export default defineNitroPlugin((nitroApp) => {
loggerInfo('[spielplan-import] Import deaktiviert; Passwort-Reset-Log-Bereinigung bleibt aktiv') 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') { if (process.env.SPIELPLAN_IMPORT_RUN_ON_START === 'true') {
runDailyJobs('Startlauf', skipSpielplanImport) runJob({ label: 'spielplan-import', run: spielplanJob.run }, 'Startlauf')
} }
nitroApp.hooks.hookOnce('close', () => { 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
}
}