feat(Calendar): integrate CalendarEvent model and enhance calendar functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Added CalendarEvent model to the backend, establishing relationships with the Club model for better event management.
- Updated server.js to include calendarEventRoutes, enabling API access for calendar events.
- Enhanced CalendarView.vue to support custom event creation and management, improving user interaction with the calendar.
- Refactored various components to streamline event handling and improve overall user experience in the calendar interface.
- Updated TODO and DEVELOPMENT documentation to reflect new calendar features and architectural decisions.
This commit is contained in:
Torsten Schulz (local)
2026-05-13 10:21:30 +02:00
parent 9be5f50ede
commit 004801b1a6
33 changed files with 2715 additions and 632 deletions

View File

@@ -0,0 +1,16 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.ClubCalendarHolidaysEnvelope
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
class CalendarHolidayApi(
private val client: AuthedHttpClient,
) {
suspend fun getClubHolidays(clubId: Int, year: Int): ClubCalendarHolidaysEnvelope =
client.http.get("/api/calendar/club/$clubId/holidays") {
parameter("year", year)
}.body()
}

View File

@@ -0,0 +1,32 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.TrainingCancellationDto
import de.tt_tagebuch.shared.api.models.TrainingCancellationUpsertBody
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
class TrainingCancellationApi(
private val client: AuthedHttpClient,
) {
suspend fun list(clubId: Int, year: Int): List<TrainingCancellationDto> =
client.http.get("/api/training-cancellations/$clubId") {
parameter("year", year)
}.body()
suspend fun upsert(clubId: Int, body: TrainingCancellationUpsertBody): TrainingCancellationDto =
client.http.post("/api/training-cancellations/$clubId") {
contentType(ContentType.Application.Json)
setBody(body)
}.body()
suspend fun delete(clubId: Int, cancellationId: Int) {
client.http.delete("/api/training-cancellations/$clubId/$cancellationId")
}
}

View File

@@ -25,6 +25,7 @@ class AuthedHttpClient(
json(
Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
)
}

View File

@@ -0,0 +1,34 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class TrainingCancellationDto(
val id: Int = 0,
val clubId: Int? = null,
val startDate: String? = null,
val endDate: String? = null,
val date: String? = null,
val reason: String? = null,
)
@Serializable
data class TrainingCancellationUpsertBody(
val startDate: String,
val endDate: String,
val reason: String? = null,
)
@Serializable
data class CalendarHolidayRowDto(
val id: String? = null,
val startDate: String? = null,
val endDate: String? = null,
val name: String? = null,
)
@Serializable
data class ClubCalendarHolidaysEnvelope(
val holidays: List<CalendarHolidayRowDto> = emptyList(),
val schoolHolidays: List<CalendarHolidayRowDto> = emptyList(),
)

View File

