feat(CalendarView): merge recurring training slots and enhance event filtering
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s

- 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.
This commit is contained in:
Torsten Schulz (local)
2026-05-13 00:07:47 +02:00
parent 54d9b9fc86
commit 61b1f27e5e
6 changed files with 293 additions and 26 deletions

View File

@@ -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();

View File

@@ -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),

View File

@@ -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)) }
}
}
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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,
)