From 61b1f27e5ef0ec2b50df5681b49e353c44ead3bf Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 13 May 2026 00:07:47 +0200 Subject: [PATCH] feat(CalendarView): merge recurring training slots and enhance event filtering - Implemented a new method to merge recurring training slots with identical weekdays and time windows, improving calendar event management. - Updated event filtering logic to exclude cancelled training sessions, ensuring only relevant training events are displayed. - Enhanced the loading process to handle source errors more effectively, improving user experience in the CalendarView. --- frontend/src/views/CalendarView.vue | 45 ++++++- .../de/tt_tagebuch/app/AppDependencies.kt | 4 + .../tt_tagebuch/app/ui/ClubSettingsScreens.kt | 54 ++++---- .../shared/api/ClickTtAccountApi.kt | 49 ++++++++ .../shared/api/MyTischtennisApi.kt | 49 ++++++++ .../api/models/IntegrationAccountDtos.kt | 118 ++++++++++++++++++ 6 files changed, 293 insertions(+), 26 deletions(-) create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClickTtAccountApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MyTischtennisApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/IntegrationAccountDtos.kt diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue index 2eaeda79..4b44c6d9 100644 --- a/frontend/src/views/CalendarView.vue +++ b/frontend/src/views/CalendarView.vue @@ -269,9 +269,10 @@ export default { .filter(event => event.type === 'trainingCancellation') .flatMap(event => this.getDateKeysForRange(event.date, event.endDate || event.date)) ); - this.events = loadedEvents.filter(event => ( + const afterCancellations = loadedEvents.filter(event => ( !event.isRecurringTraining || !cancellationDates.has(this.toDateKey(event.date)) )); + this.events = this.mergeRecurringTrainingSlots(afterCancellations); this.sourceErrors = sources .filter(result => result.status === 'rejected') .map(result => result.reason?.source) @@ -283,6 +284,48 @@ export default { this.loading = false; }, + /** + * Mehrere regelmäßige Trainingszeiten mit identischem Wochentag und gleichem Uhrzeit-Fenster + * (z. B. parallel genutzte Gruppen / Dubletten) zu einem Kalendereintrag zusammenführen. + */ + mergeRecurringTrainingSlots(events) { + const genericTitle = (t) => !t || /^training$/i.test(String(t).trim()); + const slotMap = new Map(); + const passthrough = []; + for (const e of events) { + if (e.type !== 'training' || !e.isRecurringTraining || !e.time || !e.date) { + passthrough.push(e); + continue; + } + const dk = this.toDateKey(e.date); + const slotKey = `${dk}|${e.time}`; + if (!slotMap.has(slotKey)) { + slotMap.set(slotKey, []); + } + slotMap.get(slotKey).push(e); + } + const mergedSlots = []; + for (const [slotKey, list] of slotMap) { + if (list.length === 1) { + mergedSlots.push(list[0]); + continue; + } + const sorted = list.slice().sort((a, b) => a.startsAt - b.startsAt); + const base = sorted[0]; + const rawTitles = [...new Set(sorted.map((x) => String(x.title || '').trim()).filter(Boolean))]; + const specific = rawTitles.filter((t) => !genericTitle(t)); + const titleJoined = specific.length ? specific.join(' · ') : (rawTitles.join(' · ') || base.title); + const safeIdKey = slotKey.replace(/\|/g, '-'); + mergedSlots.push({ + ...base, + id: `training-merged-${safeIdKey}`, + title: titleJoined, + subtitle: 'Regelmäßige Trainingszeit', + startsAt: Math.min(...sorted.map((x) => x.startsAt)) + }); + } + return [...passthrough, ...mergedSlots]; + }, async loadSource(source, loader) { try { const events = await loader(); diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt index 54b698b1..64948967 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt @@ -6,6 +6,7 @@ import android.net.Uri import de.tt_tagebuch.shared.api.AccidentApi import de.tt_tagebuch.shared.api.ApiLogsApi import de.tt_tagebuch.shared.api.ClubApprovalsApi +import de.tt_tagebuch.shared.api.ClickTtAccountApi import de.tt_tagebuch.shared.api.ApiConfig import de.tt_tagebuch.shared.api.AuthApi import de.tt_tagebuch.shared.api.PublicAuthApi @@ -22,6 +23,7 @@ import de.tt_tagebuch.shared.api.MemberActivitiesApi import de.tt_tagebuch.shared.api.MemberGroupPhotosApi import de.tt_tagebuch.shared.api.MemberTransferConfigApi import de.tt_tagebuch.shared.api.MembersApi +import de.tt_tagebuch.shared.api.MyTischtennisApi import de.tt_tagebuch.shared.api.OfficialTournamentsApi import de.tt_tagebuch.shared.api.PermissionsApi import de.tt_tagebuch.shared.api.SessionApi @@ -101,6 +103,8 @@ class AppDependencies(context: Context) { val clubInternalTournamentsManager = ClubInternalTournamentsManager(TournamentsApi(client)) val officialTournamentsReadManager = OfficialTournamentsReadManager(OfficialTournamentsApi(client)) val memberTransferConfigApi = MemberTransferConfigApi(client) + val myTischtennisApi = MyTischtennisApi(client) + val clickTtAccountApi = ClickTtAccountApi(client) val diaryManager = DiaryManager( DiaryApi(client), diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubSettingsScreens.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubSettingsScreens.kt index 78af4bb9..f5f7bb9a 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubSettingsScreens.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubSettingsScreens.kt @@ -122,15 +122,18 @@ private fun parseClockToApi(raw: String): String? { return h.toString().padStart(2, '0') + ":" + m.toString().padStart(2, '0') } -private fun trainingWeekdayLabel(tr: (String, String) -> String, weekday: Int): String = when (weekday) { - 0 -> tr("trainingTimesTab.sunday", "Sonntag") - 1 -> tr("trainingTimesTab.monday", "Montag") - 2 -> tr("trainingTimesTab.tuesday", "Dienstag") - 3 -> tr("trainingTimesTab.wednesday", "Mittwoch") - 4 -> tr("trainingTimesTab.thursday", "Donnerstag") - 5 -> tr("trainingTimesTab.friday", "Freitag") - 6 -> tr("trainingTimesTab.saturday", "Samstag") - else -> "" +private fun trainingWeekdayLabel(languageCode: String, weekday: Int): String { + fun g(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + return when (weekday) { + 0 -> g("trainingTimesTab.sunday", "Sonntag") + 1 -> g("trainingTimesTab.monday", "Montag") + 2 -> g("trainingTimesTab.tuesday", "Dienstag") + 3 -> g("trainingTimesTab.wednesday", "Mittwoch") + 4 -> g("trainingTimesTab.thursday", "Donnerstag") + 5 -> g("trainingTimesTab.friday", "Freitag") + 6 -> g("trainingTimesTab.saturday", "Samstag") + else -> "" + } } private fun trainingGroupMemberLabel(m: TrainingGroupMemberBrief): String { @@ -519,7 +522,6 @@ internal fun MobileClubSettingsScreen(dependencies: AppDependencies, onBack: () } } -@Composable @Composable private fun ClubTrainingGroupsTabContent( dependencies: AppDependencies, @@ -818,16 +820,16 @@ private fun ClubTrainingTimesTabContent( loading = false } - if (loading) { - Text(tr("trainingTimesTab.loading", "Lade Trainingszeiten..."), modifier = Modifier.padding(top = 24.dp)) - return - } - if (error != null) { - Text(error!!, color = MaterialTheme.colors.error) - return - } - - LazyColumn(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.fillMaxSize()) { + error?.let { + Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(bottom = 8.dp)) + } + when { + loading -> CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { items(groups, key = { it.id }) { g -> Card(modifier = Modifier.fillMaxWidth(), elevation = ClubSettingsCardElev) { Column(Modifier.padding(12.dp)) { @@ -852,7 +854,7 @@ private fun ClubTrainingTimesTabContent( Text(tr("trainingTimesTab.weekday", "Wochentag:"), style = MaterialTheme.typography.caption) Box { TextButton(onClick = { weekdayMenuNew = true }) { - Text(trainingWeekdayLabel(tr, newWeekday)) + Text(trainingWeekdayLabel(languageCode, newWeekday)) } DropdownMenu(expanded = weekdayMenuNew, onDismissRequest = { weekdayMenuNew = false }) { (0..6).forEach { d -> @@ -861,7 +863,7 @@ private fun ClubTrainingTimesTabContent( newWeekday = d weekdayMenuNew = false }, - ) { Text(trainingWeekdayLabel(tr, d)) } + ) { Text(trainingWeekdayLabel(languageCode, d)) } } } } @@ -926,7 +928,7 @@ private fun ClubTrainingTimesTabContent( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { - Text(trainingWeekdayLabel(tr, t.weekday), fontWeight = FontWeight.Medium) + Text(trainingWeekdayLabel(languageCode, t.weekday), fontWeight = FontWeight.Medium) Text("${formatTimeDisplay(t.startTime)} – ${formatTimeDisplay(t.endTime)}") } if (canWrite) { @@ -951,6 +953,8 @@ private fun ClubTrainingTimesTabContent( } } } + } + } } if (editing != null) { @@ -963,7 +967,7 @@ private fun ClubTrainingTimesTabContent( Text(tr("trainingTimesTab.weekday", "Wochentag:"), style = MaterialTheme.typography.caption) Box { TextButton(onClick = { weekdayMenuEdit = true }) { - Text(trainingWeekdayLabel(tr, editWeekday)) + Text(trainingWeekdayLabel(languageCode, editWeekday)) } DropdownMenu(expanded = weekdayMenuEdit, onDismissRequest = { weekdayMenuEdit = false }) { (0..6).forEach { d -> @@ -972,7 +976,7 @@ private fun ClubTrainingTimesTabContent( editWeekday = d weekdayMenuEdit = false }, - ) { Text(trainingWeekdayLabel(tr, d)) } + ) { Text(trainingWeekdayLabel(languageCode, d)) } } } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClickTtAccountApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClickTtAccountApi.kt new file mode 100644 index 00000000..b569d748 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClickTtAccountApi.kt @@ -0,0 +1,49 @@ +package de.tt_tagebuch.shared.api + +import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.ClickTtAccountEnvelope +import de.tt_tagebuch.shared.api.models.ClickTtAccountSaveResponse +import de.tt_tagebuch.shared.api.models.ClickTtAccountStatusDto +import de.tt_tagebuch.shared.api.models.ClickTtAccountUpsertBody +import de.tt_tagebuch.shared.api.models.ClickTtVerifyBody +import de.tt_tagebuch.shared.api.models.ClickTtVerifyResponseDto +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.serialization.json.JsonObject + +class ClickTtAccountApi( + private val client: AuthedHttpClient, +) { + suspend fun getAccount(): ClickTtAccountEnvelope = + client.http.get("/api/clicktt-account/account").body() + + suspend fun getStatus(): ClickTtAccountStatusDto = + client.http.get("/api/clicktt-account/status").body() + + suspend fun upsertAccount(body: ClickTtAccountUpsertBody): ClickTtAccountSaveResponse = + client.http.post("/api/clicktt-account/account") { + contentType(ContentType.Application.Json) + setBody(body) + }.body() + + suspend fun verifyLogin(password: String? = null): ClickTtVerifyResponseDto = + client.http.post("/api/clicktt-account/verify") { + contentType(ContentType.Application.Json) + setBody( + if (password != null) { + ClickTtVerifyBody(password = password) + } else { + JsonObject(emptyMap()) + }, + ) + }.body() + + suspend fun deleteAccount() { + client.http.delete("/api/clicktt-account/account") + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MyTischtennisApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MyTischtennisApi.kt new file mode 100644 index 00000000..85a6a14b --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MyTischtennisApi.kt @@ -0,0 +1,49 @@ +package de.tt_tagebuch.shared.api + +import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.MyTischtennisAccountEnvelope +import de.tt_tagebuch.shared.api.models.MyTischtennisAccountSaveResponse +import de.tt_tagebuch.shared.api.models.MyTischtennisAccountUpsertBody +import de.tt_tagebuch.shared.api.models.MyTischtennisStatusDto +import de.tt_tagebuch.shared.api.models.MyTischtennisVerifyBody +import de.tt_tagebuch.shared.api.models.MyTischtennisVerifyResponseDto +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.serialization.json.JsonObject + +class MyTischtennisApi( + private val client: AuthedHttpClient, +) { + suspend fun getAccount(): MyTischtennisAccountEnvelope = + client.http.get("/api/mytischtennis/account").body() + + suspend fun getStatus(): MyTischtennisStatusDto = + client.http.get("/api/mytischtennis/status").body() + + suspend fun upsertAccount(body: MyTischtennisAccountUpsertBody): MyTischtennisAccountSaveResponse = + client.http.post("/api/mytischtennis/account") { + contentType(ContentType.Application.Json) + setBody(body) + }.body() + + suspend fun verifyLogin(password: String? = null): MyTischtennisVerifyResponseDto = + client.http.post("/api/mytischtennis/verify") { + contentType(ContentType.Application.Json) + setBody( + if (password != null) { + MyTischtennisVerifyBody(password = password) + } else { + JsonObject(emptyMap()) + }, + ) + }.body() + + suspend fun deleteAccount() { + client.http.delete("/api/mytischtennis/account") + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/IntegrationAccountDtos.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/IntegrationAccountDtos.kt new file mode 100644 index 00000000..274d4c82 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/IntegrationAccountDtos.kt @@ -0,0 +1,118 @@ +package de.tt_tagebuch.shared.api.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MyTischtennisAccountDto( + val id: Int? = null, + val userId: Int? = null, + val email: String? = null, + val savePassword: Boolean? = null, + val autoUpdateRatings: Boolean? = null, + val lastLoginAttempt: String? = null, + val lastLoginSuccess: String? = null, + val lastUpdateRatings: String? = null, + val expiresAt: Long? = null, + val clubId: String? = null, + val clubName: String? = null, + val fedNickname: String? = null, + val createdAt: String? = null, + val updatedAt: String? = null, +) + +@Serializable +data class MyTischtennisAccountEnvelope( + val account: MyTischtennisAccountDto? = null, +) + +@Serializable +data class MyTischtennisStatusDto( + val exists: Boolean = false, + val hasEmail: Boolean = false, + val hasPassword: Boolean = false, + val hasValidSession: Boolean = false, + val needsConfiguration: Boolean = false, + val needsPassword: Boolean = false, +) + +@Serializable +data class MyTischtennisAccountUpsertBody( + val email: String, + val password: String? = null, + val savePassword: Boolean = false, + val autoUpdateRatings: Boolean = false, + val userPassword: String? = null, +) + +@Serializable +data class MyTischtennisVerifyBody( + val password: String? = null, +) + +@Serializable +data class MyTischtennisVerifyResponseDto( + val message: String? = null, + val success: Boolean? = null, + val accessToken: String? = null, + val expiresAt: Long? = null, + val clubId: String? = null, + val clubName: String? = null, +) + +@Serializable +data class MyTischtennisAccountSaveResponse( + val message: String? = null, + val account: MyTischtennisAccountDto? = null, +) + +@Serializable +data class ClickTtAccountDto( + val id: Int? = null, + val userId: Int? = null, + val username: String? = null, + val savePassword: Boolean? = null, + val lastLoginAttempt: String? = null, + val lastLoginSuccess: String? = null, + val createdAt: String? = null, + val updatedAt: String? = null, +) + +@Serializable +data class ClickTtAccountEnvelope( + val account: ClickTtAccountDto? = null, +) + +@Serializable +data class ClickTtAccountStatusDto( + val exists: Boolean = false, + val hasUsername: Boolean = false, + val hasPassword: Boolean = false, + val hasValidSession: Boolean = false, + val needsConfiguration: Boolean = false, + val needsPassword: Boolean = false, +) + +@Serializable +data class ClickTtAccountUpsertBody( + val username: String, + val password: String? = null, + val savePassword: Boolean = false, + val userPassword: String? = null, +) + +@Serializable +data class ClickTtVerifyBody( + val password: String? = null, +) + +@Serializable +data class ClickTtAccountSaveResponse( + val message: String? = null, + val account: ClickTtAccountDto? = null, +) + +@Serializable +data class ClickTtVerifyResponseDto( + val success: Boolean? = null, + val message: String? = null, +)