@@ -52,7 +52,7 @@
|
|||||||
<!-- Mannschaftsaufstellung -->
|
<!-- Mannschaftsaufstellung -->
|
||||||
<div class="border-t border-gray-200 pt-6">
|
<div class="border-t border-gray-200 pt-6">
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">
|
<h3 class="text-xl font-semibold text-gray-900 mb-4">
|
||||||
Mannschaftsaufstellung Saison 2025/26
|
Mannschaftsaufstellung Saison {{ selectedSeasonLabel }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div
|
<div
|
||||||
@@ -102,11 +102,36 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { Users } from 'lucide-vue-next'
|
import { Users } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
season: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const mannschaften = ref([])
|
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) {
|
async function fetchCsvText(url) {
|
||||||
const attempt = async () => {
|
const attempt = async () => {
|
||||||
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
|
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
|
||||||
@@ -125,7 +150,9 @@ async function fetchCsvText(url) {
|
|||||||
|
|
||||||
const loadMannschaften = async () => {
|
const loadMannschaften = async () => {
|
||||||
try {
|
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
|
// Vereinfachter CSV-Parser
|
||||||
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
||||||
|
|||||||
@@ -1,21 +1,58 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6 gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
|
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
|
||||||
Mannschaften verwalten
|
Mannschaften verwalten
|
||||||
</h2>
|
</h2>
|
||||||
<div class="w-24 h-1 bg-primary-600" />
|
<div class="w-24 h-1 bg-primary-600" />
|
||||||
</div>
|
</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"
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
@click="openAddModal"
|
<label class="text-sm font-medium text-gray-700">Saison</label>
|
||||||
>
|
<select
|
||||||
<Plus
|
v-model="selectedSeason"
|
||||||
:size="20"
|
class="px-3 py-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
class="mr-2"
|
:disabled="isLoading || isSaving || isCreatingSeason"
|
||||||
/> Mannschaft hinzufügen
|
@change="onSeasonChange"
|
||||||
</button>
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -356,7 +393,10 @@ import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDo
|
|||||||
|
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
|
const isCreatingSeason = ref(false)
|
||||||
const mannschaften = ref([])
|
const mannschaften = ref([])
|
||||||
|
const seasons = ref([])
|
||||||
|
const selectedSeason = ref('')
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const isEditing = ref(false)
|
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() }
|
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 mannschaftenSelectOptions = computed(() => {
|
||||||
const current = (formData.value.mannschaft || '').trim()
|
const current = (formData.value.mannschaft || '').trim()
|
||||||
const names = mannschaften.value.map(m => (m?.mannschaft || '').trim()).filter(Boolean)
|
const names = mannschaften.value.map(m => (m?.mannschaft || '').trim()).filter(Boolean)
|
||||||
@@ -392,7 +471,9 @@ function getPendingSpielerNamesForTeamIndex(teamIndex) {
|
|||||||
const loadMannschaften = async () => {
|
const loadMannschaften = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
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() !== '')
|
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
||||||
if (lines.length < 2) { mannschaften.value = []; return }
|
if (lines.length < 2) { mannschaften.value = []; return }
|
||||||
mannschaften.value = lines.slice(1).map(line => {
|
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 }
|
} 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 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 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() }
|
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 }
|
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(',')
|
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) => {
|
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() } }
|
} 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>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "harheimertc-website",
|
"name": "harheimertc-website",
|
||||||
"version": "1.4.7",
|
"version": "1.5.0",
|
||||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<!-- Mannschaftsaufstellung -->
|
<!-- Mannschaftsaufstellung -->
|
||||||
<div class="bg-white rounded-xl shadow-lg p-6">
|
<div class="bg-white rounded-xl shadow-lg p-6">
|
||||||
<h2 class="text-2xl font-semibold text-gray-900 mb-6">
|
<h2 class="text-2xl font-semibold text-gray-900 mb-6">
|
||||||
Mannschaftsaufstellung Saison 2025/26
|
Mannschaftsaufstellung Saison {{ mannschaftSeasonLabel }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div
|
<div
|
||||||
@@ -76,40 +76,64 @@
|
|||||||
<!-- Aktueller Spielplan -->
|
<!-- Aktueller Spielplan -->
|
||||||
<div class="bg-white rounded-xl shadow-lg overflow-hidden">
|
<div class="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
<h2 class="text-2xl font-semibold text-gray-900">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
Aktueller Spielplan
|
<div>
|
||||||
</h2>
|
<h2 class="text-2xl font-semibold text-gray-900">
|
||||||
<p
|
Aktueller Spielplan
|
||||||
v-if="spielplanSeasonLabel"
|
</h2>
|
||||||
class="text-sm text-gray-600 mt-1"
|
<p
|
||||||
>
|
v-if="spielplanSeasonLabel"
|
||||||
Saison {{ spielplanSeasonLabel }}
|
class="text-sm text-gray-600 mt-1"
|
||||||
</p>
|
>
|
||||||
|
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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isSpielplanLoading"
|
v-if="activePanelTab === 'matches' && isSpielplanLoading"
|
||||||
class="p-6 text-sm text-gray-600"
|
class="p-6 text-sm text-gray-600"
|
||||||
>
|
>
|
||||||
Spielplan wird geladen...
|
Spielplan wird geladen...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="spielplanError"
|
v-else-if="activePanelTab === 'matches' && spielplanError"
|
||||||
class="p-6 text-sm text-red-600"
|
class="p-6 text-sm text-red-600"
|
||||||
>
|
>
|
||||||
{{ spielplanError }}
|
{{ spielplanError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="mannschaftSpielplan.length === 0"
|
v-else-if="activePanelTab === 'matches' && mannschaftSpielplan.length === 0"
|
||||||
class="p-6 text-sm text-gray-600"
|
class="p-6 text-sm text-gray-600"
|
||||||
>
|
>
|
||||||
Für diese Mannschaft sind im aktuellen Spielplan keine Spiele vorhanden.
|
Für diese Mannschaft sind im aktuellen Spielplan keine Spiele vorhanden.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else-if="activePanelTab === 'matches'"
|
||||||
class="overflow-x-auto"
|
class="overflow-x-auto"
|
||||||
>
|
>
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
@@ -170,6 +194,101 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Links -->
|
<!-- Links -->
|
||||||
@@ -196,14 +315,17 @@
|
|||||||
<!-- Letzte Aktualisierung -->
|
<!-- Letzte Aktualisierung -->
|
||||||
<div class="bg-white rounded-xl shadow-lg p-6">
|
<div class="bg-white rounded-xl shadow-lg p-6">
|
||||||
<p class="text-sm text-gray-500 text-center">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zurück-Button -->
|
<!-- Zurück-Button -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<NuxtLink
|
<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"
|
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
|
← Zurück zur Übersicht
|
||||||
@@ -237,17 +359,55 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { Users } from 'lucide-vue-next'
|
import { Users } from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
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 mannschaft = ref(null)
|
||||||
const mannschaftSpielplan = ref([])
|
const mannschaftSpielplan = ref([])
|
||||||
const spielplanSeason = ref('')
|
const spielplanSeason = ref('')
|
||||||
const isSpielplanLoading = ref(false)
|
const isSpielplanLoading = ref(false)
|
||||||
const spielplanError = ref('')
|
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 spielplanSeasonLabel = computed(() => {
|
||||||
const match = String(spielplanSeason.value || '').match(/^(\d{2})--(\d{2})$/)
|
const match = String(spielplanSeason.value || '').match(/^(\d{2})--(\d{2})$/)
|
||||||
return match ? `20${match[1]}/${match[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) {
|
async function fetchCsvText(url) {
|
||||||
const attempt = async () => {
|
const attempt = async () => {
|
||||||
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
|
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
|
||||||
@@ -266,7 +426,9 @@ async function fetchCsvText(url) {
|
|||||||
|
|
||||||
const loadMannschaften = async () => {
|
const loadMannschaften = async () => {
|
||||||
try {
|
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
|
if (!csv) return
|
||||||
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
||||||
|
|
||||||
@@ -317,13 +479,55 @@ const loadMannschaften = async () => {
|
|||||||
useHead({
|
useHead({
|
||||||
title: `${mannschaft.value.mannschaft} - Harheimer TC`,
|
title: `${mannschaft.value.mannschaft} - Harheimer TC`,
|
||||||
})
|
})
|
||||||
await loadSpielplan()
|
await Promise.all([loadSpielplan(), loadTeamTable()])
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Mannschaften:', 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 getTeamVariants = (cmsMannschaft) => {
|
||||||
const mannschaftMapping = {
|
const mannschaftMapping = {
|
||||||
'Erwachsene 1': ['harheimer tc'],
|
'Erwachsene 1': ['harheimer tc'],
|
||||||
@@ -407,7 +611,9 @@ const loadSpielplan = async () => {
|
|||||||
spielplanError.value = ''
|
spielplanError.value = ''
|
||||||
|
|
||||||
try {
|
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()
|
const result = await response.json()
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -417,6 +623,7 @@ const loadSpielplan = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
spielplanSeason.value = result.season || ''
|
spielplanSeason.value = result.season || ''
|
||||||
|
spielplanDataUpdatedAt.value = result?.source?.updatedAt || result?.source?.importedAt || ''
|
||||||
mannschaftSpielplan.value = result.data
|
mannschaftSpielplan.value = result.data
|
||||||
.filter(row => isSpielForMannschaft(row, mannschaft.value.mannschaft))
|
.filter(row => isSpielForMannschaft(row, mannschaft.value.mannschaft))
|
||||||
.sort((a, b) => parseTerminTimestamp(a) - parseTerminTimestamp(b))
|
.sort((a, b) => parseTerminTimestamp(a) - parseTerminTimestamp(b))
|
||||||
@@ -495,6 +702,33 @@ const getRowClass = (row) => {
|
|||||||
return 'bg-white'
|
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(() => {
|
onMounted(() => {
|
||||||
loadMannschaften()
|
loadMannschaften()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
||||||
|
|
||||||
<p class="text-xl text-gray-600 mb-12">
|
<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>
|
</p>
|
||||||
|
|
||||||
<MannschaftenUebersicht />
|
<MannschaftenUebersicht :season="selectedSeason" />
|
||||||
|
|
||||||
<div class="mt-16">
|
<div class="mt-16">
|
||||||
<div class="bg-primary-50 p-8 rounded-xl border border-primary-100">
|
<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.
|
Alle aktuellen Spielpläne und Ergebnisse unserer Mannschaften finden Sie hier.
|
||||||
</p>
|
</p>
|
||||||
<NuxtLink
|
<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"
|
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
|
Zu den Spielplänen
|
||||||
@@ -33,8 +33,29 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
import MannschaftenUebersicht from '~/components/MannschaftenUebersicht.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({
|
useHead({
|
||||||
title: 'Mannschaften - Harheimer TC',
|
title: 'Mannschaften - Harheimer TC',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -361,6 +361,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'Spielpläne - Mannschaften - Harheimer TC'
|
title: 'Spielpläne - Mannschaften - Harheimer TC'
|
||||||
})
|
})
|
||||||
@@ -378,6 +381,19 @@ const seasons = ref([])
|
|||||||
const selectedSeason = ref('')
|
const selectedSeason = ref('')
|
||||||
const hasLoadedSpielplan = ref(false)
|
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) {
|
async function fetchCsvText(url) {
|
||||||
const attempt = async () => {
|
const attempt = async () => {
|
||||||
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
|
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
|
||||||
@@ -405,7 +421,7 @@ const loadData = async () => {
|
|||||||
|
|
||||||
const [spielplanResponse, mannschaftenResponse] = await Promise.all([
|
const [spielplanResponse, mannschaftenResponse] = await Promise.all([
|
||||||
fetch(`/api/spielplan${params.toString() ? `?${params.toString()}` : ''}`),
|
fetch(`/api/spielplan${params.toString() ? `?${params.toString()}` : ''}`),
|
||||||
fetchCsvText('/api/mannschaften')
|
fetchCsvText(`/api/mannschaften${params.toString() ? `?${params.toString()}` : ''}`)
|
||||||
])
|
])
|
||||||
const spielplanResult = await spielplanResponse.json()
|
const spielplanResult = await spielplanResponse.json()
|
||||||
|
|
||||||
@@ -494,11 +510,13 @@ const applyMannschaftenResponse = async (csvText) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSeasonChange = () => {
|
const onSeasonChange = () => {
|
||||||
|
router.replace({ query: { ...route.query, season: selectedSeason.value } })
|
||||||
spielplanData.value = []
|
spielplanData.value = []
|
||||||
filteredData.value = []
|
filteredData.value = []
|
||||||
headers.value = []
|
headers.value = []
|
||||||
lastUpdated.value = ''
|
lastUpdated.value = ''
|
||||||
hasLoadedSpielplan.value = false
|
hasLoadedSpielplan.value = false
|
||||||
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterData = () => {
|
const filterData = () => {
|
||||||
@@ -841,6 +859,7 @@ const getWettbewerbText = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
selectedSeason.value = normalizeSeasonOrDefault(route.query.season)
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { importSpielplan } from '../server/utils/spielplan-import.js'
|
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 result = await importSpielplan()
|
||||||
|
const published = await publishImportedSpielplan({ inputPath: result.jsonFile })
|
||||||
|
const tables = await importLeagueTables()
|
||||||
|
|
||||||
console.log(`Spielplan gespeichert: ${result.jsonFile}`)
|
console.log(`Spielplan gespeichert: ${result.jsonFile}`)
|
||||||
console.log(`Roh-HTML gespeichert: ${result.htmlFile}`)
|
console.log(`Roh-HTML gespeichert: ${result.htmlFile}`)
|
||||||
console.log(`Spiele: ${result.matchCount}`)
|
console.log(`Spiele: ${result.matchCount}`)
|
||||||
console.log(`Zeitraum: ${result.source.season.dateStart} bis ${result.source.season.dateEnd}`)
|
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',
|
'termine.csv',
|
||||||
'spielplan.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({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: 'Datei nicht erlaubt'
|
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`],
|
'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`]
|
'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 uniquePaths = [...new Set([...internalPaths])]
|
||||||
const writeResults = []
|
const writeResults = []
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
import { promises as fs } from 'fs'
|
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) {
|
async function exists(p) {
|
||||||
try {
|
try {
|
||||||
@@ -13,20 +42,27 @@ async function exists(p) {
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const cwd = process.cwd()
|
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),
|
// Prefer CMS write target first (server/data/public-data),
|
||||||
// then legacy locations.
|
// then legacy locations.
|
||||||
const candidates = [
|
const candidates = []
|
||||||
path.join(cwd, 'server/data/public-data', filename),
|
for (const filename of candidateFileNames) {
|
||||||
path.join(cwd, '../server/data/public-data', filename),
|
candidates.push(...buildCsvCandidates(cwd, 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
|
let csvPath = null
|
||||||
for (const p of candidates) {
|
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 { 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'
|
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
|
||||||
|
|
||||||
const TIME_ZONE = 'Europe/Berlin'
|
const TIME_ZONE = 'Europe/Berlin'
|
||||||
@@ -68,8 +70,25 @@ async function runImport(reason) {
|
|||||||
|
|
||||||
running = true
|
running = true
|
||||||
try {
|
try {
|
||||||
const result = await importSpielplan()
|
const spielplan = await importSpielplan()
|
||||||
loggerInfo(`[spielplan-import] ${reason}: ${result.matchCount} Spiele importiert`, { range: `${result.source.season.dateStart} - ${result.source.season.dateEnd}` })
|
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) {
|
} catch (error) {
|
||||||
loggerError('[spielplan-import] Import fehlgeschlagen:', { error })
|
loggerError('[spielplan-import] Import fehlgeschlagen:', { error })
|
||||||
} finally {
|
} 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