feat: implement LiveTicker functionality with sound notifications and UI updates
This commit is contained in:
@@ -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,
|
||||||
@@ -726,6 +742,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
class="fixed top-20 z-40 w-full border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm"
|
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">
|
<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
|
<div
|
||||||
v-for="match in matches"
|
v-for="match in matches"
|
||||||
:key="match.meetingId"
|
:key="match.meetingId"
|
||||||
@@ -32,13 +40,72 @@ import { useAuthStore } from '~/stores/auth'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const matches = ref([])
|
const matches = ref([])
|
||||||
|
const soundEnabled = ref(false)
|
||||||
const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms')))
|
const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms')))
|
||||||
let timer
|
let timer
|
||||||
async function load() {
|
let audioContext
|
||||||
if (!isInternal.value) { matches.value = []; return }
|
let tickerLoaded = false
|
||||||
try { matches.value = (await $fetch('/api/mitgliederbereich/live-ticker')).matches || [] } catch { matches.value = [] }
|
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}`
|
||||||
|
]))
|
||||||
}
|
}
|
||||||
onMounted(() => { load(); timer = setInterval(load, 30_000) })
|
|
||||||
|
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))
|
onUnmounted(() => clearInterval(timer))
|
||||||
watch(isInternal, load)
|
watch(isInternal, load)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ function score(data) {
|
|||||||
return `${home ?? 0}:${away ?? 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
|
||||||
|
}
|
||||||
|
|
||||||
export async function refreshLiveTicker(now = new Date()) {
|
export async function refreshLiveTicker(now = new Date()) {
|
||||||
const season = getCurrentSeasonSlug(now)
|
const season = getCurrentSeasonSlug(now)
|
||||||
const { data } = await readSpielplanData({ season })
|
const { data } = await readSpielplanData({ season })
|
||||||
@@ -37,7 +42,7 @@ export async function refreshLiveTicker(now = new Date()) {
|
|||||||
const payload = await response.json()
|
const payload = await response.json()
|
||||||
const report = payload?.data
|
const report = payload?.data
|
||||||
if (!report) throw new Error('Keine Spieldaten erhalten')
|
if (!report) throw new Error('Keine Spieldaten erhalten')
|
||||||
const completedAt = report.is_completed ? nowMs : null
|
const completedAt = report.is_completed ? completedTime(report, nowMs) : null
|
||||||
const previous = cache.get(meetingId)
|
const previous = cache.get(meetingId)
|
||||||
cache.set(meetingId, {
|
cache.set(meetingId, {
|
||||||
meetingId,
|
meetingId,
|
||||||
|
|||||||
Reference in New Issue
Block a user