From 21b39d4e5c99b42f53d47208e3eab44564e39442 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 20 May 2026 17:28:07 +0200 Subject: [PATCH 01/11] feat(import): add daily click-tt league table import by season --- scripts/import-spielplan.js | 4 + server/plugins/spielplan-import-scheduler.js | 16 +- server/utils/spielklassen-tables-import.js | 223 +++++++++++++++++++ 3 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 server/utils/spielklassen-tables-import.js diff --git a/scripts/import-spielplan.js b/scripts/import-spielplan.js index b95d1b1..592af77 100644 --- a/scripts/import-spielplan.js +++ b/scripts/import-spielplan.js @@ -1,10 +1,14 @@ #!/usr/bin/env node import { importSpielplan } from '../server/utils/spielplan-import.js' +import { importLeagueTables } from '../server/utils/spielklassen-tables-import.js' const result = await importSpielplan() +const tables = await importLeagueTables() console.log(`Spielplan gespeichert: ${result.jsonFile}`) console.log(`Roh-HTML gespeichert: ${result.htmlFile}`) console.log(`Spiele: ${result.matchCount}`) console.log(`Zeitraum: ${result.source.season.dateStart} bis ${result.source.season.dateEnd}`) +console.log(`Tabellen gespeichert: ${tables.outputFile}`) +console.log(`Tabellen importiert: ${tables.importedCount}/${tables.teamCount} (Fehler: ${tables.errorCount})`) diff --git a/server/plugins/spielplan-import-scheduler.js b/server/plugins/spielplan-import-scheduler.js index 02f4944..de95ea7 100644 --- a/server/plugins/spielplan-import-scheduler.js +++ b/server/plugins/spielplan-import-scheduler.js @@ -1,4 +1,5 @@ import { importSpielplan } from '../utils/spielplan-import.js' +import { importLeagueTables } from '../utils/spielklassen-tables-import.js' import { info as loggerInfo, error as loggerError } from '../utils/logger.js' const TIME_ZONE = 'Europe/Berlin' @@ -68,8 +69,19 @@ async function runImport(reason) { running = true try { - const result = await importSpielplan() - loggerInfo(`[spielplan-import] ${reason}: ${result.matchCount} Spiele importiert`, { range: `${result.source.season.dateStart} - ${result.source.season.dateEnd}` }) + const spielplan = await importSpielplan() + loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` }) + + 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 }) + } } catch (error) { loggerError('[spielplan-import] Import fehlgeschlagen:', { error }) } finally { diff --git a/server/utils/spielklassen-tables-import.js b/server/utils/spielklassen-tables-import.js new file mode 100644 index 0000000..8fab970 --- /dev/null +++ b/server/utils/spielklassen-tables-import.js @@ -0,0 +1,223 @@ +import { promises as fs } from 'fs' +import path from 'path' +import { getServerDataPath, getProjectPath } from './paths.js' +import { getSpieljahrForDate } from './spielplan-import.js' +import { parseDelimitedLine } from './spielplan-data.js' + +const OUTPUT_DIR = getServerDataPath('spielplan-import') +const DEFAULT_ASSOCIATION = 'HeTTV' + +async function fileExists(filePath) { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +function extractRemixContext(html) { + const marker = 'window.__remixContext = ' + const start = html.indexOf(marker) + if (start === -1) { + throw new Error('window.__remixContext nicht gefunden') + } + + const jsonStart = start + marker.length + let depth = 0 + let inString = false + let escaped = false + + for (let i = jsonStart; i < html.length; i += 1) { + const char = html[i] + + if (inString) { + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === '"') { + inString = false + } + continue + } + + if (char === '"') { + inString = true + } else if (char === '{') { + depth += 1 + } else if (char === '}') { + depth -= 1 + if (depth === 0) { + return JSON.parse(html.slice(jsonStart, i + 1)) + } + } + } + + throw new Error('Ende von window.__remixContext nicht gefunden') +} + +function getTableRoutePayload(loaderData) { + const exactKey = 'routes/click-tt+/$association+/$season+/$type+/$groupname.gruppe.$urlid+/tabelle.$filter' + if (loaderData?.[exactKey]) { + return { routeKey: exactKey, payload: loaderData[exactKey] } + } + + const fallbackKey = Object.keys(loaderData || {}).find((key) => key.includes('/tabelle.$filter')) + if (fallbackKey && loaderData[fallbackKey]) { + return { routeKey: fallbackKey, payload: loaderData[fallbackKey] } + } + + throw new Error('Tabellen-Route in loaderData nicht gefunden') +} + +function normalizeTableData(url, routeKey, payload) { + const data = payload?.data || {} + const leagueTable = Array.isArray(data.league_table) ? data.league_table : [] + + return { + url, + routeKey, + fetchedAt: new Date().toISOString(), + association: payload?.association || null, + season: payload?.season || null, + groupname: payload?.groupname || null, + urlid: payload?.urlid || null, + filter: payload?.filter || null, + headInfos: Array.isArray(data.head_infos) ? data.head_infos : [], + setCount: data.set_count ?? null, + gameCount: data.game_count ?? null, + noMeetings: data.no_meetings ?? null, + gamesPerSet: data.games_per_set ?? null, + leagueTtrAvg: data.league_ttr_avg ?? null, + subTableInfo: data.sub_table_info ?? null, + meetingsExcerpt: data.meetings_excerpt ?? null, + rowCount: leagueTable.length, + leagueTable + } +} + +function extractSeasonSlugFromUrl(url) { + const match = String(url || '').match(/\/click-tt\/[^/]+\/(\d{2}--\d{2})\//) + return match?.[1] || null +} + +async function resolveMannschaftenCsvPath() { + const candidates = [ + getServerDataPath('public-data', 'mannschaften.csv'), + getServerDataPath('mannschaften.csv'), + getProjectPath('.output', 'public', 'data', 'mannschaften.csv'), + getProjectPath('public', 'data', 'mannschaften.csv') + ] + + for (const candidate of candidates) { + if (await fileExists(candidate)) return candidate + } + + throw new Error('Mannschaften-Datei nicht gefunden') +} + +function extractRowsWithTableUrl(csvContent) { + const lines = csvContent.split(/\r?\n/).filter((line) => line.trim() !== '') + const rows = [] + + for (const line of lines) { + if (!line.includes('mytischtennis.de')) continue + + const values = parseDelimitedLine(line, ',') + const tableUrl = values.find((value) => /^https?:\/\/www\.mytischtennis\.de\//.test(value)) + if (!tableUrl) continue + + rows.push({ + teamName: values[0] || null, + leagueName: values[1] || null, + tableUrl + }) + } + + return rows +} + +async function fetchTable(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(`Tabellen-Download fehlgeschlagen: HTTP ${response.status}`) + } + + const html = await response.text() + const context = extractRemixContext(html) + const loaderData = context?.state?.loaderData || {} + const { routeKey, payload } = getTableRoutePayload(loaderData) + return normalizeTableData(url, routeKey, payload) +} + +export async function importLeagueTables(options = {}) { + const today = options.today ?? new Date() + const season = getSpieljahrForDate(today) + const csvPath = await resolveMannschaftenCsvPath() + const csvContent = await fs.readFile(csvPath, 'utf8') + const teams = extractRowsWithTableUrl(csvContent) + + const entries = [] + const errors = [] + + for (const team of teams) { + try { + const table = await fetchTable(team.tableUrl) + entries.push({ + teamName: team.teamName, + leagueName: team.leagueName, + table + }) + } catch (error) { + errors.push({ + teamName: team.teamName, + leagueName: team.leagueName, + tableUrl: team.tableUrl, + error: error?.message || String(error) + }) + } + } + + const fallbackSeasonSlug = teams.length > 0 ? extractSeasonSlugFromUrl(teams[0].tableUrl) : null + const seasonSlug = fallbackSeasonSlug || season.seasonSlug + const outputFile = path.join(OUTPUT_DIR, `tables_${seasonSlug}.json`) + + const payload = { + format: 'harheimertc.tables.v1', + importedAt: new Date().toISOString(), + source: { + association: DEFAULT_ASSOCIATION, + season: { + seasonSlug, + dateStart: season.dateStart, + dateEnd: season.dateEnd + }, + csvPath + }, + teamCount: teams.length, + importedCount: entries.length, + errorCount: errors.length, + tables: entries, + errors + } + + await fs.mkdir(OUTPUT_DIR, { recursive: true }) + await fs.writeFile(outputFile, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') + + return { + outputFile, + seasonSlug, + teamCount: teams.length, + importedCount: entries.length, + errorCount: errors.length, + tables: entries, + errors + } +} \ No newline at end of file From 964a68cdfda005029651388375fad80c44e5e64f Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 20 May 2026 17:36:10 +0200 Subject: [PATCH 02/11] refactor(import): drop meetings excerpt from tables export --- server/utils/spielklassen-tables-import.js | 56 ++++++++++++++++++---- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/server/utils/spielklassen-tables-import.js b/server/utils/spielklassen-tables-import.js index 8fab970..bd15bea 100644 --- a/server/utils/spielklassen-tables-import.js +++ b/server/utils/spielklassen-tables-import.js @@ -7,6 +7,33 @@ import { parseDelimitedLine } from './spielplan-data.js' const OUTPUT_DIR = getServerDataPath('spielplan-import') const DEFAULT_ASSOCIATION = 'HeTTV' +function normalizeHeaderName(value) { + return String(value || '').trim().toLowerCase() +} + +function toUrlOrNull(value) { + const raw = String(value || '').trim() + if (!raw) return null + + try { + return new URL(raw) + } catch { + return null + } +} + +function isSupportedTableUrl(urlValue) { + const parsed = toUrlOrNull(urlValue) + if (!parsed) return false + + const host = parsed.hostname.toLowerCase() + if (host !== 'www.mytischtennis.de' && host !== 'mytischtennis.de' && host !== 'click-tt.de' && host !== 'www.click-tt.de') { + return false + } + + return parsed.pathname.includes('/tabelle/') +} + async function fileExists(filePath) { try { await fs.access(filePath) @@ -73,7 +100,11 @@ function getTableRoutePayload(loaderData) { function normalizeTableData(url, routeKey, payload) { const data = payload?.data || {} - const leagueTable = Array.isArray(data.league_table) ? data.league_table : [] + if (!Array.isArray(data.league_table)) { + throw new Error('Ungueltige Tabellenstruktur: league_table fehlt') + } + + const leagueTable = data.league_table return { url, @@ -91,7 +122,6 @@ function normalizeTableData(url, routeKey, payload) { gamesPerSet: data.games_per_set ?? null, leagueTtrAvg: data.league_ttr_avg ?? null, subTableInfo: data.sub_table_info ?? null, - meetingsExcerpt: data.meetings_excerpt ?? null, rowCount: leagueTable.length, leagueTable } @@ -119,18 +149,28 @@ async function resolveMannschaftenCsvPath() { function extractRowsWithTableUrl(csvContent) { const lines = csvContent.split(/\r?\n/).filter((line) => line.trim() !== '') + if (lines.length < 2) return [] + + const headers = parseDelimitedLine(lines[0], ',').map(normalizeHeaderName) + const teamIdx = headers.indexOf('mannschaft') + const leagueIdx = headers.indexOf('liga') + const infoLinkIdx = headers.indexOf('weitere informationen link') + + if (infoLinkIdx === -1) { + throw new Error('CSV-Spalte weitere_informationen_link nicht gefunden') + } + const rows = [] - for (const line of lines) { - if (!line.includes('mytischtennis.de')) continue - + for (const line of lines.slice(1)) { const values = parseDelimitedLine(line, ',') - const tableUrl = values.find((value) => /^https?:\/\/www\.mytischtennis\.de\//.test(value)) + const tableUrl = values[infoLinkIdx] ? String(values[infoLinkIdx]).trim() : '' if (!tableUrl) continue + if (!isSupportedTableUrl(tableUrl)) continue rows.push({ - teamName: values[0] || null, - leagueName: values[1] || null, + teamName: teamIdx >= 0 ? (values[teamIdx] || null) : null, + leagueName: leagueIdx >= 0 ? (values[leagueIdx] || null) : null, tableUrl }) } From bf4db389ff991536a7c005d20f1f50c6186e884c Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 20 May 2026 17:38:17 +0200 Subject: [PATCH 03/11] feat(api): add endpoint for team table data by season --- server/api/spielplan/table.get.js | 137 ++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 server/api/spielplan/table.get.js diff --git a/server/api/spielplan/table.get.js b/server/api/spielplan/table.get.js new file mode 100644 index 0000000..6547bc8 --- /dev/null +++ b/server/api/spielplan/table.get.js @@ -0,0 +1,137 @@ +import { promises as fs } from 'fs' +import path from 'path' +import { getCurrentSeasonSlug, validateSeasonSlug } from '../../utils/spielplan-data.js' +import { getServerDataPath } from '../../utils/paths.js' +import { error as loggerError } from '../../utils/logger.js' + +const TABLES_FILE_PATTERN = /^tables_(\d{2}--\d{2})\.json$/ + +function normalizeTeamName(value) { + return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ') +} + +async function listTableSeasons() { + const importDir = getServerDataPath('spielplan-import') + + try { + const files = await fs.readdir(importDir) + return files + .map((file) => { + const match = file.match(TABLES_FILE_PATTERN) + return match ? match[1] : null + }) + .filter(Boolean) + .sort() + .reverse() + } catch (error) { + if (error?.code !== 'ENOENT') { + loggerError('Fehler beim Lesen der Tabellen-Saisons', { error }) + } + return [] + } +} + +async function readTablesBySeason(season) { + const filePath = getServerDataPath('spielplan-import', `tables_${season}.json`) + const content = await fs.readFile(filePath, 'utf8') + const parsed = JSON.parse(content) + return { filePath, parsed } +} + +function findTeamTable(tables, teamQuery) { + const normalizedQuery = normalizeTeamName(teamQuery) + + const exact = tables.find((entry) => normalizeTeamName(entry?.teamName) === normalizedQuery) + if (exact) return exact + + return tables.find((entry) => normalizeTeamName(entry?.teamName).includes(normalizedQuery)) || null +} + +export default defineEventHandler(async (event) => { + try { + const query = getQuery(event) + const team = String(query.team || query.mannschaft || '').trim() + + if (!team) { + return { + success: false, + message: 'Query-Parameter team (oder mannschaft) fehlt', + team: null, + table: null + } + } + + const requestedSeason = query.season ? String(query.season).trim() : null + if (requestedSeason && !validateSeasonSlug(requestedSeason)) { + return { + success: false, + message: 'Ungueltiger Saison-Slug', + team, + table: null, + season: requestedSeason + } + } + + const availableSeasons = await listTableSeasons() + const defaultSeason = getCurrentSeasonSlug() + const season = requestedSeason || defaultSeason + + let loaded = null + try { + loaded = await readTablesBySeason(season) + } catch (error) { + if (error?.code === 'ENOENT' && !requestedSeason && availableSeasons.length > 0) { + loaded = await readTablesBySeason(availableSeasons[0]) + } else { + throw error + } + } + + if (!loaded) { + return { + success: false, + message: 'Keine Tabellen-Daten gefunden', + team, + table: null, + season, + availableSeasons + } + } + + const payload = loaded.parsed || {} + const tables = Array.isArray(payload.tables) ? payload.tables : [] + const found = findTeamTable(tables, team) + const effectiveSeason = payload?.source?.season?.seasonSlug || season + + if (!found) { + return { + success: false, + message: 'Keine Tabellen-Daten fuer die Mannschaft gefunden', + team, + table: null, + season: effectiveSeason, + filePath: loaded.filePath, + availableTeams: tables.map((entry) => entry?.teamName).filter(Boolean) + } + } + + return { + success: true, + message: 'Tabellen-Daten erfolgreich geladen', + team, + table: found, + season: effectiveSeason, + filePath: loaded.filePath, + importedAt: payload.importedAt || null, + availableSeasons + } + } catch (error) { + loggerError('Fehler beim Laden der Tabellen-Daten:', { error }) + return { + success: false, + message: 'Fehler beim Laden der Tabellen-Daten', + team: null, + table: null + } + } +}) \ No newline at end of file From e19158558db5f9869ac4f231dd1a34040853f100 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 20 May 2026 17:41:43 +0200 Subject: [PATCH 04/11] feat(mannschaften): add matches/table tabs on team detail pages --- pages/mannschaften/[slug].vue | 204 +++++++++++++++++++++++++++++++--- 1 file changed, 190 insertions(+), 14 deletions(-) diff --git a/pages/mannschaften/[slug].vue b/pages/mannschaften/[slug].vue index 6c21e3b..9f3ecc8 100644 --- a/pages/mannschaften/[slug].vue +++ b/pages/mannschaften/[slug].vue @@ -76,40 +76,64 @@
-

