feat(CalendarView): merge recurring training slots and enhance event filtering
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
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:
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user