From bf74d4177308ec27623393047925ceceebf67894 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 23 Oct 2026 00:25:07 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20erweitere=20Benachrichtigungseinstellun?= =?UTF-8?q?gen=20um=20Live-Ticker-Optionen=20und=20implementiere=20Push-Be?= =?UTF-8?q?nachrichtigungen=20f=C3=BCr=20Live-Spielereignisse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/de/harheimertc/data/ApiService.kt | 3 + .../notifications/HarheimerNotifications.kt | 1 + .../NotificationPreferencesRepository.kt | 18 ++++ .../NotificationSettingsScreen.kt | 9 ++ server/utils/live-ticker.js | 102 +++++++++++++++++- server/utils/notification-settings.js | 6 ++ server/utils/push-notifications.js | 55 ++++++++++ 7 files changed, 191 insertions(+), 3 deletions(-) diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index f5936b1..d2ba996 100755 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -286,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 = emptyList(), val selectedTeamSeason: String? = null, val notificationTime: String = "09:00", diff --git a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt index 18c6762..ef0ceaf 100755 --- a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt +++ b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt @@ -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 diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt index e2de8ee..9656b2e 100755 --- a/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt @@ -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 = 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, diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt index 4fb09da..f4a3f99 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt @@ -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, diff --git a/server/utils/live-ticker.js b/server/utils/live-ticker.js index 412a785..6ff4f75 100644 --- a/server/utils/live-ticker.js +++ b/server/utils/live-ticker.js @@ -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()] diff --git a/server/utils/notification-settings.js b/server/utils/notification-settings.js index 3e4f39b..1c2f2c4 100755 --- a/server/utils/notification-settings.js +++ b/server/utils/notification-settings.js @@ -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 diff --git a/server/utils/push-notifications.js b/server/utils/push-notifications.js index c064a2d..43776b5 100755 --- a/server/utils/push-notifications.js +++ b/server/utils/push-notifications.js @@ -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',