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 MAX_TIMEOUT = 2_147_483_647 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', { timeZone: TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).formatToParts(date) return Object.fromEntries(parts.map((part) => [part.type, part.value])) } function getTimeZoneOffset(date) { const parts = getTimeParts(date) const zonedAsUtc = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second) ) return zonedAsUtc - date.getTime() } function zonedDateToUtc(year, month, day, hour, minute) { const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, 0)) const offset = getTimeZoneOffset(utcGuess) return new Date(utcGuess.getTime() - offset) } 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, hour, minute) if (candidate <= now) { const nextDay = zonedDateToUtc(year, month, day + 1, 12, 0) const nextParts = getTimeParts(nextDay) year = Number(nextParts.year) month = Number(nextParts.month) day = Number(nextParts.day) candidate = zonedDateToUtc(year, month, day, hour, minute) } return candidate } async function runJob(job, reason) { if (runningJobs.has(job.label)) return runningJobs.add(job.label) try { await job.run(reason) } catch (error) { loggerError(`[${job.label}] Import fehlgeschlagen:`, { error }) } finally { runningJobs.delete(job.label) } } function scheduleNext(job) { const runAt = nextRunAt(job.hour, job.minute) const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT) const timer = setTimeout(async () => { await runJob(job, 'taeglicher Lauf') scheduleNext(job) }, delay) timer.unref?.() 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) => { const skipSpielplanImport = process.env.SPIELPLAN_IMPORT_DISABLED === 'true' if (skipSpielplanImport) { loggerInfo('[spielplan-import] Import deaktiviert; Passwort-Reset-Log-Bereinigung bleibt aktiv') } const spielplanJob = createSpielplanJob(skipSpielplanImport) const qttrJob = createQttrJob() scheduleNext({ ...JOBS[0], ...spielplanJob }) scheduleNext({ ...JOBS[1], ...qttrJob }) if (process.env.SPIELPLAN_IMPORT_RUN_ON_START === 'true') { runJob({ label: 'spielplan-import', run: spielplanJob.run }, 'Startlauf') } nitroApp.hooks.hookOnce('close', () => { for (const timer of timers.values()) { clearTimeout(timer) } }) })