feat: add QTTR values feature to member area
- 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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Binary file not shown.
16
android-app/app/proguard-rules.pro
vendored
16
android-app/app/proguard-rules.pro
vendored
@@ -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 { *; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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¤t-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)) {
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
android-app/app/src/release/AndroidManifest.xml
Normal file
12
android-app/app/src/release/AndroidManifest.xml
Normal 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>
|
||||||
6
android-app/gradle-local.properties.example
Normal file
6
android-app/gradle-local.properties.example
Normal 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=
|
||||||
@@ -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=***
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
176
pages/mitgliederbereich/qttr.vue
Normal file
176
pages/mitgliederbereich/qttr.vue
Normal 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¤t-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>
|
||||||
90
server/api/mitgliederbereich/qttr.get.js
Normal file
90
server/api/mitgliederbereich/qttr.get.js
Normal 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.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
220
server/utils/qttr-import.js
Normal 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¤t-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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user