@@ -52,7 +52,7 @@
|
||||
<!-- Mannschaftsaufstellung -->
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">
|
||||
Mannschaftsaufstellung Saison 2025/26
|
||||
Mannschaftsaufstellung Saison {{ selectedSeasonLabel }}
|
||||
</h3>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
@@ -102,11 +102,36 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { Users } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
season: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const mannschaften = ref([])
|
||||
|
||||
const getCurrentSeasonSlug = () => {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const startYear = now.getMonth() >= 6 ? year : year - 1
|
||||
const endYear = startYear + 1
|
||||
return `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`
|
||||
}
|
||||
|
||||
const selectedSeason = computed(() => {
|
||||
const value = String(props.season || '').trim()
|
||||
return /^\d{2}--\d{2}$/.test(value) ? value : getCurrentSeasonSlug()
|
||||
})
|
||||
|
||||
const selectedSeasonLabel = computed(() => {
|
||||
const match = String(selectedSeason.value || '').match(/^(\d{2})--(\d{2})$/)
|
||||
return match ? `20${match[1]}/${match[2]}` : selectedSeason.value
|
||||
})
|
||||
|
||||
async function fetchCsvText(url) {
|
||||
const attempt = async () => {
|
||||
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
|
||||
@@ -125,7 +150,9 @@ async function fetchCsvText(url) {
|
||||
|
||||
const loadMannschaften = async () => {
|
||||
try {
|
||||
const csv = await fetchCsvText('/api/mannschaften')
|
||||
const params = new URLSearchParams()
|
||||
if (selectedSeason.value) params.set('season', selectedSeason.value)
|
||||
const csv = await fetchCsvText(`/api/mannschaften${params.toString() ? `?${params.toString()}` : ''}`)
|
||||
|
||||
// Vereinfachter CSV-Parser
|
||||
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
||||
|
||||
@@ -1,21 +1,58 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex justify-between items-center mb-6 gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
|
||||
Mannschaften verwalten
|
||||
</h2>
|
||||
<div class="w-24 h-1 bg-primary-600" />
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||
@click="openAddModal"
|
||||
>
|
||||
<Plus
|
||||
:size="20"
|
||||
class="mr-2"
|
||||
/> Mannschaft hinzufügen
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<label class="text-sm font-medium text-gray-700">Saison</label>
|
||||
<select
|
||||
v-model="selectedSeason"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isLoading || isSaving || isCreatingSeason"
|
||||
@change="onSeasonChange"
|
||||
>
|
||||
<option
|
||||
v-for="season in seasons"
|
||||
:key="season"
|
||||
:value="season"
|
||||
>
|
||||
{{ formatSeasonLabel(season) }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
class="flex items-center px-4 py-2 bg-white border border-primary-300 text-primary-700 hover:bg-primary-50 font-semibold rounded-lg transition-colors disabled:opacity-50"
|
||||
:disabled="isLoading || isSaving || isCreatingSeason"
|
||||
@click="createNextSeason"
|
||||
>
|
||||
<Loader2
|
||||
v-if="isCreatingSeason"
|
||||
:size="18"
|
||||
class="animate-spin mr-2"
|
||||
/>
|
||||
<Plus
|
||||
v-else
|
||||
:size="18"
|
||||
class="mr-2"
|
||||
/>
|
||||
Neue Saison anlegen
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||
@click="openAddModal"
|
||||
>
|
||||
<Plus
|
||||
:size="20"
|
||||
class="mr-2"
|
||||
/> Mannschaft hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -356,7 +393,10 @@ import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDo
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const isCreatingSeason = ref(false)
|
||||
const mannschaften = ref([])
|
||||
const seasons = ref([])
|
||||
const selectedSeason = ref('')
|
||||
const showModal = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const isEditing = ref(false)
|
||||
@@ -376,6 +416,45 @@ async function fetchCsvText(url) {
|
||||
try { return await attempt() } catch { await new Promise(r => setTimeout(r, 150)); return await attempt() }
|
||||
}
|
||||
|
||||
function getMannschaftenFilenameForSeason(seasonSlug = '') {
|
||||
const season = String(seasonSlug || '').trim()
|
||||
return season ? `mannschaften_${season}.csv` : 'mannschaften.csv'
|
||||
}
|
||||
|
||||
function formatSeasonLabel(seasonSlug) {
|
||||
const match = String(seasonSlug || '').match(/^(\d{2})--(\d{2})$/)
|
||||
return match ? `20${match[1]}/${match[2]}` : String(seasonSlug || '')
|
||||
}
|
||||
|
||||
function getNextSeasonSlug(seasonSlug) {
|
||||
const match = String(seasonSlug || '').match(/^(\d{2})--(\d{2})$/)
|
||||
if (!match) return ''
|
||||
const start = Number(match[1])
|
||||
const nextStart = (start + 1) % 100
|
||||
const nextEnd = (nextStart + 1) % 100
|
||||
return `${String(nextStart).padStart(2, '0')}--${String(nextEnd).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function loadSeasons() {
|
||||
try {
|
||||
const res = await fetch('/api/mannschaften/seasons')
|
||||
const result = await res.json()
|
||||
if (!result?.success || !Array.isArray(result.seasons) || result.seasons.length === 0) {
|
||||
seasons.value = [result?.currentSeason || '']
|
||||
selectedSeason.value = seasons.value[0] || ''
|
||||
return
|
||||
}
|
||||
|
||||
seasons.value = result.seasons
|
||||
if (!selectedSeason.value || !seasons.value.includes(selectedSeason.value)) {
|
||||
selectedSeason.value = result.defaultSeason || seasons.value[0]
|
||||
}
|
||||
} catch (_error) {
|
||||
if (!seasons.value.length) seasons.value = ['']
|
||||
if (!selectedSeason.value) selectedSeason.value = seasons.value[0] || ''
|
||||
}
|
||||
}
|
||||
|
||||
const mannschaftenSelectOptions = computed(() => {
|
||||
const current = (formData.value.mannschaft || '').trim()
|
||||
const names = mannschaften.value.map(m => (m?.mannschaft || '').trim()).filter(Boolean)
|
||||
@@ -392,7 +471,9 @@ function getPendingSpielerNamesForTeamIndex(teamIndex) {
|
||||
const loadMannschaften = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const csv = await fetchCsvText('/api/mannschaften')
|
||||
const params = new URLSearchParams()
|
||||
if (selectedSeason.value) params.set('season', selectedSeason.value)
|
||||
const csv = await fetchCsvText(`/api/mannschaften${params.toString() ? `?${params.toString()}` : ''}`)
|
||||
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
||||
if (lines.length < 2) { mannschaften.value = []; return }
|
||||
mannschaften.value = lines.slice(1).map(line => {
|
||||
@@ -405,6 +486,10 @@ const loadMannschaften = async () => {
|
||||
} catch (error) { console.error('Fehler beim Laden:', error); errorMessage.value = 'Fehler beim Laden der Mannschaften'; throw error } finally { isLoading.value = false }
|
||||
}
|
||||
|
||||
const onSeasonChange = async () => {
|
||||
await loadMannschaften().catch(() => {})
|
||||
}
|
||||
|
||||
const getSpielerListe = (m) => { if (!m.spieler) return []; return m.spieler.split(';').map(s => s.trim()).filter(s => s !== '') }
|
||||
const openAddModal = () => { formData.value = { mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: nowIsoDate() }; showModal.value = true; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
|
||||
const closeModal = () => { showModal.value = false; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
|
||||
@@ -451,7 +536,59 @@ const saveCSV = async () => {
|
||||
const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s }
|
||||
return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung)].join(',')
|
||||
})
|
||||
await $fetch('/api/cms/save-csv', { method: 'POST', body: { filename: 'mannschaften.csv', content: [header, ...rows].join('\n') } })
|
||||
await $fetch('/api/cms/save-csv', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
filename: getMannschaftenFilenameForSeason(selectedSeason.value),
|
||||
content: [header, ...rows].join('\n')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const createNextSeason = async () => {
|
||||
const baseSeason = selectedSeason.value || seasons.value[0]
|
||||
const nextSeason = getNextSeasonSlug(baseSeason)
|
||||
if (!nextSeason) {
|
||||
errorMessage.value = 'Naechste Saison konnte nicht ermittelt werden.'
|
||||
return
|
||||
}
|
||||
|
||||
if (seasons.value.includes(nextSeason)) {
|
||||
selectedSeason.value = nextSeason
|
||||
await loadMannschaften().catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
isCreatingSeason.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung'
|
||||
const rows = mannschaften.value.map(m => {
|
||||
const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s }
|
||||
return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung || nowIsoDate())].join(',')
|
||||
})
|
||||
|
||||
await $fetch('/api/cms/save-csv', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
filename: getMannschaftenFilenameForSeason(nextSeason),
|
||||
content: [header, ...rows].join('\n')
|
||||
}
|
||||
})
|
||||
|
||||
await loadSeasons()
|
||||
selectedSeason.value = nextSeason
|
||||
await loadMannschaften().catch(() => {})
|
||||
if (window.showSuccessModal) {
|
||||
window.showSuccessModal('Erfolg', `Neue Saison ${formatSeasonLabel(nextSeason)} wurde angelegt.`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Anlegen der Saison:', error)
|
||||
errorMessage.value = error?.data?.statusMessage || error?.statusMessage || 'Saison konnte nicht angelegt werden.'
|
||||
if (window.showErrorModal) window.showErrorModal('Fehler', errorMessage.value)
|
||||
} finally {
|
||||
isCreatingSeason.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (mannschaft, index) => {
|
||||
@@ -462,5 +599,8 @@ const confirmDelete = (mannschaft, index) => {
|
||||
} else { if (confirm(`Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`)) { mannschaften.value.splice(index, 1); saveCSV(); loadMannschaften() } }
|
||||
}
|
||||
|
||||
onMounted(() => { loadMannschaften().catch(() => {}) })
|
||||
onMounted(async () => {
|
||||
await loadSeasons()
|
||||
await loadMannschaften().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.4.7",
|
||||
"version": "1.5.0",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<!-- Mannschaftsaufstellung -->
|
||||
<div class="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-900 mb-6">
|
||||
Mannschaftsaufstellung Saison 2025/26
|
||||
Mannschaftsaufstellung Saison {{ mannschaftSeasonLabel }}
|
||||
</h2>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
@@ -76,40 +76,64 @@
|
||||
<!-- Aktueller Spielplan -->
|
||||
<div class="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-2xl font-semibold text-gray-900">
|
||||
Aktueller Spielplan
|
||||
</h2>
|
||||
<p
|
||||
v-if="spielplanSeasonLabel"
|
||||
class="text-sm text-gray-600 mt-1"
|
||||
>
|
||||
Saison {{ spielplanSeasonLabel }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-900">
|
||||
Aktueller Spielplan
|
||||
</h2>
|
||||
<p
|
||||
v-if="spielplanSeasonLabel"
|
||||
class="text-sm text-gray-600 mt-1"
|
||||
>
|
||||
Saison {{ spielplanSeasonLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex rounded-lg border border-gray-200 p-1 bg-gray-50">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
:class="activePanelTab === 'matches' ? 'bg-white text-primary-700 shadow-sm' : 'text-gray-600 hover:text-gray-900'"
|
||||
@click="activePanelTab = 'matches'"
|
||||
>
|
||||
Matches
|
||||
</button>
|
||||
<button
|
||||
v-if="hasTableLink"
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
:class="activePanelTab === 'table' ? 'bg-white text-primary-700 shadow-sm' : 'text-gray-600 hover:text-gray-900'"
|
||||
@click="activePanelTab = 'table'"
|
||||
>
|
||||
Tabelle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isSpielplanLoading"
|
||||
v-if="activePanelTab === 'matches' && isSpielplanLoading"
|
||||
class="p-6 text-sm text-gray-600"
|
||||
>
|
||||
Spielplan wird geladen...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="spielplanError"
|
||||
v-else-if="activePanelTab === 'matches' && spielplanError"
|
||||
class="p-6 text-sm text-red-600"
|
||||
>
|
||||
{{ spielplanError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="mannschaftSpielplan.length === 0"
|
||||
v-else-if="activePanelTab === 'matches' && mannschaftSpielplan.length === 0"
|
||||
class="p-6 text-sm text-gray-600"
|
||||
>
|
||||
Für diese Mannschaft sind im aktuellen Spielplan keine Spiele vorhanden.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-else-if="activePanelTab === 'matches'"
|
||||
class="overflow-x-auto"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
@@ -170,6 +194,101 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activePanelTab === 'table' && isTableLoading"
|
||||
class="p-6 text-sm text-gray-600"
|
||||
>
|
||||
Tabelle wird geladen...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activePanelTab === 'table' && tableError"
|
||||
class="p-6 text-sm text-red-600"
|
||||
>
|
||||
{{ tableError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activePanelTab === 'table' && teamTableRows.length === 0"
|
||||
class="p-6 text-sm text-gray-600"
|
||||
>
|
||||
Für diese Mannschaft ist aktuell keine Tabelle hinterlegt.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activePanelTab === 'table'"
|
||||
class="overflow-x-auto"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Platz
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Mannschaft
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Spiele
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
S
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
U
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Sätze
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Bälle
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Punkte
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr
|
||||
v-for="(row, index) in teamTableRows"
|
||||
:key="`${row.team_id || row.team_name || 'row'}-${index}`"
|
||||
:class="isCurrentTeamRow(row) ? 'bg-primary-50' : 'bg-white'"
|
||||
>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ row.table_rank ?? '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-medium">
|
||||
{{ row.team_name || '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ row.meetings_count ?? '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ row.meetings_won ?? 0 }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ row.meetings_tie ?? 0 }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ row.meetings_lost ?? 0 }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ formatSaetze(row) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ formatBaelle(row) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ formatPunkte(row) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
@@ -196,14 +315,17 @@
|
||||
<!-- Letzte Aktualisierung -->
|
||||
<div class="bg-white rounded-xl shadow-lg p-6">
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
Zuletzt aktualisiert am: {{ formatDate(mannschaft.letzte_aktualisierung) }}
|
||||
Mannschaftsinformationen aktualisiert am: {{ formatDate(mannschaft.letzte_aktualisierung) }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 text-center mt-1">
|
||||
Daten aktualisiert am: {{ dataUpdatedLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Zurück-Button -->
|
||||
<div class="text-center">
|
||||
<NuxtLink
|
||||
to="/mannschaften"
|
||||
:to="{ path: '/mannschaften', query: selectedSeason ? { season: selectedSeason } : {} }"
|
||||
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
← Zurück zur Übersicht
|
||||
@@ -237,17 +359,55 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { Users } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const getCurrentSeasonSlug = () => {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const startYear = now.getMonth() >= 6 ? year : year - 1
|
||||
const endYear = startYear + 1
|
||||
return `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`
|
||||
}
|
||||
|
||||
const selectedSeason = computed(() => {
|
||||
const value = String(route.query.season || '').trim()
|
||||
return /^\d{2}--\d{2}$/.test(value) ? value : getCurrentSeasonSlug()
|
||||
})
|
||||
const mannschaft = ref(null)
|
||||
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 spielplanDataUpdatedAt = ref('')
|
||||
const tableDataUpdatedAt = 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})$/)
|
||||
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)}`
|
||||
})
|
||||
|
||||
const dataUpdatedLabel = computed(() => {
|
||||
const dateValue = tableDataUpdatedAt.value || spielplanDataUpdatedAt.value || mannschaft.value?.letzte_aktualisierung || ''
|
||||
return formatDate(dateValue)
|
||||
})
|
||||
|
||||
async function fetchCsvText(url) {
|
||||
const attempt = async () => {
|
||||
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
|
||||
@@ -266,7 +426,9 @@ async function fetchCsvText(url) {
|
||||
|
||||
const loadMannschaften = async () => {
|
||||
try {
|
||||
const csv = await fetchCsvText('/api/mannschaften')
|
||||
const params = new URLSearchParams()
|
||||
if (selectedSeason.value) params.set('season', selectedSeason.value)
|
||||
const csv = await fetchCsvText(`/api/mannschaften${params.toString() ? `?${params.toString()}` : ''}`)
|
||||
if (!csv) return
|
||||
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
||||
|
||||
@@ -317,13 +479,55 @@ 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 (selectedSeason.value) {
|
||||
params.set('season', selectedSeason.value)
|
||||
} else 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
|
||||
: []
|
||||
tableDataUpdatedAt.value = result?.importedAt || ''
|
||||
} 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'],
|
||||
@@ -407,7 +611,9 @@ const loadSpielplan = async () => {
|
||||
spielplanError.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/spielplan')
|
||||
const params = new URLSearchParams()
|
||||
if (selectedSeason.value) params.set('season', selectedSeason.value)
|
||||
const response = await fetch(`/api/spielplan${params.toString() ? `?${params.toString()}` : ''}`)
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
@@ -417,6 +623,7 @@ const loadSpielplan = async () => {
|
||||
}
|
||||
|
||||
spielplanSeason.value = result.season || ''
|
||||
spielplanDataUpdatedAt.value = result?.source?.updatedAt || result?.source?.importedAt || ''
|
||||
mannschaftSpielplan.value = result.data
|
||||
.filter(row => isSpielForMannschaft(row, mannschaft.value.mannschaft))
|
||||
.sort((a, b) => parseTerminTimestamp(a) - parseTerminTimestamp(b))
|
||||
@@ -495,6 +702,33 @@ const getRowClass = (row) => {
|
||||
return 'bg-white'
|
||||
}
|
||||
|
||||
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 formatBaelle = (row) => {
|
||||
const won = row?.games_won
|
||||
const lost = row?.games_lost
|
||||
if (won == null && lost == null) return row?.games_relation || '-'
|
||||
return `${won ?? 0}:${lost ?? 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()
|
||||
})
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
||||
|
||||
<p class="text-xl text-gray-600 mb-12">
|
||||
Unsere aktiven Mannschaften in der Saison 2025/26
|
||||
Unsere aktiven Mannschaften in der Saison {{ selectedSeasonLabel }}
|
||||
</p>
|
||||
|
||||
<MannschaftenUebersicht />
|
||||
<MannschaftenUebersicht :season="selectedSeason" />
|
||||
|
||||
<div class="mt-16">
|
||||
<div class="bg-primary-50 p-8 rounded-xl border border-primary-100">
|
||||
@@ -21,7 +21,7 @@
|
||||
Alle aktuellen Spielpläne und Ergebnisse unserer Mannschaften finden Sie hier.
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/mannschaften/spielplaene"
|
||||
:to="{ path: '/mannschaften/spielplaene', query: { season: selectedSeason } }"
|
||||
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
Zu den Spielplänen
|
||||
@@ -33,8 +33,29 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import MannschaftenUebersicht from '~/components/MannschaftenUebersicht.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const getCurrentSeasonSlug = () => {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const startYear = now.getMonth() >= 6 ? year : year - 1
|
||||
const endYear = startYear + 1
|
||||
return `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`
|
||||
}
|
||||
|
||||
const selectedSeason = computed(() => {
|
||||
const value = String(route.query.season || '').trim()
|
||||
return /^\d{2}--\d{2}$/.test(value) ? value : getCurrentSeasonSlug()
|
||||
})
|
||||
|
||||
const selectedSeasonLabel = computed(() => {
|
||||
const match = String(selectedSeason.value || '').match(/^(\d{2})--(\d{2})$/)
|
||||
return match ? `20${match[1]}/${match[2]}` : selectedSeason.value
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Mannschaften - Harheimer TC',
|
||||
})
|
||||
|
||||
@@ -361,6 +361,9 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
useHead({
|
||||
title: 'Spielpläne - Mannschaften - Harheimer TC'
|
||||
})
|
||||
@@ -378,6 +381,19 @@ const seasons = ref([])
|
||||
const selectedSeason = ref('')
|
||||
const hasLoadedSpielplan = ref(false)
|
||||
|
||||
function getCurrentSeasonSlug() {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const startYear = now.getMonth() >= 6 ? year : year - 1
|
||||
const endYear = startYear + 1
|
||||
return `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`
|
||||
}
|
||||
|
||||
function normalizeSeasonOrDefault(value) {
|
||||
const season = String(value || '').trim()
|
||||
return /^\d{2}--\d{2}$/.test(season) ? season : getCurrentSeasonSlug()
|
||||
}
|
||||
|
||||
async function fetchCsvText(url) {
|
||||
const attempt = async () => {
|
||||
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
|
||||
@@ -405,7 +421,7 @@ const loadData = async () => {
|
||||
|
||||
const [spielplanResponse, mannschaftenResponse] = await Promise.all([
|
||||
fetch(`/api/spielplan${params.toString() ? `?${params.toString()}` : ''}`),
|
||||
fetchCsvText('/api/mannschaften')
|
||||
fetchCsvText(`/api/mannschaften${params.toString() ? `?${params.toString()}` : ''}`)
|
||||
])
|
||||
const spielplanResult = await spielplanResponse.json()
|
||||
|
||||
@@ -494,11 +510,13 @@ const applyMannschaftenResponse = async (csvText) => {
|
||||
}
|
||||
|
||||
const onSeasonChange = () => {
|
||||
router.replace({ query: { ...route.query, season: selectedSeason.value } })
|
||||
spielplanData.value = []
|
||||
filteredData.value = []
|
||||
headers.value = []
|
||||
lastUpdated.value = ''
|
||||
hasLoadedSpielplan.value = false
|
||||
loadData()
|
||||
}
|
||||
|
||||
const filterData = () => {
|
||||
@@ -841,6 +859,7 @@ const getWettbewerbText = () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
selectedSeason.value = normalizeSeasonOrDefault(route.query.season)
|
||||
loadData()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { importSpielplan } from '../server/utils/spielplan-import.js'
|
||||
import { importLeagueTables } from '../server/utils/spielklassen-tables-import.js'
|
||||
import { publishImportedSpielplan } from '../server/utils/spielplan-publish.js'
|
||||
|
||||
const result = await importSpielplan()
|
||||
const published = await publishImportedSpielplan({ inputPath: result.jsonFile })
|
||||
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(`Spielplan publiziert: ${published.internalSeasonPath}`)
|
||||
console.log(`Spielplan publiziert (public): ${published.publicSeasonPath}`)
|
||||
console.log(`Tabellen gespeichert: ${tables.outputFile}`)
|
||||
console.log(`Tabellen importiert: ${tables.importedCount}/${tables.teamCount} (Fehler: ${tables.errorCount})`)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
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`
|
||||
}
|
||||
|
||||
function isAllowedFilename(filename) {
|
||||
return filename === 'mannschaften.csv' || /^mannschaften_\d{2}--\d{2}\.csv$/.test(String(filename || ''))
|
||||
}
|
||||
|
||||
function buildCsvCandidates(cwd, filename) {
|
||||
const safeFilename = String(filename || '').trim()
|
||||
if (!isAllowedFilename(safeFilename)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Ungueltiger Dateiname fuer Mannschaften'
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
`${cwd}/server/data/public-data/${safeFilename}`,
|
||||
`${cwd}/../server/data/public-data/${safeFilename}`,
|
||||
`${cwd}/.output/server/data/${safeFilename}`,
|
||||
`${cwd}/server/data/${safeFilename}`,
|
||||
`${cwd}/.output/public/data/${safeFilename}`,
|
||||
`${cwd}/public/data/${safeFilename}`,
|
||||
`${cwd}/../.output/public/data/${safeFilename}`,
|
||||
`${cwd}/../public/data/${safeFilename}`
|
||||
]
|
||||
}
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
@@ -13,20 +42,27 @@ 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(...buildCsvCandidates(cwd, filename))
|
||||
}
|
||||
|
||||
let csvPath = null
|
||||
for (const p of candidates) {
|
||||
|
||||
55
server/api/mannschaften/seasons.get.js
Normal file
55
server/api/mannschaften/seasons.get.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { getCurrentSeasonSlug } from '../../utils/spielplan-data.js'
|
||||
|
||||
const SEASON_FILE_PATTERN = /^mannschaften_(\d{2}--\d{2})\.csv$/
|
||||
|
||||
async function collectSeasonFiles(dirPath) {
|
||||
try {
|
||||
const files = await fs.readdir(dirPath)
|
||||
return files
|
||||
.map((name) => {
|
||||
const match = name.match(SEASON_FILE_PATTERN)
|
||||
return match ? match[1] : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
try {
|
||||
const cwd = process.cwd()
|
||||
const dirs = [
|
||||
path.join(cwd, 'server/data/public-data'),
|
||||
path.join(cwd, '../server/data/public-data'),
|
||||
path.join(cwd, '.output/public/data'),
|
||||
path.join(cwd, 'public/data')
|
||||
]
|
||||
|
||||
const seasonLists = await Promise.all(dirs.map((dir) => collectSeasonFiles(dir)))
|
||||
const allSeasons = [...new Set(seasonLists.flat())].sort().reverse()
|
||||
|
||||
const currentSeason = getCurrentSeasonSlug()
|
||||
const seasons = allSeasons.includes(currentSeason)
|
||||
? allSeasons
|
||||
: [currentSeason, ...allSeasons]
|
||||
|
||||
return {
|
||||
success: true,
|
||||
seasons,
|
||||
currentSeason,
|
||||
defaultSeason: seasons[0] || currentSeason
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Mannschafts-Saisons:', error)
|
||||
return {
|
||||
success: false,
|
||||
seasons: [getCurrentSeasonSlug()],
|
||||
currentSeason: getCurrentSeasonSlug(),
|
||||
defaultSeason: getCurrentSeasonSlug()
|
||||
}
|
||||
}
|
||||
})
|
||||
137
server/api/spielplan/table.get.js
Normal file
137
server/api/spielplan/table.get.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { importSpielplan } from '../utils/spielplan-import.js'
|
||||
import { importLeagueTables } from '../utils/spielklassen-tables-import.js'
|
||||
import { publishImportedSpielplan } from '../utils/spielplan-publish.js'
|
||||
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
|
||||
|
||||
const TIME_ZONE = 'Europe/Berlin'
|
||||
@@ -68,8 +70,25 @@ 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}` })
|
||||
|
||||
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 })
|
||||
}
|
||||
} catch (error) {
|
||||
loggerError('[spielplan-import] Import fehlgeschlagen:', { error })
|
||||
} finally {
|
||||
|
||||
267
server/utils/spielklassen-tables-import.js
Normal file
267
server/utils/spielklassen-tables-import.js
Normal file
@@ -0,0 +1,267 @@
|
||||
import { promises as fs } from 'fs'
|
||||
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'
|
||||
|
||||
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)
|
||||
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 || {}
|
||||
if (!Array.isArray(data.league_table)) {
|
||||
throw new Error('Ungueltige Tabellenstruktur: league_table fehlt')
|
||||
}
|
||||
|
||||
const leagueTable = 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,
|
||||
rowCount: leagueTable.length,
|
||||
leagueTable
|
||||
}
|
||||
}
|
||||
|
||||
function extractSeasonSlugFromUrl(url) {
|
||||
const match = String(url || '').match(/\/click-tt\/[^/]+\/(\d{2}--\d{2})\//)
|
||||
return match?.[1] || null
|
||||
}
|
||||
|
||||
function sanitizeSeasonSlug(value) {
|
||||
const seasonSlug = String(value || '').trim()
|
||||
return /^\d{2}--\d{2}$/.test(seasonSlug) ? seasonSlug : 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() !== '')
|
||||
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.slice(1)) {
|
||||
const values = parseDelimitedLine(line, ',')
|
||||
const tableUrl = values[infoLinkIdx] ? String(values[infoLinkIdx]).trim() : ''
|
||||
if (!tableUrl) continue
|
||||
if (!isSupportedTableUrl(tableUrl)) continue
|
||||
|
||||
rows.push({
|
||||
teamName: teamIdx >= 0 ? (values[teamIdx] || null) : null,
|
||||
leagueName: leagueIdx >= 0 ? (values[leagueIdx] || null) : 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 = sanitizeSeasonSlug(fallbackSeasonSlug) || sanitizeSeasonSlug(season.seasonSlug) || getSpieljahrForDate(today).seasonSlug
|
||||
const outputFile = `${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
|
||||
}
|
||||
}
|
||||
42
server/utils/spielplan-publish.js
Normal file
42
server/utils/spielplan-publish.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import { convertImportedSpielplanToJson, validateImportedSpielplan } from './spielplan-data.js'
|
||||
import { getProjectPath, getServerDataPath } from './paths.js'
|
||||
|
||||
const DEFAULT_INPUT_PATH = getServerDataPath('spielplan-import', 'harheimer_tc_spielplan.json')
|
||||
|
||||
async function writeJsonAtomic(filePath, data) {
|
||||
await fs.mkdir(filePath.substring(0, filePath.lastIndexOf('/')), { recursive: true })
|
||||
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
|
||||
const content = `${JSON.stringify(data, null, 2)}\n`
|
||||
|
||||
await fs.writeFile(tmpPath, content, 'utf8')
|
||||
await fs.rename(tmpPath, filePath)
|
||||
}
|
||||
|
||||
export async function publishImportedSpielplan(options = {}) {
|
||||
const inputPath = options.inputPath || DEFAULT_INPUT_PATH
|
||||
const raw = await fs.readFile(inputPath, 'utf8')
|
||||
const imported = JSON.parse(raw)
|
||||
|
||||
validateImportedSpielplan(imported)
|
||||
|
||||
const spielplan = convertImportedSpielplanToJson(imported)
|
||||
const seasonSlug = imported.source?.season?.seasonSlug
|
||||
if (!seasonSlug) {
|
||||
throw new Error('Saison-Slug fehlt in der Import-Datei')
|
||||
}
|
||||
|
||||
const internalSeasonPath = getServerDataPath('public-data', 'spielplaene', `spielplan-${seasonSlug}.json`)
|
||||
const publicSeasonPath = getProjectPath('public', 'data', 'spielplaene', `spielplan-${seasonSlug}.json`)
|
||||
|
||||
await writeJsonAtomic(internalSeasonPath, spielplan)
|
||||
await writeJsonAtomic(publicSeasonPath, spielplan)
|
||||
|
||||
return {
|
||||
seasonSlug,
|
||||
matchCount: imported.matchCount,
|
||||
inputPath,
|
||||
internalSeasonPath,
|
||||
publicSeasonPath
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user