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

This commit is contained in:
Torsten Schulz (local)
2026-10-23 00:25:07 +02:00
parent 6369249f40
commit bf74d41773
7 changed files with 191 additions and 3 deletions

View File

@@ -1,9 +1,20 @@
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)
@@ -21,7 +32,84 @@ function completedTime(data, fallback) {
return Number.isFinite(timestamp) ? timestamp : fallback
}
export async function refreshLiveTicker(now = new Date()) {
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()
@@ -44,7 +132,7 @@ export async function refreshLiveTicker(now = new Date()) {
if (!report) throw new Error('Keine Spieldaten erhalten')
const completedAt = report.is_completed ? completedTime(report, nowMs) : null
const previous = cache.get(meetingId)
cache.set(meetingId, {
const entry = {
meetingId,
homeTeam: report.team_home || row.HeimMannschaft,
awayTeam: report.team_guest || row.GastMannschaft,
@@ -54,7 +142,9 @@ export async function refreshLiveTicker(now = new Date()) {
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' })
@@ -67,6 +157,12 @@ export async function refreshLiveTicker(now = new Date()) {
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()]

View File

@@ -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

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) {
return sendPushToUsers({
title: 'Harheimer TC: Push-Test',