- Aktueller Spielplan -

-

- Saison {{ spielplanSeasonLabel }} -

+
+
+

+ Aktueller Spielplan +

+

+ Saison {{ spielplanSeasonLabel }} +

+
+ +
+ + +
+
Spielplan wird geladen...
{{ spielplanError }}
Für diese Mannschaft sind im aktuellen Spielplan keine Spiele vorhanden.
@@ -170,6 +194,89 @@
+ +
+ Tabelle wird geladen... +
+ +
+ {{ tableError }} +
+ +
+ Für diese Mannschaft ist aktuell keine Tabelle hinterlegt. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ Platz + + Mannschaft + + Spiele + + S/U/N + + Sätze + + Bälle + + Punkte +
+ {{ row.table_rank ?? '-' }} + + {{ row.team_name || '-' }} + + {{ row.meetings_count ?? '-' }} + + {{ formatSun(row) }} + + {{ row.sets_relation || '-' }} + + {{ row.games_relation || '-' }} + + {{ formatPunkte(row) }} +
+
@@ -242,6 +349,15 @@ const mannschaftSpielplan = ref([]) const spielplanSeason = ref('') const isSpielplanLoading = ref(false) const spielplanError = ref('') +const activePanelTab = ref('matches') +const isTableLoading = ref(false) +const tableError = ref('') +const teamTableRows = ref([]) + +const hasTableLink = computed(() => { + const link = String(mannschaft.value?.weitere_informationen_link || '').trim() + return link.includes('/tabelle/') +}) const spielplanSeasonLabel = computed(() => { const match = String(spielplanSeason.value || '').match(/^(\d{2})--(\d{2})$/) @@ -317,13 +433,52 @@ const loadMannschaften = async () => { useHead({ title: `${mannschaft.value.mannschaft} - Harheimer TC`, }) - await loadSpielplan() + await Promise.all([loadSpielplan(), loadTeamTable()]) } } catch (error) { console.error('Fehler beim Laden der Mannschaften:', error) } } +const loadTeamTable = async () => { + if (!mannschaft.value) return + + if (!hasTableLink.value) { + teamTableRows.value = [] + tableError.value = '' + return + } + + isTableLoading.value = true + tableError.value = '' + + try { + const params = new URLSearchParams({ team: mannschaft.value.mannschaft }) + if (spielplanSeason.value) { + params.set('season', spielplanSeason.value) + } + + const response = await fetch(`/api/spielplan/table?${params.toString()}`) + const result = await response.json() + + if (!result.success) { + tableError.value = result.message || 'Tabelle konnte nicht geladen werden.' + teamTableRows.value = [] + return + } + + teamTableRows.value = Array.isArray(result?.table?.table?.leagueTable) + ? result.table.table.leagueTable + : [] + } catch (error) { + console.error('Fehler beim Laden der Tabelle:', error) + tableError.value = 'Tabelle konnte nicht geladen werden.' + teamTableRows.value = [] + } finally { + isTableLoading.value = false + } +} + const getTeamVariants = (cmsMannschaft) => { const mannschaftMapping = { 'Erwachsene 1': ['harheimer tc'], @@ -495,6 +650,27 @@ const getRowClass = (row) => { return 'bg-white' } +const formatSun = (row) => { + const s = row?.meetings_won + const u = row?.meetings_tie + const n = row?.meetings_lost + if (s == null && u == null && n == null) return '-' + return `${s ?? 0}/${u ?? 0}/${n ?? 0}` +} + +const formatPunkte = (row) => { + if (row?.points_won == null && row?.points_lost == null) return '-' + return `${row?.points_won ?? 0}:${row?.points_lost ?? 0}` +} + +const isCurrentTeamRow = (row) => { + const teamName = String(row?.team_name || '').toLowerCase() + if (!teamName.includes('harheimer tc')) return false + + const variants = getTeamVariants(mannschaft.value?.mannschaft || '') + return variants.some((variant) => isExactHarheimTeam(teamName, variant)) +} + onMounted(() => { loadMannschaften() }) From 2d42ef3ecdb8f3f93b15aafa94500a0a44e8c336 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 20 May 2026 17:45:14 +0200 Subject: [PATCH 05/11] feat(mannschaften): split SUN columns and prepare seasonal team CSVs --- pages/mannschaften/[slug].vue | 42 +++++++++++++++++++++----------- server/api/cms/save-csv.post.js | 7 ++++-- server/api/mannschaften.get.js | 43 ++++++++++++++++++++++++--------- 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/pages/mannschaften/[slug].vue b/pages/mannschaften/[slug].vue index 9f3ecc8..05e8665 100644 --- a/pages/mannschaften/[slug].vue +++ b/pages/mannschaften/[slug].vue @@ -51,7 +51,7 @@

- Mannschaftsaufstellung Saison 2025/26 + Mannschaftsaufstellung Saison {{ mannschaftSeasonLabel }}

- S/U/N + S + + + U + + + N Sätze - - Bälle - Punkte @@ -262,13 +265,16 @@ {{ row.meetings_count ?? '-' }} - {{ formatSun(row) }} + {{ row.meetings_won ?? 0 }} - {{ row.sets_relation || '-' }} + {{ row.meetings_tie ?? 0 }} - {{ row.games_relation || '-' }} + {{ row.meetings_lost ?? 0 }} + + + {{ formatSaetze(row) }} {{ formatPunkte(row) }} @@ -364,6 +370,15 @@ const spielplanSeasonLabel = computed(() => { return match ? `20${match[1]}/${match[2]}` : '' }) +const mannschaftSeasonLabel = computed(() => { + if (spielplanSeasonLabel.value) return spielplanSeasonLabel.value + + const now = new Date() + const year = now.getFullYear() + const start = now.getMonth() >= 6 ? year : year - 1 + return `${start}/${String(start + 1).slice(-2)}` +}) + async function fetchCsvText(url) { const attempt = async () => { const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}` @@ -650,12 +665,11 @@ const getRowClass = (row) => { return 'bg-white' } -const formatSun = (row) => { - const s = row?.meetings_won - const u = row?.meetings_tie - const n = row?.meetings_lost - if (s == null && u == null && n == null) return '-' - return `${s ?? 0}/${u ?? 0}/${n ?? 0}` +const formatSaetze = (row) => { + const won = row?.sets_won + const lost = row?.sets_lost + if (won == null && lost == null) return row?.sets_relation || '-' + return `${won ?? 0}:${lost ?? 0}` } const formatPunkte = (row) => { diff --git a/server/api/cms/save-csv.post.js b/server/api/cms/save-csv.post.js index b54d7cc..cb04f52 100644 --- a/server/api/cms/save-csv.post.js +++ b/server/api/cms/save-csv.post.js @@ -51,8 +51,9 @@ export default defineEventHandler(async (event) => { 'termine.csv', 'spielplan.csv' ] + const isSeasonalMannschaftenFile = /^mannschaften_\d{2}--\d{2}\.csv$/.test(String(filename)) - if (!allowedFiles.includes(filename)) { + if (!allowedFiles.includes(filename) && !isSeasonalMannschaftenFile) { throw createError({ statusCode: 403, statusMessage: 'Datei nicht erlaubt' @@ -105,7 +106,9 @@ export default defineEventHandler(async (event) => { 'termine.csv': [`${cwd}/server/data/public-data/termine.csv`, `${cwd}/../server/data/public-data/termine.csv`], 'spielplan.csv': [`${cwd}/server/data/public-data/spielplan.csv`, `${cwd}/../server/data/public-data/spielplan.csv`] } - const internalPaths = dataTargetsByFile[filename] || [] + const internalPaths = isSeasonalMannschaftenFile + ? [`${cwd}/server/data/public-data/${filename}`, `${cwd}/../server/data/public-data/${filename}`] + : (dataTargetsByFile[filename] || []) const uniquePaths = [...new Set([...internalPaths])] const writeResults = [] diff --git a/server/api/mannschaften.get.js b/server/api/mannschaften.get.js index 22f73d8..374528b 100644 --- a/server/api/mannschaften.get.js +++ b/server/api/mannschaften.get.js @@ -1,5 +1,10 @@ import { promises as fs } from 'fs' import path from 'path' +import { getCurrentSeasonSlug, validateSeasonSlug } from '../utils/spielplan-data.js' + +function normalizeSeasonFilename(season) { + return `mannschaften_${season}.csv` +} async function exists(p) { try { @@ -13,20 +18,36 @@ async function exists(p) { export default defineEventHandler(async (event) => { try { const cwd = process.cwd() - const filename = 'mannschaften.csv' + const query = getQuery(event) + const requestedSeason = query.season ? String(query.season).trim() : '' + + if (requestedSeason && !validateSeasonSlug(requestedSeason)) { + throw createError({ + statusCode: 400, + statusMessage: 'Ungueltiger Saison-Slug' + }) + } + + const defaultSeason = getCurrentSeasonSlug() + const candidateFileNames = requestedSeason + ? [normalizeSeasonFilename(requestedSeason), 'mannschaften.csv'] + : [normalizeSeasonFilename(defaultSeason), 'mannschaften.csv'] // Prefer CMS write target first (server/data/public-data), // then legacy locations. - const candidates = [ - path.join(cwd, 'server/data/public-data', filename), - path.join(cwd, '../server/data/public-data', filename), - path.join(cwd, '.output/server/data', filename), - path.join(cwd, 'server/data', filename), - path.join(cwd, '.output/public/data', filename), - path.join(cwd, 'public/data', filename), - path.join(cwd, '../.output/public/data', filename), - path.join(cwd, '../public/data', filename) - ] + const candidates = [] + for (const filename of candidateFileNames) { + candidates.push( + path.join(cwd, 'server/data/public-data', filename), + path.join(cwd, '../server/data/public-data', filename), + path.join(cwd, '.output/server/data', filename), + path.join(cwd, 'server/data', filename), + path.join(cwd, '.output/public/data', filename), + path.join(cwd, 'public/data', filename), + path.join(cwd, '../.output/public/data', filename), + path.join(cwd, '../public/data', filename) + ) + } let csvPath = null for (const p of candidates) { From f2f76dec5691189289ae43e549b41446c3cfb0d7 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 20 May 2026 17:49:19 +0200 Subject: [PATCH 06/11] feat(cms): add season dropdown/create and restore baelle ratio --- components/cms/CmsMannschaften.vue | 166 +++++++++++++++++++++++-- pages/mannschaften/[slug].vue | 13 ++ server/api/mannschaften/seasons.get.js | 55 ++++++++ 3 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 server/api/mannschaften/seasons.get.js diff --git a/components/cms/CmsMannschaften.vue b/components/cms/CmsMannschaften.vue index 0c4171b..4aefdbd 100644 --- a/components/cms/CmsMannschaften.vue +++ b/components/cms/CmsMannschaften.vue @@ -1,21 +1,58 @@