4 Commits

Author SHA1 Message Date
d26737e38c Merge pull request 'dev' (#58) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 3m6s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #58
2026-10-23 12:28:51 +02:00
Torsten Schulz (local)
bf74d41773 feat: erweitere Benachrichtigungseinstellungen um Live-Ticker-Optionen und implementiere Push-Benachrichtigungen für Live-Spielereignisse
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m32s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m5s
2026-10-23 00:25:07 +02:00
Torsten Schulz (local)
6369249f40 feat: implement LiveTicker functionality with sound notifications and UI updates
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m11s
2026-10-22 23:57:22 +02:00
Torsten Schulz (local)
6b9198ac41 feat: füge LiveTickerBanner-Komponente hinzu und erweitere Spielplan-Daten mit MeetingId und Live-Daten-Flag
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m28s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m17s
2026-10-22 23:41:33 +02:00
20 changed files with 576 additions and 3 deletions

View File

@@ -37,6 +37,22 @@ data class SpielplanResponse(
val season: String? = null, val season: String? = null,
val seasons: List<SeasonDto> = emptyList(), val seasons: List<SeasonDto> = emptyList(),
) )
data class LiveTickerResponse(
val matches: List<LiveTickerMatchDto> = emptyList(),
val updatedAt: String? = null,
)
data class LiveTickerMatchDto(
val meetingId: String = "",
val homeTeam: String = "",
val awayTeam: String = "",
val score: String = "",
val startsAt: Long = 0,
val hasLiveData: Boolean = false,
val completed: Boolean = false,
val completedAt: Long? = null,
val updatedAt: String? = null,
val error: String? = null,
)
data class SeasonDto(val slug: String = "", val label: String = "") data class SeasonDto(val slug: String = "", val label: String = "")
data class MannschaftenSeasonsResponse( data class MannschaftenSeasonsResponse(
val success: Boolean = false, val success: Boolean = false,
@@ -270,6 +286,9 @@ data class NotificationSettingsDto(
val newContactRequest: Boolean = false, val newContactRequest: Boolean = false,
val newUserRegistration: Boolean = false, val newUserRegistration: Boolean = false,
val ownTtrChanges: Boolean = false, val ownTtrChanges: Boolean = false,
val liveTickerStarts: Boolean = false,
val liveTickerScoreChanges: Boolean = false,
val liveTickerFinished: Boolean = false,
val selectedTeamSlugs: List<String> = emptyList(), val selectedTeamSlugs: List<String> = emptyList(),
val selectedTeamSeason: String? = null, val selectedTeamSeason: String? = null,
val notificationTime: String = "09:00", val notificationTime: String = "09:00",
@@ -726,6 +745,9 @@ interface ApiService {
@GET("/api/mitgliederbereich/qttr") @GET("/api/mitgliederbereich/qttr")
suspend fun qttrValues(): Response<QttrValuesResponse> suspend fun qttrValues(): Response<QttrValuesResponse>
@GET("/api/mitgliederbereich/live-ticker")
suspend fun liveTicker(): Response<LiveTickerResponse>
@GET("/api/members") @GET("/api/members")
suspend fun members(): Response<MembersResponse> suspend fun members(): Response<MembersResponse>

View File

@@ -81,6 +81,7 @@ object HarheimerNotifications {
"news", "news_expiring" -> Destinations.MemberNews.route "news", "news_expiring" -> Destinations.MemberNews.route
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route "event", "events_today", "events_tomorrow" -> Destinations.Termine.route
"team_matches" -> Destinations.Spielplan.route "team_matches" -> Destinations.Spielplan.route
"live_ticker_started", "live_ticker_score_changed", "live_ticker_finished" -> Destinations.MemberArea.route
"qttr_list_updated", "ttr_change" -> Destinations.Qttr.route "qttr_list_updated", "ttr_change" -> Destinations.Qttr.route
"birthdays" -> Destinations.MemberArea.route "birthdays" -> Destinations.MemberArea.route
"contact_request" -> Destinations.CmsContactRequests.route "contact_request" -> Destinations.CmsContactRequests.route

View File

@@ -0,0 +1,17 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.LiveTickerMatchDto
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LiveTickerRepository @Inject constructor(private val api: ApiService) {
suspend fun fetch(): Result<List<LiveTickerMatchDto>> = runCatching {
retryOnNetworkFailure {
val response = api.liveTicker()
if (!response.isSuccessful) error("Live-Ergebnisse konnten nicht geladen werden.")
response.body()?.matches ?: emptyList()
}
}
}

View File

@@ -20,6 +20,9 @@ data class NotificationPreferences(
val newContactRequest: Boolean = false, val newContactRequest: Boolean = false,
val newUserRegistration: Boolean = false, val newUserRegistration: Boolean = false,
val ownTtrChanges: Boolean = false, val ownTtrChanges: Boolean = false,
val liveTickerStarts: Boolean = false,
val liveTickerScoreChanges: Boolean = false,
val liveTickerFinished: Boolean = false,
val selectedTeamSlugs: Set<String> = emptySet(), val selectedTeamSlugs: Set<String> = emptySet(),
val selectedTeamSeason: String? = null, val selectedTeamSeason: String? = null,
val notificationTime: String = DEFAULT_NOTIFICATION_TIME, val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
@@ -45,6 +48,9 @@ class NotificationPreferencesRepository @Inject constructor(
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false), newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false), newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false),
ownTtrChanges = preferences.getBoolean(KEY_OWN_TTR_CHANGES, false), ownTtrChanges = preferences.getBoolean(KEY_OWN_TTR_CHANGES, false),
liveTickerStarts = preferences.getBoolean(KEY_LIVE_TICKER_STARTS, false),
liveTickerScoreChanges = preferences.getBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, false),
liveTickerFinished = preferences.getBoolean(KEY_LIVE_TICKER_FINISHED, false),
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(), selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() }, selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME, notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
@@ -72,6 +78,9 @@ class NotificationPreferencesRepository @Inject constructor(
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest) .putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration) .putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
.putBoolean(KEY_OWN_TTR_CHANGES, settings.ownTtrChanges) .putBoolean(KEY_OWN_TTR_CHANGES, settings.ownTtrChanges)
.putBoolean(KEY_LIVE_TICKER_STARTS, settings.liveTickerStarts)
.putBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, settings.liveTickerScoreChanges)
.putBoolean(KEY_LIVE_TICKER_FINISHED, settings.liveTickerFinished)
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs) .putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason) .putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime) .putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
@@ -102,6 +111,9 @@ class NotificationPreferencesRepository @Inject constructor(
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request" const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
const val KEY_NEW_USER_REGISTRATION = "new_user_registration" const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
const val KEY_OWN_TTR_CHANGES = "own_ttr_changes" const val KEY_OWN_TTR_CHANGES = "own_ttr_changes"
const val KEY_LIVE_TICKER_STARTS = "live_ticker_starts"
const val KEY_LIVE_TICKER_SCORE_CHANGES = "live_ticker_score_changes"
const val KEY_LIVE_TICKER_FINISHED = "live_ticker_finished"
const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs" const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs"
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season" const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
const val KEY_NOTIFICATION_TIME = "notification_time" const val KEY_NOTIFICATION_TIME = "notification_time"
@@ -119,6 +131,9 @@ private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = N
newContactRequest = newContactRequest, newContactRequest = newContactRequest,
newUserRegistration = newUserRegistration, newUserRegistration = newUserRegistration,
ownTtrChanges = ownTtrChanges, ownTtrChanges = ownTtrChanges,
liveTickerStarts = liveTickerStarts,
liveTickerScoreChanges = liveTickerScoreChanges,
liveTickerFinished = liveTickerFinished,
selectedTeamSlugs = selectedTeamSlugs.toSet(), selectedTeamSlugs = selectedTeamSlugs.toSet(),
selectedTeamSeason = selectedTeamSeason, selectedTeamSeason = selectedTeamSeason,
notificationTime = notificationTime, notificationTime = notificationTime,
@@ -135,6 +150,9 @@ private fun NotificationPreferences.toDto(): NotificationSettingsDto = Notificat
newContactRequest = newContactRequest, newContactRequest = newContactRequest,
newUserRegistration = newUserRegistration, newUserRegistration = newUserRegistration,
ownTtrChanges = ownTtrChanges, ownTtrChanges = ownTtrChanges,
liveTickerStarts = liveTickerStarts,
liveTickerScoreChanges = liveTickerScoreChanges,
liveTickerFinished = liveTickerFinished,
selectedTeamSlugs = selectedTeamSlugs.toList(), selectedTeamSlugs = selectedTeamSlugs.toList(),
selectedTeamSeason = selectedTeamSeason, selectedTeamSeason = selectedTeamSeason,
notificationTime = notificationTime, notificationTime = notificationTime,

View File

@@ -0,0 +1,78 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.harheimertc.data.LiveTickerMatchDto
@Composable
fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
var observedLoadedState by remember { mutableStateOf(false) }
var previousStates by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
LaunchedEffect(matches, loaded) {
if (!loaded) {
observedLoadedState = false
previousStates = emptyMap()
return@LaunchedEffect
}
val currentStates = matches.associate { match ->
(match.meetingId.ifBlank { "${match.homeTeam}-${match.awayTeam}-${match.startsAt}" }) to
"${match.hasLiveData}|${match.score}|${match.completed}"
}
if (observedLoadedState && currentStates.any { (id, state) -> previousStates[id] != state }) {
LiveTickerSound.play()
}
previousStates = currentStates
observedLoadedState = true
}
if (matches.isEmpty()) return
Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 9.dp),
) {
matches.forEachIndexed { index, match ->
if (index > 0) {
Text(" • ", color = Color(0xFF64748B))
}
val status = when {
match.completed -> "✓ beendet"
match.hasLiveData -> "● LIVE"
else -> "◌ Keine Live-Daten"
}
Text(
text = "$status ${match.homeTeam} – ${match.awayTeam} ${match.score}",
color = when {
match.completed -> Color(0xFF64748B)
match.hasLiveData -> Color(0xFFB91C1C)
else -> Color(0xFF334155)
},
style = MaterialTheme.typography.labelLarge,
fontWeight = if (match.completed) FontWeight.Normal else FontWeight.SemiBold,
fontStyle = if (match.completed) FontStyle.Italic else FontStyle.Normal,
maxLines = 1,
)
}
}
}
}

