feat: add QTTR values feature to member area
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m49s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m7s

- 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:
Torsten Schulz (local)
2026-05-30 23:43:06 +02:00
parent 387ce6e08e
commit 6507afea5f
29 changed files with 1182 additions and 94 deletions

View File

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