@@ -1,6 +1,25 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonTransformingSerializer
object MemberDataQualityRequirementsSerializer :
JsonTransformingSerializer<MemberDataQualityRequirements>(MemberDataQualityRequirements.serializer()) {
override fun transformDeserialize(element: JsonElement): JsonElement {
return if (element is JsonPrimitive && element.isString) {
try {
Json.parseToJsonElement(element.content)
} catch (e: Exception) {
element
}
} else {
element
}
}
}
@Serializable
data class MemberDataQualityRequirements(
@@ -19,6 +38,7 @@ data class UpdateClubSettingsBody(
val stateCode: String? = null,
val myTischtennisFedNickname: String? = null,
val autoFetchRankings: Boolean? = null,
@Serializable(with = MemberDataQualityRequirementsSerializer::class)
val memberDataQualityRequirements: MemberDataQualityRequirements? = null,
)
@@ -32,6 +52,7 @@ data class Club(
val autoFetchRankings: Boolean? = null,
val countryCode: String? = null,
val stateCode: String? = null,
@Serializable(with = MemberDataQualityRequirementsSerializer::class)
val memberDataQualityRequirements: MemberDataQualityRequirements? = null,
)

View File

@@ -43,6 +43,7 @@ fun UserClubPermissions.canWriteSchedule(): Boolean {
fun UserClubPermissions.canReadApprovals(): Boolean {
if (isOwner) return true
if (role.equals("admin", ignoreCase = true)) return true
return permissions.boolAt("approvals", "read")
}
@@ -101,3 +102,8 @@ fun UserClubPermissions.canWritePredefinedActivities(): Boolean {
if (isOwner) return true
return permissions.boolAt("predefined_activities", "write")
}
fun UserClubPermissions.canReadStatistics(): Boolean {
if (isOwner) return true
return permissions.boolAt("statistics", "read")
}

View File

@@ -0,0 +1,55 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
* Akzeptiert `true`/`false`, Zahlen 0/1 und einige String-Formen vermeidet Deserialisierungsfehler,
* wenn die API (oder ein Proxy) Booleans als Zahl liefert.
*/
object FlexibleNullableBooleanSerializer : KSerializer<Boolean?> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("FlexibleNullableBoolean", PrimitiveKind.BOOLEAN)
override fun deserialize(decoder: Decoder): Boolean? {
val input = decoder as? JsonDecoder
?: error("FlexibleNullableBooleanSerializer requires JsonDecoder")
return parseBooleanElement(input.decodeJsonElement())
}
private fun parseBooleanElement(element: JsonElement): Boolean? {
if (element is JsonNull) return null
if (element !is JsonPrimitive) return null
element.booleanOrNull?.let { return it }
element.intOrNull?.let { return it != 0 }
element.longOrNull?.let { return it != 0L }
val s = element.contentOrNull ?: return null
return when (s.lowercase()) {
"true", "1", "yes" -> true
"false", "0", "no" -> false
else -> null
}
}
override fun serialize(encoder: Encoder, value: Boolean?) {
when (encoder) {
is JsonEncoder -> encoder.encodeJsonElement(
if (value == null) JsonNull else JsonPrimitive(value),
)
else -> if (value != null) encoder.encodeBoolean(value)
}
}
}

View File

@@ -1,5 +1,6 @@
package de.tt_tagebuch.shared.api.models
import de.tt_tagebuch.shared.api.serialization.LenientIntListSerializer
import kotlinx.serialization.Serializable
@Serializable
@@ -65,8 +66,11 @@ data class ScheduleMatchDto(
val guestMatchPoints: Int = 0,
val isCompleted: Boolean = false,
val pdfUrl: String? = null,
@Serializable(with = LenientIntListSerializer::class)
val playersReady: List<Int> = emptyList(),
@Serializable(with = LenientIntListSerializer::class)
val playersPlanned: List<Int> = emptyList(),
@Serializable(with = LenientIntListSerializer::class)
val playersPlayed: List<Int> = emptyList(),
val homeTeam: ScheduleTeamNameDto? = null,
val guestTeam: ScheduleTeamNameDto? = null,

View File

@@ -7,8 +7,10 @@ data class InternalTournamentSummaryDto(
val id: Int = 0,
val name: String? = null,
val date: String? = null,
@Serializable(with = FlexibleNullableBooleanSerializer::class)
val allowsExternal: Boolean? = null,
val miniChampionshipYear: Int? = null,
@Serializable(with = FlexibleNullableBooleanSerializer::class)
val isDoublesTournament: Boolean? = null,
)
@@ -20,11 +22,13 @@ data class InternalTournamentDetailDto(
val type: String? = null,
val clubId: Int? = null,
val winningSets: Int? = null,
@Serializable(with = FlexibleNullableBooleanSerializer::class)
val allowsExternal: Boolean? = null,
val miniChampionshipYear: Int? = null,
val numberOfTables: Int? = null,
val numberOfGroups: Int? = null,
val advancingPerGroup: Int? = null,
@Serializable(with = FlexibleNullableBooleanSerializer::class)
val isDoublesTournament: Boolean? = null,
val bestOfEndroundSize: Int? = null,
)

View File

@@ -0,0 +1,47 @@
package de.tt_tagebuch.shared.api.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
/**
* Backend liefert `playersReady` / `playersPlanned` / `playersPlayed` als JSON-Spalte: mal `null`,
* mal leeres Objekt statt Array. Die Web-UI parst das nicht strikt; kotlinx.serialization würde sonst
* bei HTTP 200 trotzdem abbrechen.
*/
object LenientIntListSerializer : KSerializer<List<Int>> {
private val delegate = ListSerializer(Int.serializer())
override val descriptor: SerialDescriptor = delegate.descriptor
override fun serialize(encoder: Encoder, value: List<Int>) {
delegate.serialize(encoder, value)
}
override fun deserialize(decoder: Decoder): List<Int> {
if (decoder !is JsonDecoder) {
return delegate.deserialize(decoder)
}
return when (val element = decoder.decodeJsonElement()) {
JsonNull -> emptyList()
is JsonArray -> element.mapNotNull { parseIntJsonElement(it) }
else -> emptyList()
}
}
private fun parseIntJsonElement(el: JsonElement): Int? {
val p = el as? JsonPrimitive ?: return null
val c = p.content
if (c.isBlank()) return null
c.toIntOrNull()?.let { return it }
c.toDoubleOrNull()?.let { return it.toInt() }
return null
}
}

View File

@@ -12,15 +12,25 @@ import org.koin.dsl.module
import org.koin.core.module.Module
import org.koin.dsl.module
fun initKoin(baseUrl: String, additionalModules: List<Module> = emptyList(), appDeclaration: KoinAppDeclaration = {}) =
fun initKoin(
baseUrl: String,
socketBaseUrl: String? = null,
additionalModules: List<Module> = emptyList(),
appDeclaration: KoinAppDeclaration = {},
) =
startKoin {
appDeclaration()
modules(commonModule(baseUrl) + additionalModules)
modules(commonModule(baseUrl, socketBaseUrl) + additionalModules)
}
fun commonModule(baseUrl: String) = module {
fun commonModule(baseUrl: String, socketBaseUrl: String? = null) = module {
single { ApiClient(baseUrl) }
single { SocketService(baseUrl.replace("https://", "wss://").replace("http://", "ws://")) } // Simplified
single {
SocketService(
socketBaseUrl
?: baseUrl.replace("https://", "wss://").replace("http://", "ws://"),
)
}
single { AuthRepository(get()) }
single { DiaryRepository(get(), get()) }
single { MemberRepository(get()) }

View File

@@ -19,7 +19,39 @@ class ClubManager(
suspend fun hydrate() {
val stored = clubStorage.loadCurrentClubId()
_state.value = _state.value.copy(currentClubId = stored)
if (stored == null) {
_state.value = _state.value.copy(
currentClubId = null,
currentPermissions = null,
isLoading = false,
error = null,
)
return
}
_state.value = _state.value.copy(
currentClubId = stored,
isLoading = true,
error = null,
)
try {
val permissions = permissionsApi.getUserPermissions(stored)
clubStorage.saveCurrentClubId(stored)
_state.value = _state.value.copy(
currentClubId = stored,
currentPermissions = permissions,
isLoading = false,
error = null,
)
} catch (t: Throwable) {
if (t is CancellationException) throw t
_state.value = _state.value.copy(
isLoading = false,
error = t.toUserMessage("Keine Berechtigung oder Fehler beim Laden der Permissions"),
currentPermissions = null,
currentClubId = null,
)
clubStorage.saveCurrentClubId(null)
}
}
suspend fun loadClubs() {

View File

@@ -21,6 +21,7 @@ import de.tt_tagebuch.shared.api.models.CreateDiaryPlanActivityRequest
import de.tt_tagebuch.shared.api.models.CreateTrainingGroupBody
import de.tt_tagebuch.shared.api.models.DeleteTrainingGroupBody
import de.tt_tagebuch.shared.api.models.DiaryDateActivityItem
import de.tt_tagebuch.shared.api.models.DiaryDate
import de.tt_tagebuch.shared.api.models.DiaryFreeformActivity
import de.tt_tagebuch.shared.api.models.DiaryMemberActivityLink
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto
@@ -46,6 +47,8 @@ class DiaryManager(
private val _state = MutableStateFlow(DiaryState())
val state: StateFlow<DiaryState> = _state.asStateFlow()
suspend fun listDates(clubId: Int): List<DiaryDate> = diaryApi.listDates(clubId)
suspend fun fetchDateActivities(clubId: Int, diaryDateId: Int): List<DiaryDateActivityItem> {
return diaryApi.listDateActivities(clubId, diaryDateId)
}

View File

@@ -47,4 +47,7 @@ class OfficialTournamentsReadManager(
}
}
}
suspend fun fetchParticipationSummary(clubId: Int): List<OfficialParticipationBucketDto> =
api.listParticipationSummary(clubId)
}