@@ -6,6 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -118,9 +119,8 @@ jobs:
|
||||
./osv-scanner --lockfile ./package-lock.json
|
||||
|
||||
deploy-production:
|
||||
needs: analyze
|
||||
runs-on: ubuntu-latest
|
||||
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
|
||||
1
.gitignore
vendored
@@ -93,6 +93,7 @@ dist
|
||||
/android-app/.kotlin/
|
||||
/android-app/**/build/
|
||||
/android-app/local.properties
|
||||
/android-app/gradle-local.properties
|
||||
|
||||
# Build output (but keep production data!)
|
||||
.output
|
||||
|
||||
@@ -36,13 +36,12 @@ Ausgabe in:
|
||||
|
||||
## 3) Screenshots (anonymisiert)
|
||||
|
||||
### Grobe Anforderungen (Telefon)
|
||||
- Mindestens 2 Screenshots
|
||||
- PNG oder JPEG
|
||||
- Seitenlaenge je Seite zwischen 320 px und 3840 px
|
||||
### Zielgroessen fuer Store-Upload
|
||||
- Telefon (Portrait): 1080 x 1920
|
||||
- Medium 7" Tablet (Portrait): 1200 x 1920
|
||||
- 10" Tablet (Portrait): 1600 x 2560
|
||||
|
||||
Empfehlung fuer Android-Phone:
|
||||
- 1080 x 1920 (Portrait)
|
||||
Alle Dateien als PNG oder JPEG.
|
||||
|
||||
### Anonymisierung
|
||||
|
||||
@@ -61,10 +60,33 @@ Beispiel:
|
||||
'68,118,520,72;70,706,560,98'
|
||||
```
|
||||
|
||||
### Zielprofile erzeugen (Telefon, 7", 10")
|
||||
|
||||
Aus allen Dateien in `android-app/playstore-assets/anon` werden die drei Profile erzeugt:
|
||||
|
||||
```bash
|
||||
./scripts/playstore-screenshot-sizes.sh
|
||||
```
|
||||
|
||||
Optional mit eigenen Ordnern:
|
||||
|
||||
```bash
|
||||
./scripts/playstore-screenshot-sizes.sh \
|
||||
--input-dir android-app/playstore-assets/anon \
|
||||
--output-dir android-app/playstore-assets/final
|
||||
```
|
||||
|
||||
Output:
|
||||
- android-app/playstore-assets/final/phone
|
||||
- android-app/playstore-assets/final/tablet-7
|
||||
- android-app/playstore-assets/final/tablet-10
|
||||
|
||||
## 4) Upload in Play Console
|
||||
|
||||
- Datenschutzerklaerung: URL eintragen
|
||||
- Konto-Loeschung: URL eintragen
|
||||
- App-Icon: playstore-icon-512.png
|
||||
- Feature Graphic: playstore-feature-graphic-1024x500.png
|
||||
- Screenshots: anonymisierte PNG/JPEG hochladen
|
||||
- Screenshots Telefon: Dateien aus `.../final/phone`
|
||||
- Screenshots 7" Tablet: Dateien aus `.../final/tablet-7`
|
||||
- Screenshots 10" Tablet: Dateien aus `.../final/tablet-10`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("com.google.devtools.ksp")
|
||||
@@ -25,15 +27,40 @@ val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
|
||||
.orElse("true")
|
||||
.get()
|
||||
.toBoolean()
|
||||
val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
|
||||
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull
|
||||
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull
|
||||
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull
|
||||
|
||||
val localSigningProperties = Properties().apply {
|
||||
val localSigningFile = rootProject.file("gradle-local.properties")
|
||||
if (localSigningFile.exists()) {
|
||||
localSigningFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun signingProperty(name: String): String? =
|
||||
providers.gradleProperty(name).orNull
|
||||
?: providers.environmentVariable(name).orNull
|
||||
?: localSigningProperties.getProperty(name)
|
||||
|
||||
val releaseStoreFile = signingProperty("RELEASE_STORE_FILE")
|
||||
val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD")
|
||||
val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS")
|
||||
val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD")
|
||||
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
|
||||
!releaseStorePassword.isNullOrBlank() &&
|
||||
!releaseKeyAlias.isNullOrBlank() &&
|
||||
!releaseKeyPassword.isNullOrBlank()
|
||||
|
||||
val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
|
||||
doFirst {
|
||||
if (!hasReleaseSigning) {
|
||||
throw GradleException(
|
||||
"Production release signing is not configured. " +
|
||||
"Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " +
|
||||
"(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.harheimertc"
|
||||
compileSdk = 35
|
||||
@@ -135,6 +162,7 @@ val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("package
|
||||
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
||||
group = "distribution"
|
||||
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
||||
dependsOn(ensureReleaseSigning)
|
||||
dependsOn(":app:bundleProductionRelease")
|
||||
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
||||
|
||||
@@ -161,6 +189,12 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching {
|
||||
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
|
||||
}.configureEach {
|
||||
dependsOn(ensureReleaseSigning)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
|
||||
37
android-app/app/proguard-rules.pro
vendored
@@ -1,2 +1,37 @@
|
||||
# Project-specific R8/ProGuard rules for release builds.
|
||||
# Keep this file intentionally minimal and add rules only when needed.
|
||||
|
||||
# Keep reflection/generic metadata used by Retrofit + Moshi.
|
||||
-keepattributes Signature,InnerClasses,EnclosingMethod
|
||||
-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault
|
||||
-keep class kotlin.Metadata { *; }
|
||||
|
||||
# Keep Retrofit service interfaces and HTTP method annotations.
|
||||
-keep,allowobfuscation interface * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
# Retrofit + R8 full mode: keep interfaces with HTTP methods and suspend continuation generics.
|
||||
-if interface * { @retrofit2.http.* <methods>; }
|
||||
-keep,allowobfuscation interface <1>
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||
|
||||
# Avoid Retrofit generic signature loss on release builds for our API interface.
|
||||
-keep interface de.harheimertc.data.ApiService { *; }
|
||||
-keepclassmembers interface de.harheimertc.data.ApiService { *; }
|
||||
|
||||
# Keep app DTO/request/response models used via Moshi reflection.
|
||||
-keep class de.harheimertc.data.*Dto { *; }
|
||||
-keep class de.harheimertc.data.*Request { *; }
|
||||
-keep class de.harheimertc.data.*Response { *; }
|
||||
|
||||
# Keep fields annotated with @Json names.
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.Json <fields>;
|
||||
}
|
||||
|
||||
# Keep WorkManager + Room generated classes used reflectively at startup.
|
||||
-keep class * extends androidx.work.ListenableWorker {
|
||||
<init>(android.content.Context, androidx.work.WorkerParameters);
|
||||
}
|
||||
-keep class androidx.work.impl.WorkDatabase_Impl { *; }
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
|
||||
@@ -81,7 +81,7 @@ data class LeagueTableRowDto(
|
||||
)
|
||||
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
|
||||
data class NewsDto(
|
||||
val id: Int? = null,
|
||||
val id: String? = null,
|
||||
val title: String = "",
|
||||
val content: String = "",
|
||||
val created: String? = null,
|
||||
@@ -96,7 +96,7 @@ data class NewsResponse(
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
)
|
||||
data class NewsSaveRequest(
|
||||
val id: Int? = null,
|
||||
val id: String? = null,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val isPublic: Boolean = false,
|
||||
@@ -260,6 +260,28 @@ data class BirthdaysResponse(
|
||||
val success: Boolean = false,
|
||||
val birthdays: List<BirthdayDto> = emptyList(),
|
||||
)
|
||||
data class QttrSourceDto(
|
||||
val url: String = "",
|
||||
)
|
||||
data class QttrRowDto(
|
||||
val rank: Int? = null,
|
||||
val playerNumber: Int? = null,
|
||||
val gender: String? = null,
|
||||
val playerName: String = "",
|
||||
val clubName: String = "",
|
||||
val currentQttr: Int? = null,
|
||||
val previousQttr: Int? = null,
|
||||
val birthdate: String? = null,
|
||||
)
|
||||
data class QttrValuesResponse(
|
||||
val format: String = "",
|
||||
val importedAt: String = "",
|
||||
val source: QttrSourceDto = QttrSourceDto(),
|
||||
val title: String? = null,
|
||||
val headerCount: Int = 0,
|
||||
val rowCount: Int = 0,
|
||||
val rows: List<QttrRowDto> = emptyList(),
|
||||
)
|
||||
data class MemberDto(
|
||||
val id: String? = null,
|
||||
val name: String = "",
|
||||
@@ -548,7 +570,7 @@ interface ApiService {
|
||||
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/news")
|
||||
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse>
|
||||
suspend fun deleteNews(@Query("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/mannschaften")
|
||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||
@@ -620,6 +642,9 @@ interface ApiService {
|
||||
@GET("/api/birthdays")
|
||||
suspend fun birthdays(): Response<BirthdaysResponse>
|
||||
|
||||
@GET("/api/mitgliederbereich/qttr")
|
||||
suspend fun qttrValues(): Response<QttrValuesResponse>
|
||||
|
||||
@GET("/api/members")
|
||||
suspend fun members(): Response<MembersResponse>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class SecureOfflineCache @Inject constructor(
|
||||
) {
|
||||
private companion object {
|
||||
const val KEY_BIRTHDAYS = "birthdays"
|
||||
const val KEY_QTTR_VALUES = "qttr_values"
|
||||
const val KEY_MEMBERS = "members"
|
||||
const val KEY_MEMBER_NEWS = "member_news"
|
||||
const val KEY_CMS_CONFIG = "cms_config"
|
||||
@@ -43,6 +44,9 @@ class SecureOfflineCache @Inject constructor(
|
||||
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
||||
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putQttrValues(response: QttrValuesResponse) = put(KEY_QTTR_VALUES, response, QttrValuesResponse::class.java)
|
||||
fun getQttrValues(maxAgeMillis: Long? = null): QttrValuesResponse? = get(KEY_QTTR_VALUES, QttrValuesResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
|
||||
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
|
||||
|
||||
@@ -93,6 +97,7 @@ class SecureOfflineCache @Inject constructor(
|
||||
KEY_NEWSLETTER_GROUPS,
|
||||
KEY_PASSWORD_RESET_DIAGNOSTICS,
|
||||
KEY_MEMBER_NEWS,
|
||||
KEY_QTTR_VALUES,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ class CmsRepository @Inject constructor(
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
suspend fun deleteNews(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
cache.clearCmsNewsCache()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
@@ -7,6 +8,7 @@ import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TerminDto
|
||||
import io.sentry.Sentry
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -17,19 +19,121 @@ data class HomeData(
|
||||
val selectedSpielplanSeason: String?,
|
||||
val news: List<NewsDto>,
|
||||
val homepageSections: List<HomepageSectionDto>,
|
||||
val diagnostics: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
|
||||
val termine = api.termine().body()?.termine.orEmpty()
|
||||
val spielplanResponse = api.spielplan().body()
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val termine = runCatching {
|
||||
val response = api.termine()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/termine",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()?.termine.orEmpty()
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.termine", error)
|
||||
if (diagnostics.none { it.contains("GET /api/termine") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/termine",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val spielplanResponse = runCatching {
|
||||
val response = api.spielplan()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/spielplan",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.spielplan", error)
|
||||
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/spielplan",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
val spiele = spielplanResponse?.data.orEmpty()
|
||||
val news = api.publicNews().body()?.news.orEmpty()
|
||||
|
||||
val news = runCatching {
|
||||
val response = api.publicNews()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/news-public",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()?.news.orEmpty()
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.news", error)
|
||||
if (diagnostics.none { it.contains("GET /api/news-public") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/news-public",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val homepageSections = runCatching {
|
||||
val configResponse = api.config()
|
||||
if (!configResponse.isSuccessful) return@runCatching emptyList()
|
||||
configResponse.body()?.homepage?.sections.orEmpty()
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/config",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()?.homepage?.sections.orEmpty()
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.config", error)
|
||||
if (diagnostics.none { it.contains("GET /api/config") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/config",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
HomeData(
|
||||
termine = termine,
|
||||
@@ -38,12 +142,56 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||
selectedSpielplanSeason = spielplanResponse?.season,
|
||||
news = news,
|
||||
homepageSections = homepageSections,
|
||||
diagnostics = diagnostics,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "HomeRepository")
|
||||
scope.setTag("operation", "fetchHomeData")
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "HomeRepository")
|
||||
scope.setTag("operation", "fetchSpielplanForSeason")
|
||||
scope.setExtra("season", season)
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureLoadIssue(operation: String, error: Throwable) {
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "HomeRepository")
|
||||
scope.setTag("operation", operation)
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDiagnostic(
|
||||
endpoint: String,
|
||||
requestPayload: String,
|
||||
httpCode: Int?,
|
||||
responseBody: String?,
|
||||
throwable: Throwable?,
|
||||
): String {
|
||||
val responsePreview = responseBody?.trim()?.take(500).orEmpty().ifBlank { "none" }
|
||||
val throwableInfo = throwable?.let { "${it::class.simpleName}: ${it.message}" }.orEmpty().ifBlank { "none" }
|
||||
return buildString {
|
||||
append("Endpoint: ").append(endpoint).append('\n')
|
||||
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint.substringAfter(' ')).append('\n')
|
||||
append("Request: ").append(requestPayload).append('\n')
|
||||
append("HTTP: ").append(httpCode?.toString() ?: "none").append('\n')
|
||||
append("Response: ").append(responsePreview).append('\n')
|
||||
append("Throwable: ").append(throwableInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
@@ -9,6 +10,7 @@ import de.harheimertc.data.LogoutRequest
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.ResetPasswordRequest
|
||||
import de.harheimertc.data.SessionRefresher
|
||||
import io.sentry.Sentry
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -19,13 +21,41 @@ class LoginRepository @Inject constructor(
|
||||
private val sessionRefresher: SessionRefresher,
|
||||
) {
|
||||
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
||||
val endpoint = "api/auth/login"
|
||||
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
|
||||
val response = api.login(LoginRequest(email.trim(), password))
|
||||
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
|
||||
if (!response.isSuccessful) {
|
||||
val body = response.errorBody()?.string().orEmpty()
|
||||
val serverMessage = extractServerMessage(body)
|
||||
val fallback = when (response.code()) {
|
||||
401 -> "Ungueltige Anmeldedaten"
|
||||
403 -> "Konto nicht freigeschaltet"
|
||||
429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen."
|
||||
else -> "Anmeldung fehlgeschlagen"
|
||||
}
|
||||
val diagnostic = buildString {
|
||||
append("\n\nDiagnose:\n")
|
||||
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n')
|
||||
append("Request: ").append(requestPreview).append('\n')
|
||||
append("HTTP: ").append(response.code()).append('\n')
|
||||
append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n')
|
||||
append("Server message: ").append(serverMessage ?: "none")
|
||||
}
|
||||
error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic")
|
||||
}
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "LoginRepository")
|
||||
scope.setTag("operation", "login")
|
||||
scope.setExtra("emailDomain", email.substringAfter('@', "unknown"))
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logout(): Result<Unit> = runCatching {
|
||||
@@ -51,6 +81,15 @@ class LoginRepository @Inject constructor(
|
||||
}
|
||||
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
|
||||
status
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "LoginRepository")
|
||||
scope.setTag("operation", "status")
|
||||
scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString())
|
||||
scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString())
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
||||
@@ -64,4 +103,19 @@ class LoginRepository @Inject constructor(
|
||||
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
|
||||
private fun extractServerMessage(raw: String): String? {
|
||||
if (raw.isBlank()) return null
|
||||
val msgRegex = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"")
|
||||
return msgRegex.find(raw)?.groupValues?.getOrNull(1)
|
||||
}
|
||||
|
||||
private fun maskEmail(rawEmail: String): String {
|
||||
val email = rawEmail.trim()
|
||||
if (!email.contains('@')) return "hidden"
|
||||
val local = email.substringBefore('@')
|
||||
val domain = email.substringAfter('@')
|
||||
val localMasked = if (local.length <= 2) "**" else local.take(2) + "***"
|
||||
return "$localMasked@$domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.harheimertc.data.BirthdaysResponse
|
||||
import de.harheimertc.data.MembersResponse
|
||||
import de.harheimertc.data.NewsResponse
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.data.QttrValuesResponse
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -24,6 +25,18 @@ class MemberAreaRepository @Inject constructor(
|
||||
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun qttrValues(): Result<QttrValuesResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.qttrValues()
|
||||
if (!response.isSuccessful) error("QTTR-Werte konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = cache::putQttrValues,
|
||||
cached = { cache.getQttrValues(24L * 60L * 60L * 1000L) },
|
||||
fallbackMessage = "QTTR-Werte konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun members(): Result<MembersResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
@@ -88,7 +101,7 @@ class MemberAreaRepository @Inject constructor(
|
||||
response.body() ?: emptyMap()
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: Int): Result<Unit> = runCatching {
|
||||
suspend fun deleteNews(id: String): Result<Unit> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
}
|
||||
|
||||
@@ -72,15 +72,72 @@ private fun CompactNavigation(
|
||||
onNavigate: (String) -> Unit,
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
) {
|
||||
val section = menuSection(selectedRoute)
|
||||
val subItems = submenu(section, navigationState)
|
||||
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
|
||||
|
||||
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate)
|
||||
CompactLink("Verein", Destinations.VereinAbout.route, selectedRoute, onNavigate)
|
||||
CompactLink("Mannschaften", Destinations.Mannschaften.route, selectedRoute, onNavigate)
|
||||
CompactLink("Training", Destinations.Training.route, selectedRoute, onNavigate)
|
||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate)
|
||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate)
|
||||
CompactLink("Newsletter", Destinations.NewsletterSubscribe.route, selectedRoute, onNavigate)
|
||||
if (navigationState.showGallery) {
|
||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate)
|
||||
}
|
||||
if (navigationState.loggedIn) {
|
||||
CompactLink("Intern", Destinations.MemberArea.route, selectedRoute, onNavigate)
|
||||
}
|
||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate)
|
||||
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
|
||||
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate)
|
||||
}
|
||||
}
|
||||
|
||||
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
|
||||
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
|
||||
if (cmsChildren.any { it.route == selectedRoute }) {
|
||||
cmsExpanded.value = true
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(top = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
subItems.forEachIndexed { idx, item ->
|
||||
if (idx == cmsIndex) {
|
||||
SubLink(item.label, item.route == selectedRoute) {
|
||||
cmsExpanded.value = !cmsExpanded.value
|
||||
}
|
||||
} else if (idx > cmsIndex && cmsIndex >= 0) {
|
||||
// CMS children are rendered below when expanded.
|
||||
} else {
|
||||
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(top = 6.dp, bottom = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
cmsChildren.forEach { child ->
|
||||
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,7 +164,6 @@ private fun WebTabletNavigation(
|
||||
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
|
||||
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
|
||||
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
|
||||
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) })
|
||||
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
|
||||
if (navigationState.showGallery) {
|
||||
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
|
||||
@@ -214,8 +270,8 @@ private fun MainLink(
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.94f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
@@ -239,7 +295,7 @@ private fun CompactLink(
|
||||
label,
|
||||
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
@@ -256,7 +312,7 @@ private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) Color.White else Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
@@ -289,6 +345,7 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
||||
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
|
||||
Destinations.MemberArea.route,
|
||||
Destinations.Members.route,
|
||||
Destinations.Qttr.route,
|
||||
Destinations.MemberNews.route,
|
||||
Destinations.Profile.route,
|
||||
Destinations.MemberApi.route,
|
||||
@@ -339,6 +396,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
||||
MenuSection.INTERN -> buildList {
|
||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
||||
add(MenuTarget("QTTR", Destinations.Qttr.route))
|
||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
||||
|
||||
@@ -36,6 +36,7 @@ sealed class Destinations(val route: String) {
|
||||
object Register : Destinations("register")
|
||||
object MemberArea : Destinations("intern")
|
||||
object Members : Destinations("intern/mitglieder")
|
||||
object Qttr : Destinations("intern/qttr")
|
||||
object MemberNews : Destinations("intern/news")
|
||||
object Profile : Destinations("intern/profil")
|
||||
object MemberApi : Destinations("intern/api")
|
||||
|
||||
@@ -261,6 +261,12 @@ fun NavGraph(
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Qttr.route) {
|
||||
de.harheimertc.ui.screens.memberarea.QttrScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MemberNews.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
|
||||
navController = navController,
|
||||
|
||||
@@ -49,7 +49,7 @@ import java.util.Locale
|
||||
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var selection by remember { mutableStateOf(setOf<Int>()) }
|
||||
var selection by remember { mutableStateOf(setOf<String>()) }
|
||||
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
|
||||
val loginState by loginVm.state.collectAsState()
|
||||
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
|
||||
@@ -62,7 +62,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
||||
|
||||
// Local dialog state for create/edit + delete confirmation (hoisted)
|
||||
var dialogOpen by remember { mutableStateOf(false) }
|
||||
var deletingIds by remember { mutableStateOf<List<Int>?>(null) }
|
||||
var deletingIds by remember { mutableStateOf<List<String>?>(null) }
|
||||
var editing by remember { mutableStateOf<NewsDto?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var content by remember { mutableStateOf("") }
|
||||
@@ -256,9 +256,9 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
||||
private fun NewsListItem(
|
||||
news: NewsDto,
|
||||
selected: Boolean = false,
|
||||
onSelect: (Int?, Boolean) -> Unit = { _, _ -> },
|
||||
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
|
||||
onEdit: (NewsDto) -> Unit,
|
||||
onDelete: (Int) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
|
||||
@@ -207,7 +207,7 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkSetPublic(ids: List<Int>, makePublic: Boolean) {
|
||||
fun bulkSetPublic(ids: List<String>, makePublic: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
@@ -229,7 +229,7 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkSetHidden(ids: List<Int>, makeHidden: Boolean) {
|
||||
fun bulkSetHidden(ids: List<String>, makeHidden: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
@@ -251,7 +251,7 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkDelete(ids: List<Int>) {
|
||||
fun bulkDelete(ids: List<String>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
@@ -262,7 +262,7 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNews(id: Int) {
|
||||
fun deleteNews(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.deleteNews(id)
|
||||
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -126,6 +127,47 @@ fun HomeScreen(
|
||||
onReset = viewModel::resetSections,
|
||||
)
|
||||
}
|
||||
if (state.error) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = state.errorMessage ?: "Daten konnten nicht geladen werden.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(onClick = viewModel::load) {
|
||||
Text("Erneut versuchen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.debugDiagnostics.isNotEmpty()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Technische Diagnose (vorübergehend)",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = state.debugDiagnostics.joinToString("\n\n---\n\n"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
state.homepageSections.forEachIndexed { index, section ->
|
||||
if (!section.enabled) return@forEachIndexed
|
||||
val sectionKey = homeSectionKey(section)
|
||||
|
||||
@@ -44,6 +44,8 @@ data class HomeUiState(
|
||||
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
|
||||
val widgetsLoading: Boolean = false,
|
||||
val error: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val debugDiagnostics: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -62,7 +64,12 @@ class HomeViewModel @Inject constructor(
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = false)
|
||||
_state.value = _state.value.copy(
|
||||
loading = true,
|
||||
error = false,
|
||||
errorMessage = null,
|
||||
debugDiagnostics = emptyList(),
|
||||
)
|
||||
repository.fetchHomeData()
|
||||
.onSuccess { data ->
|
||||
serverSections = normalizedHomepageSections(data.homepageSections)
|
||||
@@ -99,10 +106,16 @@ class HomeViewModel @Inject constructor(
|
||||
spielplanTeamsBySeason = widgetData.teamsBySeason,
|
||||
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
|
||||
spielplanWidgetErrors = widgetData.errorsBySectionKey,
|
||||
debugDiagnostics = data.diagnostics,
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = HomeUiState(loading = false, error = true)
|
||||
.onFailure { err ->
|
||||
_state.value = HomeUiState(
|
||||
loading = false,
|
||||
error = true,
|
||||
errorMessage = err.message ?: "Daten konnten nicht geladen werden.",
|
||||
debugDiagnostics = listOf(err.message ?: "Unbekannter Fehler"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
@@ -25,15 +26,19 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.MemberDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.QttrRowDto
|
||||
import de.harheimertc.ui.components.RichText
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
@@ -166,7 +171,14 @@ fun MembersScreen(
|
||||
val m = display[index]
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Column(Modifier.weight(1f)) { Text(m.name, color = Accent900) }
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
m.name,
|
||||
color = Accent900,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f)) { Text(m.email ?: "-", color = Primary600) }
|
||||
Column(Modifier.weight(1f)) { Text(m.phone ?: "-", color = Accent700) }
|
||||
}
|
||||
@@ -200,7 +212,7 @@ fun MemberNewsScreen(
|
||||
fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) {
|
||||
val groups = listOf(
|
||||
"Authentifizierung" to listOf("POST /api/auth/login", "POST /api/auth/refresh", "GET /api/auth/status", "POST /api/auth/logout"),
|
||||
"Mitgliederbereich" to listOf("GET /api/members", "GET /api/news", "GET /api/profile", "PUT /api/profile"),
|
||||
"Mitgliederbereich" to listOf("GET /api/members", "GET /api/mitgliederbereich/qttr", "GET /api/news", "GET /api/profile", "PUT /api/profile"),
|
||||
"CMS" to listOf("GET /api/cms/users/list", "GET /api/cms/contact-requests", "GET /api/newsletter/list", "GET /api/config"),
|
||||
)
|
||||
MemberAreaPage(navController, showBackNavigation, "API-Dokumentation", "Kurzüberblick der wichtigsten App-Endpunkte") {
|
||||
@@ -229,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
|
||||
private fun MemberAreaPage(
|
||||
navController: NavController,
|
||||
@@ -259,7 +307,13 @@ private fun MemberAreaPage(
|
||||
private fun MemberCard(member: MemberDto, onEdit: (MemberDto) -> Unit = {}, onDelete: (MemberDto) -> Unit = {}) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text(member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(
|
||||
member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Accent900,
|
||||
)
|
||||
if (!member.email.isNullOrBlank()) Text(member.email, color = Primary600)
|
||||
if (!member.phone.isNullOrBlank()) Text(member.phone, color = Accent700)
|
||||
if (!member.birthday.isNullOrBlank()) {
|
||||
@@ -308,6 +362,83 @@ private fun Badge(label: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QttrRowCard(row: QttrRowDto, highlighted: Boolean) {
|
||||
Surface(
|
||||
color = if (highlighted) Primary100 else Color.White,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
shadowElevation = 3.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(18.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(color = if (highlighted) Primary100 else Accent100, shape = RoundedCornerShape(10.dp), modifier = Modifier.size(46.dp)) {
|
||||
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Text(row.rank?.toString() ?: "-", color = Accent900, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
row.playerName.ifBlank { "Unbekannt" },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = qttrNameColor(row.gender, isMinor(row.birthdate)),
|
||||
fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Medium,
|
||||
)
|
||||
Text(row.clubName.ifBlank { "Harheimer TC" }, color = qttrNameColor(row.gender, isMinor(row.birthdate)).copy(alpha = 0.88f))
|
||||
}
|
||||
Text(row.currentQttr?.toString() ?: "-", color = Primary600, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isOwnRow(playerName: String?, currentUserName: String): Boolean {
|
||||
fun normalize(value: String?): String {
|
||||
return java.text.Normalizer.normalize(value.orEmpty().trim().lowercase(), java.text.Normalizer.Form.NFKD)
|
||||
.replace(Regex("[\\u0300-\\u036f]"), "")
|
||||
.replace(Regex("[’'`]"), "")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
}
|
||||
|
||||
val current = normalize(currentUserName)
|
||||
if (current.isBlank()) return false
|
||||
return normalize(playerName) == current
|
||||
}
|
||||
|
||||
private fun qttrNameColor(gender: String?, isMinor: Boolean): Color {
|
||||
val value = gender.orEmpty().trim().lowercase()
|
||||
return when {
|
||||
value.startsWith('m') || value.contains("männ") || value.contains("maenn") -> if (isMinor) Color(0xFF60A5FA) else Color(0xFF2563EB)
|
||||
value.startsWith('w') || value.contains("weib") || value.contains("frau") -> if (isMinor) Color(0xFFF9A8D4) else Color(0xFF9D174D)
|
||||
else -> Accent900
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMinor(birthdate: String?): Boolean {
|
||||
val date = parseBirthdate(birthdate) ?: return false
|
||||
val today = java.time.LocalDate.now()
|
||||
var age = today.year - date.year
|
||||
if (today.monthValue < date.monthValue || (today.monthValue == date.monthValue && today.dayOfMonth < date.dayOfMonth)) {
|
||||
age -= 1
|
||||
}
|
||||
return age < 18
|
||||
}
|
||||
|
||||
private fun parseBirthdate(value: String?): java.time.LocalDate? {
|
||||
val raw = value.orEmpty().trim()
|
||||
if (raw.isBlank()) return null
|
||||
return try {
|
||||
if (Regex("^\\d{4}$").matches(raw)) {
|
||||
java.time.LocalDate.of(raw.toInt(), 1, 1)
|
||||
} else {
|
||||
java.time.LocalDate.parse(raw)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorCard(message: String, onRetry: () -> Unit) {
|
||||
Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(12.dp)) {
|
||||
|
||||
@@ -3,9 +3,12 @@ package de.harheimertc.ui.screens.memberarea
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.MemberDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.QttrRowDto
|
||||
import de.harheimertc.repositories.MemberAreaRepository
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -101,3 +104,45 @@ class MemberNewsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class QttrUiState(
|
||||
val rows: List<QttrRowDto> = emptyList(),
|
||||
val title: String? = null,
|
||||
val importedAt: String? = null,
|
||||
val currentUserName: String = "",
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class QttrViewModel @Inject constructor(
|
||||
private val repository: MemberAreaRepository,
|
||||
private val loginRepository: LoginRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(QttrUiState())
|
||||
val state: StateFlow<QttrUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
val authStatus = loginRepository.status().getOrDefault(AuthStatusResponse())
|
||||
repository.qttrValues()
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
rows = response.rows,
|
||||
title = response.title,
|
||||
importedAt = response.importedAt,
|
||||
currentUserName = authStatus.user?.name.orEmpty(),
|
||||
loading = false,
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(loading = false, error = it.message ?: "QTTR-Werte konnten nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ private fun MemberAreaCardGrid(navController: NavController) {
|
||||
marker = "N",
|
||||
onClick = { navController.navigate(Destinations.MemberNews.route) },
|
||||
)
|
||||
MemberAreaCard(
|
||||
title = "QTTR",
|
||||
description = "Aktuelle QTTR-Werte der Vereinsmitglieder",
|
||||
marker = "Q",
|
||||
onClick = { navController.navigate(Destinations.Qttr.route) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,32 +10,91 @@ import de.harheimertc.R
|
||||
|
||||
// Bundled variable fonts in res/font:
|
||||
val InterFamily = FontFamily(Font(R.font.inter_variable))
|
||||
val MontserratFamily = FontFamily(Font(R.font.montserrat_variable, FontWeight.SemiBold))
|
||||
val MontserratFamily = FontFamily(
|
||||
Font(R.font.montserrat_variable, FontWeight.SemiBold),
|
||||
Font(R.font.montserrat_variable, FontWeight.Bold),
|
||||
Font(R.font.montserrat_variable, FontWeight.ExtraBold),
|
||||
)
|
||||
// Android headings: use system sans-serif for stronger strokes/readability on tablets.
|
||||
val HeaderFamily = FontFamily.SansSerif
|
||||
|
||||
val AppTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = MontserratFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 30.sp
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 38.sp
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 34.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 30.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = MontserratFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 20.sp
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 17.sp,
|
||||
lineHeight = 25.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 22.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 19.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
)
|
||||
|
||||
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
@@ -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/
|
||||
|
||||
# Production backend for Play Store build variant
|
||||
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
||||
PRODUCTION_API_BASE_URL=https://harheimertc.tsschulz.de/
|
||||
|
||||
# Android app versioning for Play Store uploads
|
||||
ANDROID_VERSION_CODE=4
|
||||
ANDROID_VERSION_NAME=1.0.0
|
||||
ANDROID_VERSION_CODE=17
|
||||
ANDROID_VERSION_NAME=0.9.12
|
||||
|
||||
# Enable R8 for release by default so mapping.txt is generated for Play Console.
|
||||
RELEASE_MINIFY_ENABLED=true
|
||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||
RELEASE_MINIFY_ENABLED=false
|
||||
|
||||
# Release signing (set in local, untracked gradle.properties or via CI secrets)
|
||||
# RELEASE_STORE_FILE=/absolute/path/to/keystore.jks
|
||||
RELEASE_STORE_FILE=/home/torsten/android\ keystore/harheimertc.jks
|
||||
# Keep secrets out of git. Use ~/.gradle/gradle.properties or environment variables.
|
||||
# RELEASE_STORE_PASSWORD=***
|
||||
# RELEASE_KEY_ALIAS=***
|
||||
# RELEASE_KEY_PASSWORD=***
|
||||
|
||||
BIN
android-app/playstore-assets/anon/Screenshot_20260530_000103.png
Normal file
|
After Width: | Height: | Size: 814 KiB |
BIN
android-app/playstore-assets/anon/Screenshot_20260530_000133.png
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
android-app/playstore-assets/anon/Screenshot_20260530_000230.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
android-app/playstore-assets/anon/Screenshot_20260530_000301.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
BIN
android-app/playstore-assets/anon/Screenshot_20260530_000429.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 863 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 859 KiB |
|
After Width: | Height: | Size: 737 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 977 KiB |
|
After Width: | Height: | Size: 845 KiB |
BIN
android-app/playstore-assets/raw/Screenshot_20260530_000103.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
android-app/playstore-assets/raw/Screenshot_20260530_000301.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 958 KiB |
|
After Width: | Height: | Size: 128 KiB |
@@ -274,6 +274,13 @@
|
||||
>
|
||||
Mitgliederliste
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/qttr"
|
||||
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||
active-class="text-white bg-primary-600"
|
||||
>
|
||||
QTTR
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/news"
|
||||
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||
@@ -714,6 +721,13 @@
|
||||
>
|
||||
Mitgliederliste
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/qttr"
|
||||
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
QTTR
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/news"
|
||||
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@@ -814,7 +828,7 @@
|
||||
Einstellungen
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="getAuthStore()?.hasAnyRole('admin', 'vorstand')"
|
||||
v-if="canManageUsers"
|
||||
to="/cms/benutzer"
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
@@ -849,34 +863,13 @@ const mobileSubmenu = ref(null)
|
||||
const mannschaften = ref([])
|
||||
const hasGalleryImages = ref(false)
|
||||
const showCmsDropdown = ref(false)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Lazy store access to avoid Pinia initialization issues
|
||||
const getAuthStore = () => {
|
||||
try {
|
||||
return useAuthStore()
|
||||
} catch (e) {
|
||||
// Fallback if Pinia is not yet initialized
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive auth state from store (lazy)
|
||||
const isLoggedIn = computed(() => {
|
||||
const store = getAuthStore()
|
||||
return store?.isLoggedIn ?? false
|
||||
})
|
||||
const isAdmin = computed(() => {
|
||||
const store = getAuthStore()
|
||||
return store?.isAdmin ?? false
|
||||
})
|
||||
const canAccessNewsletter = computed(() => {
|
||||
const store = getAuthStore()
|
||||
return store?.hasAnyRole('admin', 'vorstand', 'newsletter') ?? false
|
||||
})
|
||||
const canAccessContactRequests = computed(() => {
|
||||
const store = getAuthStore()
|
||||
return store?.hasAnyRole('admin', 'vorstand', 'trainer') ?? false
|
||||
})
|
||||
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
const canAccessNewsletter = computed(() => authStore.hasAnyRole('admin', 'vorstand', 'newsletter'))
|
||||
const canAccessContactRequests = computed(() => authStore.hasAnyRole('admin', 'vorstand', 'trainer'))
|
||||
const canManageUsers = computed(() => authStore.hasAnyRole('admin', 'vorstand'))
|
||||
|
||||
// Automatisches Setzen des Submenus basierend auf der Route
|
||||
const currentSubmenu = computed(() => {
|
||||
@@ -982,10 +975,7 @@ const handleDocumentClick = (e) => {
|
||||
onMounted(() => {
|
||||
loadMannschaften()
|
||||
checkGalleryImages()
|
||||
const store = getAuthStore()
|
||||
if (store) {
|
||||
store.checkAuth()
|
||||
}
|
||||
authStore.checkAuth()
|
||||
|
||||
// Close CMS dropdown when clicking outside
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.0",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -23,6 +23,7 @@
|
||||
"publish-spielplan": "node scripts/publish-imported-spielplan.js",
|
||||
"playstore:assets": "./scripts/playstore-assets.sh",
|
||||
"playstore:anonymize": "./scripts/anonymize-playstore-screenshot.sh",
|
||||
"playstore:screenshots": "./scripts/playstore-screenshot-sizes.sh",
|
||||
"test:watch": "vitest watch",
|
||||
"lint": "eslint . --fix"
|
||||
},
|
||||
|
||||
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>
|
||||
@@ -24,7 +24,7 @@ IFS=';' read -r -a BOXES <<< "$RECTS"
|
||||
for box in "${BOXES[@]}"; do
|
||||
IFS=',' read -r x y w h <<< "$box"
|
||||
magick "$TMP" \
|
||||
\( -size "${w}x${h}" xc:black -alpha set -channel a -evaluate set 70% +channel \) \
|
||||
\( -size "${w}x${h}" xc:black -alpha off \) \
|
||||
-geometry "+${x}+${y}" -composite "$TMP"
|
||||
done
|
||||
|
||||
|
||||
82
scripts/playstore-screenshot-sizes.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
import { readdir, mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
function readArg(flag, fallback = '') {
|
||||
const idx = args.indexOf(flag)
|
||||
if (idx === -1) return fallback
|
||||
return args[idx + 1] || fallback
|
||||
}
|
||||
|
||||
const inputDirArg = readArg('--input-dir', 'android-app/playstore-assets/anon')
|
||||
const outputDirArg = readArg('--output-dir', 'android-app/playstore-assets/final')
|
||||
const inputDir = path.resolve(rootDir, inputDirArg)
|
||||
const outputDir = path.resolve(rootDir, outputDirArg)
|
||||
|
||||
const profiles = [
|
||||
{ key: 'phone', width: 1080, height: 1920 },
|
||||
{ key: 'tablet-7', width: 1200, height: 1920 },
|
||||
{ key: 'tablet-10', width: 1600, height: 2560 },
|
||||
]
|
||||
|
||||
function isImageFile(name) {
|
||||
const lower = name.toLowerCase()
|
||||
return lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.jpeg')
|
||||
}
|
||||
|
||||
async function processFile(fileName) {
|
||||
const inputPath = path.join(inputDir, fileName)
|
||||
const parsed = path.parse(fileName)
|
||||
|
||||
for (const profile of profiles) {
|
||||
const profileDir = path.join(outputDir, profile.key)
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
|
||||
const outputPath = path.join(
|
||||
profileDir,
|
||||
`${parsed.name}-${profile.width}x${profile.height}.png`,
|
||||
)
|
||||
|
||||
// Use contain to preserve all UI content and add solid bars only if needed.
|
||||
await sharp(inputPath)
|
||||
.resize(profile.width, profile.height, {
|
||||
fit: 'contain',
|
||||
background: { r: 0, g: 0, b: 0, alpha: 1 },
|
||||
})
|
||||
.png()
|
||||
.toFile(outputPath)
|
||||
|
||||
console.log(`Created: ${path.relative(rootDir, outputPath)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const files = await readdir(inputDir)
|
||||
const images = files.filter(isImageFile)
|
||||
|
||||
if (images.length === 0) {
|
||||
console.error(`No PNG/JPG files found in: ${path.relative(rootDir, inputDir)}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
await mkdir(outputDir, { recursive: true })
|
||||
for (const image of images) {
|
||||
await processFile(image)
|
||||
}
|
||||
|
||||
console.log(`Done. Output dir: ${path.relative(rootDir, outputDir)}`)
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error('Failed to generate screenshot profiles:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
6
scripts/playstore-screenshot-sizes.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
node "$ROOT_DIR/scripts/playstore-screenshot-sizes.mjs" "$@"
|
||||
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 { importLeagueTables } from '../utils/spielklassen-tables-import.js'
|
||||
import { importQttrValues } from '../utils/qttr-import.js'
|
||||
import { publishImportedSpielplan } from '../utils/spielplan-publish.js'
|
||||
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
|
||||
import { cleanupPasswordResetLogs } from '../utils/password-reset-log.js'
|
||||
|
||||
const TIME_ZONE = 'Europe/Berlin'
|
||||
const RUN_HOUR = 7
|
||||
const RUN_MINUTE = 0
|
||||
const MAX_TIMEOUT = 2_147_483_647
|
||||
|
||||
let timer = null
|
||||
let running = false
|
||||
const JOBS = [
|
||||
{ label: 'spielplan-import', hour: 7, minute: 0 },
|
||||
{ label: 'qttr-import', hour: 7, minute: 30 }
|
||||
]
|
||||
|
||||
const timers = new Map()
|
||||
const runningJobs = new Set()
|
||||
|
||||
function getTimeParts(date) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
@@ -47,12 +51,12 @@ function zonedDateToUtc(year, month, day, hour, minute) {
|
||||
return new Date(utcGuess.getTime() - offset)
|
||||
}
|
||||
|
||||
function nextRunAt(now = new Date()) {
|
||||
function nextRunAt(hour, minute, now = new Date()) {
|
||||
const parts = getTimeParts(now)
|
||||
let year = Number(parts.year)
|
||||
let month = Number(parts.month)
|
||||
let day = Number(parts.day)
|
||||
let candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE)
|
||||
let candidate = zonedDateToUtc(year, month, day, hour, minute)
|
||||
|
||||
if (candidate <= now) {
|
||||
const nextDay = zonedDateToUtc(year, month, day + 1, 12, 0)
|
||||
@@ -60,65 +64,86 @@ function nextRunAt(now = new Date()) {
|
||||
year = Number(nextParts.year)
|
||||
month = Number(nextParts.month)
|
||||
day = Number(nextParts.day)
|
||||
candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE)
|
||||
candidate = zonedDateToUtc(year, month, day, hour, minute)
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
async function runDailyJobs(reason, skipSpielplanImport = false) {
|
||||
if (running) return
|
||||
async function runJob(job, reason) {
|
||||
if (runningJobs.has(job.label)) return
|
||||
|
||||
running = true
|
||||
runningJobs.add(job.label)
|
||||
try {
|
||||
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 })
|
||||
}
|
||||
await job.run(reason)
|
||||
} catch (error) {
|
||||
loggerError('[spielplan-import] Import fehlgeschlagen:', { error })
|
||||
loggerError(`[${job.label}] Import fehlgeschlagen:`, { error })
|
||||
} finally {
|
||||
running = false
|
||||
runningJobs.delete(job.label)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNext(skipSpielplanImport = false) {
|
||||
const runAt = nextRunAt()
|
||||
function scheduleNext(job) {
|
||||
const runAt = nextRunAt(job.hour, job.minute)
|
||||
const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT)
|
||||
|
||||
timer = setTimeout(async () => {
|
||||
await runDailyJobs('taeglicher Lauf', skipSpielplanImport)
|
||||
scheduleNext(skipSpielplanImport)
|
||||
const timer = setTimeout(async () => {
|
||||
await runJob(job, 'taeglicher Lauf')
|
||||
scheduleNext(job)
|
||||
}, delay)
|
||||
|
||||
timer.unref?.()
|
||||
loggerInfo('[spielplan-import] Naechster Lauf', { runAt: runAt.toISOString(), tz: TIME_ZONE, time: `${String(RUN_HOUR).padStart(2, '0')}:${String(RUN_MINUTE).padStart(2, '0')}` })
|
||||
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) => {
|
||||
@@ -127,13 +152,19 @@ export default defineNitroPlugin((nitroApp) => {
|
||||
loggerInfo('[spielplan-import] Import deaktiviert; Passwort-Reset-Log-Bereinigung bleibt aktiv')
|
||||
}
|
||||
|
||||
scheduleNext(skipSpielplanImport)
|
||||
const spielplanJob = createSpielplanJob(skipSpielplanImport)
|
||||
const qttrJob = createQttrJob()
|
||||
|
||||
scheduleNext({ ...JOBS[0], ...spielplanJob })
|
||||
scheduleNext({ ...JOBS[1], ...qttrJob })
|
||||
|
||||
if (process.env.SPIELPLAN_IMPORT_RUN_ON_START === 'true') {
|
||||
runDailyJobs('Startlauf', skipSpielplanImport)
|
||||
runJob({ label: 'spielplan-import', run: spielplanJob.run }, 'Startlauf')
|
||||
}
|
||||
|
||||
nitroApp.hooks.hookOnce('close', () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
for (const timer of timers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
220
server/utils/qttr-import.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||