View File

@@ -0,0 +1,15 @@
package de.harheimertc.ui.components
import android.media.AudioManager
import android.media.ToneGenerator
/** A generated system tone; no audio asset or third-party licence is required. */
object LiveTickerSound {
private val toneGenerator by lazy { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 65) }
fun play() {
runCatching {
toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP2, 170)
}
}
}

View File

@@ -25,6 +25,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import de.harheimertc.ui.components.AppNavigationHeader import de.harheimertc.ui.components.AppNavigationHeader
import de.harheimertc.ui.components.LiveTickerBanner
@Composable @Composable
fun NavGraph( fun NavGraph(
@@ -44,6 +45,11 @@ fun NavGraph(
LaunchedEffect(currentRoute) { LaunchedEffect(currentRoute) {
navigationViewModel.refreshSession() navigationViewModel.refreshSession()
} }
LaunchedEffect(currentRoute, navigationState.loggedIn) {
navigationViewModel.setLiveTickerActive(
navigationState.loggedIn && (currentRoute == "intern" || currentRoute?.startsWith("intern/") == true || currentRoute == "cms" || currentRoute?.startsWith("cms/") == true),
)
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val persistentNavigation = maxWidth >= 600.dp val persistentNavigation = maxWidth >= 600.dp
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
@@ -72,6 +78,10 @@ fun NavGraph(
navigationState = navigationState, navigationState = navigationState,
) )
} }
LiveTickerBanner(
matches = navigationState.liveTickerMatches,
loaded = navigationState.liveTickerLoaded,
)
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,

View File

@@ -4,15 +4,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.LiveTickerMatchDto
import de.harheimertc.repositories.AuthRepository import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.GalleryRepository import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.LiveTickerRepository
import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.PushTokenRepository import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -22,6 +27,8 @@ data class NavigationUiState(
val loggedIn: Boolean = false, val loggedIn: Boolean = false,
val roles: Set<String> = emptySet(), val roles: Set<String> = emptySet(),
val connectionNote: String? = null, val connectionNote: String? = null,
val liveTickerMatches: List<LiveTickerMatchDto> = emptyList(),
val liveTickerLoaded: Boolean = false,
) { ) {
val isAdmin: Boolean get() = "admin" in roles val isAdmin: Boolean get() = "admin" in roles
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") } val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
@@ -39,9 +46,12 @@ class NavigationViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val connectivityMonitor: ConnectivityMonitor, private val connectivityMonitor: ConnectivityMonitor,
private val pushTokenRepository: PushTokenRepository, private val pushTokenRepository: PushTokenRepository,
private val liveTickerRepository: LiveTickerRepository,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(NavigationUiState()) private val _state = MutableStateFlow(NavigationUiState())
val state: StateFlow<NavigationUiState> = _state val state: StateFlow<NavigationUiState> = _state
private var tickerJob: Job? = null
private var tickerActive = false
init { init {
loadNavigationData() loadNavigationData()
@@ -70,6 +80,8 @@ class NavigationViewModel @Inject constructor(
loggedIn = loggedIn, loggedIn = loggedIn,
roles = status.navigationRoles(), roles = status.navigationRoles(),
connectionNote = null, connectionNote = null,
liveTickerMatches = _state.value.liveTickerMatches,
liveTickerLoaded = _state.value.liveTickerLoaded,
) )
if (loggedIn) registerPushToken() if (loggedIn) registerPushToken()
} }
@@ -89,6 +101,24 @@ class NavigationViewModel @Inject constructor(
} }
} }
fun setLiveTickerActive(active: Boolean) {
if (tickerActive == active) return
tickerActive = active
tickerJob?.cancel()
if (!active) {
_state.value = _state.value.copy(liveTickerMatches = emptyList(), liveTickerLoaded = false)
return
}
tickerJob = viewModelScope.launch {
while (isActive) {
liveTickerRepository.fetch().onSuccess { matches ->
_state.value = _state.value.copy(liveTickerMatches = matches, liveTickerLoaded = true)
}
delay(30_000)
}
}
}
private fun registerPushToken() { private fun registerPushToken() {
viewModelScope.launch { viewModelScope.launch {
pushTokenRepository.registerCurrentDevice() pushTokenRepository.registerCurrentDevice()
@@ -96,6 +126,7 @@ class NavigationViewModel @Inject constructor(
} }
fun logout(onComplete: () -> Unit = {}) { fun logout(onComplete: () -> Unit = {}) {
setLiveTickerActive(false)
viewModelScope.launch { viewModelScope.launch {
loginRepository.logout() loginRepository.logout()
_state.value = _state.value.copy( _state.value = _state.value.copy(

View File

@@ -164,6 +164,15 @@ fun NotificationSettingsScreen(
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) { ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
viewModel.update(state.settings.copy(allTeamMatches = it)) viewModel.update(state.settings.copy(allTeamMatches = it))
} }
ToggleRow("Live-Spiel gestartet (Live-Ticker)", state.settings.liveTickerStarts) {
viewModel.update(state.settings.copy(liveTickerStarts = it))
}
ToggleRow("Live-Ergebnis geändert", state.settings.liveTickerScoreChanges) {
viewModel.update(state.settings.copy(liveTickerScoreChanges = it))
}
ToggleRow("Live-Spiel beendet", state.settings.liveTickerFinished) {
viewModel.update(state.settings.copy(liveTickerFinished = it))
}
TeamSelection( TeamSelection(
teams = state.teams, teams = state.teams,
seasons = state.seasons, seasons = state.seasons,

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads # Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=37 ANDROID_VERSION_CODE=38
ANDROID_VERSION_NAME=0.10.1 ANDROID_VERSION_NAME=0.11.0
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false RELEASE_MINIFY_ENABLED=false

View File

@@ -1,6 +1,7 @@
<template> <template>
<div class="h-screen flex flex-col overflow-hidden"> <div class="h-screen flex flex-col overflow-hidden">
<Navigation /> <Navigation />
<LiveTickerBanner />
<main class="flex-1 overflow-y-auto pt-20 pb-20 sm:pb-0"> <main class="flex-1 overflow-y-auto pt-20 pb-20 sm:pb-0">
<NuxtPage /> <NuxtPage />
</main> </main>
@@ -11,6 +12,7 @@
<script setup> <script setup>
import Navigation from '~/components/Navigation.vue' import Navigation from '~/components/Navigation.vue'
import LiveTickerBanner from '~/components/LiveTickerBanner.vue'
import Footer from '~/components/Footer.vue' import Footer from '~/components/Footer.vue'
import ModalDialog from '~/components/ModalDialog.vue' import ModalDialog from '~/components/ModalDialog.vue'
</script> </script>

View File

@@ -0,0 +1,111 @@
<template>
<section
v-if="isInternal && matches.length"
class="fixed top-20 z-40 w-full border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm"
>
<div class="mx-auto flex max-w-7xl items-center gap-3 overflow-x-auto px-4 py-2 text-sm sm:px-6">
<button
type="button"
class="shrink-0 rounded border border-primary-300 bg-white px-2 py-1 text-xs font-semibold text-primary-900 hover:bg-primary-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
:aria-pressed="soundEnabled"
@click="toggleSound"
>
{{ soundEnabled ? '🔔 Ton an' : '🔕 Ton aus' }}
</button>
<div
v-for="match in matches"
:key="match.meetingId"
:class="['shrink-0 whitespace-nowrap', match.completed ? 'italic text-gray-600' : 'font-semibold']"
>
<span v-if="match.completed">✓ beendet</span>
<span
v-else-if="match.hasLiveData"
class="text-red-700"
>● LIVE</span>
<span
v-else
class="text-gray-600"
>◌ Keine Live-Daten</span>
<span class="mx-2">{{ match.homeTeam }} <strong>{{ match.score }}</strong> {{ match.awayTeam }}</span>
</div>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '~/stores/auth'
const route = useRoute()
const authStore = useAuthStore()
const matches = ref([])
const soundEnabled = ref(false)
const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms')))
let timer
let audioContext
let tickerLoaded = false
let previousStates = new Map()
function stateByMeeting(items) {
return new Map(items.map(match => [
match.meetingId || `${match.homeTeam}-${match.awayTeam}-${match.startsAt}`,
`${match.hasLiveData}|${match.score}|${match.completed}`
]))
}
function hasRelevantChange(nextStates) {
return [...nextStates].some(([meetingId, state]) => previousStates.get(meetingId) !== state)
}
async function playPing() {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext
if (!AudioContext) return
audioContext ||= new AudioContext()
await audioContext.resume()
const oscillator = audioContext.createOscillator()
const gain = audioContext.createGain()
oscillator.type = 'sine'
oscillator.frequency.setValueAtTime(880, audioContext.currentTime)
gain.gain.setValueAtTime(0.0001, audioContext.currentTime)
gain.gain.exponentialRampToValueAtTime(0.16, audioContext.currentTime + 0.01)
gain.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + 0.17)
oscillator.connect(gain).connect(audioContext.destination)
oscillator.start()
oscillator.stop(audioContext.currentTime + 0.18)
} catch {
soundEnabled.value = false
}
}
function toggleSound() {
soundEnabled.value = !soundEnabled.value
localStorage.setItem('live-ticker-sound-enabled', String(soundEnabled.value))
if (soundEnabled.value) playPing()
}
async function load() {
if (!isInternal.value) {
matches.value = []
tickerLoaded = false
previousStates = new Map()
return
}
try {
const nextMatches = (await $fetch('/api/mitgliederbereich/live-ticker')).matches || []
const nextStates = stateByMeeting(nextMatches)
if (tickerLoaded && soundEnabled.value && hasRelevantChange(nextStates)) playPing()
previousStates = nextStates
tickerLoaded = true
matches.value = nextMatches
} catch { matches.value = [] }
}
onMounted(() => {
soundEnabled.value = localStorage.getItem('live-ticker-sound-enabled') === 'true'
load()
timer = setInterval(load, 30_000)
})
onUnmounted(() => clearInterval(timer))
watch(isInternal, load)
</script>

View File

@@ -1,6 +1,6 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.13", "version": "1.9.0",
"description": "Moderne Webseite für den Harheimer Tischtennis Club", "description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@@ -0,0 +1,11 @@
import { requireAuthenticatedUser } from '../../utils/auth.js'
import { getLiveTicker, refreshLiveTicker } from '../../utils/live-ticker.js'
export default defineEventHandler(async (event) => {
await requireAuthenticatedUser(event)
try {
return await refreshLiveTicker()
} catch {
return getLiveTicker()
}
})

View File

@@ -0,0 +1,10 @@
import { refreshLiveTicker } from '../utils/live-ticker.js'
import { error as loggerError } from '../utils/logger.js'
export default defineNitroPlugin((nitroApp) => {
const run = () => refreshLiveTicker().catch(error => loggerError('[live-ticker] Aktualisierung fehlgeschlagen', { error }))
const timer = setInterval(run, 30_000)
timer.unref?.()
run()
nitroApp.hooks.hookOnce('close', () => clearInterval(timer))
})

172
server/utils/live-ticker.js Normal file
View File

@@ -0,0 +1,172 @@
import { promises as fs } from 'fs'
import path from 'path'
import { getCurrentSeasonSlug, readSpielplanData } from './spielplan-data.js'
import { getServerDataPath } from './paths.js'
import { sendLiveTickerFinishedPush, sendLiveTickerScoreChangedPush, sendLiveTickerStartedPush } from './push-notifications.js'
import { error as loggerError } from './logger.js'
const TICKER_WINDOW_BEFORE_MS = 30 * 60 * 1000
const TICKER_WINDOW_AFTER_MS = 6 * 60 * 60 * 1000
const FINISHED_VISIBLE_MS = 60 * 60 * 1000
const LIVE_START_STATE_FILE = getServerDataPath('live-ticker-notifications.json')
const LIVE_TICKER_STATE_FILE = getServerDataPath('live-ticker-observed-state.json')
const LIVE_START_STATE_RETENTION_MS = 2 * 24 * 60 * 60 * 1000
const cache = new Map()
let liveStartNotifications = null
let observedLiveTickerStates = null
let refreshInFlight = null
function matchTime(row) {
const timestamp = Number(row.Timestamp)
return Number.isFinite(timestamp) ? timestamp * 1000 : NaN
}
function score(data) {
const home = data.is_completed ? data.matches_home : data.match?.reduce((sum, item) => sum + (Number(item.matches_home) || 0), 0)
const away = data.is_completed ? data.matches_guest : data.match?.reduce((sum, item) => sum + (Number(item.matches_guest) || 0), 0)
return `${home ?? 0}:${away ?? 0}`
}
function completedTime(data, fallback) {
const timestamp = Date.parse(data.end_date || data.entry_date || '')
return Number.isFinite(timestamp) ? timestamp : fallback
}
async function readLiveStartNotifications() {
if (liveStartNotifications) return liveStartNotifications
try {
const parsed = JSON.parse(await fs.readFile(LIVE_START_STATE_FILE, 'utf8'))
liveStartNotifications = parsed && typeof parsed === 'object' ? parsed : {}
} catch (error) {
if (error?.code !== 'ENOENT') loggerError('[live-ticker] Push-Status konnte nicht gelesen werden', { error })
liveStartNotifications = {}
}
return liveStartNotifications
}
async function saveLiveStartNotifications(nowMs) {
const state = await readLiveStartNotifications()
for (const [meetingId, notifiedAt] of Object.entries(state)) {
if (nowMs - Date.parse(notifiedAt) > LIVE_START_STATE_RETENTION_MS) delete state[meetingId]
}
await fs.mkdir(path.dirname(LIVE_START_STATE_FILE), { recursive: true })
await fs.writeFile(LIVE_START_STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
}
async function notifyLiveStart(entry, nowMs) {
const state = await readLiveStartNotifications()
if (state[entry.meetingId]) return
state[entry.meetingId] = new Date(nowMs).toISOString()
try {
await saveLiveStartNotifications(nowMs)
await sendLiveTickerStartedPush(entry)
} catch (error) {
loggerError('[live-ticker] Start-Push konnte nicht gesendet werden', { meetingId: entry.meetingId, error })
}
}
async function readObservedLiveTickerStates() {
if (observedLiveTickerStates) return observedLiveTickerStates
try {
const parsed = JSON.parse(await fs.readFile(LIVE_TICKER_STATE_FILE, 'utf8'))
observedLiveTickerStates = parsed && typeof parsed === 'object' ? parsed : {}
} catch (error) {
if (error?.code !== 'ENOENT') loggerError('[live-ticker] Beobachtete Stände konnten nicht gelesen werden', { error })
observedLiveTickerStates = {}
}
return observedLiveTickerStates
}
async function saveObservedLiveTickerStates(nowMs) {
const states = await readObservedLiveTickerStates()
for (const [meetingId, state] of Object.entries(states)) {
if (nowMs - Date.parse(state?.updatedAt || '') > LIVE_START_STATE_RETENTION_MS) delete states[meetingId]
}
await fs.mkdir(path.dirname(LIVE_TICKER_STATE_FILE), { recursive: true })
await fs.writeFile(LIVE_TICKER_STATE_FILE, `${JSON.stringify(states, null, 2)}\n`, 'utf8')
}
async function notifyLiveTickerChanges(entry, nowMs) {
const states = await readObservedLiveTickerStates()
const previous = states[entry.meetingId]
const startedNow = entry.hasLiveData && !previous?.hasLiveData
const finishedNow = entry.completed && previous && !previous.completed
const scoreChanged = entry.hasLiveData && !entry.completed && previous?.hasLiveData && previous.score !== entry.score
states[entry.meetingId] = {
hasLiveData: entry.hasLiveData,
completed: entry.completed,
score: entry.score,
updatedAt: new Date(nowMs).toISOString()
}
try {
await saveObservedLiveTickerStates(nowMs)
if (startedNow) await notifyLiveStart(entry, nowMs)
if (scoreChanged) await sendLiveTickerScoreChangedPush(entry)
if (finishedNow) await sendLiveTickerFinishedPush(entry)
} catch (error) {
loggerError('[live-ticker] Live-Push konnte nicht gesendet werden', { meetingId: entry.meetingId, error })
}
}
async function refreshLiveTickerOnce(now = new Date()) {
const season = getCurrentSeasonSlug(now)
const { data } = await readSpielplanData({ season })
const nowMs = now.getTime()
const candidates = data.filter((row) => {
const startsAt = matchTime(row)
return Number.isFinite(startsAt) && startsAt - TICKER_WINDOW_BEFORE_MS <= nowMs && nowMs <= startsAt + TICKER_WINDOW_AFTER_MS
})
await Promise.all(candidates.map(async (row) => {
const meetingId = String(row.MeetingId || row.BegegnungNr || '').trim()
if (!/^\d{6,}$/.test(meetingId)) return
try {
const response = await fetch(`https://www.mytischtennis.de/api/meeting/${meetingId}/live`, {
headers: { accept: 'application/json', 'accept-language': 'de-DE,de;q=0.9' },
signal: AbortSignal.timeout(10_000)
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const payload = await response.json()
const report = payload?.data
if (!report) throw new Error('Keine Spieldaten erhalten')
const completedAt = report.is_completed ? completedTime(report, nowMs) : null
const previous = cache.get(meetingId)
const entry = {
meetingId,
homeTeam: report.team_home || row.HeimMannschaft,
awayTeam: report.team_guest || row.GastMannschaft,
score: score(report),
startsAt: matchTime(row),
hasLiveData: report.has_nu_live_push_data === true,
completed: report.is_completed === true,
completedAt: completedAt || previous?.completedAt || null,
updatedAt: new Date(nowMs).toISOString()
}
cache.set(meetingId, entry)
await notifyLiveTickerChanges(entry, nowMs)
} catch (error) {
const previous = cache.get(meetingId)
if (previous) cache.set(meetingId, { ...previous, updatedAt: previous.updatedAt, error: 'Live-Daten derzeit nicht erreichbar' })
}
}))
for (const [meetingId, entry] of cache) {
if (entry.completed && entry.completedAt && nowMs - entry.completedAt > FINISHED_VISIBLE_MS) cache.delete(meetingId)
}
return getLiveTicker(now)
}
export function refreshLiveTicker(now = new Date()) {
if (refreshInFlight) return refreshInFlight
refreshInFlight = refreshLiveTickerOnce(now).finally(() => { refreshInFlight = null })
return refreshInFlight
}
export function getLiveTicker(now = new Date()) {
const nowMs = now.getTime()
const matches = [...cache.values()]
.filter((entry) => !entry.completed || !entry.completedAt || nowMs - entry.completedAt <= FINISHED_VISIBLE_MS)
.sort((a, b) => Number(b.hasLiveData && !b.completed) - Number(a.hasLiveData && !a.completed) || a.startsAt - b.startsAt)
return { matches, updatedAt: new Date(nowMs).toISOString() }
}

View File

@@ -9,6 +9,9 @@ export const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({
newContactRequest: false, newContactRequest: false,
newUserRegistration: false, newUserRegistration: false,
ownTtrChanges: false, ownTtrChanges: false,
liveTickerStarts: false,
liveTickerScoreChanges: false,
liveTickerFinished: false,
selectedTeamSlugs: [], selectedTeamSlugs: [],
selectedTeamSeason: null, selectedTeamSeason: null,
notificationTime: '09:00' notificationTime: '09:00'
@@ -43,6 +46,9 @@ export function sanitizeNotificationSettings(input = {}) {
newContactRequest: coerceBoolean(input.newContactRequest), newContactRequest: coerceBoolean(input.newContactRequest),
newUserRegistration: coerceBoolean(input.newUserRegistration), newUserRegistration: coerceBoolean(input.newUserRegistration),
ownTtrChanges: coerceBoolean(input.ownTtrChanges), ownTtrChanges: coerceBoolean(input.ownTtrChanges),
liveTickerStarts: coerceBoolean(input.liveTickerStarts),
liveTickerScoreChanges: coerceBoolean(input.liveTickerScoreChanges),
liveTickerFinished: coerceBoolean(input.liveTickerFinished),
selectedTeamSlugs: [...new Set(selectedTeamSlugs)], selectedTeamSlugs: [...new Set(selectedTeamSlugs)],
selectedTeamSeason, selectedTeamSeason,
notificationTime notificationTime

View File

@@ -347,6 +347,61 @@ export async function sendQttrListUpdatedPush({ importedAt, rowCount }) {
}) })
} }
export async function sendLiveTickerStartedPush(match) {
const homeTeam = String(match?.homeTeam || 'Heimmannschaft').trim()
const awayTeam = String(match?.awayTeam || 'Gastmannschaft').trim()
const meetingId = String(match?.meetingId || '').trim()
return sendPushToUsers({
title: 'Live-Spiel gestartet',
body: `${homeTeam} – ${awayTeam}: Live-Ticker ist verfügbar.`,
data: {
type: 'live_ticker_started',
meetingId,
notificationId: notificationIdFor(`live-ticker-start:${meetingId}`)
},
predicate: (_user, settings) => settings.liveTickerStarts,
failureLabel: 'FCM Live-Ticker-Start-Push'
})
}
export async function sendLiveTickerScoreChangedPush(match) {
const homeTeam = String(match?.homeTeam || 'Heimmannschaft').trim()
const awayTeam = String(match?.awayTeam || 'Gastmannschaft').trim()
const score = String(match?.score || '–').trim()
const meetingId = String(match?.meetingId || '').trim()
return sendPushToUsers({
title: 'Live-Ergebnis geändert',
body: `${homeTeam} – ${awayTeam}: ${score}`,
data: {
type: 'live_ticker_score_changed',
meetingId,
score,
notificationId: notificationIdFor(`live-ticker-score:${meetingId}:${score}`)
},
predicate: (_user, settings) => settings.liveTickerScoreChanges,
failureLabel: 'FCM Live-Ticker-Ergebnis-Push'
})
}
export async function sendLiveTickerFinishedPush(match) {
const homeTeam = String(match?.homeTeam || 'Heimmannschaft').trim()
const awayTeam = String(match?.awayTeam || 'Gastmannschaft').trim()
const score = String(match?.score || '–').trim()
const meetingId = String(match?.meetingId || '').trim()
return sendPushToUsers({
title: 'Spiel beendet',
body: `${homeTeam} – ${awayTeam}: ${score}`,
data: {
type: 'live_ticker_finished',
meetingId,
score,
notificationId: notificationIdFor(`live-ticker-finished:${meetingId}`)
},
predicate: (_user, settings) => settings.liveTickerFinished,
failureLabel: 'FCM Live-Ticker-Ende-Push'
})
}
export async function sendTestPushToUser(userId) { export async function sendTestPushToUser(userId) {
return sendPushToUsers({ return sendPushToUsers({
title: 'Harheimer TC: Push-Test', title: 'Harheimer TC: Push-Test',

View File

@@ -15,6 +15,8 @@ const SPIELPLAN_HEADERS = [
'Staffel', 'Staffel',
'Runde', 'Runde',
'BegegnungNr', 'BegegnungNr',
'MeetingId',
'HasNuLivePushData',
'HalleNr', 'HalleNr',
'HalleName', 'HalleName',
'HalleStrasse', 'HalleStrasse',
@@ -242,6 +244,8 @@ export function convertImportedSpielplanToJson(imported) {
Staffel: match.leagueShortName || '', Staffel: match.leagueShortName || '',
Runde: match.roundName || '', Runde: match.roundName || '',
BegegnungNr: match.meetingNumber || match.meetingId || '', BegegnungNr: match.meetingNumber || match.meetingId || '',
MeetingId: match.meetingId || '',
HasNuLivePushData: match.hasNuLivePushData === true ? 'true' : 'false',
HalleNr: match.hallNumber || '', HalleNr: match.hallNumber || '',
HalleName: match.location?.label || '', HalleName: match.location?.label || '',
HalleStrasse: match.location?.street || '', HalleStrasse: match.location?.street || '',

View File

@@ -111,6 +111,7 @@ function normalizeMatch(day, match) {
formattedDay: match.formattedDay ?? null, formattedDay: match.formattedDay ?? null,
formattedTime: match.formattedTime ?? null, formattedTime: match.formattedTime ?? null,
state: match.state ?? null, state: match.state ?? null,
hasNuLivePushData: match.has_nu_live_push_data === true,
meetingId: match.meeting_id ?? null, meetingId: match.meeting_id ?? null,
meetingNumber: match.meeting_number ?? null, meetingNumber: match.meeting_number ?? null,
leagueId: match.league_id ?? null, leagueId: match.league_id ?? null,