dev #58
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>
|
||||
|
||||
44
components/LiveTickerBanner.vue
Normal file
44
components/LiveTickerBanner.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<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">
|
||||
<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 isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms')))
|
||||
let timer
|
||||
async function load() {
|
||||
if (!isInternal.value) { matches.value = []; return }
|
||||
try { matches.value = (await $fetch('/api/mitgliederbereich/live-ticker')).matches || [] } catch { matches.value = [] }
|
||||
}
|
||||
onMounted(() => { load(); timer = setInterval(load, 30_000) })
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
watch(isInternal, load)
|
||||
</script>
|
||||
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))
|
||||
})
|
||||
71
server/utils/live-ticker.js
Normal file
71
server/utils/live-ticker.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getCurrentSeasonSlug, readSpielplanData } from './spielplan-data.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 cache = new Map()
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
export async function refreshLiveTicker(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 ? nowMs : null
|
||||
const previous = cache.get(meetingId)
|
||||
cache.set(meetingId, {
|
||||
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()
|
||||
})
|
||||
} 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 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() }
|
||||
}
|
||||
@@ -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