5 Commits

Author SHA1 Message Date
Torsten Schulz (local)
1a7ba583d8 Aktualisiere die Version auf 1.8.10 und füge Vereinsrang (QTTR / TTR) zur QTTR-Tabelle hinzu. Implementiere die Logik zur Berechnung und Sortierung der Vereinsränge in den API- und Import-Skripten.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m18s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-09-23 16:00:06 +02:00
Torsten Schulz (local)
645e4b9655 Füge .aab-Dateien zum .gitignore hinzu und aktualisiere die Android-Version auf 0.10.0
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 3m42s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m19s
2026-09-11 15:15:17 +02:00
Torsten Schulz (local)
93b76d4446 Entferne nicht mehr benötigte AAB-Binärdateien für Instant, Local und Production Builds.
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-09-11 15:15:11 +02:00
Torsten Schulz (local)
2842c89bef Füge Unterstützung für QTTR-Listenaktualisierungen hinzu: Implementiere Push-Benachrichtigung für aktualisierte QTTR-Listen und verbessere die Importlogik.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 4m6s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m41s
2026-09-11 15:07:30 +02:00
Torsten Schulz (local)
dc08ae98ea Füge Unterstützung für die Installation-ID in Push-Token-Registrierung hinzu: Aktualisiere API- und Datenbank-Logik zur Speicherung der Installation-ID.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 3m36s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m25s
2026-09-09 14:04:41 +02:00
16 changed files with 120 additions and 19 deletions

2
.gitignore vendored
View File

@@ -94,6 +94,8 @@ dist
/android-app/**/build/
/android-app/local.properties
/android-app/gradle-local.properties
# Android Play Store / release artifacts are generated locally or in CI.
/android-app/**/*.aab
# JVM Heap-Dumps sind lokale Diagnoseartefakte und dürfen nie ins Repository.
*.hprof

View File

@@ -283,6 +283,7 @@ data class PushTokenRequest(
val token: String,
val platform: String = "android",
val appVersion: String? = null,
val installationId: String? = null,
)
data class BirthdayDto(
val name: String = "",

View File

@@ -21,7 +21,7 @@ class HarheimerMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
serviceScope.launch {
pushTokenRepository.registerToken(token)
pushTokenRepository.registerCurrentDevice()
}
}

View File

@@ -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
"qttr_list_updated", "ttr_change" -> Destinations.Qttr.route
"birthdays" -> Destinations.MemberArea.route
"contact_request" -> Destinations.CmsContactRequests.route
"user_registration" -> Destinations.CmsBenutzer.route

View File

