Merge pull request 'dev' (#58) from dev into main
Reviewed-on: #58
This commit was merged in pull request #58.
This commit is contained in:
@@ -37,6 +37,22 @@ data class SpielplanResponse(
|
||||
val season: String? = null,
|
||||
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 MannschaftenSeasonsResponse(
|
||||
val success: Boolean = false,
|
||||
@@ -270,6 +286,9 @@ data class NotificationSettingsDto(
|
||||
val newContactRequest: Boolean = false,
|
||||
val newUserRegistration: Boolean = false,
|
||||
val ownTtrChanges: Boolean = false,
|
||||
val liveTickerStarts: Boolean = false,
|
||||
val liveTickerScoreChanges: Boolean = false,
|
||||
val liveTickerFinished: Boolean = false,
|
||||
val selectedTeamSlugs: List<String> = emptyList(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = "09:00",
|
||||
@@ -726,6 +745,9 @@ interface ApiService {
|
||||
@GET("/api/mitgliederbereich/qttr")
|
||||
suspend fun qttrValues(): Response<QttrValuesResponse>
|
||||
|
||||
@GET("/api/mitgliederbereich/live-ticker")
|
||||
suspend fun liveTicker(): Response<LiveTickerResponse>
|
||||
|
||||
@GET("/api/members")
|
||||
suspend fun members(): Response<MembersResponse>
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ object HarheimerNotifications {
|
||||
"news", "news_expiring" -> Destinations.MemberNews.route
|
||||
"event", "events_today", "events_tomorrow" -> Destinations.Termine.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
|
||||
"birthdays" -> Destinations.MemberArea.route
|
||||
"contact_request" -> Destinations.CmsContactRequests.route
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ data class NotificationPreferences(
|
||||
val newContactRequest: Boolean = false,
|
||||
val newUserRegistration: Boolean = false,
|
||||
val ownTtrChanges: Boolean = false,
|
||||
val liveTickerStarts: Boolean = false,
|
||||
val liveTickerScoreChanges: Boolean = false,
|
||||
val liveTickerFinished: Boolean = false,
|
||||
val selectedTeamSlugs: Set<String> = emptySet(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
|
||||
@@ -45,6 +48,9 @@ class NotificationPreferencesRepository @Inject constructor(
|
||||
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
|
||||
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, 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(),
|
||||
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
|
||||
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_USER_REGISTRATION, settings.newUserRegistration)
|
||||
.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)
|
||||
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
|
||||
.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_USER_REGISTRATION = "new_user_registration"
|
||||
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_SEASON = "selected_team_season"
|
||||
const val KEY_NOTIFICATION_TIME = "notification_time"
|
||||
@@ -119,6 +131,9 @@ private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = N
|
||||
newContactRequest = newContactRequest,
|
||||
newUserRegistration = newUserRegistration,
|
||||
ownTtrChanges = ownTtrChanges,
|
||||
liveTickerStarts = liveTickerStarts,
|
||||
liveTickerScoreChanges = liveTickerScoreChanges,
|
||||
liveTickerFinished = liveTickerFinished,
|
||||
selectedTeamSlugs = selectedTeamSlugs.toSet(),
|
||||
selectedTeamSeason = selectedTeamSeason,
|
||||
notificationTime = notificationTime,
|
||||
@@ -135,6 +150,9 @@ private fun NotificationPreferences.toDto(): NotificationSettingsDto = Notificat
|
||||
newContactRequest = newContactRequest,
|
||||
newUserRegistration = newUserRegistration,
|
||||
ownTtrChanges = ownTtrChanges,
|
||||
liveTickerStarts = liveTickerStarts,
|
||||
liveTickerScoreChanges = liveTickerScoreChanges,
|
||||
liveTickerFinished = liveTickerFinished,
|
||||
selectedTeamSlugs = selectedTeamSlugs.toList(),
|
||||
selectedTeamSeason = selectedTeamSeason,
|
||||
notificationTime = notificationTime,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
import de.harheimertc.ui.components.LiveTickerBanner
|
||||
|
||||
@Composable
|
||||
fun NavGraph(
|
||||
@@ -44,6 +45,11 @@ fun NavGraph(
|
||||
LaunchedEffect(currentRoute) {
|
||||
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()) {
|
||||
val persistentNavigation = maxWidth >= 600.dp
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -72,6 +78,10 @@ fun NavGraph(
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
LiveTickerBanner(
|
||||
matches = navigationState.liveTickerMatches,
|
||||
loaded = navigationState.liveTickerLoaded,
|
||||
)
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
|
||||
@@ -4,15 +4,20 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConnectivityMonitor
|
||||
import de.harheimertc.data.LiveTickerMatchDto
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.repositories.GalleryRepository
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.LiveTickerRepository
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -22,6 +27,8 @@ data class NavigationUiState(
|
||||
val loggedIn: Boolean = false,
|
||||
val roles: Set<String> = emptySet(),
|
||||
val connectionNote: String? = null,
|
||||
val liveTickerMatches: List<LiveTickerMatchDto> = emptyList(),
|
||||
val liveTickerLoaded: Boolean = false,
|
||||
) {
|
||||
val isAdmin: Boolean get() = "admin" in roles
|
||||
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 connectivityMonitor: ConnectivityMonitor,
|
||||
private val pushTokenRepository: PushTokenRepository,
|
||||
private val liveTickerRepository: LiveTickerRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NavigationUiState())
|
||||
val state: StateFlow<NavigationUiState> = _state
|
||||
private var tickerJob: Job? = null
|
||||
private var tickerActive = false
|
||||
|
||||
init {
|
||||
loadNavigationData()
|
||||
@@ -70,6 +80,8 @@ class NavigationViewModel @Inject constructor(
|
||||
loggedIn = loggedIn,
|
||||
roles = status.navigationRoles(),
|
||||
connectionNote = null,
|
||||
liveTickerMatches = _state.value.liveTickerMatches,
|
||||
liveTickerLoaded = _state.value.liveTickerLoaded,
|
||||
)
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
@@ -96,6 +126,7 @@ class NavigationViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun logout(onComplete: () -> Unit = {}) {
|
||||
setLiveTickerActive(false)
|
||||
viewModelScope.launch {
|
||||
loginRepository.logout()
|
||||
_state.value = _state.value.copy(
|
||||
|
||||
@@ -164,6 +164,15 @@ fun NotificationSettingsScreen(
|
||||
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
|
||||
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(
|
||||
teams = state.teams,
|
||||
seasons = state.seasons,
|
||||
|
||||
@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
|
||||
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
||||
|
||||
# Android app versioning for Play Store uploads
|
||||
ANDROID_VERSION_CODE=37
|
||||
ANDROID_VERSION_NAME=0.10.1
|
||||
ANDROID_VERSION_CODE=38
|
||||
ANDROID_VERSION_NAME=0.11.0
|
||||
|
||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||
RELEASE_MINIFY_ENABLED=false
|
||||
|
||||
2
app.vue
2
app.vue
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col overflow-hidden">
|
||||
<Navigation />
|
||||
<LiveTickerBanner />
|
||||
<main class="flex-1 overflow-y-auto pt-20 pb-20 sm:pb-0">
|
||||
<NuxtPage />
|
||||
</main>
|
||||
@@ -11,6 +12,7 @@
|
||||
|
||||
<script setup>
|
||||
import Navigation from '~/components/Navigation.vue'
|
||||
import LiveTickerBanner from '~/components/LiveTickerBanner.vue'
|
||||
import Footer from '~/components/Footer.vue'
|
||||
import ModalDialog from '~/components/ModalDialog.vue'
|
||||
</script>
|
||||
|
||||
111
components/LiveTickerBanner.vue
Normal file
111
components/LiveTickerBanner.vue
Normal 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>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.8.13",
|
||||
"version": "1.9.0",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
11
server/api/mitgliederbereich/live-ticker.get.js
Normal file
11
server/api/mitgliederbereich/live-ticker.get.js
Normal 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()
|
||||
}
|
||||
})
|
||||
10
server/plugins/live-ticker-scheduler.js
Normal file
10
server/plugins/live-ticker-scheduler.js
Normal 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
172
server/utils/live-ticker.js
Normal 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() }
|
||||
}
|
||||
@@ -9,6 +9,9 @@ export const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({
|
||||
newContactRequest: false,
|
||||
newUserRegistration: false,
|
||||
ownTtrChanges: false,
|
||||
liveTickerStarts: false,
|
||||
liveTickerScoreChanges: false,
|
||||
liveTickerFinished: false,
|
||||
selectedTeamSlugs: [],
|
||||
selectedTeamSeason: null,
|
||||
notificationTime: '09:00'
|
||||
@@ -43,6 +46,9 @@ export function sanitizeNotificationSettings(input = {}) {
|
||||
newContactRequest: coerceBoolean(input.newContactRequest),
|
||||
newUserRegistration: coerceBoolean(input.newUserRegistration),
|
||||
ownTtrChanges: coerceBoolean(input.ownTtrChanges),
|
||||
liveTickerStarts: coerceBoolean(input.liveTickerStarts),
|
||||
liveTickerScoreChanges: coerceBoolean(input.liveTickerScoreChanges),
|
||||
liveTickerFinished: coerceBoolean(input.liveTickerFinished),
|
||||
selectedTeamSlugs: [...new Set(selectedTeamSlugs)],
|
||||
selectedTeamSeason,
|
||||
notificationTime
|
||||
|
||||
@@ -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) {
|
||||
return sendPushToUsers({
|
||||
title: 'Harheimer TC: Push-Test',
|
||||
|
||||
@@ -15,6 +15,8 @@ const SPIELPLAN_HEADERS = [
|
||||
'Staffel',
|
||||
'Runde',
|
||||
'BegegnungNr',
|
||||
'MeetingId',
|
||||
'HasNuLivePushData',
|
||||
'HalleNr',
|
||||
'HalleName',
|
||||
'HalleStrasse',
|
||||
@@ -242,6 +244,8 @@ export function convertImportedSpielplanToJson(imported) {
|
||||
Staffel: match.leagueShortName || '',
|
||||
Runde: match.roundName || '',
|
||||
BegegnungNr: match.meetingNumber || match.meetingId || '',
|
||||
MeetingId: match.meetingId || '',
|
||||
HasNuLivePushData: match.hasNuLivePushData === true ? 'true' : 'false',
|
||||
HalleNr: match.hallNumber || '',
|
||||
HalleName: match.location?.label || '',
|
||||
HalleStrasse: match.location?.street || '',
|
||||
|
||||
@@ -111,6 +111,7 @@ function normalizeMatch(day, match) {
|
||||
formattedDay: match.formattedDay ?? null,
|
||||
formattedTime: match.formattedTime ?? null,
|
||||
state: match.state ?? null,
|
||||
hasNuLivePushData: match.has_nu_live_push_data === true,
|
||||
meetingId: match.meeting_id ?? null,
|
||||
meetingNumber: match.meeting_number ?? null,
|
||||
leagueId: match.league_id ?? null,
|
||||
|
||||
Reference in New Issue
Block a user