dev #58
@@ -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<String> = emptyList(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = "09:00",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user