@@ -1,6 +1,7 @@
package de.harheimertc.repositories
import android.util.Log
import com.google.firebase.installations.FirebaseInstallations
import com.google.firebase.messaging.FirebaseMessaging
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService
@@ -15,16 +16,18 @@ class PushTokenRepository @Inject constructor(
) {
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
val token = FirebaseMessaging.getInstance().token.await()
registerToken(token).getOrThrow()
val installationId = FirebaseInstallations.getInstance().id.await()
registerToken(token, installationId).getOrThrow()
}
suspend fun registerToken(token: String): Result<Unit> = runCatching {
suspend fun registerToken(token: String, installationId: String? = null): Result<Unit> = runCatching {
if (token.isBlank()) return@runCatching
retryOnNetworkFailure {
val response = api.registerPushToken(
PushTokenRequest(
token = token,
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
installationId = installationId,
),
)
if (!response.isSuccessful) {

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=35
ANDROID_VERSION_NAME=0.9.30
ANDROID_VERSION_CODE=36
ANDROID_VERSION_NAME=0.10.0
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false

View File

@@ -1,6 +1,6 @@
{
"name": "harheimertc-website",
"version": "1.8.9",
"version": "1.8.10",
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true,
"type": "module",

View File

@@ -46,7 +46,7 @@
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Rang
Vereinsrang<br>(QTTR / TTR)
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Spieler
@@ -69,7 +69,9 @@
:class="isOwnRow(row.playerName) ? 'bg-primary-100' : ''"
>
<td class="px-4 py-3 text-sm text-gray-600">
{{ row.rank ?? '' }}
<span :title="clubRankTitle(row)">
{{ formatClubRank(row) }}
</span>
</td>
<td class="px-4 py-3">
<div :class="['font-medium', getPlayerNameClass(row)]">
@@ -133,6 +135,20 @@ function isOwnRow(playerName) {
return normalizeName(playerName) === current
}
function formatClubRank(row) {
const qttrRank = row.clubQttrRank
const ttrRank = row.clubTtrRank
if (qttrRank == null) return ''
return ttrRank == null ? String(qttrRank) : `${qttrRank} (${ttrRank})`
}
function clubRankTitle(row) {
if (row.clubQttrRank == null) return 'Keine QTTR-Platzierung verfügbar'
return row.clubTtrRank == null
? `QTTR-Platzierung im Verein: ${row.clubQttrRank}`
: `QTTR-Platzierung im Verein: ${row.clubQttrRank}; TTR-Platzierung im Verein: ${row.clubTtrRank}`
}
function getPlayerNameClass(row) {
const minor = isMinor(row.birthdate)
if (minor && isMaleGender(row.gender)) return 'text-blue-400'

View File

@@ -37,6 +37,26 @@ function buildBirthdateLookup(entries) {
return lookup
}
function addClubRanks(rows) {
const addRankFor = (valueKey, rankKey) => {
rows
.filter((row) => row[valueKey] != null)
.sort((a, b) => b[valueKey] - a[valueKey] || String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de'))
.forEach((row, index) => {
row[rankKey] = index + 1
})
}
addRankFor('currentQttr', 'clubQttrRank')
addRankFor('currentTtr', 'clubTtrRank')
return rows.sort((a, b) =>
(a.clubQttrRank ?? Number.POSITIVE_INFINITY) - (b.clubQttrRank ?? Number.POSITIVE_INFINITY)
|| (a.clubTtrRank ?? Number.POSITIVE_INFINITY) - (b.clubTtrRank ?? Number.POSITIVE_INFINITY)
|| String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de')
)
}
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
if (!token || !verifyToken(token)) {
@@ -73,16 +93,16 @@ export default defineEventHandler(async (event) => {
].flatMap(entry => [entry?.name, `${entry?.firstName || ''} ${entry?.lastName || ''}`.trim()]).map(normalizeName).filter(Boolean))
const birthdateLookup = buildBirthdateLookup([...visibleManualMembers, ...visibleUsers])
const rankedRows = addClubRanks(Array.isArray(payload.rows) ? payload.rows.map(row => ({ ...row })) : [])
return {
...payload,
rows: Array.isArray(payload.rows)
? payload.rows
rows: rankedRows
.filter(row => !hiddenNames.has(normalizeName(row.playerName)))
.map((row) => ({
...row,
birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || ''
}))
: []
}
} catch (error) {
if (error?.code === 'ENOENT') {
@@ -98,4 +118,4 @@ export default defineEventHandler(async (event) => {
message: 'Fehler beim Laden der QTTR-Werte.'
})
}
})
})

View File

@@ -22,7 +22,8 @@ export default defineEventHandler(async (event) => {
upsertPushToken(users[userIndex], {
token: body.token,
platform: body.platform || 'android',
appVersion: body.appVersion || null
appVersion: body.appVersion || null,
installationId: body.installationId || null
})
await writeUsers(users)
return { success: true, message: 'Push-Token gespeichert.' }

View File

@@ -85,16 +85,21 @@ function pushTokensForUser(user) {
: []
}
export function upsertPushToken(user, { token, platform = 'android', appVersion = null }) {
export function upsertPushToken(user, { token, platform = 'android', appVersion = null, installationId = null }) {
const normalizedToken = String(token || '').trim()
if (!normalizedToken) return user
const normalizedInstallationId = String(installationId || '').trim().slice(0, 200) || null
const now = new Date().toISOString()
const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : []
const next = tokens.filter(entry => entry?.token !== normalizedToken)
// A Firebase token can rotate for the same app installation. Retain tokens
// from other devices, but replace the previous token of this installation.
const next = tokens.filter(entry => entry?.token !== normalizedToken &&
(!normalizedInstallationId || entry?.installationId !== normalizedInstallationId))
next.push({
token: normalizedToken,
platform: String(platform || 'android').slice(0, 30),
appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
installationId: normalizedInstallationId,
updatedAt: now,
createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
})
@@ -329,6 +334,19 @@ export async function sendOwnTtrChangePush(changes = []) {
})
}
export async function sendQttrListUpdatedPush({ importedAt, rowCount }) {
return sendPushToUsers({
title: 'QTTR-Liste aktualisiert',
body: `Die aktuelle TTR- und QTTR-Liste wurde aktualisiert (${rowCount} Einträge).`,
data: {
type: 'qttr_list_updated',
importedAt: importedAt || '',
notificationId: notificationIdFor(`qttr-list:${importedAt || Date.now()}`)
},
failureLabel: 'FCM QTTR-Listen-Push'
})
}
export async function sendTestPushToUser(userId) {
return sendPushToUsers({
title: 'Harheimer TC: Push-Test',

View File

@@ -1,6 +1,6 @@
import { promises as fs } from 'fs'
import { getServerDataPath } from './paths.js'
import { sendOwnTtrChangePush } from './push-notifications.js'
import { sendOwnTtrChangePush, sendQttrListUpdatedPush } from './push-notifications.js'
import { fetchClubRankings } from './mytischtennis-client.js'
import { readMyTischtennisConnection, saveMyTischtennisConnection } from './mytischtennis-connection.js'
@@ -67,6 +67,26 @@ function normalizeGender(value) {
return normalized || null
}
function addClubRanks(rows) {
const addRankFor = (valueKey, rankKey) => {
rows
.filter((row) => row[valueKey] != null)
.sort((a, b) => b[valueKey] - a[valueKey] || String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de'))
.forEach((row, index) => {
row[rankKey] = index + 1
})
}
addRankFor('currentQttr', 'clubQttrRank')
addRankFor('currentTtr', 'clubTtrRank')
return rows.sort((a, b) =>
(a.clubQttrRank ?? Number.POSITIVE_INFINITY) - (b.clubQttrRank ?? Number.POSITIVE_INFINITY)
|| (a.clubTtrRank ?? Number.POSITIVE_INFINITY) - (b.clubTtrRank ?? Number.POSITIVE_INFINITY)
|| String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de')
)
}
function extractTableBlocks(html) {
return [...String(html || '').matchAll(/<table\b[^>]*>[\s\S]*?<\/table>/gi)].map((match) => match[0])
}
@@ -190,7 +210,7 @@ export async function importQttrValues(options = {}) {
throw new Error('QTTR-Tabelle ist leer oder unvollständig')
}
const parsedRows = rows.map((cells) => deriveQttrFields(headers, cells))
const parsedRows = addClubRanks(rows.map((cells) => deriveQttrFields(headers, cells)))
const payload = {
format: 'harheimertc.qttr.v1',
importedAt: new Date().toISOString(),
@@ -243,7 +263,11 @@ async function importAuthenticatedTtrValues(connection) {
})
}).filter(row => row.playerName && (row.currentTtr != null || row.currentQttr != null))
addClubRanks(rows)
if (!rows.length) throw new Error('Keine verwertbaren TTR-Werte in der myTischtennis-Rangliste gefunden.')
const previousQttrSignature = qttrListSignature(previousPayload.rows)
const nextQttrSignature = qttrListSignature(rows)
const importedAt = new Date().toISOString()
const payload = {
format: 'harheimertc.qttr.v2', importedAt,
@@ -262,6 +286,21 @@ async function importAuthenticatedTtrValues(connection) {
: []
})
await sendOwnTtrChangePush(changes)
if (previousQttrSignature && previousQttrSignature !== nextQttrSignature) {
await sendQttrListUpdatedPush({ importedAt, rowCount: rows.length })
}
await saveMyTischtennisConnection({ ...connection, lastSuccessfulImportAt: importedAt, lastImportError: null })
return { outputFile: OUTPUT_FILE, tableCount: 1, ...payload }
}
function qttrListSignature(rows) {
if (!Array.isArray(rows) || rows.length === 0) return ''
return rows
.map(row => [
String(row?.playerName || '').trim().toLocaleLowerCase('de-DE'),
row?.rank ?? '',
row?.currentQttr ?? ''
].join('|'))
.sort()
.join('\n')
}

View File

@@ -247,7 +247,7 @@ describe('Config & Profil Endpoints', () => {
it('speichert Android-Push-Token am Benutzer', async () => {
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })
mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1', installationId: 'firebase-installation-id' })
const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }]
authUtils.verifyToken.mockReturnValue({ id: '1' })
authUtils.getUserFromToken.mockResolvedValue(users[0])
@@ -260,7 +260,7 @@ describe('Config & Profil Endpoints', () => {
expect(authUtils.writeUsers).toHaveBeenCalledWith([
expect.objectContaining({
id: '1',
pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })]
pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1', installationId: 'firebase-installation-id' })]
})
])
})