feat: add QTTR values feature to member area
- Implemented QTTR values screen in the member area with data fetching and display. - Added new API endpoint for QTTR values retrieval. - Created a new view model for managing QTTR data state. - Updated navigation to include QTTR section. - Enhanced error handling and loading states for QTTR data. - Adjusted server-side logic to import QTTR values from external source. - Updated Android app version and adjusted build configurations. - Added necessary UI components and styling for QTTR display.
This commit is contained in:
90
server/api/mitgliederbereich/qttr.get.js
Normal file
90
server/api/mitgliederbereich/qttr.get.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { getServerDataPath } from '../../utils/paths.js'
|
||||
import { getUserFromToken, verifyToken } from '../../utils/auth.js'
|
||||
import { readMembers } from '../../utils/members.js'
|
||||
import { readUsers } from '../../utils/auth.js'
|
||||
|
||||
const QTTR_FILE = getServerDataPath('qttr-values.json')
|
||||
|
||||
function normalizeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[’'`]/g, '')
|
||||
}
|
||||
|
||||
function buildBirthdateLookup(entries) {
|
||||
const lookup = new Map()
|
||||
|
||||
for (const entry of entries || []) {
|
||||
const candidates = [
|
||||
entry?.name,
|
||||
`${entry?.firstName || ''} ${entry?.lastName || ''}`.trim(),
|
||||
]
|
||||
|
||||
const birthdate = entry?.geburtsdatum || entry?.birthday || entry?.birthDate || ''
|
||||
if (!birthdate) continue
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeName(candidate)
|
||||
if (!normalized || lookup.has(normalized)) continue
|
||||
lookup.set(normalized, birthdate)
|
||||
}
|
||||
}
|
||||
|
||||
return lookup
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
if (!token || !verifyToken(token)) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: 'Nicht authentifiziert.'
|
||||
})
|
||||
}
|
||||
|
||||
const currentUser = await getUserFromToken(token)
|
||||
if (!currentUser) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: 'Ungültiges Token.'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(QTTR_FILE, 'utf8')
|
||||
const payload = JSON.parse(content)
|
||||
const [manualMembers, registeredUsers] = await Promise.all([
|
||||
readMembers(),
|
||||
readUsers()
|
||||
])
|
||||
const birthdateLookup = buildBirthdateLookup([...manualMembers, ...registeredUsers])
|
||||
|
||||
return {
|
||||
...payload,
|
||||
rows: Array.isArray(payload.rows)
|
||||
? payload.rows.map((row) => ({
|
||||
...row,
|
||||
birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || ''
|
||||
}))
|
||||
: []
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'QTTR-Datei nicht gefunden.'
|
||||
})
|
||||
}
|
||||
|
||||
console.error('Fehler beim Laden der QTTR-Werte:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'Fehler beim Laden der QTTR-Werte.'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,16 +1,20 @@
|
||||
import { importSpielplan } from '../utils/spielplan-import.js'
|
||||
import { importLeagueTables } from '../utils/spielklassen-tables-import.js'
|
||||
import { importQttrValues } from '../utils/qttr-import.js'
|
||||
import { publishImportedSpielplan } from '../utils/spielplan-publish.js'
|
||||
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
|
||||
import { cleanupPasswordResetLogs } from '../utils/password-reset-log.js'
|
||||
|
||||
const TIME_ZONE = 'Europe/Berlin'
|
||||
const RUN_HOUR = 7
|
||||
const RUN_MINUTE = 0
|
||||
const MAX_TIMEOUT = 2_147_483_647
|
||||
|
||||
let timer = null
|
||||
let running = false
|
||||
const JOBS = [
|
||||
{ label: 'spielplan-import', hour: 7, minute: 0 },
|
||||
{ label: 'qttr-import', hour: 7, minute: 30 }
|
||||
]
|
||||
|
||||
const timers = new Map()
|
||||
const runningJobs = new Set()
|
||||
|
||||
function getTimeParts(date) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
@@ -47,12 +51,12 @@ function zonedDateToUtc(year, month, day, hour, minute) {
|
||||
return new Date(utcGuess.getTime() - offset)
|
||||
}
|
||||
|
||||
function nextRunAt(now = new Date()) {
|
||||
function nextRunAt(hour, minute, now = new Date()) {
|
||||
const parts = getTimeParts(now)
|
||||
let year = Number(parts.year)
|
||||
let month = Number(parts.month)
|
||||
let day = Number(parts.day)
|
||||
let candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE)
|
||||
let candidate = zonedDateToUtc(year, month, day, hour, minute)
|
||||
|
||||
if (candidate <= now) {
|
||||
const nextDay = zonedDateToUtc(year, month, day + 1, 12, 0)
|
||||
@@ -60,65 +64,86 @@ function nextRunAt(now = new Date()) {
|
||||
year = Number(nextParts.year)
|
||||
month = Number(nextParts.month)
|
||||
day = Number(nextParts.day)
|
||||
candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE)
|
||||
candidate = zonedDateToUtc(year, month, day, hour, minute)
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
async function runDailyJobs(reason, skipSpielplanImport = false) {
|
||||
if (running) return
|
||||
async function runJob(job, reason) {
|
||||
if (runningJobs.has(job.label)) return
|
||||
|
||||
running = true
|
||||
runningJobs.add(job.label)
|
||||
try {
|
||||
try {
|
||||
const cleanup = await cleanupPasswordResetLogs()
|
||||
loggerInfo(`[password-reset-log] ${reason}: Bereinigung abgeschlossen`, cleanup)
|
||||
} catch (error) {
|
||||
loggerError('[password-reset-log] Bereinigung fehlgeschlagen:', { error })
|
||||
}
|
||||
|
||||
if (skipSpielplanImport) {
|
||||
return
|
||||
}
|
||||
|
||||
const spielplan = await importSpielplan()
|
||||
loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` })
|
||||
|
||||
const published = await publishImportedSpielplan({ inputPath: spielplan.jsonFile })
|
||||
loggerInfo(`[spielplan-import] ${reason}: Spielplan publiziert`, {
|
||||
season: published.seasonSlug,
|
||||
internalPath: published.internalSeasonPath
|
||||
})
|
||||
|
||||
try {
|
||||
const tables = await importLeagueTables()
|
||||
loggerInfo(`[spielplan-import] ${reason}: ${tables.importedCount}/${tables.teamCount} Tabellen importiert`, {
|
||||
season: tables.seasonSlug,
|
||||
outputFile: tables.outputFile,
|
||||
errors: tables.errorCount
|
||||
})
|
||||
} catch (error) {
|
||||
loggerError('[spielplan-import] Tabellen-Import fehlgeschlagen:', { error })
|
||||
}
|
||||
await job.run(reason)
|
||||
} catch (error) {
|
||||
loggerError('[spielplan-import] Import fehlgeschlagen:', { error })
|
||||
loggerError(`[${job.label}] Import fehlgeschlagen:`, { error })
|
||||
} finally {
|
||||
running = false
|
||||
runningJobs.delete(job.label)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNext(skipSpielplanImport = false) {
|
||||
const runAt = nextRunAt()
|
||||
function scheduleNext(job) {
|
||||
const runAt = nextRunAt(job.hour, job.minute)
|
||||
const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT)
|
||||
|
||||
timer = setTimeout(async () => {
|
||||
await runDailyJobs('taeglicher Lauf', skipSpielplanImport)
|
||||
scheduleNext(skipSpielplanImport)
|
||||
const timer = setTimeout(async () => {
|
||||
await runJob(job, 'taeglicher Lauf')
|
||||
scheduleNext(job)
|
||||
}, delay)
|
||||
|
||||
timer.unref?.()
|
||||
loggerInfo('[spielplan-import] Naechster Lauf', { runAt: runAt.toISOString(), tz: TIME_ZONE, time: `${String(RUN_HOUR).padStart(2, '0')}:${String(RUN_MINUTE).padStart(2, '0')}` })
|
||||
timers.set(job.label, timer)
|
||||
loggerInfo(`[${job.label}] Naechster Lauf`, { runAt: runAt.toISOString(), tz: TIME_ZONE, time: `${String(job.hour).padStart(2, '0')}:${String(job.minute).padStart(2, '0')}` })
|
||||
}
|
||||
|
||||
function createSpielplanJob(skipSpielplanImport) {
|
||||
return {
|
||||
run: async (reason) => {
|
||||
try {
|
||||
const cleanup = await cleanupPasswordResetLogs()
|
||||
loggerInfo(`[password-reset-log] ${reason}: Bereinigung abgeschlossen`, cleanup)
|
||||
} catch (error) {
|
||||
loggerError('[password-reset-log] Bereinigung fehlgeschlagen:', { error })
|
||||
}
|
||||
|
||||
if (skipSpielplanImport) {
|
||||
return
|
||||
}
|
||||
|
||||
const spielplan = await importSpielplan()
|
||||
loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` })
|
||||
|
||||
const published = await publishImportedSpielplan({ inputPath: spielplan.jsonFile })
|
||||
loggerInfo(`[spielplan-import] ${reason}: Spielplan publiziert`, {
|
||||
season: published.seasonSlug,
|
||||
internalPath: published.internalSeasonPath
|
||||
})
|
||||
|
||||
try {
|
||||
const tables = await importLeagueTables()
|
||||
loggerInfo(`[spielplan-import] ${reason}: ${tables.importedCount}/${tables.teamCount} Tabellen importiert`, {
|
||||
season: tables.seasonSlug,
|
||||
outputFile: tables.outputFile,
|
||||
errors: tables.errorCount
|
||||
})
|
||||
} catch (error) {
|
||||
loggerError('[spielplan-import] Tabellen-Import fehlgeschlagen:', { error })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createQttrJob() {
|
||||
return {
|
||||
run: async (reason) => {
|
||||
const qttr = await importQttrValues()
|
||||
loggerInfo(`[qttr-import] ${reason}: ${qttr.rowCount} QTTR-Werte importiert`, {
|
||||
outputFile: qttr.outputFile,
|
||||
tableCount: qttr.tableCount
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
@@ -127,13 +152,19 @@ export default defineNitroPlugin((nitroApp) => {
|
||||
loggerInfo('[spielplan-import] Import deaktiviert; Passwort-Reset-Log-Bereinigung bleibt aktiv')
|
||||
}
|
||||
|
||||
scheduleNext(skipSpielplanImport)
|
||||
const spielplanJob = createSpielplanJob(skipSpielplanImport)
|
||||
const qttrJob = createQttrJob()
|
||||
|
||||
scheduleNext({ ...JOBS[0], ...spielplanJob })
|
||||
scheduleNext({ ...JOBS[1], ...qttrJob })
|
||||
|
||||
if (process.env.SPIELPLAN_IMPORT_RUN_ON_START === 'true') {
|
||||
runDailyJobs('Startlauf', skipSpielplanImport)
|
||||
runJob({ label: 'spielplan-import', run: spielplanJob.run }, 'Startlauf')
|
||||
}
|
||||
|
||||
nitroApp.hooks.hookOnce('close', () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
for (const timer of timers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
220
server/utils/qttr-import.js
Normal file
220
server/utils/qttr-import.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import { getServerDataPath } from './paths.js'
|
||||
|
||||
const QTTR_URL = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all¤t-ranking=no&ttr-range=100%3B3000&birth-range=1926%3B2021'
|
||||
const OUTPUT_FILE = getServerDataPath('qttr-values.json')
|
||||
|
||||
function decodeHtmlEntities(value) {
|
||||
const namedEntities = {
|
||||
amp: '&',
|
||||
apos: "'",
|
||||
gt: '>',
|
||||
lt: '<',
|
||||
nbsp: ' ',
|
||||
quot: '"'
|
||||
}
|
||||
|
||||
return String(value || '')
|
||||
.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity) => {
|
||||
if (entity.startsWith('#x')) {
|
||||
const codePoint = Number.parseInt(entity.slice(2), 16)
|
||||
return Number.isNaN(codePoint) ? match : String.fromCodePoint(codePoint)
|
||||
}
|
||||
|
||||
if (entity.startsWith('#')) {
|
||||
const codePoint = Number.parseInt(entity.slice(1), 10)
|
||||
return Number.isNaN(codePoint) ? match : String.fromCodePoint(codePoint)
|
||||
}
|
||||
|
||||
return Object.prototype.hasOwnProperty.call(namedEntities, entity) ? namedEntities[entity] : match
|
||||
})
|
||||
}
|
||||
|
||||
function stripTags(value) {
|
||||
return decodeHtmlEntities(String(value || '')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<br\s*\/?/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim())
|
||||
}
|
||||
|
||||
function normalizeHeaderKey(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
}
|
||||
|
||||
function toNumberOrNull(value) {
|
||||
const raw = String(value || '').replace(',', '.').match(/-?\d+(?:\.\d+)?/)
|
||||
if (!raw) return null
|
||||
const numberValue = Number(raw[0])
|
||||
return Number.isNaN(numberValue) ? null : numberValue
|
||||
}
|
||||
|
||||
function normalizeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[’'`]/g, '')
|
||||
}
|
||||
|
||||
function normalizeGender(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (normalized === 'm' || normalized === 'männlich') return 'männlich'
|
||||
if (normalized === 'w' || normalized === 'weiblich') return 'weiblich'
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function extractTableBlocks(html) {
|
||||
return [...String(html || '').matchAll(/<table\b[^>]*>[\s\S]*?<\/table>/gi)].map((match) => match[0])
|
||||
}
|
||||
|
||||
function extractCellTexts(rowHtml) {
|
||||
return [...String(rowHtml || '').matchAll(/<(?:t[hd])\b[^>]*>([\s\S]*?)<\/t[hd]>/gi)].map((match) => stripTags(match[1]))
|
||||
}
|
||||
|
||||
function findBestTable(html) {
|
||||
const tables = extractTableBlocks(html)
|
||||
if (tables.length === 0) return null
|
||||
|
||||
return (
|
||||
tables.find((table) => /Harheimer TC/i.test(table) && /Q-?TTR/i.test(table)) ||
|
||||
tables.find((table) => /Harheimer TC/i.test(table)) ||
|
||||
tables.find((table) => /Q-?TTR/i.test(table)) ||
|
||||
tables[0]
|
||||
)
|
||||
}
|
||||
|
||||
function extractTableTitle(html, tableHtml) {
|
||||
const tableIndex = String(html || '').indexOf(tableHtml)
|
||||
if (tableIndex === -1) return null
|
||||
|
||||
const prefix = String(html || '').slice(0, tableIndex)
|
||||
const headingMatches = [...prefix.matchAll(/<(h[1-6])\b[^>]*>([\s\S]*?)<\/\1>/gi)]
|
||||
if (headingMatches.length === 0) return null
|
||||
|
||||
return stripTags(headingMatches[headingMatches.length - 1][2]) || null
|
||||
}
|
||||
|
||||
function extractRowsFromTable(tableHtml) {
|
||||
const rows = [...String(tableHtml || '').matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)]
|
||||
if (rows.length === 0) return { headers: [], rows: [] }
|
||||
|
||||
const headerRow = rows.find((row) => /<th\b/i.test(row[0]))
|
||||
const headerCells = headerRow ? extractCellTexts(headerRow[1]) : extractCellTexts(rows[0][1])
|
||||
const headers = headerCells.map((cell) => ({
|
||||
label: cell,
|
||||
key: normalizeHeaderKey(cell)
|
||||
}))
|
||||
|
||||
const dataRows = rows
|
||||
.filter((row) => row !== headerRow)
|
||||
.map((row) => extractCellTexts(row[1]))
|
||||
.filter((cells) => cells.length > 0 && cells.some((cell) => cell !== ''))
|
||||
|
||||
return { headers, rows: dataRows }
|
||||
}
|
||||
|
||||
function deriveQttrFields(headers, cells) {
|
||||
const valuesByHeader = {}
|
||||
headers.forEach((header, index) => {
|
||||
valuesByHeader[header.key] = cells[index] ?? null
|
||||
})
|
||||
|
||||
const lookup = (pattern) => {
|
||||
const entry = headers.find((header) => pattern.test(header.key))
|
||||
return entry ? valuesByHeader[entry.key] : null
|
||||
}
|
||||
|
||||
const rank = lookup(/platz|rank|rang/) ?? cells[0] ?? null
|
||||
const playerNumber = toNumberOrNull(cells[1] ?? lookup(/spieler.*nr|spielernr|id/))
|
||||
let gender = lookup(/geschlecht/) ?? null
|
||||
let playerName = lookup(/^(name|spielername)$/) ?? lookup(/\bspieler\b/) ?? null
|
||||
const combinedPlayerCell = cells[2] ?? lookup(/\bspieler\b/) ?? null
|
||||
const clubName = lookup(/verein|club/) ?? cells[3] ?? null
|
||||
const currentQttr = toNumberOrNull(cells[4] ?? lookup(/aktuell.*q.*ttr|current.*q.*ttr|q.*ttr/))
|
||||
const previousQttr = toNumberOrNull(lookup(/vorher|previous/))
|
||||
|
||||
if (combinedPlayerCell) {
|
||||
const genderAndName = String(combinedPlayerCell).match(/^(m|w|männlich|weiblich)\s+(.*)$/i)
|
||||
if (genderAndName) {
|
||||
gender = gender ?? normalizeGender(genderAndName[1])
|
||||
playerName = genderAndName[2].trim()
|
||||
} else {
|
||||
playerName = playerName ?? String(combinedPlayerCell).trim()
|
||||
}
|
||||
}
|
||||
|
||||
gender = normalizeGender(gender)
|
||||
|
||||
return {
|
||||
rank: toNumberOrNull(rank),
|
||||
playerNumber,
|
||||
gender,
|
||||
playerName,
|
||||
clubName,
|
||||
currentQttr,
|
||||
previousQttr,
|
||||
valuesByHeader,
|
||||
rawCells: cells
|
||||
}
|
||||
}
|
||||
|
||||
export async function importQttrValues(options = {}) {
|
||||
const url = options.url || QTTR_URL
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
accept: 'text/html,application/xhtml+xml',
|
||||
'accept-language': 'de-DE,de;q=0.9'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`QTTR-Download fehlgeschlagen: HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
const tableHtml = findBestTable(html)
|
||||
|
||||
if (!tableHtml) {
|
||||
throw new Error('Keine QTTR-Tabelle im HTML gefunden')
|
||||
}
|
||||
|
||||
const { headers, rows } = extractRowsFromTable(tableHtml)
|
||||
if (headers.length === 0 || rows.length === 0) {
|
||||
throw new Error('QTTR-Tabelle ist leer oder unvollständig')
|
||||
}
|
||||
|
||||
const parsedRows = rows.map((cells) => deriveQttrFields(headers, cells))
|
||||
const payload = {
|
||||
format: 'harheimertc.qttr.v1',
|
||||
importedAt: new Date().toISOString(),
|
||||
source: {
|
||||
url
|
||||
},
|
||||
title: extractTableTitle(html, tableHtml),
|
||||
headerCount: headers.length,
|
||||
rowCount: parsedRows.length,
|
||||
headers,
|
||||
rows: parsedRows
|
||||
}
|
||||
|
||||
await fs.mkdir(getServerDataPath(), { recursive: true })
|
||||
await fs.writeFile(OUTPUT_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
||||
|
||||
return {
|
||||
outputFile: OUTPUT_FILE,
|
||||
tableCount: 1,
|
||||
rowCount: parsedRows.length,
|
||||
...payload
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user